diff --git a/.gitignore b/.gitignore
new file mode 100644
index 000000000..7226bb73f
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,8 @@
+Linphone.xcworkspace
+Pods
+Podfile.lock
+xcuserdata/
+Pods/
+.DS_Store
+build
+Linphone.xcodeproj/xcuserdata
\ No newline at end of file
diff --git a/.swiftlint.yml b/.swiftlint.yml
new file mode 100644
index 000000000..0133ab567
--- /dev/null
+++ b/.swiftlint.yml
@@ -0,0 +1,32 @@
+disabled_rules:
+- trailing_whitespace
+opt_in_rules:
+- empty_count
+- empty_string
+excluded:
+- Carthage
+- Pods
+- SwiftLint/Common/3rdPartyLib
+line_length:
+ warning: 150
+ error: 200
+ ignores_function_declarations: true
+ ignores_comments: true
+ ignores_urls: true
+function_body_length:
+ warning: 300
+ error: 500
+function_parameter_count:
+ warning: 6
+ error: 8
+type_body_length:
+ warning: 300
+ error: 500
+file_length:
+ warning: 1000
+ error: 1500
+ ignore_comment_only_lines: true
+cyclomatic_complexity:
+ warning: 15
+ error: 25
+reporter: "xcode"
diff --git a/GoogleService-Info.plist b/GoogleService-Info.plist
new file mode 100644
index 000000000..f996be8f2
--- /dev/null
+++ b/GoogleService-Info.plist
@@ -0,0 +1,36 @@
+
+
+
+
+ CLIENT_ID
+ 221368768663-0ufgu96cel0auk4v0me863lgm252b9n2.apps.googleusercontent.com
+ REVERSED_CLIENT_ID
+ com.googleusercontent.apps.221368768663-0ufgu96cel0auk4v0me863lgm252b9n2
+ API_KEY
+ AIzaSyDJTtlRCM7IqdVUU2dSIYq2YIsTz6bqnkI
+ GCM_SENDER_ID
+ 221368768663
+ PLIST_VERSION
+ 1
+ BUNDLE_ID
+ org.linphone.phone
+ PROJECT_ID
+ linphone-iphone
+ STORAGE_BUCKET
+ linphone-iphone.appspot.com
+ IS_ADS_ENABLED
+
+ IS_ANALYTICS_ENABLED
+
+ IS_APPINVITE_ENABLED
+
+ IS_GCM_ENABLED
+
+ IS_SIGNIN_ENABLED
+
+ GOOGLE_APP_ID
+ 1:221368768663:ios:a2c822bc087b5a219431d2
+ DATABASE_URL
+ https://linphone-iphone.firebaseio.com
+
+
diff --git a/Linphone.xcodeproj/project.pbxproj b/Linphone.xcodeproj/project.pbxproj
new file mode 100644
index 000000000..64321689e
--- /dev/null
+++ b/Linphone.xcodeproj/project.pbxproj
@@ -0,0 +1,1589 @@
+// !$*UTF8*$!
+{
+ archiveVersion = 1;
+ classes = {
+ };
+ objectVersion = 56;
+ objects = {
+
+/* Begin PBXBuildFile section */
+ 4ED1F0A881A9ACB5977A8987 /* BuildFile in Frameworks */ = {isa = PBXBuildFile; };
+ 660AAF7F2B839272004C0FA6 /* msgNotificationService.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 660AAF7B2B839271004C0FA6 /* msgNotificationService.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
+ 660D8A712B517D260092694D /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = 660D8A702B517D260092694D /* GoogleService-Info.plist */; };
+ 6613A0AE2BAEB7DF008923A4 /* MeetingFragment.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6613A0AD2BAEB7DF008923A4 /* MeetingFragment.swift */; };
+ 6613A0B02BAEB7F4008923A4 /* MeetingsListFragment.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6613A0AF2BAEB7F4008923A4 /* MeetingsListFragment.swift */; };
+ 6613A0B42BAEBE3F008923A4 /* MeetingViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6613A0B32BAEBE3F008923A4 /* MeetingViewModel.swift */; };
+ 66162A202BDFC2F900DCE913 /* AddParticipantsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 66162A1F2BDFC2F900DCE913 /* AddParticipantsViewModel.swift */; };
+ 66246C6A2C622AE900973E97 /* TimeZoneExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 66246C692C622AE900973E97 /* TimeZoneExtension.swift */; };
+ 662B69D92B25DE18007118BF /* TelecomManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 662B69D82B25DE18007118BF /* TelecomManager.swift */; };
+ 662B69DB2B25DE25007118BF /* ProviderDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 662B69DA2B25DE25007118BF /* ProviderDelegate.swift */; };
+ 6646A7A32BB2E224006B842A /* ScheduleMeetingFragment.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6646A7A22BB2E224006B842A /* ScheduleMeetingFragment.swift */; };
+ 667E5D7F2B8E430C00EBCFC4 /* linphonerc-factory in Resources */ = {isa = PBXBuildFile; fileRef = D732A90B2B0376F500DB42BA /* linphonerc-factory */; };
+ 667E5D812B8E444E00EBCFC4 /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = 667E5D802B8E444D00EBCFC4 /* GoogleService-Info.plist */; };
+ 6691CA7E2B839C2D00B2A7B8 /* NotificationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6691CA7D2B839C2D00B2A7B8 /* NotificationService.swift */; };
+ 66A3E5B72CAE8E5C00FCB7FA /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = D70C93DD2AC2D0F60063CA3B /* Localizable.xcstrings */; };
+ 66C491F92B24D25B00CEA16D /* ConfigExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 66C491F82B24D25A00CEA16D /* ConfigExtension.swift */; };
+ 66C491FB2B24D32600CEA16D /* CoreExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 66C491FA2B24D32600CEA16D /* CoreExtension.swift */; };
+ 66C491FD2B24D36500CEA16D /* AudioRouteUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = 66C491FC2B24D36500CEA16D /* AudioRouteUtils.swift */; };
+ 66C491FF2B24D4AC00CEA16D /* FileUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = 66C491FE2B24D4AC00CEA16D /* FileUtils.swift */; };
+ 66C492012B24DB6900CEA16D /* Log.swift in Sources */ = {isa = PBXBuildFile; fileRef = 66C492002B24DB6900CEA16D /* Log.swift */; };
+ 66E50A492BD12B2300AD61CA /* MeetingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 66E50A482BD12B2300AD61CA /* MeetingsView.swift */; };
+ 66E50A4B2BD12B7800AD61CA /* MeetingsFragment.swift in Sources */ = {isa = PBXBuildFile; fileRef = 66E50A4A2BD12B7800AD61CA /* MeetingsFragment.swift */; };
+ 66E56BC92BA4A6D7006CE56F /* MeetingsListViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 66E56BC82BA4A6D7006CE56F /* MeetingsListViewModel.swift */; };
+ 66E56BCC2BA9A1E0006CE56F /* MeetingsListItemModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 66E56BCB2BA9A1E0006CE56F /* MeetingsListItemModel.swift */; };
+ 66E56BCE2BA9A1F8006CE56F /* MeetingModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 66E56BCD2BA9A1F8006CE56F /* MeetingModel.swift */; };
+ 66F08C892C2AFEF700D9AE2F /* MeetingsListBottomSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 66F08C882C2AFEF700D9AE2F /* MeetingsListBottomSheet.swift */; };
+ 66F626B22BCEBB86003E2DEC /* AddParticipantsFragment.swift in Sources */ = {isa = PBXBuildFile; fileRef = 66F626B12BCEBB86003E2DEC /* AddParticipantsFragment.swift */; };
+ 66FBFC482B83B8CC00BC6AB1 /* Log.swift in Sources */ = {isa = PBXBuildFile; fileRef = 66C492002B24DB6900CEA16D /* Log.swift */; };
+ 66FBFC492B83BD2400BC6AB1 /* ConfigExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 66C491F82B24D25A00CEA16D /* ConfigExtension.swift */; };
+ 66FBFC4A2B83BD3300BC6AB1 /* FileUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = 66C491FE2B24D4AC00CEA16D /* FileUtils.swift */; };
+ 66FBFC4B2B83BD7B00BC6AB1 /* CoreExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 66C491FA2B24D32600CEA16D /* CoreExtension.swift */; };
+ 66FDB7812C7C689A00561566 /* EventEditViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 66FDB7802C7C689A00561566 /* EventEditViewController.swift */; };
+ C60E8F192C0F649200A06DB8 /* UIApplicationExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = C60E8F182C0F649200A06DB8 /* UIApplicationExtension.swift */; };
+ C62817282C1B389700DBA646 /* SideMenuAccountRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = C62817272C1B389700DBA646 /* SideMenuAccountRow.swift */; };
+ C628172E2C1C3A3600DBA646 /* AccountExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = C628172D2C1C3A3600DBA646 /* AccountExtension.swift */; };
+ C62817302C1C3DCC00DBA646 /* AccountModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = C628172F2C1C3DCC00DBA646 /* AccountModel.swift */; };
+ C62817322C1C400A00DBA646 /* StringExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = C62817312C1C400A00DBA646 /* StringExtension.swift */; };
+ C62817342C1C7C7400DBA646 /* HelpView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C62817332C1C7C7400DBA646 /* HelpView.swift */; };
+ C67586AE2C09F23C002E77BF /* URLExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = C67586AD2C09F23C002E77BF /* URLExtension.swift */; };
+ C67586B02C09F247002E77BF /* URIHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = C67586AF2C09F247002E77BF /* URIHandler.swift */; };
+ C67586B52C09F617002E77BF /* SingleSignOnManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = C67586B22C09F617002E77BF /* SingleSignOnManager.swift */; };
+ C6A5A9412C10B5D50070FEA4 /* EncodableExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = C6A5A9402C10B5D50070FEA4 /* EncodableExtension.swift */; };
+ C6A5A9432C10B5ED0070FEA4 /* DecodableExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = C6A5A9422C10B5ED0070FEA4 /* DecodableExtension.swift */; };
+ C6A5A9452C10B6270070FEA4 /* OIDAuthStateExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = C6A5A9442C10B6270070FEA4 /* OIDAuthStateExtension.swift */; };
+ C6A5A9482C10B6A30070FEA4 /* AuthState.swift in Sources */ = {isa = PBXBuildFile; fileRef = C6A5A9472C10B6A30070FEA4 /* AuthState.swift */; };
+ C6DC4E3D2C199C4E009096FD /* BundleExtenion.swift in Sources */ = {isa = PBXBuildFile; fileRef = C6DC4E3C2C199C4E009096FD /* BundleExtenion.swift */; };
+ C6DC4E3F2C19C289009096FD /* SideMenuEntry.swift in Sources */ = {isa = PBXBuildFile; fileRef = C6DC4E3E2C19C289009096FD /* SideMenuEntry.swift */; };
+ D706BA822ADD72D100278F45 /* DeviceRotationViewModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = D706BA812ADD72D100278F45 /* DeviceRotationViewModifier.swift */; };
+ D70959F12B8DF3EC0014AC0B /* ConversationModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D70959F02B8DF3EC0014AC0B /* ConversationModel.swift */; };
+ D70A26EE2B7CF60B006CC8FC /* ConversationsListBottomSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = D70A26ED2B7CF60B006CC8FC /* ConversationsListBottomSheet.swift */; };
+ D70A26F02B7D02E6006CC8FC /* ConversationViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D70A26EF2B7D02E6006CC8FC /* ConversationViewModel.swift */; };
+ D70A26F22B7F5D95006CC8FC /* ConversationFragment.swift in Sources */ = {isa = PBXBuildFile; fileRef = D70A26F12B7F5D95006CC8FC /* ConversationFragment.swift */; };
+ D70C82A52C85EDCA0087F43F /* ConversationForwardMessageFragment.swift in Sources */ = {isa = PBXBuildFile; fileRef = D70C82A42C85EDC90087F43F /* ConversationForwardMessageFragment.swift */; };
+ D70C82A72C85F5910087F43F /* ConversationForwardMessageViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D70C82A62C85F5910087F43F /* ConversationForwardMessageViewModel.swift */; };
+ D70C93DE2AC2D0F60063CA3B /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = D70C93DD2AC2D0F60063CA3B /* Localizable.xcstrings */; };
+ D714035B2BE11E00004BD8CA /* CallMediaEncryptionModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D714035A2BE11E00004BD8CA /* CallMediaEncryptionModel.swift */; };
+ D714DE602C1B3B34006C1F1D /* RegisterViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D714DE5F2C1B3B34006C1F1D /* RegisterViewModel.swift */; };
+ D714DE622C1C4636006C1F1D /* RegisterCodeConfirmationFragment.swift in Sources */ = {isa = PBXBuildFile; fileRef = D714DE612C1C4636006C1F1D /* RegisterCodeConfirmationFragment.swift */; };
+ D71556362C297DB1009A8CEF /* StartGroupCallFragment.swift in Sources */ = {isa = PBXBuildFile; fileRef = D71556352C297DB1009A8CEF /* StartGroupCallFragment.swift */; };
+ D717071E2AC5922E0037746F /* ColorExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = D717071D2AC5922E0037746F /* ColorExtension.swift */; };
+ D71707202AC5989C0037746F /* TextExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = D717071F2AC5989C0037746F /* TextExtension.swift */; };
+ D7173EBE2B7A5C0A00BCC481 /* LinphoneUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7173EBD2B7A5C0A00BCC481 /* LinphoneUtils.swift */; };
+ D717630D2BD7BD0E00464097 /* ParticipantsListFragment.swift in Sources */ = {isa = PBXBuildFile; fileRef = D717630C2BD7BD0E00464097 /* ParticipantsListFragment.swift */; };
+ D71968922B86369D00DF4459 /* ChatBubbleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D71968912B86369D00DF4459 /* ChatBubbleView.swift */; };
+ D719ABB72ABC67BF00B41C10 /* LinphoneApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = D719ABB62ABC67BF00B41C10 /* LinphoneApp.swift */; };
+ D719ABB92ABC67BF00B41C10 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D719ABB82ABC67BF00B41C10 /* ContentView.swift */; };
+ D719ABBB2ABC67BF00B41C10 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = D719ABBA2ABC67BF00B41C10 /* Assets.xcassets */; };
+ D719ABBF2ABC67BF00B41C10 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = D719ABBE2ABC67BF00B41C10 /* Preview Assets.xcassets */; };
+ D719ABC92ABC6FD700B41C10 /* CoreContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = D719ABC82ABC6FD700B41C10 /* CoreContext.swift */; };
+ D719ABCC2ABC769C00B41C10 /* AssistantView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D719ABCB2ABC769C00B41C10 /* AssistantView.swift */; };
+ D719ABCF2ABC779A00B41C10 /* AccountLoginViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D719ABCE2ABC779A00B41C10 /* AccountLoginViewModel.swift */; };
+ D71A0E192B485ADF0002C6CD /* ViewExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = D71A0E182B485ADF0002C6CD /* ViewExtension.swift */; };
+ D71FCA7F2AE1397200D2E43E /* ContactsListViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D71FCA7E2AE1397200D2E43E /* ContactsListViewModel.swift */; };
+ D71FCA812AE14CFC00D2E43E /* ContactsListFragment.swift in Sources */ = {isa = PBXBuildFile; fileRef = D71FCA802AE14CFC00D2E43E /* ContactsListFragment.swift */; };
+ D71FCA832AE14D6E00D2E43E /* ContactFragment.swift in Sources */ = {isa = PBXBuildFile; fileRef = D71FCA822AE14D6E00D2E43E /* ContactFragment.swift */; };
+ D720E6AD2BAD822000DDFD87 /* ParticipantModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D720E6AC2BAD822000DDFD87 /* ParticipantModel.swift */; };
+ D72250632ADE9615008FB426 /* HistoryViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D72250622ADE9615008FB426 /* HistoryViewModel.swift */; };
+ D72250692ADFBF2D008FB426 /* SideMenu.swift in Sources */ = {isa = PBXBuildFile; fileRef = D72250682ADFBF2D008FB426 /* SideMenu.swift */; };
+ D72343302ACEFEF8009AA24E /* QrCodeScannerFragment.swift in Sources */ = {isa = PBXBuildFile; fileRef = D723432F2ACEFEF8009AA24E /* QrCodeScannerFragment.swift */; };
+ D72343322ACEFF58009AA24E /* QRScannerController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D72343312ACEFF58009AA24E /* QRScannerController.swift */; };
+ D72343342ACEFFC3009AA24E /* QRScanner.swift in Sources */ = {isa = PBXBuildFile; fileRef = D72343332ACEFFC3009AA24E /* QRScanner.swift */; };
+ D72343362AD037AF009AA24E /* ToastView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D72343352AD037AF009AA24E /* ToastView.swift */; };
+ D726E4392B16440C0083C415 /* ContactAvatarModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D726E4382B16440C0083C415 /* ContactAvatarModel.swift */; };
+ D726E43D2B19E4FE0083C415 /* StartCallFragment.swift in Sources */ = {isa = PBXBuildFile; fileRef = D726E43C2B19E4FE0083C415 /* StartCallFragment.swift */; };
+ D726E43F2B19E56F0083C415 /* StartCallViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D726E43E2B19E56F0083C415 /* StartCallViewModel.swift */; };
+ D72992392ADD7F68003AF125 /* HistoryContactFragment.swift in Sources */ = {isa = PBXBuildFile; fileRef = D72992382ADD7F68003AF125 /* HistoryContactFragment.swift */; };
+ D72A9A052B9750A1000DC093 /* UIList.swift in Sources */ = {isa = PBXBuildFile; fileRef = D72A9A042B9750A1000DC093 /* UIList.swift */; };
+ D732A9092AFD235500DB42BA /* ShareSheetController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D732A9082AFD235500DB42BA /* ShareSheetController.swift */; };
+ D732A90C2B0376F500DB42BA /* linphonerc-default in Resources */ = {isa = PBXBuildFile; fileRef = D732A90A2B0376F500DB42BA /* linphonerc-default */; };
+ D732A90D2B0376F500DB42BA /* linphonerc-factory in Resources */ = {isa = PBXBuildFile; fileRef = D732A90B2B0376F500DB42BA /* linphonerc-factory */; };
+ D732A90F2B04C3B400DB42BA /* HistoryFragment.swift in Sources */ = {isa = PBXBuildFile; fileRef = D732A90E2B04C3B400DB42BA /* HistoryFragment.swift */; };
+ D732A9132B04C7A300DB42BA /* HistoryListFragment.swift in Sources */ = {isa = PBXBuildFile; fileRef = D732A9122B04C7A300DB42BA /* HistoryListFragment.swift */; };
+ D732A9152B04C7FE00DB42BA /* HistoryListViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D732A9142B04C7FE00DB42BA /* HistoryListViewModel.swift */; };
+ D732A91B2B061BD900DB42BA /* HistoryListBottomSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = D732A91A2B061BD900DB42BA /* HistoryListBottomSheet.swift */; };
+ D73449992BC6932A00778C56 /* MeetingWaitingRoomFragment.swift in Sources */ = {isa = PBXBuildFile; fileRef = D73449982BC6932A00778C56 /* MeetingWaitingRoomFragment.swift */; };
+ D734499B2BC694C900778C56 /* MeetingWaitingRoomViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D734499A2BC694C900778C56 /* MeetingWaitingRoomViewModel.swift */; };
+ D748BF2C2ACD82D2004844EB /* ThirdPartySipAccountLoginFragment.swift in Sources */ = {isa = PBXBuildFile; fileRef = D748BF2B2ACD82D2004844EB /* ThirdPartySipAccountLoginFragment.swift */; };
+ D748BF2E2ACD82E7004844EB /* ThirdPartySipAccountWarningFragment.swift in Sources */ = {isa = PBXBuildFile; fileRef = D748BF2D2ACD82E7004844EB /* ThirdPartySipAccountWarningFragment.swift */; };
+ D74C9CF82ACACECE0021626A /* WelcomePage1Fragment.swift in Sources */ = {isa = PBXBuildFile; fileRef = D74C9CF72ACACECE0021626A /* WelcomePage1Fragment.swift */; };
+ D74C9CFA2ACACF2D0021626A /* WelcomePage2Fragment.swift in Sources */ = {isa = PBXBuildFile; fileRef = D74C9CF92ACACF2D0021626A /* WelcomePage2Fragment.swift */; };
+ D74C9CFC2ACACF370021626A /* WelcomePage3Fragment.swift in Sources */ = {isa = PBXBuildFile; fileRef = D74C9CFB2ACACF370021626A /* WelcomePage3Fragment.swift */; };
+ D74C9CFF2ACAEC5E0021626A /* PopupView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D74C9CFE2ACAEC5E0021626A /* PopupView.swift */; };
+ D74C9D012ACB098C0021626A /* PermissionManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = D74C9D002ACB098C0021626A /* PermissionManager.swift */; };
+ D74DA0122C047F0700A8561D /* HistoryModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D74DA0112C047F0700A8561D /* HistoryModel.swift */; };
+ D750D3392AD3E6EE00EC99C5 /* PopupLoadingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D750D3382AD3E6EE00EC99C5 /* PopupLoadingView.swift */; };
+ D75759322B56D40900E7AC10 /* ZRTPPopup.swift in Sources */ = {isa = PBXBuildFile; fileRef = D75759312B56D40900E7AC10 /* ZRTPPopup.swift */; };
+ D759CB642C3FBD4200AC35E8 /* StartConversationFragment.swift in Sources */ = {isa = PBXBuildFile; fileRef = D759CB632C3FBD4200AC35E8 /* StartConversationFragment.swift */; };
+ D759CB662C3FBE1D00AC35E8 /* StartConversationViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D759CB652C3FBE1D00AC35E8 /* StartConversationViewModel.swift */; };
+ D76005F62B0798B00054B79A /* IntExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = D76005F52B0798B00054B79A /* IntExtension.swift */; };
+ D7702EF22AC7205000557C00 /* WelcomeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7702EF12AC7205000557C00 /* WelcomeView.swift */; };
+ D777DBB32AE12C5900565A99 /* ContactsManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = D777DBB22AE12C5900565A99 /* ContactsManager.swift */; };
+ D77A080E2CB6BCAF0095D589 /* MessageConferenceInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = D77A080D2CB6BCA10095D589 /* MessageConferenceInfo.swift */; };
+ D78290B82ADD3910004AA85C /* ContactsFragment.swift in Sources */ = {isa = PBXBuildFile; fileRef = D78290B72ADD3910004AA85C /* ContactsFragment.swift */; };
+ D78290BB2ADD40B2004AA85C /* ContactViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D78290BA2ADD40B2004AA85C /* ContactViewModel.swift */; };
+ D783C77C2B1089B200622CC2 /* assistant_linphone_default_values in Resources */ = {isa = PBXBuildFile; fileRef = D783C77A2B1089B200622CC2 /* assistant_linphone_default_values */; };
+ D783C77D2B1089B200622CC2 /* assistant_third_party_default_values in Resources */ = {isa = PBXBuildFile; fileRef = D783C77B2B1089B200622CC2 /* assistant_third_party_default_values */; };
+ D78E06282BE3811D00CE3783 /* CallStatsModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D78E06272BE3811D00CE3783 /* CallStatsModel.swift */; };
+ D78E062A2BEA698E00CE3783 /* MediaEncryptedSheetBottomSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = D78E06292BEA698E00CE3783 /* MediaEncryptedSheetBottomSheet.swift */; };
+ 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 */; };
+ D79622342B1DFE600037EACD /* DialerBottomSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = D79622332B1DFE600037EACD /* DialerBottomSheet.swift */; };
+ D796F2002B0BB61A0041115F /* ToastViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D796F1FF2B0BB61A0041115F /* ToastViewModel.swift */; };
+ D79F2D0A2C47F4BF0038FA07 /* TouchFeedback.swift in Sources */ = {isa = PBXBuildFile; fileRef = D79F2D092C47F4BF0038FA07 /* TouchFeedback.swift */; };
+ D7A03FBD2ACC2DB60081A588 /* ContactsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7A03FBC2ACC2DB60081A588 /* ContactsView.swift */; };
+ D7A03FC02ACC2E390081A588 /* HistoryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7A03FBF2ACC2E390081A588 /* HistoryView.swift */; };
+ D7A03FC62ACC458A0081A588 /* SplashScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7A03FC52ACC458A0081A588 /* SplashScreen.swift */; };
+ D7A0ACBB2C415D630043AE79 /* StartGroupConversationFragment.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7A0ACBA2C415D630043AE79 /* StartGroupConversationFragment.swift */; };
+ D7A2EDD62AC18115005D90FC /* SharedMainViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7A2EDD52AC18115005D90FC /* SharedMainViewModel.swift */; };
+ D7ADF6002AFE356400212231 /* Avatar.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7ADF5FF2AFE356400212231 /* Avatar.swift */; };
+ D7B5066D2AEFA9B900CEB4E9 /* ContactInnerFragment.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7B5066C2AEFA9B900CEB4E9 /* ContactInnerFragment.swift */; };
+ D7B5678E2B28888F00DE63EB /* CallView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7B5678D2B28888F00DE63EB /* CallView.swift */; };
+ D7B99E992B29B39000BE7BF2 /* CallViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7B99E982B29B39000BE7BF2 /* CallViewModel.swift */; };
+ D7B99E9B2B29F7C300BE7BF2 /* ActivityIndicator.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7B99E9A2B29F7C200BE7BF2 /* ActivityIndicator.swift */; };
+ D7C2DA1D2CA44DE400A2441B /* EventModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7C2DA1C2CA44DE400A2441B /* EventModel.swift */; };
+ D7C365082AEFAB7F00FE6142 /* ContactListBottomSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7C365072AEFAB7F00FE6142 /* ContactListBottomSheet.swift */; };
+ D7C3650A2AF001C300FE6142 /* EditContactFragment.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7C365092AF001C300FE6142 /* EditContactFragment.swift */; };
+ D7C3650C2AF0084000FE6142 /* EditContactViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7C3650B2AF0084000FE6142 /* EditContactViewModel.swift */; };
+ D7C3650E2AF15BF200FE6142 /* PhotoPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7C3650D2AF15BF200FE6142 /* PhotoPicker.swift */; };
+ D7C48DF42AFA66F900D938CB /* EditContactController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7C48DF32AFA66F900D938CB /* EditContactController.swift */; };
+ D7C48DF62AFCDF4700D938CB /* ContactInnerActionsFragment.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7C48DF52AFCDF4700D938CB /* ContactInnerActionsFragment.swift */; };
+ D7CEE0352B7A210300FD79B7 /* ConversationsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7CEE0342B7A210300FD79B7 /* ConversationsView.swift */; };
+ D7CEE0382B7A214F00FD79B7 /* ConversationsListViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7CEE0372B7A214F00FD79B7 /* ConversationsListViewModel.swift */; };
+ D7CEE03B2B7A234200FD79B7 /* ConversationsFragment.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7CEE03A2B7A234200FD79B7 /* ConversationsFragment.swift */; };
+ D7CEE03D2B7A23B200FD79B7 /* ConversationsListFragment.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7CEE03C2B7A23B200FD79B7 /* ConversationsListFragment.swift */; };
+ D7D1698C2AE66FA500109A5C /* MagicSearchSingleton.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7D1698B2AE66FA500109A5C /* MagicSearchSingleton.swift */; };
+ D7D24D132AC1B4E800C6F35B /* NotoSans-Medium.ttf in Resources */ = {isa = PBXBuildFile; fileRef = D7D24D0D2AC1B4E800C6F35B /* NotoSans-Medium.ttf */; };
+ D7D24D142AC1B4E800C6F35B /* NotoSans-Regular.ttf in Resources */ = {isa = PBXBuildFile; fileRef = D7D24D0E2AC1B4E800C6F35B /* NotoSans-Regular.ttf */; };
+ D7D24D152AC1B4E800C6F35B /* NotoSans-Light.ttf in Resources */ = {isa = PBXBuildFile; fileRef = D7D24D0F2AC1B4E800C6F35B /* NotoSans-Light.ttf */; };
+ D7D24D162AC1B4E800C6F35B /* NotoSans-SemiBold.ttf in Resources */ = {isa = PBXBuildFile; fileRef = D7D24D102AC1B4E800C6F35B /* NotoSans-SemiBold.ttf */; };
+ D7D24D172AC1B4E800C6F35B /* NotoSans-Bold.ttf in Resources */ = {isa = PBXBuildFile; fileRef = D7D24D112AC1B4E800C6F35B /* NotoSans-Bold.ttf */; };
+ D7D24D182AC1B4E800C6F35B /* NotoSans-ExtraBold.ttf in Resources */ = {isa = PBXBuildFile; fileRef = D7D24D122AC1B4E800C6F35B /* NotoSans-ExtraBold.ttf */; };
+ D7DA67622ACCB2FA00E95002 /* LoginFragment.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7DA67612ACCB2FA00E95002 /* LoginFragment.swift */; };
+ D7DA67642ACCB31700E95002 /* ProfileModeFragment.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7DA67632ACCB31700E95002 /* ProfileModeFragment.swift */; };
+ D7E6ADF32B9875C20009A2BC /* Message.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7E6ADF22B9875C20009A2BC /* Message.swift */; };
+ D7E6ADF52B9876ED0009A2BC /* Attachment.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7E6ADF42B9876ED0009A2BC /* Attachment.swift */; };
+ D7E6D0492AE933AD00A57AAF /* FavoriteContactsListFragment.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7E6D0482AE933AD00A57AAF /* FavoriteContactsListFragment.swift */; };
+ D7E6D04B2AE9347D00A57AAF /* FavoriteContactsListViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7E6D04A2AE9347D00A57AAF /* FavoriteContactsListViewModel.swift */; };
+ D7E6D04D2AEBD77600A57AAF /* CustomBottomSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7E6D04C2AEBD77600A57AAF /* CustomBottomSheet.swift */; };
+ D7E6D0512AEBDBD500A57AAF /* ContactsListBottomSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7E6D0502AEBDBD500A57AAF /* ContactsListBottomSheet.swift */; };
+ D7E6D0552AEBFCCE00A57AAF /* ContactsInnerFragment.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7E6D0542AEBFCCE00A57AAF /* ContactsInnerFragment.swift */; };
+ D7EAACCF2AD6ED8000AA6A8A /* PermissionsFragment.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7EAACCE2AD6ED8000AA6A8A /* PermissionsFragment.swift */; };
+ D7F4D9CB2B5FD27200CDCD76 /* CallsListFragment.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7F4D9CA2B5FD27200CDCD76 /* CallsListFragment.swift */; };
+ D7F5F6412C359F3B007FCF2F /* SipAddressesPopup.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7F5F6402C359F3B007FCF2F /* SipAddressesPopup.swift */; };
+ D7FB55112AD447FD00A5AB15 /* RegisterFragment.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7FB55102AD447FD00A5AB15 /* RegisterFragment.swift */; };
+/* End PBXBuildFile section */
+
+/* Begin PBXContainerItemProxy section */
+ 660AAF7D2B839272004C0FA6 /* PBXContainerItemProxy */ = {
+ isa = PBXContainerItemProxy;
+ containerPortal = D719ABAB2ABC67BF00B41C10 /* Project object */;
+ proxyType = 1;
+ remoteGlobalIDString = 660AAF7A2B839271004C0FA6;
+ remoteInfo = msgNotificationService;
+ };
+/* End PBXContainerItemProxy section */
+
+/* Begin PBXCopyFilesBuildPhase section */
+ 660AAF802B839272004C0FA6 /* Embed Foundation Extensions */ = {
+ isa = PBXCopyFilesBuildPhase;
+ buildActionMask = 2147483647;
+ dstPath = "";
+ dstSubfolderSpec = 13;
+ files = (
+ 660AAF7F2B839272004C0FA6 /* msgNotificationService.appex in Embed Foundation Extensions */,
+ );
+ name = "Embed Foundation Extensions";
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+/* End PBXCopyFilesBuildPhase section */
+
+/* Begin PBXFileReference section */
+ 660AAF7B2B839271004C0FA6 /* msgNotificationService.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = msgNotificationService.appex; sourceTree = BUILT_PRODUCTS_DIR; };
+ 660AAF842B8392E0004C0FA6 /* msgNotificationService.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = msgNotificationService.entitlements; sourceTree = ""; };
+ 660D8A702B517D260092694D /* GoogleService-Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = "GoogleService-Info.plist"; sourceTree = ""; };
+ 6613A0AD2BAEB7DF008923A4 /* MeetingFragment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeetingFragment.swift; sourceTree = ""; };
+ 6613A0AF2BAEB7F4008923A4 /* MeetingsListFragment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeetingsListFragment.swift; sourceTree = ""; };
+ 6613A0B32BAEBE3F008923A4 /* MeetingViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MeetingViewModel.swift; sourceTree = ""; };
+ 66162A1F2BDFC2F900DCE913 /* AddParticipantsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddParticipantsViewModel.swift; sourceTree = ""; };
+ 66246C692C622AE900973E97 /* TimeZoneExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimeZoneExtension.swift; sourceTree = ""; };
+ 662B69D82B25DE18007118BF /* TelecomManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TelecomManager.swift; sourceTree = ""; };
+ 662B69DA2B25DE25007118BF /* ProviderDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProviderDelegate.swift; sourceTree = ""; };
+ 6646A7A22BB2E224006B842A /* ScheduleMeetingFragment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScheduleMeetingFragment.swift; sourceTree = ""; };
+ 667E5D802B8E444D00EBCFC4 /* GoogleService-Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = "GoogleService-Info.plist"; sourceTree = ""; };
+ 6691CA7D2B839C2D00B2A7B8 /* NotificationService.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NotificationService.swift; sourceTree = ""; };
+ 66C491F82B24D25A00CEA16D /* ConfigExtension.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ConfigExtension.swift; sourceTree = ""; };
+ 66C491FA2B24D32600CEA16D /* CoreExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoreExtension.swift; sourceTree = ""; };
+ 66C491FC2B24D36500CEA16D /* AudioRouteUtils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioRouteUtils.swift; sourceTree = ""; };
+ 66C491FE2B24D4AC00CEA16D /* FileUtils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileUtils.swift; sourceTree = ""; };
+ 66C492002B24DB6900CEA16D /* Log.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Log.swift; sourceTree = ""; };
+ 66E50A482BD12B2300AD61CA /* MeetingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeetingsView.swift; sourceTree = ""; };
+ 66E50A4A2BD12B7800AD61CA /* MeetingsFragment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeetingsFragment.swift; sourceTree = ""; };
+ 66E56BC82BA4A6D7006CE56F /* MeetingsListViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeetingsListViewModel.swift; sourceTree = ""; };
+ 66E56BCB2BA9A1E0006CE56F /* MeetingsListItemModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeetingsListItemModel.swift; sourceTree = ""; };
+ 66E56BCD2BA9A1F8006CE56F /* MeetingModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeetingModel.swift; sourceTree = ""; };
+ 66F08C882C2AFEF700D9AE2F /* MeetingsListBottomSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeetingsListBottomSheet.swift; sourceTree = ""; };
+ 66F626B12BCEBB86003E2DEC /* AddParticipantsFragment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddParticipantsFragment.swift; sourceTree = ""; };
+ 66FDB7802C7C689A00561566 /* EventEditViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EventEditViewController.swift; sourceTree = ""; };
+ C60E8F182C0F649200A06DB8 /* UIApplicationExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIApplicationExtension.swift; sourceTree = ""; };
+ C62817272C1B389700DBA646 /* SideMenuAccountRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SideMenuAccountRow.swift; sourceTree = ""; };
+ C628172D2C1C3A3600DBA646 /* AccountExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountExtension.swift; sourceTree = ""; };
+ C628172F2C1C3DCC00DBA646 /* AccountModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountModel.swift; sourceTree = ""; };
+ C62817312C1C400A00DBA646 /* StringExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StringExtension.swift; sourceTree = ""; };
+ C62817332C1C7C7400DBA646 /* HelpView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HelpView.swift; sourceTree = ""; };
+ C67586AD2C09F23C002E77BF /* URLExtension.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = URLExtension.swift; sourceTree = ""; };
+ C67586AF2C09F247002E77BF /* URIHandler.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = URIHandler.swift; sourceTree = ""; };
+ C67586B22C09F617002E77BF /* SingleSignOnManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SingleSignOnManager.swift; sourceTree = ""; };
+ C6A5A9402C10B5D50070FEA4 /* EncodableExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EncodableExtension.swift; sourceTree = ""; };
+ C6A5A9422C10B5ED0070FEA4 /* DecodableExtension.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DecodableExtension.swift; sourceTree = ""; };
+ C6A5A9442C10B6270070FEA4 /* OIDAuthStateExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OIDAuthStateExtension.swift; sourceTree = ""; };
+ C6A5A9472C10B6A30070FEA4 /* AuthState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthState.swift; sourceTree = ""; };
+ C6DC4E3C2C199C4E009096FD /* BundleExtenion.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BundleExtenion.swift; sourceTree = ""; };
+ C6DC4E3E2C19C289009096FD /* SideMenuEntry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SideMenuEntry.swift; sourceTree = ""; };
+ D706BA812ADD72D100278F45 /* DeviceRotationViewModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceRotationViewModifier.swift; sourceTree = ""; };
+ D70959F02B8DF3EC0014AC0B /* ConversationModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConversationModel.swift; sourceTree = ""; };
+ D70A26ED2B7CF60B006CC8FC /* ConversationsListBottomSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConversationsListBottomSheet.swift; sourceTree = ""; };
+ D70A26EF2B7D02E6006CC8FC /* ConversationViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConversationViewModel.swift; sourceTree = ""; };
+ D70A26F12B7F5D95006CC8FC /* ConversationFragment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConversationFragment.swift; sourceTree = ""; };
+ D70C82A42C85EDC90087F43F /* ConversationForwardMessageFragment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConversationForwardMessageFragment.swift; sourceTree = ""; };
+ D70C82A62C85F5910087F43F /* ConversationForwardMessageViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConversationForwardMessageViewModel.swift; sourceTree = ""; };
+ D70C93DD2AC2D0F60063CA3B /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; sourceTree = ""; };
+ D714035A2BE11E00004BD8CA /* CallMediaEncryptionModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallMediaEncryptionModel.swift; sourceTree = ""; };
+ D714DE5F2C1B3B34006C1F1D /* RegisterViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RegisterViewModel.swift; sourceTree = ""; };
+ D714DE612C1C4636006C1F1D /* RegisterCodeConfirmationFragment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RegisterCodeConfirmationFragment.swift; sourceTree = ""; };
+ D71556352C297DB1009A8CEF /* StartGroupCallFragment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StartGroupCallFragment.swift; sourceTree = ""; };
+ D717071D2AC5922E0037746F /* ColorExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ColorExtension.swift; sourceTree = ""; };
+ D717071F2AC5989C0037746F /* TextExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextExtension.swift; sourceTree = ""; };
+ D7173EBD2B7A5C0A00BCC481 /* LinphoneUtils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinphoneUtils.swift; sourceTree = ""; };
+ D717630C2BD7BD0E00464097 /* ParticipantsListFragment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParticipantsListFragment.swift; sourceTree = ""; };
+ D71968912B86369D00DF4459 /* ChatBubbleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatBubbleView.swift; sourceTree = ""; };
+ D719ABB32ABC67BF00B41C10 /* Linphone.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Linphone.app; sourceTree = BUILT_PRODUCTS_DIR; };
+ D719ABB62ABC67BF00B41C10 /* LinphoneApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinphoneApp.swift; sourceTree = ""; };
+ D719ABB82ABC67BF00B41C10 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; };
+ D719ABBA2ABC67BF00B41C10 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; };
+ D719ABBC2ABC67BF00B41C10 /* Linphone.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Linphone.entitlements; sourceTree = ""; };
+ D719ABBE2ABC67BF00B41C10 /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; };
+ D719ABC82ABC6FD700B41C10 /* CoreContext.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoreContext.swift; sourceTree = ""; };
+ D719ABCB2ABC769C00B41C10 /* AssistantView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AssistantView.swift; sourceTree = ""; };
+ D719ABCE2ABC779A00B41C10 /* AccountLoginViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountLoginViewModel.swift; sourceTree = ""; };
+ D71A0E182B485ADF0002C6CD /* ViewExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewExtension.swift; sourceTree = ""; };
+ D71FCA7E2AE1397200D2E43E /* ContactsListViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactsListViewModel.swift; sourceTree = ""; };
+ D71FCA802AE14CFC00D2E43E /* ContactsListFragment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactsListFragment.swift; sourceTree = ""; };
+ D71FCA822AE14D6E00D2E43E /* ContactFragment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactFragment.swift; sourceTree = ""; };
+ D720E6AC2BAD822000DDFD87 /* ParticipantModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParticipantModel.swift; sourceTree = ""; };
+ D72250622ADE9615008FB426 /* HistoryViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HistoryViewModel.swift; sourceTree = ""; };
+ D72250682ADFBF2D008FB426 /* SideMenu.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SideMenu.swift; sourceTree = ""; };
+ D723432F2ACEFEF8009AA24E /* QrCodeScannerFragment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QrCodeScannerFragment.swift; sourceTree = ""; };
+ D72343312ACEFF58009AA24E /* QRScannerController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QRScannerController.swift; sourceTree = ""; };
+ D72343332ACEFFC3009AA24E /* QRScanner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QRScanner.swift; sourceTree = ""; };
+ D72343352AD037AF009AA24E /* ToastView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToastView.swift; sourceTree = ""; };
+ D726E4382B16440C0083C415 /* ContactAvatarModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactAvatarModel.swift; sourceTree = ""; };
+ D726E43C2B19E4FE0083C415 /* StartCallFragment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StartCallFragment.swift; sourceTree = ""; };
+ D726E43E2B19E56F0083C415 /* StartCallViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StartCallViewModel.swift; sourceTree = ""; };
+ D72992382ADD7F68003AF125 /* HistoryContactFragment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HistoryContactFragment.swift; sourceTree = ""; };
+ D72A9A042B9750A1000DC093 /* UIList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIList.swift; sourceTree = ""; };
+ D732A9082AFD235500DB42BA /* ShareSheetController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareSheetController.swift; sourceTree = ""; };
+ D732A90A2B0376F500DB42BA /* linphonerc-default */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text; path = "linphonerc-default"; sourceTree = ""; };
+ D732A90B2B0376F500DB42BA /* linphonerc-factory */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text; path = "linphonerc-factory"; sourceTree = ""; };
+ D732A90E2B04C3B400DB42BA /* HistoryFragment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HistoryFragment.swift; sourceTree = ""; };
+ D732A9122B04C7A300DB42BA /* HistoryListFragment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HistoryListFragment.swift; sourceTree = ""; };
+ D732A9142B04C7FE00DB42BA /* HistoryListViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HistoryListViewModel.swift; sourceTree = ""; };
+ D732A91A2B061BD900DB42BA /* HistoryListBottomSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HistoryListBottomSheet.swift; sourceTree = ""; };
+ D73449982BC6932A00778C56 /* MeetingWaitingRoomFragment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeetingWaitingRoomFragment.swift; sourceTree = ""; };
+ D734499A2BC694C900778C56 /* MeetingWaitingRoomViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeetingWaitingRoomViewModel.swift; sourceTree = ""; };
+ D748BF2B2ACD82D2004844EB /* ThirdPartySipAccountLoginFragment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThirdPartySipAccountLoginFragment.swift; sourceTree = ""; };
+ D748BF2D2ACD82E7004844EB /* ThirdPartySipAccountWarningFragment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThirdPartySipAccountWarningFragment.swift; sourceTree = ""; };
+ D74C9CF72ACACECE0021626A /* WelcomePage1Fragment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WelcomePage1Fragment.swift; sourceTree = ""; };
+ D74C9CF92ACACF2D0021626A /* WelcomePage2Fragment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WelcomePage2Fragment.swift; sourceTree = ""; };
+ D74C9CFB2ACACF370021626A /* WelcomePage3Fragment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WelcomePage3Fragment.swift; sourceTree = ""; };
+ D74C9CFE2ACAEC5E0021626A /* PopupView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PopupView.swift; sourceTree = ""; };
+ D74C9D002ACB098C0021626A /* PermissionManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PermissionManager.swift; sourceTree = ""; };
+ D74DA0112C047F0700A8561D /* HistoryModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HistoryModel.swift; sourceTree = ""; };
+ D750D3382AD3E6EE00EC99C5 /* PopupLoadingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PopupLoadingView.swift; sourceTree = ""; };
+ D75759312B56D40900E7AC10 /* ZRTPPopup.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ZRTPPopup.swift; sourceTree = ""; };
+ D759CB632C3FBD4200AC35E8 /* StartConversationFragment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StartConversationFragment.swift; sourceTree = ""; };
+ D759CB652C3FBE1D00AC35E8 /* StartConversationViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StartConversationViewModel.swift; sourceTree = ""; };
+ D76005F52B0798B00054B79A /* IntExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IntExtension.swift; sourceTree = ""; };
+ D7702EF12AC7205000557C00 /* WelcomeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WelcomeView.swift; sourceTree = ""; };
+ D777DBB22AE12C5900565A99 /* ContactsManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactsManager.swift; sourceTree = ""; };
+ D77A080D2CB6BCA10095D589 /* MessageConferenceInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageConferenceInfo.swift; sourceTree = ""; };
+ D78290B72ADD3910004AA85C /* ContactsFragment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactsFragment.swift; sourceTree = ""; };
+ D78290BA2ADD40B2004AA85C /* ContactViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactViewModel.swift; sourceTree = ""; };
+ D783C77A2B1089B200622CC2 /* assistant_linphone_default_values */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xml; path = assistant_linphone_default_values; sourceTree = ""; };
+ D783C77B2B1089B200622CC2 /* assistant_third_party_default_values */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xml; path = assistant_third_party_default_values; sourceTree = ""; };
+ D78E06272BE3811D00CE3783 /* CallStatsModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallStatsModel.swift; sourceTree = ""; };
+ D78E06292BEA698E00CE3783 /* MediaEncryptedSheetBottomSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaEncryptedSheetBottomSheet.swift; sourceTree = ""; };
+ D78E062B2BEA69BC00CE3783 /* CallStatisticsSheetBottomSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallStatisticsSheetBottomSheet.swift; sourceTree = ""; };
+ D78E062D2BEA69F400CE3783 /* AudioRouteBottomSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioRouteBottomSheet.swift; sourceTree = ""; };
+ D78E062F2BEA6A4A00CE3783 /* ChangeLayoutBottomSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChangeLayoutBottomSheet.swift; sourceTree = ""; };
+ D79622332B1DFE600037EACD /* DialerBottomSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DialerBottomSheet.swift; sourceTree = ""; };
+ D796F1FF2B0BB61A0041115F /* ToastViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToastViewModel.swift; sourceTree = ""; };
+ D79F2D092C47F4BF0038FA07 /* TouchFeedback.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TouchFeedback.swift; sourceTree = ""; };
+ D7A03FBC2ACC2DB60081A588 /* ContactsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactsView.swift; sourceTree = ""; };
+ D7A03FBF2ACC2E390081A588 /* HistoryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HistoryView.swift; sourceTree = ""; };
+ D7A03FC52ACC458A0081A588 /* SplashScreen.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SplashScreen.swift; sourceTree = ""; };
+ D7A0ACBA2C415D630043AE79 /* StartGroupConversationFragment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StartGroupConversationFragment.swift; sourceTree = ""; };
+ D7A2EDD52AC18115005D90FC /* SharedMainViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SharedMainViewModel.swift; sourceTree = ""; };
+ D7A2EDDA2AC19EEC005D90FC /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; };
+ D7ADF5FF2AFE356400212231 /* Avatar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Avatar.swift; sourceTree = ""; };
+ D7B5066C2AEFA9B900CEB4E9 /* ContactInnerFragment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactInnerFragment.swift; sourceTree = ""; };
+ D7B5678D2B28888F00DE63EB /* CallView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallView.swift; sourceTree = ""; };
+ D7B99E982B29B39000BE7BF2 /* CallViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallViewModel.swift; sourceTree = ""; };
+ D7B99E9A2B29F7C200BE7BF2 /* ActivityIndicator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActivityIndicator.swift; sourceTree = ""; };
+ D7C2DA1C2CA44DE400A2441B /* EventModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = EventModel.swift; sourceTree = ""; };
+ D7C365072AEFAB7F00FE6142 /* ContactListBottomSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactListBottomSheet.swift; sourceTree = ""; };
+ D7C365092AF001C300FE6142 /* EditContactFragment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditContactFragment.swift; sourceTree = ""; };
+ D7C3650B2AF0084000FE6142 /* EditContactViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditContactViewModel.swift; sourceTree = ""; };
+ D7C3650D2AF15BF200FE6142 /* PhotoPicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhotoPicker.swift; sourceTree = ""; };
+ D7C48DF32AFA66F900D938CB /* EditContactController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditContactController.swift; sourceTree = ""; };
+ D7C48DF52AFCDF4700D938CB /* ContactInnerActionsFragment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactInnerActionsFragment.swift; sourceTree = ""; };
+ D7CEE0342B7A210300FD79B7 /* ConversationsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConversationsView.swift; sourceTree = ""; };
+ D7CEE0372B7A214F00FD79B7 /* ConversationsListViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConversationsListViewModel.swift; sourceTree = ""; };
+ D7CEE03A2B7A234200FD79B7 /* ConversationsFragment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConversationsFragment.swift; sourceTree = ""; };
+ D7CEE03C2B7A23B200FD79B7 /* ConversationsListFragment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConversationsListFragment.swift; sourceTree = ""; };
+ D7D1698B2AE66FA500109A5C /* MagicSearchSingleton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MagicSearchSingleton.swift; sourceTree = ""; };
+ D7D24D0D2AC1B4E800C6F35B /* NotoSans-Medium.ttf */ = {isa = PBXFileReference; lastKnownFileType = file; path = "NotoSans-Medium.ttf"; sourceTree = ""; };
+ D7D24D0E2AC1B4E800C6F35B /* NotoSans-Regular.ttf */ = {isa = PBXFileReference; lastKnownFileType = file; path = "NotoSans-Regular.ttf"; sourceTree = ""; };
+ D7D24D0F2AC1B4E800C6F35B /* NotoSans-Light.ttf */ = {isa = PBXFileReference; lastKnownFileType = file; path = "NotoSans-Light.ttf"; sourceTree = ""; };
+ D7D24D102AC1B4E800C6F35B /* NotoSans-SemiBold.ttf */ = {isa = PBXFileReference; lastKnownFileType = file; path = "NotoSans-SemiBold.ttf"; sourceTree = ""; };
+ D7D24D112AC1B4E800C6F35B /* NotoSans-Bold.ttf */ = {isa = PBXFileReference; lastKnownFileType = file; path = "NotoSans-Bold.ttf"; sourceTree = ""; };
+ D7D24D122AC1B4E800C6F35B /* NotoSans-ExtraBold.ttf */ = {isa = PBXFileReference; lastKnownFileType = file; path = "NotoSans-ExtraBold.ttf"; sourceTree = ""; };
+ D7DA67612ACCB2FA00E95002 /* LoginFragment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginFragment.swift; sourceTree = ""; };
+ D7DA67632ACCB31700E95002 /* ProfileModeFragment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileModeFragment.swift; sourceTree = ""; };
+ D7E6ADF22B9875C20009A2BC /* Message.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Message.swift; sourceTree = ""; };
+ D7E6ADF42B9876ED0009A2BC /* Attachment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Attachment.swift; sourceTree = ""; };
+ D7E6D0482AE933AD00A57AAF /* FavoriteContactsListFragment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FavoriteContactsListFragment.swift; sourceTree = ""; };
+ D7E6D04A2AE9347D00A57AAF /* FavoriteContactsListViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FavoriteContactsListViewModel.swift; sourceTree = ""; };
+ D7E6D04C2AEBD77600A57AAF /* CustomBottomSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomBottomSheet.swift; sourceTree = ""; };
+ D7E6D0502AEBDBD500A57AAF /* ContactsListBottomSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactsListBottomSheet.swift; sourceTree = ""; };
+ D7E6D0542AEBFCCE00A57AAF /* ContactsInnerFragment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactsInnerFragment.swift; sourceTree = ""; };
+ D7EAACCE2AD6ED8000AA6A8A /* PermissionsFragment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PermissionsFragment.swift; sourceTree = ""; };
+ D7F4D9CA2B5FD27200CDCD76 /* CallsListFragment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallsListFragment.swift; sourceTree = ""; };
+ D7F5F6402C359F3B007FCF2F /* SipAddressesPopup.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SipAddressesPopup.swift; sourceTree = ""; };
+ D7FB55102AD447FD00A5AB15 /* RegisterFragment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RegisterFragment.swift; sourceTree = ""; };
+/* End PBXFileReference section */
+
+/* Begin PBXFrameworksBuildPhase section */
+ 660AAF782B839271004C0FA6 /* Frameworks */ = {
+ isa = PBXFrameworksBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+ D719ABB02ABC67BF00B41C10 /* Frameworks */ = {
+ isa = PBXFrameworksBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ 4ED1F0A881A9ACB5977A8987 /* BuildFile in Frameworks */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+/* End PBXFrameworksBuildPhase section */
+
+/* Begin PBXGroup section */
+ 660AAF7C2B839272004C0FA6 /* msgNotificationService */ = {
+ isa = PBXGroup;
+ children = (
+ 667E5D802B8E444D00EBCFC4 /* GoogleService-Info.plist */,
+ 6691CA7D2B839C2D00B2A7B8 /* NotificationService.swift */,
+ 660AAF842B8392E0004C0FA6 /* msgNotificationService.entitlements */,
+ );
+ path = msgNotificationService;
+ sourceTree = "";
+ };
+ 662B69D72B25DDF6007118BF /* TelecomManager */ = {
+ isa = PBXGroup;
+ children = (
+ 662B69D82B25DE18007118BF /* TelecomManager.swift */,
+ 662B69DA2B25DE25007118BF /* ProviderDelegate.swift */,
+ );
+ path = TelecomManager;
+ sourceTree = "";
+ };
+ 66C491F72B24D25A00CEA16D /* Extensions */ = {
+ isa = PBXGroup;
+ children = (
+ C67586AD2C09F23C002E77BF /* URLExtension.swift */,
+ D717071D2AC5922E0037746F /* ColorExtension.swift */,
+ 66C491F82B24D25A00CEA16D /* ConfigExtension.swift */,
+ D76005F52B0798B00054B79A /* IntExtension.swift */,
+ D717071F2AC5989C0037746F /* TextExtension.swift */,
+ D71A0E182B485ADF0002C6CD /* ViewExtension.swift */,
+ C60E8F182C0F649200A06DB8 /* UIApplicationExtension.swift */,
+ C6A5A9402C10B5D50070FEA4 /* EncodableExtension.swift */,
+ C6A5A9422C10B5ED0070FEA4 /* DecodableExtension.swift */,
+ C6DC4E3C2C199C4E009096FD /* BundleExtenion.swift */,
+ C628172D2C1C3A3600DBA646 /* AccountExtension.swift */,
+ C62817312C1C400A00DBA646 /* StringExtension.swift */,
+ 66246C692C622AE900973E97 /* TimeZoneExtension.swift */,
+ );
+ path = Extensions;
+ sourceTree = "";
+ };
+ 66E56BC52BA45E49006CE56F /* Meetings */ = {
+ isa = PBXGroup;
+ children = (
+ 66E56BC62BA49938006CE56F /* Fragments */,
+ 66E56BCA2BA9A1A0006CE56F /* Models */,
+ 66E56BC72BA4993E006CE56F /* ViewModel */,
+ 66E50A482BD12B2300AD61CA /* MeetingsView.swift */,
+ );
+ path = Meetings;
+ sourceTree = "";
+ };
+ 66E56BC62BA49938006CE56F /* Fragments */ = {
+ isa = PBXGroup;
+ children = (
+ 66E50A4A2BD12B7800AD61CA /* MeetingsFragment.swift */,
+ 6613A0AD2BAEB7DF008923A4 /* MeetingFragment.swift */,
+ 6613A0AF2BAEB7F4008923A4 /* MeetingsListFragment.swift */,
+ 66F08C882C2AFEF700D9AE2F /* MeetingsListBottomSheet.swift */,
+ 6646A7A22BB2E224006B842A /* ScheduleMeetingFragment.swift */,
+ 66F626B12BCEBB86003E2DEC /* AddParticipantsFragment.swift */,
+ );
+ path = Fragments;
+ sourceTree = "";
+ };
+ 66E56BC72BA4993E006CE56F /* ViewModel */ = {
+ isa = PBXGroup;
+ children = (
+ 66E56BC82BA4A6D7006CE56F /* MeetingsListViewModel.swift */,
+ 6613A0B32BAEBE3F008923A4 /* MeetingViewModel.swift */,
+ 66FDB7802C7C689A00561566 /* EventEditViewController.swift */,
+ );
+ path = ViewModel;
+ sourceTree = "";
+ };
+ 66E56BCA2BA9A1A0006CE56F /* Models */ = {
+ isa = PBXGroup;
+ children = (
+ 66E56BCB2BA9A1E0006CE56F /* MeetingsListItemModel.swift */,
+ 66E56BCD2BA9A1F8006CE56F /* MeetingModel.swift */,
+ );
+ path = Models;
+ sourceTree = "";
+ };
+ A31AF2AB8C6A3D7B7EA3B424 /* Pods */ = {
+ isa = PBXGroup;
+ children = (
+ );
+ path = Pods;
+ sourceTree = "";
+ };
+ C6A5A9462C10B64A0070FEA4 /* SingleSignOn */ = {
+ isa = PBXGroup;
+ children = (
+ C6A5A9442C10B6270070FEA4 /* OIDAuthStateExtension.swift */,
+ C67586B22C09F617002E77BF /* SingleSignOnManager.swift */,
+ C6A5A9472C10B6A30070FEA4 /* AuthState.swift */,
+ );
+ path = SingleSignOn;
+ sourceTree = "";
+ };
+ D70959EF2B8DF33B0014AC0B /* Model */ = {
+ isa = PBXGroup;
+ children = (
+ D77A080D2CB6BCA10095D589 /* MessageConferenceInfo.swift */,
+ D70959F02B8DF3EC0014AC0B /* ConversationModel.swift */,
+ D7E6ADF22B9875C20009A2BC /* Message.swift */,
+ D7C2DA1C2CA44DE400A2441B /* EventModel.swift */,
+ D7E6ADF42B9876ED0009A2BC /* Attachment.swift */,
+ );
+ path = Model;
+ sourceTree = "";
+ };
+ D717071C2AC591EF0037746F /* Utils */ = {
+ isa = PBXGroup;
+ children = (
+ 66C491F72B24D25A00CEA16D /* Extensions */,
+ 66C491FC2B24D36500CEA16D /* AudioRouteUtils.swift */,
+ D7ADF5FF2AFE356400212231 /* Avatar.swift */,
+ D7C48DF32AFA66F900D938CB /* EditContactController.swift */,
+ 66C491FE2B24D4AC00CEA16D /* FileUtils.swift */,
+ 66C492002B24DB6900CEA16D /* Log.swift */,
+ D7D1698B2AE66FA500109A5C /* MagicSearchSingleton.swift */,
+ D74C9D002ACB098C0021626A /* PermissionManager.swift */,
+ D7C3650D2AF15BF200FE6142 /* PhotoPicker.swift */,
+ D732A9082AFD235500DB42BA /* ShareSheetController.swift */,
+ D7B99E9A2B29F7C200BE7BF2 /* ActivityIndicator.swift */,
+ D7173EBD2B7A5C0A00BCC481 /* LinphoneUtils.swift */,
+ C67586AF2C09F247002E77BF /* URIHandler.swift */,
+ C6A5A9462C10B64A0070FEA4 /* SingleSignOn */,
+ D79F2D092C47F4BF0038FA07 /* TouchFeedback.swift */,
+ );
+ path = Utils;
+ sourceTree = "";
+ };
+ D719ABAA2ABC67BF00B41C10 = {
+ isa = PBXGroup;
+ children = (
+ 660D8A702B517D260092694D /* GoogleService-Info.plist */,
+ D719ABB52ABC67BF00B41C10 /* Linphone */,
+ 660AAF7C2B839272004C0FA6 /* msgNotificationService */,
+ D719ABB42ABC67BF00B41C10 /* Products */,
+ A31AF2AB8C6A3D7B7EA3B424 /* Pods */,
+ );
+ sourceTree = "";
+ };
+ D719ABB42ABC67BF00B41C10 /* Products */ = {
+ isa = PBXGroup;
+ children = (
+ D719ABB32ABC67BF00B41C10 /* Linphone.app */,
+ 660AAF7B2B839271004C0FA6 /* msgNotificationService.appex */,
+ );
+ name = Products;
+ sourceTree = "";
+ };
+ D719ABB52ABC67BF00B41C10 /* Linphone */ = {
+ isa = PBXGroup;
+ children = (
+ D7A03FC52ACC458A0081A588 /* SplashScreen.swift */,
+ D719ABB62ABC67BF00B41C10 /* LinphoneApp.swift */,
+ D777DBB12AE12C4000565A99 /* Contacts */,
+ D719ABC72ABC6FB200B41C10 /* Core */,
+ 662B69D72B25DDF6007118BF /* TelecomManager */,
+ D719ABC52ABC6EE800B41C10 /* UI */,
+ D717071C2AC591EF0037746F /* Utils */,
+ D719ABBA2ABC67BF00B41C10 /* Assets.xcassets */,
+ D719ABBC2ABC67BF00B41C10 /* Linphone.entitlements */,
+ D7A2EDDA2AC19EEC005D90FC /* Info.plist */,
+ D70C93DD2AC2D0F60063CA3B /* Localizable.xcstrings */,
+ D719ABBD2ABC67BF00B41C10 /* Preview Content */,
+ D7D24D0C2AC1B4C700C6F35B /* Fonts */,
+ D7ADF6012AFE5C7C00212231 /* Ressources */,
+ );
+ path = Linphone;
+ sourceTree = "";
+ };
+ D719ABBD2ABC67BF00B41C10 /* Preview Content */ = {
+ isa = PBXGroup;
+ children = (
+ D719ABBE2ABC67BF00B41C10 /* Preview Assets.xcassets */,
+ );
+ path = "Preview Content";
+ sourceTree = "";
+ };
+ D719ABC52ABC6EE800B41C10 /* UI */ = {
+ isa = PBXGroup;
+ children = (
+ D719ABCA2ABC761800B41C10 /* Assistant */,
+ D7B5678C2B28883700DE63EB /* Call */,
+ D719ABC62ABC6F0200B41C10 /* Main */,
+ D7702EF02AC7200600557C00 /* Welcome */,
+ );
+ path = UI;
+ sourceTree = "";
+ };
+ D719ABC62ABC6F0200B41C10 /* Main */ = {
+ isa = PBXGroup;
+ children = (
+ D7CEE0332B7A20A400FD79B7 /* Conversations */,
+ D7A03FBB2ACC2D850081A588 /* Contacts */,
+ D74C9CFD2ACAEC150021626A /* Fragments */,
+ D7A03FBE2ACC2E010081A588 /* History */,
+ 66E56BC52BA45E49006CE56F /* Meetings */,
+ D7A2EDD42AC180FE005D90FC /* Viewmodel */,
+ D719ABB82ABC67BF00B41C10 /* ContentView.swift */,
+ );
+ path = Main;
+ sourceTree = "";
+ };
+ D719ABC72ABC6FB200B41C10 /* Core */ = {
+ isa = PBXGroup;
+ children = (
+ D719ABC82ABC6FD700B41C10 /* CoreContext.swift */,
+ 66C491FA2B24D32600CEA16D /* CoreExtension.swift */,
+ );
+ path = Core;
+ sourceTree = "";
+ };
+ D719ABCA2ABC761800B41C10 /* Assistant */ = {
+ isa = PBXGroup;
+ children = (
+ D7DA67602ACCB2D700E95002 /* Fragments */,
+ D719ABCD2ABC777600B41C10 /* Viewmodel */,
+ D719ABCB2ABC769C00B41C10 /* AssistantView.swift */,
+ );
+ path = Assistant;
+ sourceTree = "";
+ };
+ D719ABCD2ABC777600B41C10 /* Viewmodel */ = {
+ isa = PBXGroup;
+ children = (
+ D719ABCE2ABC779A00B41C10 /* AccountLoginViewModel.swift */,
+ D72343332ACEFFC3009AA24E /* QRScanner.swift */,
+ D72343312ACEFF58009AA24E /* QRScannerController.swift */,
+ D714DE5F2C1B3B34006C1F1D /* RegisterViewModel.swift */,
+ );
+ path = Viewmodel;
+ sourceTree = "";
+ };
+ D720E6AB2BAD81C800DDFD87 /* Model */ = {
+ isa = PBXGroup;
+ children = (
+ D720E6AC2BAD822000DDFD87 /* ParticipantModel.swift */,
+ D714035A2BE11E00004BD8CA /* CallMediaEncryptionModel.swift */,
+ D78E06272BE3811D00CE3783 /* CallStatsModel.swift */,
+ );
+ path = Model;
+ sourceTree = "";
+ };
+ D72250612ADE95E4008FB426 /* ViewModel */ = {
+ isa = PBXGroup;
+ children = (
+ D72250622ADE9615008FB426 /* HistoryViewModel.swift */,
+ D732A9142B04C7FE00DB42BA /* HistoryListViewModel.swift */,
+ D726E43E2B19E56F0083C415 /* StartCallViewModel.swift */,
+ );
+ path = ViewModel;
+ sourceTree = "";
+ };
+ D726E4372B1643FF0083C415 /* Model */ = {
+ isa = PBXGroup;
+ children = (
+ D726E4382B16440C0083C415 /* ContactAvatarModel.swift */,
+ );
+ path = Model;
+ sourceTree = "";
+ };
+ D72992372ADD7F1C003AF125 /* Fragments */ = {
+ isa = PBXGroup;
+ children = (
+ D72992382ADD7F68003AF125 /* HistoryContactFragment.swift */,
+ D732A90E2B04C3B400DB42BA /* HistoryFragment.swift */,
+ D732A9122B04C7A300DB42BA /* HistoryListFragment.swift */,
+ D732A91A2B061BD900DB42BA /* HistoryListBottomSheet.swift */,
+ D726E43C2B19E4FE0083C415 /* StartCallFragment.swift */,
+ D79622332B1DFE600037EACD /* DialerBottomSheet.swift */,
+ D71556352C297DB1009A8CEF /* StartGroupCallFragment.swift */,
+ );
+ path = Fragments;
+ sourceTree = "";
+ };
+ D74C9CF62ACACEB70021626A /* Fragments */ = {
+ isa = PBXGroup;
+ children = (
+ D74C9CF72ACACECE0021626A /* WelcomePage1Fragment.swift */,
+ D74C9CF92ACACF2D0021626A /* WelcomePage2Fragment.swift */,
+ D74C9CFB2ACACF370021626A /* WelcomePage3Fragment.swift */,
+ );
+ path = Fragments;
+ sourceTree = "";
+ };
+ D74C9CFD2ACAEC150021626A /* Fragments */ = {
+ isa = PBXGroup;
+ children = (
+ D74C9CFE2ACAEC5E0021626A /* PopupView.swift */,
+ D72343352AD037AF009AA24E /* ToastView.swift */,
+ D750D3382AD3E6EE00EC99C5 /* PopupLoadingView.swift */,
+ D706BA812ADD72D100278F45 /* DeviceRotationViewModifier.swift */,
+ D72250682ADFBF2D008FB426 /* SideMenu.swift */,
+ C62817332C1C7C7400DBA646 /* HelpView.swift */,
+ C6DC4E3E2C19C289009096FD /* SideMenuEntry.swift */,
+ C62817272C1B389700DBA646 /* SideMenuAccountRow.swift */,
+ D7E6D04C2AEBD77600A57AAF /* CustomBottomSheet.swift */,
+ );
+ path = Fragments;
+ sourceTree = "";
+ };
+ D74DA0102C047EE300A8561D /* Model */ = {
+ isa = PBXGroup;
+ children = (
+ D74DA0112C047F0700A8561D /* HistoryModel.swift */,
+ );
+ path = Model;
+ sourceTree = "";
+ };
+ D75759302B56D3CE00E7AC10 /* Fragments */ = {
+ isa = PBXGroup;
+ children = (
+ D75759312B56D40900E7AC10 /* ZRTPPopup.swift */,
+ D7F4D9CA2B5FD27200CDCD76 /* CallsListFragment.swift */,
+ D717630C2BD7BD0E00464097 /* ParticipantsListFragment.swift */,
+ D78E06292BEA698E00CE3783 /* MediaEncryptedSheetBottomSheet.swift */,
+ D78E062B2BEA69BC00CE3783 /* CallStatisticsSheetBottomSheet.swift */,
+ D78E062D2BEA69F400CE3783 /* AudioRouteBottomSheet.swift */,
+ D78E062F2BEA6A4A00CE3783 /* ChangeLayoutBottomSheet.swift */,
+ );
+ path = Fragments;
+ sourceTree = "";
+ };
+ D7702EF02AC7200600557C00 /* Welcome */ = {
+ isa = PBXGroup;
+ children = (
+ D74C9CF62ACACEB70021626A /* Fragments */,
+ D7702EF12AC7205000557C00 /* WelcomeView.swift */,
+ );
+ path = Welcome;
+ sourceTree = "";
+ };
+ D777DBB12AE12C4000565A99 /* Contacts */ = {
+ isa = PBXGroup;
+ children = (
+ D777DBB22AE12C5900565A99 /* ContactsManager.swift */,
+ );
+ path = Contacts;
+ sourceTree = "";
+ };
+ D78290B62ADD38F9004AA85C /* Fragments */ = {
+ isa = PBXGroup;
+ children = (
+ D78290B72ADD3910004AA85C /* ContactsFragment.swift */,
+ D71FCA802AE14CFC00D2E43E /* ContactsListFragment.swift */,
+ D71FCA822AE14D6E00D2E43E /* ContactFragment.swift */,
+ D7E6D0482AE933AD00A57AAF /* FavoriteContactsListFragment.swift */,
+ D7E6D0502AEBDBD500A57AAF /* ContactsListBottomSheet.swift */,
+ D7E6D0542AEBFCCE00A57AAF /* ContactsInnerFragment.swift */,
+ D7B5066C2AEFA9B900CEB4E9 /* ContactInnerFragment.swift */,
+ D7C365072AEFAB7F00FE6142 /* ContactListBottomSheet.swift */,
+ D7C365092AF001C300FE6142 /* EditContactFragment.swift */,
+ D7C48DF52AFCDF4700D938CB /* ContactInnerActionsFragment.swift */,
+ D7F5F6402C359F3B007FCF2F /* SipAddressesPopup.swift */,
+ );
+ path = Fragments;
+ sourceTree = "";
+ };
+ D78290B92ADD409D004AA85C /* ViewModel */ = {
+ isa = PBXGroup;
+ children = (
+ D78290BA2ADD40B2004AA85C /* ContactViewModel.swift */,
+ D71FCA7E2AE1397200D2E43E /* ContactsListViewModel.swift */,
+ D7E6D04A2AE9347D00A57AAF /* FavoriteContactsListViewModel.swift */,
+ D7C3650B2AF0084000FE6142 /* EditContactViewModel.swift */,
+ );
+ path = ViewModel;
+ sourceTree = "";
+ };
+ D7A03FBB2ACC2D850081A588 /* Contacts */ = {
+ isa = PBXGroup;
+ children = (
+ D78290B62ADD38F9004AA85C /* Fragments */,
+ D726E4372B1643FF0083C415 /* Model */,
+ D78290B92ADD409D004AA85C /* ViewModel */,
+ D7A03FBC2ACC2DB60081A588 /* ContactsView.swift */,
+ );
+ path = Contacts;
+ sourceTree = "";
+ };
+ D7A03FBE2ACC2E010081A588 /* History */ = {
+ isa = PBXGroup;
+ children = (
+ D72992372ADD7F1C003AF125 /* Fragments */,
+ D74DA0102C047EE300A8561D /* Model */,
+ D72250612ADE95E4008FB426 /* ViewModel */,
+ D7A03FBF2ACC2E390081A588 /* HistoryView.swift */,
+ );
+ path = History;
+ sourceTree = "";
+ };
+ D7A2EDD42AC180FE005D90FC /* Viewmodel */ = {
+ isa = PBXGroup;
+ children = (
+ 66162A1F2BDFC2F900DCE913 /* AddParticipantsViewModel.swift */,
+ D7A2EDD52AC18115005D90FC /* SharedMainViewModel.swift */,
+ D796F1FF2B0BB61A0041115F /* ToastViewModel.swift */,
+ C628172F2C1C3DCC00DBA646 /* AccountModel.swift */,
+ );
+ path = Viewmodel;
+ sourceTree = "";
+ };
+ D7ADF6012AFE5C7C00212231 /* Ressources */ = {
+ isa = PBXGroup;
+ children = (
+ D783C77A2B1089B200622CC2 /* assistant_linphone_default_values */,
+ D783C77B2B1089B200622CC2 /* assistant_third_party_default_values */,
+ D732A90A2B0376F500DB42BA /* linphonerc-default */,
+ D732A90B2B0376F500DB42BA /* linphonerc-factory */,
+ );
+ path = Ressources;
+ sourceTree = "";
+ };
+ D7B5678C2B28883700DE63EB /* Call */ = {
+ isa = PBXGroup;
+ children = (
+ D75759302B56D3CE00E7AC10 /* Fragments */,
+ D720E6AB2BAD81C800DDFD87 /* Model */,
+ D7B99E972B29B37F00BE7BF2 /* ViewModel */,
+ D7B5678D2B28888F00DE63EB /* CallView.swift */,
+ D73449982BC6932A00778C56 /* MeetingWaitingRoomFragment.swift */,
+ );
+ path = Call;
+ sourceTree = "";
+ };
+ D7B99E972B29B37F00BE7BF2 /* ViewModel */ = {
+ isa = PBXGroup;
+ children = (
+ D7B99E982B29B39000BE7BF2 /* CallViewModel.swift */,
+ D734499A2BC694C900778C56 /* MeetingWaitingRoomViewModel.swift */,
+ );
+ path = ViewModel;
+ sourceTree = "";
+ };
+ D7CEE0332B7A20A400FD79B7 /* Conversations */ = {
+ isa = PBXGroup;
+ children = (
+ D7CEE0392B7A232200FD79B7 /* Fragments */,
+ D70959EF2B8DF33B0014AC0B /* Model */,
+ D7CEE0362B7A212C00FD79B7 /* ViewModel */,
+ D7CEE0342B7A210300FD79B7 /* ConversationsView.swift */,
+ );
+ path = Conversations;
+ sourceTree = "";
+ };
+ D7CEE0362B7A212C00FD79B7 /* ViewModel */ = {
+ isa = PBXGroup;
+ children = (
+ D7CEE0372B7A214F00FD79B7 /* ConversationsListViewModel.swift */,
+ D70A26EF2B7D02E6006CC8FC /* ConversationViewModel.swift */,
+ D759CB652C3FBE1D00AC35E8 /* StartConversationViewModel.swift */,
+ D70C82A62C85F5910087F43F /* ConversationForwardMessageViewModel.swift */,
+ );
+ path = ViewModel;
+ sourceTree = "";
+ };
+ D7CEE0392B7A232200FD79B7 /* Fragments */ = {
+ isa = PBXGroup;
+ children = (
+ D7CEE03A2B7A234200FD79B7 /* ConversationsFragment.swift */,
+ D7CEE03C2B7A23B200FD79B7 /* ConversationsListFragment.swift */,
+ D70A26ED2B7CF60B006CC8FC /* ConversationsListBottomSheet.swift */,
+ D70A26F12B7F5D95006CC8FC /* ConversationFragment.swift */,
+ D71968912B86369D00DF4459 /* ChatBubbleView.swift */,
+ D72A9A042B9750A1000DC093 /* UIList.swift */,
+ D759CB632C3FBD4200AC35E8 /* StartConversationFragment.swift */,
+ D7A0ACBA2C415D630043AE79 /* StartGroupConversationFragment.swift */,
+ D70C82A42C85EDC90087F43F /* ConversationForwardMessageFragment.swift */,
+ );
+ path = Fragments;
+ sourceTree = "";
+ };
+ D7D24D0C2AC1B4C700C6F35B /* Fonts */ = {
+ isa = PBXGroup;
+ children = (
+ D7D24D0F2AC1B4E800C6F35B /* NotoSans-Light.ttf */,
+ D7D24D0E2AC1B4E800C6F35B /* NotoSans-Regular.ttf */,
+ D7D24D0D2AC1B4E800C6F35B /* NotoSans-Medium.ttf */,
+ D7D24D102AC1B4E800C6F35B /* NotoSans-SemiBold.ttf */,
+ D7D24D112AC1B4E800C6F35B /* NotoSans-Bold.ttf */,
+ D7D24D122AC1B4E800C6F35B /* NotoSans-ExtraBold.ttf */,
+ );
+ path = Fonts;
+ sourceTree = "";
+ };
+ D7DA67602ACCB2D700E95002 /* Fragments */ = {
+ isa = PBXGroup;
+ children = (
+ D7DA67612ACCB2FA00E95002 /* LoginFragment.swift */,
+ D7EAACCE2AD6ED8000AA6A8A /* PermissionsFragment.swift */,
+ D7DA67632ACCB31700E95002 /* ProfileModeFragment.swift */,
+ D723432F2ACEFEF8009AA24E /* QrCodeScannerFragment.swift */,
+ D7FB55102AD447FD00A5AB15 /* RegisterFragment.swift */,
+ D748BF2B2ACD82D2004844EB /* ThirdPartySipAccountLoginFragment.swift */,
+ D748BF2D2ACD82E7004844EB /* ThirdPartySipAccountWarningFragment.swift */,
+ D714DE612C1C4636006C1F1D /* RegisterCodeConfirmationFragment.swift */,
+ );
+ path = Fragments;
+ sourceTree = "";
+ };
+/* End PBXGroup section */
+
+/* Begin PBXNativeTarget section */
+ 660AAF7A2B839271004C0FA6 /* msgNotificationService */ = {
+ isa = PBXNativeTarget;
+ buildConfigurationList = 660AAF832B839272004C0FA6 /* Build configuration list for PBXNativeTarget "msgNotificationService" */;
+ buildPhases = (
+ 660AAF772B839271004C0FA6 /* Sources */,
+ 660AAF782B839271004C0FA6 /* Frameworks */,
+ 660AAF792B839271004C0FA6 /* Resources */,
+ 6677CE082C73D71A0020FD0E /* Crashlytics */,
+ );
+ buildRules = (
+ );
+ dependencies = (
+ );
+ name = msgNotificationService;
+ productName = msgNotificationService;
+ productReference = 660AAF7B2B839271004C0FA6 /* msgNotificationService.appex */;
+ productType = "com.apple.product-type.app-extension";
+ };
+ D719ABB22ABC67BF00B41C10 /* Linphone */ = {
+ isa = PBXNativeTarget;
+ buildConfigurationList = D719ABC22ABC67BF00B41C10 /* Build configuration list for PBXNativeTarget "Linphone" */;
+ buildPhases = (
+ D719ABAF2ABC67BF00B41C10 /* Sources */,
+ D719ABB02ABC67BF00B41C10 /* Frameworks */,
+ 660AAF802B839272004C0FA6 /* Embed Foundation Extensions */,
+ D719ABB12ABC67BF00B41C10 /* Resources */,
+ D7FB55122AD53FE200A5AB15 /* Run Script */,
+ 66BF2D4B2B558A3100A5F2E3 /* Crashlytics */,
+ );
+ buildRules = (
+ );
+ dependencies = (
+ 660AAF7E2B839272004C0FA6 /* PBXTargetDependency */,
+ );
+ name = Linphone;
+ productName = Linphone;
+ productReference = D719ABB32ABC67BF00B41C10 /* Linphone.app */;
+ productType = "com.apple.product-type.application";
+ };
+/* End PBXNativeTarget section */
+
+/* Begin PBXProject section */
+ D719ABAB2ABC67BF00B41C10 /* Project object */ = {
+ isa = PBXProject;
+ attributes = {
+ BuildIndependentTargetsInParallel = 1;
+ LastSwiftUpdateCheck = 1430;
+ LastUpgradeCheck = 1430;
+ TargetAttributes = {
+ 660AAF7A2B839271004C0FA6 = {
+ CreatedOnToolsVersion = 15.0.1;
+ LastSwiftMigration = 1500;
+ };
+ D719ABB22ABC67BF00B41C10 = {
+ CreatedOnToolsVersion = 14.3.1;
+ };
+ };
+ };
+ buildConfigurationList = D719ABAE2ABC67BF00B41C10 /* Build configuration list for PBXProject "Linphone" */;
+ compatibilityVersion = "Xcode 14.0";
+ developmentRegion = en;
+ hasScannedForEncodings = 0;
+ knownRegions = (
+ en,
+ Base,
+ );
+ mainGroup = D719ABAA2ABC67BF00B41C10;
+ productRefGroup = D719ABB42ABC67BF00B41C10 /* Products */;
+ projectDirPath = "";
+ projectRoot = "";
+ targets = (
+ D719ABB22ABC67BF00B41C10 /* Linphone */,
+ 660AAF7A2B839271004C0FA6 /* msgNotificationService */,
+ );
+ };
+/* End PBXProject section */
+
+/* Begin PBXResourcesBuildPhase section */
+ 660AAF792B839271004C0FA6 /* Resources */ = {
+ isa = PBXResourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ 667E5D7F2B8E430C00EBCFC4 /* linphonerc-factory in Resources */,
+ 667E5D812B8E444E00EBCFC4 /* GoogleService-Info.plist in Resources */,
+ 66A3E5B72CAE8E5C00FCB7FA /* Localizable.xcstrings in Resources */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+ D719ABB12ABC67BF00B41C10 /* Resources */ = {
+ isa = PBXResourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ D7D24D142AC1B4E800C6F35B /* NotoSans-Regular.ttf in Resources */,
+ D7D24D182AC1B4E800C6F35B /* NotoSans-ExtraBold.ttf in Resources */,
+ D7D24D152AC1B4E800C6F35B /* NotoSans-Light.ttf in Resources */,
+ D783C77D2B1089B200622CC2 /* assistant_third_party_default_values in Resources */,
+ D7D24D162AC1B4E800C6F35B /* NotoSans-SemiBold.ttf in Resources */,
+ D7D24D172AC1B4E800C6F35B /* NotoSans-Bold.ttf in Resources */,
+ D719ABBF2ABC67BF00B41C10 /* Preview Assets.xcassets in Resources */,
+ D719ABBB2ABC67BF00B41C10 /* Assets.xcassets in Resources */,
+ D7D24D132AC1B4E800C6F35B /* NotoSans-Medium.ttf in Resources */,
+ D732A90C2B0376F500DB42BA /* linphonerc-default in Resources */,
+ D732A90D2B0376F500DB42BA /* linphonerc-factory in Resources */,
+ D783C77C2B1089B200622CC2 /* assistant_linphone_default_values in Resources */,
+ D70C93DE2AC2D0F60063CA3B /* Localizable.xcstrings in Resources */,
+ 660D8A712B517D260092694D /* GoogleService-Info.plist in Resources */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+/* End PBXResourcesBuildPhase section */
+
+/* Begin PBXShellScriptBuildPhase section */
+ 6677CE082C73D71A0020FD0E /* Crashlytics */ = {
+ isa = PBXShellScriptBuildPhase;
+ alwaysOutOfDate = 1;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ inputFileListPaths = (
+ );
+ inputPaths = (
+ "${DWARF_DSYM_FOLDER_PATH}/${DWARF_DSYM_FILE_NAME}",
+ "${DWARF_DSYM_FOLDER_PATH}/${DWARF_DSYM_FILE_NAME}/Contents/Resources/DWARF/${PRODUCT_NAME}",
+ "${DWARF_DSYM_FOLDER_PATH}/${DWARF_DSYM_FILE_NAME}/Contents/Info.plist",
+ "$(TARGET_BUILD_DIR)/$(UNLOCALIZED_RESOURCES_FOLDER_PATH)/GoogleService-Info.plist",
+ "$(TARGET_BUILD_DIR)/$(EXECUTABLE_PATH)",
+ );
+ name = Crashlytics;
+ outputFileListPaths = (
+ );
+ outputPaths = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ shellPath = /bin/sh;
+ shellScript = "val=`expr \"$GCC_PREPROCESSOR_DEFINITIONS\" : \".*USE_CRASHLYTICS=\\([0-9]*\\)\"`\nif [ $val = 1 ]; then\n ${PODS_ROOT}/FirebaseCrashlytics/run\nfi\n";
+ };
+ 66BF2D4B2B558A3100A5F2E3 /* Crashlytics */ = {
+ isa = PBXShellScriptBuildPhase;
+ alwaysOutOfDate = 1;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ inputFileListPaths = (
+ );
+ inputPaths = (
+ "${DWARF_DSYM_FOLDER_PATH}/${DWARF_DSYM_FILE_NAME}",
+ "${DWARF_DSYM_FOLDER_PATH}/${DWARF_DSYM_FILE_NAME}/Contents/Resources/DWARF/${PRODUCT_NAME}",
+ "${DWARF_DSYM_FOLDER_PATH}/${DWARF_DSYM_FILE_NAME}/Contents/Info.plist",
+ "$(TARGET_BUILD_DIR)/$(UNLOCALIZED_RESOURCES_FOLDER_PATH)/GoogleService-Info.plist",
+ "$(TARGET_BUILD_DIR)/$(EXECUTABLE_PATH)",
+ );
+ name = Crashlytics;
+ outputFileListPaths = (
+ );
+ outputPaths = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ shellPath = /bin/sh;
+ shellScript = "val=`expr \"$GCC_PREPROCESSOR_DEFINITIONS\" : \".*USE_CRASHLYTICS=\\([0-9]*\\)\"`\nif [ $val = 1 ]; then\n ${PODS_ROOT}/FirebaseCrashlytics/run\nfi\n\n";
+ };
+ D7FB55122AD53FE200A5AB15 /* Run Script */ = {
+ isa = PBXShellScriptBuildPhase;
+ alwaysOutOfDate = 1;
+ buildActionMask = 12;
+ files = (
+ );
+ inputFileListPaths = (
+ );
+ inputPaths = (
+ );
+ name = "Run Script";
+ outputFileListPaths = (
+ );
+ outputPaths = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ shellPath = /bin/sh;
+ shellScript = "if [[ \"$(uname -m)\" == arm64 ]]; then\n export PATH=\"/opt/homebrew/bin:$PATH\"\nfi\n\nif which swiftlint > /dev/null; then\n swiftlint\nelse\n echo \"warning: SwiftLint not installed, download from https://github.com/realm/SwiftLint\"\nfi\n";
+ };
+/* End PBXShellScriptBuildPhase section */
+
+/* Begin PBXSourcesBuildPhase section */
+ 660AAF772B839271004C0FA6 /* Sources */ = {
+ isa = PBXSourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ 66FBFC482B83B8CC00BC6AB1 /* Log.swift in Sources */,
+ 6691CA7E2B839C2D00B2A7B8 /* NotificationService.swift in Sources */,
+ 66FBFC492B83BD2400BC6AB1 /* ConfigExtension.swift in Sources */,
+ 66FBFC4A2B83BD3300BC6AB1 /* FileUtils.swift in Sources */,
+ 66FBFC4B2B83BD7B00BC6AB1 /* CoreExtension.swift in Sources */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+ D719ABAF2ABC67BF00B41C10 /* Sources */ = {
+ isa = PBXSourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ D7C3650E2AF15BF200FE6142 /* PhotoPicker.swift in Sources */,
+ D7ADF6002AFE356400212231 /* Avatar.swift in Sources */,
+ D7CEE03B2B7A234200FD79B7 /* ConversationsFragment.swift in Sources */,
+ D71707202AC5989C0037746F /* TextExtension.swift in Sources */,
+ 66C491F92B24D25B00CEA16D /* ConfigExtension.swift in Sources */,
+ D719ABB92ABC67BF00B41C10 /* ContentView.swift in Sources */,
+ D71FCA832AE14D6E00D2E43E /* ContactFragment.swift in Sources */,
+ D7C3650C2AF0084000FE6142 /* EditContactViewModel.swift in Sources */,
+ D78E062A2BEA698E00CE3783 /* MediaEncryptedSheetBottomSheet.swift in Sources */,
+ D7B5678E2B28888F00DE63EB /* CallView.swift in Sources */,
+ D71A0E192B485ADF0002C6CD /* ViewExtension.swift in Sources */,
+ D759CB642C3FBD4200AC35E8 /* StartConversationFragment.swift in Sources */,
+ 66FDB7812C7C689A00561566 /* EventEditViewController.swift in Sources */,
+ D750D3392AD3E6EE00EC99C5 /* PopupLoadingView.swift in Sources */,
+ D7E6D0492AE933AD00A57AAF /* FavoriteContactsListFragment.swift in Sources */,
+ 662B69DB2B25DE25007118BF /* ProviderDelegate.swift in Sources */,
+ D706BA822ADD72D100278F45 /* DeviceRotationViewModifier.swift in Sources */,
+ D732A9132B04C7A300DB42BA /* HistoryListFragment.swift in Sources */,
+ D7C2DA1D2CA44DE400A2441B /* EventModel.swift in Sources */,
+ D719ABC92ABC6FD700B41C10 /* CoreContext.swift in Sources */,
+ D70A26F22B7F5D95006CC8FC /* ConversationFragment.swift in Sources */,
+ 66C491FD2B24D36500CEA16D /* AudioRouteUtils.swift in Sources */,
+ D70959F12B8DF3EC0014AC0B /* ConversationModel.swift in Sources */,
+ C6DC4E3D2C199C4E009096FD /* BundleExtenion.swift in Sources */,
+ D7EAACCF2AD6ED8000AA6A8A /* PermissionsFragment.swift in Sources */,
+ D759CB662C3FBE1D00AC35E8 /* StartConversationViewModel.swift in Sources */,
+ D777DBB32AE12C5900565A99 /* ContactsManager.swift in Sources */,
+ D720E6AD2BAD822000DDFD87 /* ParticipantModel.swift in Sources */,
+ D796F2002B0BB61A0041115F /* ToastViewModel.swift in Sources */,
+ D7C3650A2AF001C300FE6142 /* EditContactFragment.swift in Sources */,
+ D7A03FBD2ACC2DB60081A588 /* ContactsView.swift in Sources */,
+ 66E50A492BD12B2300AD61CA /* MeetingsView.swift in Sources */,
+ D719ABCF2ABC779A00B41C10 /* AccountLoginViewModel.swift in Sources */,
+ D717630D2BD7BD0E00464097 /* ParticipantsListFragment.swift in Sources */,
+ D71556362C297DB1009A8CEF /* StartGroupCallFragment.swift in Sources */,
+ C6A5A9452C10B6270070FEA4 /* OIDAuthStateExtension.swift in Sources */,
+ D732A90F2B04C3B400DB42BA /* HistoryFragment.swift in Sources */,
+ D79622342B1DFE600037EACD /* DialerBottomSheet.swift in Sources */,
+ C67586B02C09F247002E77BF /* URIHandler.swift in Sources */,
+ C62817282C1B389700DBA646 /* SideMenuAccountRow.swift in Sources */,
+ C60E8F192C0F649200A06DB8 /* UIApplicationExtension.swift in Sources */,
+ D78290BB2ADD40B2004AA85C /* ContactViewModel.swift in Sources */,
+ C67586B52C09F617002E77BF /* SingleSignOnManager.swift in Sources */,
+ D7F4D9CB2B5FD27200CDCD76 /* CallsListFragment.swift in Sources */,
+ C6A5A9482C10B6A30070FEA4 /* AuthState.swift in Sources */,
+ D72992392ADD7F68003AF125 /* HistoryContactFragment.swift in Sources */,
+ D70A26F02B7D02E6006CC8FC /* ConversationViewModel.swift in Sources */,
+ 6613A0AE2BAEB7DF008923A4 /* MeetingFragment.swift in Sources */,
+ D7B5066D2AEFA9B900CEB4E9 /* ContactInnerFragment.swift in Sources */,
+ D7E6D04D2AEBD77600A57AAF /* CustomBottomSheet.swift in Sources */,
+ C62817302C1C3DCC00DBA646 /* AccountModel.swift in Sources */,
+ D73449992BC6932A00778C56 /* MeetingWaitingRoomFragment.swift in Sources */,
+ D7C48DF42AFA66F900D938CB /* EditContactController.swift in Sources */,
+ C628172E2C1C3A3600DBA646 /* AccountExtension.swift in Sources */,
+ 66C491FF2B24D4AC00CEA16D /* FileUtils.swift in Sources */,
+ C62817322C1C400A00DBA646 /* StringExtension.swift in Sources */,
+ D74C9D012ACB098C0021626A /* PermissionManager.swift in Sources */,
+ D7B99E992B29B39000BE7BF2 /* CallViewModel.swift in Sources */,
+ D7702EF22AC7205000557C00 /* WelcomeView.swift in Sources */,
+ D732A9152B04C7FE00DB42BA /* HistoryListViewModel.swift in Sources */,
+ 6646A7A32BB2E224006B842A /* ScheduleMeetingFragment.swift in Sources */,
+ D71FCA7F2AE1397200D2E43E /* ContactsListViewModel.swift in Sources */,
+ D71FCA812AE14CFC00D2E43E /* ContactsListFragment.swift in Sources */,
+ D734499B2BC694C900778C56 /* MeetingWaitingRoomViewModel.swift in Sources */,
+ D719ABB72ABC67BF00B41C10 /* LinphoneApp.swift in Sources */,
+ 66E50A4B2BD12B7800AD61CA /* MeetingsFragment.swift in Sources */,
+ 66162A202BDFC2F900DCE913 /* AddParticipantsViewModel.swift in Sources */,
+ D732A91B2B061BD900DB42BA /* HistoryListBottomSheet.swift in Sources */,
+ D72250632ADE9615008FB426 /* HistoryViewModel.swift in Sources */,
+ D78E06302BEA6A4A00CE3783 /* ChangeLayoutBottomSheet.swift in Sources */,
+ 66E56BC92BA4A6D7006CE56F /* MeetingsListViewModel.swift in Sources */,
+ D726E4392B16440C0083C415 /* ContactAvatarModel.swift in Sources */,
+ C67586AE2C09F23C002E77BF /* URLExtension.swift in Sources */,
+ 6613A0B02BAEB7F4008923A4 /* MeetingsListFragment.swift in Sources */,
+ D76005F62B0798B00054B79A /* IntExtension.swift in Sources */,
+ D79F2D0A2C47F4BF0038FA07 /* TouchFeedback.swift in Sources */,
+ D78E06282BE3811D00CE3783 /* CallStatsModel.swift in Sources */,
+ D7E6D0512AEBDBD500A57AAF /* ContactsListBottomSheet.swift in Sources */,
+ D75759322B56D40900E7AC10 /* ZRTPPopup.swift in Sources */,
+ D78E062E2BEA69F400CE3783 /* AudioRouteBottomSheet.swift in Sources */,
+ D7A0ACBB2C415D630043AE79 /* StartGroupConversationFragment.swift in Sources */,
+ D7A2EDD62AC18115005D90FC /* SharedMainViewModel.swift in Sources */,
+ D7A03FC62ACC458A0081A588 /* SplashScreen.swift in Sources */,
+ D70C82A52C85EDCA0087F43F /* ConversationForwardMessageFragment.swift in Sources */,
+ D7E6ADF52B9876ED0009A2BC /* Attachment.swift in Sources */,
+ D7A03FC02ACC2E390081A588 /* HistoryView.swift in Sources */,
+ D748BF2E2ACD82E7004844EB /* ThirdPartySipAccountWarningFragment.swift in Sources */,
+ 6613A0B42BAEBE3F008923A4 /* MeetingViewModel.swift in Sources */,
+ D7173EBE2B7A5C0A00BCC481 /* LinphoneUtils.swift in Sources */,
+ 66C492012B24DB6900CEA16D /* Log.swift in Sources */,
+ C6A5A9432C10B5ED0070FEA4 /* DecodableExtension.swift in Sources */,
+ D714035B2BE11E00004BD8CA /* CallMediaEncryptionModel.swift in Sources */,
+ D748BF2C2ACD82D2004844EB /* ThirdPartySipAccountLoginFragment.swift in Sources */,
+ D7CEE0382B7A214F00FD79B7 /* ConversationsListViewModel.swift in Sources */,
+ D74C9CF82ACACECE0021626A /* WelcomePage1Fragment.swift in Sources */,
+ 66E56BCE2BA9A1F8006CE56F /* MeetingModel.swift in Sources */,
+ D7E6D0552AEBFCCE00A57AAF /* ContactsInnerFragment.swift in Sources */,
+ D732A9092AFD235500DB42BA /* ShareSheetController.swift in Sources */,
+ D72343362AD037AF009AA24E /* ToastView.swift in Sources */,
+ D7FB55112AD447FD00A5AB15 /* RegisterFragment.swift in Sources */,
+ D7E6ADF32B9875C20009A2BC /* Message.swift in Sources */,
+ D7C48DF62AFCDF4700D938CB /* ContactInnerActionsFragment.swift in Sources */,
+ D72343322ACEFF58009AA24E /* QRScannerController.swift in Sources */,
+ 662B69D92B25DE18007118BF /* TelecomManager.swift in Sources */,
+ D72343342ACEFFC3009AA24E /* QRScanner.swift in Sources */,
+ 66246C6A2C622AE900973E97 /* TimeZoneExtension.swift in Sources */,
+ 66C491FB2B24D32600CEA16D /* CoreExtension.swift in Sources */,
+ D7F5F6412C359F3B007FCF2F /* SipAddressesPopup.swift in Sources */,
+ D72A9A052B9750A1000DC093 /* UIList.swift in Sources */,
+ D726E43D2B19E4FE0083C415 /* StartCallFragment.swift in Sources */,
+ 66E56BCC2BA9A1E0006CE56F /* MeetingsListItemModel.swift in Sources */,
+ D7B99E9B2B29F7C300BE7BF2 /* ActivityIndicator.swift in Sources */,
+ 66F626B22BCEBB86003E2DEC /* AddParticipantsFragment.swift in Sources */,
+ D72343302ACEFEF8009AA24E /* QrCodeScannerFragment.swift in Sources */,
+ 66F08C892C2AFEF700D9AE2F /* MeetingsListBottomSheet.swift in Sources */,
+ D726E43F2B19E56F0083C415 /* StartCallViewModel.swift in Sources */,
+ D70A26EE2B7CF60B006CC8FC /* ConversationsListBottomSheet.swift in Sources */,
+ D7D1698C2AE66FA500109A5C /* MagicSearchSingleton.swift in Sources */,
+ D714DE602C1B3B34006C1F1D /* RegisterViewModel.swift in Sources */,
+ D70C82A72C85F5910087F43F /* ConversationForwardMessageViewModel.swift in Sources */,
+ D74DA0122C047F0700A8561D /* HistoryModel.swift in Sources */,
+ D72250692ADFBF2D008FB426 /* SideMenu.swift in Sources */,
+ C6DC4E3F2C19C289009096FD /* SideMenuEntry.swift in Sources */,
+ D714DE622C1C4636006C1F1D /* RegisterCodeConfirmationFragment.swift in Sources */,
+ D7CEE0352B7A210300FD79B7 /* ConversationsView.swift in Sources */,
+ D717071E2AC5922E0037746F /* ColorExtension.swift in Sources */,
+ D71968922B86369D00DF4459 /* ChatBubbleView.swift in Sources */,
+ D78290B82ADD3910004AA85C /* ContactsFragment.swift in Sources */,
+ D7DA67642ACCB31700E95002 /* ProfileModeFragment.swift in Sources */,
+ D7CEE03D2B7A23B200FD79B7 /* ConversationsListFragment.swift in Sources */,
+ D74C9CFC2ACACF370021626A /* WelcomePage3Fragment.swift in Sources */,
+ D77A080E2CB6BCAF0095D589 /* MessageConferenceInfo.swift in Sources */,
+ C6A5A9412C10B5D50070FEA4 /* EncodableExtension.swift in Sources */,
+ D719ABCC2ABC769C00B41C10 /* AssistantView.swift in Sources */,
+ C62817342C1C7C7400DBA646 /* HelpView.swift in Sources */,
+ D78E062C2BEA69BC00CE3783 /* CallStatisticsSheetBottomSheet.swift in Sources */,
+ D7C365082AEFAB7F00FE6142 /* ContactListBottomSheet.swift in Sources */,
+ D7E6D04B2AE9347D00A57AAF /* FavoriteContactsListViewModel.swift in Sources */,
+ D74C9CFA2ACACF2D0021626A /* WelcomePage2Fragment.swift in Sources */,
+ D74C9CFF2ACAEC5E0021626A /* PopupView.swift in Sources */,
+ D7DA67622ACCB2FA00E95002 /* LoginFragment.swift in Sources */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+/* End PBXSourcesBuildPhase section */
+
+/* Begin PBXTargetDependency section */
+ 660AAF7E2B839272004C0FA6 /* PBXTargetDependency */ = {
+ isa = PBXTargetDependency;
+ target = 660AAF7A2B839271004C0FA6 /* msgNotificationService */;
+ targetProxy = 660AAF7D2B839272004C0FA6 /* PBXContainerItemProxy */;
+ };
+/* End PBXTargetDependency section */
+
+/* Begin XCBuildConfiguration section */
+ 660AAF812B839272004C0FA6 /* Debug */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
+ CLANG_ENABLE_MODULES = YES;
+ CODE_SIGN_ENTITLEMENTS = msgNotificationService/msgNotificationService.entitlements;
+ CODE_SIGN_IDENTITY = "Apple Development";
+ CODE_SIGN_STYLE = Automatic;
+ CURRENT_PROJECT_VERSION = 56;
+ DEVELOPMENT_TEAM = Z2V957B3D6;
+ ENABLE_USER_SCRIPT_SANDBOXING = NO;
+ GCC_C_LANGUAGE_STANDARD = gnu17;
+ GCC_PREPROCESSOR_DEFINITIONS = (
+ "$(inherited)",
+ "DEBUG=1",
+ "USE_CRASHLYTICS=1",
+ );
+ GENERATE_INFOPLIST_FILE = YES;
+ INFOPLIST_FILE = msgNotificationService/Info.plist;
+ INFOPLIST_KEY_CFBundleDisplayName = msgNotificationService;
+ INFOPLIST_KEY_NSHumanReadableCopyright = "";
+ IPHONEOS_DEPLOYMENT_TARGET = 15.0;
+ LD_RUNPATH_SEARCH_PATHS = (
+ "$(inherited)",
+ "@executable_path/Frameworks",
+ "@executable_path/../../Frameworks",
+ );
+ LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
+ MARKETING_VERSION = 6.0.0;
+ OTHER_SWIFT_FLAGS = "$(inherited) -DUSE_CRASHLYTICS";
+ PRODUCT_BUNDLE_IDENTIFIER = org.linphone.phone.msgNotificationService;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ PROVISIONING_PROFILE_SPECIFIER = "";
+ SDKROOT = iphoneos;
+ SKIP_INSTALL = YES;
+ SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
+ SWIFT_EMIT_LOC_STRINGS = YES;
+ SWIFT_OPTIMIZATION_LEVEL = "-Onone";
+ SWIFT_VERSION = 5.0;
+ TARGETED_DEVICE_FAMILY = "1,2";
+ };
+ name = Debug;
+ };
+ 660AAF822B839272004C0FA6 /* Release */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
+ CLANG_ENABLE_MODULES = YES;
+ CODE_SIGN_ENTITLEMENTS = msgNotificationService/msgNotificationService.entitlements;
+ CODE_SIGN_IDENTITY = "Apple Development";
+ CODE_SIGN_STYLE = Automatic;
+ CURRENT_PROJECT_VERSION = 56;
+ DEVELOPMENT_TEAM = Z2V957B3D6;
+ ENABLE_USER_SCRIPT_SANDBOXING = NO;
+ GCC_C_LANGUAGE_STANDARD = gnu17;
+ GCC_PREPROCESSOR_DEFINITIONS = (
+ "$(inherited)",
+ "USE_CRASHLYTICS=1",
+ );
+ GENERATE_INFOPLIST_FILE = YES;
+ INFOPLIST_FILE = msgNotificationService/Info.plist;
+ INFOPLIST_KEY_CFBundleDisplayName = msgNotificationService;
+ INFOPLIST_KEY_NSHumanReadableCopyright = "";
+ IPHONEOS_DEPLOYMENT_TARGET = 15.0;
+ LD_RUNPATH_SEARCH_PATHS = (
+ "$(inherited)",
+ "@executable_path/Frameworks",
+ "@executable_path/../../Frameworks",
+ );
+ LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
+ MARKETING_VERSION = 6.0.0;
+ OTHER_SWIFT_FLAGS = "$(inherited) -DUSE_CRASHLYTICS";
+ PRODUCT_BUNDLE_IDENTIFIER = org.linphone.phone.msgNotificationService;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ PROVISIONING_PROFILE_SPECIFIER = "";
+ SDKROOT = iphoneos;
+ SKIP_INSTALL = YES;
+ SWIFT_EMIT_LOC_STRINGS = YES;
+ SWIFT_VERSION = 5.0;
+ TARGETED_DEVICE_FAMILY = "1,2";
+ VALIDATE_PRODUCT = YES;
+ };
+ name = Release;
+ };
+ D719ABC02ABC67BF00B41C10 /* Debug */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ALWAYS_SEARCH_USER_PATHS = NO;
+ ASSETCATALOG_COMPILER_GENERATE_ASSET_SYMBOLS = NO;
+ CLANG_ANALYZER_NONNULL = YES;
+ CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
+ CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
+ CLANG_ENABLE_MODULES = YES;
+ CLANG_ENABLE_OBJC_ARC = YES;
+ CLANG_ENABLE_OBJC_WEAK = YES;
+ CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
+ CLANG_WARN_BOOL_CONVERSION = YES;
+ CLANG_WARN_COMMA = YES;
+ CLANG_WARN_CONSTANT_CONVERSION = YES;
+ CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
+ CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
+ CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
+ CLANG_WARN_EMPTY_BODY = YES;
+ CLANG_WARN_ENUM_CONVERSION = YES;
+ CLANG_WARN_INFINITE_RECURSION = YES;
+ CLANG_WARN_INT_CONVERSION = YES;
+ CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
+ CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
+ CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
+ CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
+ CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
+ CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
+ CLANG_WARN_STRICT_PROTOTYPES = YES;
+ CLANG_WARN_SUSPICIOUS_MOVE = YES;
+ CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
+ CLANG_WARN_UNREACHABLE_CODE = YES;
+ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
+ COPY_PHASE_STRIP = NO;
+ DEAD_CODE_STRIPPING = YES;
+ DEBUG_INFORMATION_FORMAT = dwarf;
+ ENABLE_STRICT_OBJC_MSGSEND = YES;
+ ENABLE_TESTABILITY = YES;
+ GCC_C_LANGUAGE_STANDARD = gnu11;
+ GCC_DYNAMIC_NO_PIC = NO;
+ GCC_NO_COMMON_BLOCKS = YES;
+ GCC_OPTIMIZATION_LEVEL = 0;
+ GCC_PREPROCESSOR_DEFINITIONS = (
+ "DEBUG=1",
+ "$(inherited)",
+ );
+ GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
+ GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
+ GCC_WARN_UNDECLARED_SELECTOR = YES;
+ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
+ GCC_WARN_UNUSED_FUNCTION = YES;
+ GCC_WARN_UNUSED_VARIABLE = YES;
+ IPHONEOS_DEPLOYMENT_TARGET = 15.0;
+ MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
+ MTL_FAST_MATH = YES;
+ ONLY_ACTIVE_ARCH = YES;
+ SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
+ SWIFT_OPTIMIZATION_LEVEL = "-Onone";
+ };
+ name = Debug;
+ };
+ D719ABC12ABC67BF00B41C10 /* Release */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ALWAYS_SEARCH_USER_PATHS = NO;
+ ASSETCATALOG_COMPILER_GENERATE_ASSET_SYMBOLS = NO;
+ CLANG_ANALYZER_NONNULL = YES;
+ CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
+ CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
+ CLANG_ENABLE_MODULES = YES;
+ CLANG_ENABLE_OBJC_ARC = YES;
+ CLANG_ENABLE_OBJC_WEAK = YES;
+ CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
+ CLANG_WARN_BOOL_CONVERSION = YES;
+ CLANG_WARN_COMMA = YES;
+ CLANG_WARN_CONSTANT_CONVERSION = YES;
+ CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
+ CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
+ CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
+ CLANG_WARN_EMPTY_BODY = YES;
+ CLANG_WARN_ENUM_CONVERSION = YES;
+ CLANG_WARN_INFINITE_RECURSION = YES;
+ CLANG_WARN_INT_CONVERSION = YES;
+ CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
+ CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
+ CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
+ CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
+ CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
+ CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
+ CLANG_WARN_STRICT_PROTOTYPES = YES;
+ CLANG_WARN_SUSPICIOUS_MOVE = YES;
+ CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
+ CLANG_WARN_UNREACHABLE_CODE = YES;
+ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
+ COPY_PHASE_STRIP = NO;
+ DEAD_CODE_STRIPPING = YES;
+ DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
+ ENABLE_NS_ASSERTIONS = NO;
+ ENABLE_STRICT_OBJC_MSGSEND = YES;
+ GCC_C_LANGUAGE_STANDARD = gnu11;
+ GCC_NO_COMMON_BLOCKS = YES;
+ GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
+ GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
+ GCC_WARN_UNDECLARED_SELECTOR = YES;
+ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
+ GCC_WARN_UNUSED_FUNCTION = YES;
+ GCC_WARN_UNUSED_VARIABLE = YES;
+ IPHONEOS_DEPLOYMENT_TARGET = 15.0;
+ MTL_ENABLE_DEBUG_INFO = NO;
+ MTL_FAST_MATH = YES;
+ SWIFT_COMPILATION_MODE = wholemodule;
+ SWIFT_OPTIMIZATION_LEVEL = "-O";
+ };
+ name = Release;
+ };
+ D719ABC32ABC67BF00B41C10 /* Debug */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
+ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
+ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
+ ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = NO;
+ CODE_SIGN_ENTITLEMENTS = Linphone/Linphone.entitlements;
+ CODE_SIGN_STYLE = Automatic;
+ CURRENT_PROJECT_VERSION = 56;
+ DEAD_CODE_STRIPPING = YES;
+ DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
+ DEVELOPMENT_ASSET_PATHS = "\"Linphone/Preview Content\"";
+ DEVELOPMENT_TEAM = Z2V957B3D6;
+ ENABLE_HARDENED_RUNTIME = YES;
+ ENABLE_PREVIEWS = YES;
+ GCC_PREPROCESSOR_DEFINITIONS = (
+ "$(inherited)",
+ "DEBUG=1",
+ "USE_CRASHLYTICS=1",
+ );
+ GENERATE_INFOPLIST_FILE = YES;
+ INFOPLIST_FILE = Linphone/Info.plist;
+ INFOPLIST_KEY_NSCameraUsageDescription = "Camera usage is required for video VOIP calls";
+ INFOPLIST_KEY_NSContactsUsageDescription = "Make calls with your friends";
+ INFOPLIST_KEY_NSMicrophoneUsageDescription = "Microphone usage is required for VOIP calls";
+ INFOPLIST_KEY_NSPhotoLibraryUsageDescription = "Share photos with your friends and customize avatars";
+ INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
+ "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES;
+ "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES;
+ INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
+ "INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES;
+ "INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphonesimulator*]" = YES;
+ "INFOPLIST_KEY_UILaunchScreen_Generation[sdk=iphoneos*]" = YES;
+ "INFOPLIST_KEY_UILaunchScreen_Generation[sdk=iphonesimulator*]" = YES;
+ INFOPLIST_KEY_UIStatusBarStyle = UIStatusBarStyleDefault;
+ "INFOPLIST_KEY_UIStatusBarStyle[sdk=iphoneos*]" = UIStatusBarStyleDefault;
+ "INFOPLIST_KEY_UIStatusBarStyle[sdk=iphonesimulator*]" = UIStatusBarStyleDefault;
+ INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait";
+ INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown";
+ INFOPLIST_KEY_UIUserInterfaceStyle = Light;
+ IPHONEOS_DEPLOYMENT_TARGET = 15.0;
+ LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
+ "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
+ MACOSX_DEPLOYMENT_TARGET = 13.3;
+ MARKETING_VERSION = 6.0.0;
+ OTHER_SWIFT_FLAGS = "$(inherited) -DUSE_CRASHLYTICS";
+ PRODUCT_BUNDLE_IDENTIFIER = org.linphone.phone;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ SDKROOT = iphoneos;
+ SUPPORTED_PLATFORMS = "iphonesimulator iphoneos";
+ SWIFT_EMIT_LOC_STRINGS = YES;
+ SWIFT_VERSION = 5.0;
+ TARGETED_DEVICE_FAMILY = "1,2";
+ };
+ name = Debug;
+ };
+ D719ABC42ABC67BF00B41C10 /* Release */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
+ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
+ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
+ ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = NO;
+ CODE_SIGN_ENTITLEMENTS = Linphone/Linphone.entitlements;
+ CODE_SIGN_STYLE = Automatic;
+ CURRENT_PROJECT_VERSION = 56;
+ DEAD_CODE_STRIPPING = YES;
+ DEVELOPMENT_ASSET_PATHS = "\"Linphone/Preview Content\"";
+ DEVELOPMENT_TEAM = Z2V957B3D6;
+ ENABLE_HARDENED_RUNTIME = YES;
+ ENABLE_PREVIEWS = YES;
+ GCC_PREPROCESSOR_DEFINITIONS = (
+ "$(inherited)",
+ "USE_CRASHLYTICS=1",
+ );
+ GENERATE_INFOPLIST_FILE = YES;
+ INFOPLIST_FILE = Linphone/Info.plist;
+ INFOPLIST_KEY_NSCameraUsageDescription = "Camera usage is required for video VOIP calls";
+ INFOPLIST_KEY_NSContactsUsageDescription = "Make calls with your friends";
+ INFOPLIST_KEY_NSMicrophoneUsageDescription = "Microphone usage is required for VOIP calls";
+ INFOPLIST_KEY_NSPhotoLibraryUsageDescription = "Share photos with your friends and customize avatars";
+ INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
+ "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES;
+ "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES;
+ INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
+ "INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES;
+ "INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphonesimulator*]" = YES;
+ "INFOPLIST_KEY_UILaunchScreen_Generation[sdk=iphoneos*]" = YES;
+ "INFOPLIST_KEY_UILaunchScreen_Generation[sdk=iphonesimulator*]" = YES;
+ INFOPLIST_KEY_UIStatusBarStyle = UIStatusBarStyleDefault;
+ "INFOPLIST_KEY_UIStatusBarStyle[sdk=iphoneos*]" = UIStatusBarStyleDefault;
+ "INFOPLIST_KEY_UIStatusBarStyle[sdk=iphonesimulator*]" = UIStatusBarStyleDefault;
+ INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait";
+ INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown";
+ INFOPLIST_KEY_UIUserInterfaceStyle = Light;
+ IPHONEOS_DEPLOYMENT_TARGET = 15.0;
+ LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
+ "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
+ MACOSX_DEPLOYMENT_TARGET = 13.3;
+ MARKETING_VERSION = 6.0.0;
+ OTHER_SWIFT_FLAGS = "$(inherited) -DUSE_CRASHLYTICS";
+ PRODUCT_BUNDLE_IDENTIFIER = org.linphone.phone;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ SDKROOT = iphoneos;
+ SUPPORTED_PLATFORMS = "iphonesimulator iphoneos";
+ SWIFT_EMIT_LOC_STRINGS = YES;
+ SWIFT_VERSION = 5.0;
+ TARGETED_DEVICE_FAMILY = "1,2";
+ };
+ name = Release;
+ };
+/* End XCBuildConfiguration section */
+
+/* Begin XCConfigurationList section */
+ 660AAF832B839272004C0FA6 /* Build configuration list for PBXNativeTarget "msgNotificationService" */ = {
+ isa = XCConfigurationList;
+ buildConfigurations = (
+ 660AAF812B839272004C0FA6 /* Debug */,
+ 660AAF822B839272004C0FA6 /* Release */,
+ );
+ defaultConfigurationIsVisible = 0;
+ defaultConfigurationName = Release;
+ };
+ D719ABAE2ABC67BF00B41C10 /* Build configuration list for PBXProject "Linphone" */ = {
+ isa = XCConfigurationList;
+ buildConfigurations = (
+ D719ABC02ABC67BF00B41C10 /* Debug */,
+ D719ABC12ABC67BF00B41C10 /* Release */,
+ );
+ defaultConfigurationIsVisible = 0;
+ defaultConfigurationName = Release;
+ };
+ D719ABC22ABC67BF00B41C10 /* Build configuration list for PBXNativeTarget "Linphone" */ = {
+ isa = XCConfigurationList;
+ buildConfigurations = (
+ D719ABC32ABC67BF00B41C10 /* Debug */,
+ D719ABC42ABC67BF00B41C10 /* Release */,
+ );
+ defaultConfigurationIsVisible = 0;
+ defaultConfigurationName = Release;
+ };
+/* End XCConfigurationList section */
+ };
+ rootObject = D719ABAB2ABC67BF00B41C10 /* Project object */;
+}
diff --git a/Linphone.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/Linphone.xcodeproj/project.xcworkspace/contents.xcworkspacedata
new file mode 100644
index 000000000..919434a62
--- /dev/null
+++ b/Linphone.xcodeproj/project.xcworkspace/contents.xcworkspacedata
@@ -0,0 +1,7 @@
+
+
+
+
+
diff --git a/Linphone.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/Linphone.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist
new file mode 100644
index 000000000..18d981003
--- /dev/null
+++ b/Linphone.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist
@@ -0,0 +1,8 @@
+
+
+
+
+ IDEDidComputeMac32BitWarning
+
+
+
diff --git a/Linphone.xcodeproj/xcshareddata/xcschemes/Linphone.xcscheme b/Linphone.xcodeproj/xcshareddata/xcschemes/Linphone.xcscheme
new file mode 100644
index 000000000..f5986b566
--- /dev/null
+++ b/Linphone.xcodeproj/xcshareddata/xcschemes/Linphone.xcscheme
@@ -0,0 +1,77 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Linphone/Assets.xcassets/AccentColor.colorset/Contents.json b/Linphone/Assets.xcassets/AccentColor.colorset/Contents.json
new file mode 100644
index 000000000..eb8789700
--- /dev/null
+++ b/Linphone/Assets.xcassets/AccentColor.colorset/Contents.json
@@ -0,0 +1,11 @@
+{
+ "colors" : [
+ {
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Linphone/Assets.xcassets/AppIcon.appiconset/1024.png b/Linphone/Assets.xcassets/AppIcon.appiconset/1024.png
new file mode 100644
index 000000000..7b3df4579
Binary files /dev/null and b/Linphone/Assets.xcassets/AppIcon.appiconset/1024.png differ
diff --git a/Linphone/Assets.xcassets/AppIcon.appiconset/Contents.json b/Linphone/Assets.xcassets/AppIcon.appiconset/Contents.json
new file mode 100644
index 000000000..eab818e5a
--- /dev/null
+++ b/Linphone/Assets.xcassets/AppIcon.appiconset/Contents.json
@@ -0,0 +1,64 @@
+{
+ "images" : [
+ {
+ "filename" : "1024.png",
+ "idiom" : "universal",
+ "platform" : "ios",
+ "size" : "1024x1024"
+ },
+ {
+ "idiom" : "mac",
+ "scale" : "1x",
+ "size" : "16x16"
+ },
+ {
+ "idiom" : "mac",
+ "scale" : "2x",
+ "size" : "16x16"
+ },
+ {
+ "idiom" : "mac",
+ "scale" : "1x",
+ "size" : "32x32"
+ },
+ {
+ "idiom" : "mac",
+ "scale" : "2x",
+ "size" : "32x32"
+ },
+ {
+ "idiom" : "mac",
+ "scale" : "1x",
+ "size" : "128x128"
+ },
+ {
+ "idiom" : "mac",
+ "scale" : "2x",
+ "size" : "128x128"
+ },
+ {
+ "idiom" : "mac",
+ "scale" : "1x",
+ "size" : "256x256"
+ },
+ {
+ "idiom" : "mac",
+ "scale" : "2x",
+ "size" : "256x256"
+ },
+ {
+ "idiom" : "mac",
+ "scale" : "1x",
+ "size" : "512x512"
+ },
+ {
+ "idiom" : "mac",
+ "scale" : "2x",
+ "size" : "512x512"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Linphone/Assets.xcassets/Contents.json b/Linphone/Assets.xcassets/Contents.json
new file mode 100644
index 000000000..73c00596a
--- /dev/null
+++ b/Linphone/Assets.xcassets/Contents.json
@@ -0,0 +1,6 @@
+{
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Linphone/Assets.xcassets/address-book.imageset/Contents.json b/Linphone/Assets.xcassets/address-book.imageset/Contents.json
new file mode 100644
index 000000000..7e7aa5f15
--- /dev/null
+++ b/Linphone/Assets.xcassets/address-book.imageset/Contents.json
@@ -0,0 +1,21 @@
+{
+ "images" : [
+ {
+ "filename" : "address-book.svg",
+ "idiom" : "universal",
+ "scale" : "1x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "2x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "3x"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Linphone/Assets.xcassets/address-book.imageset/address-book.svg b/Linphone/Assets.xcassets/address-book.imageset/address-book.svg
new file mode 100644
index 000000000..11cabcde0
--- /dev/null
+++ b/Linphone/Assets.xcassets/address-book.imageset/address-book.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/Linphone/Assets.xcassets/arrow-bend-up-left-bold.imageset/Contents.json b/Linphone/Assets.xcassets/arrow-bend-up-left-bold.imageset/Contents.json
new file mode 100644
index 000000000..43dd1a3fe
--- /dev/null
+++ b/Linphone/Assets.xcassets/arrow-bend-up-left-bold.imageset/Contents.json
@@ -0,0 +1,21 @@
+{
+ "images" : [
+ {
+ "filename" : "arrow-bend-up-left-bold.svg",
+ "idiom" : "universal",
+ "scale" : "1x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "2x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "3x"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Linphone/Assets.xcassets/arrow-bend-up-left-bold.imageset/arrow-bend-up-left-bold.svg b/Linphone/Assets.xcassets/arrow-bend-up-left-bold.imageset/arrow-bend-up-left-bold.svg
new file mode 100644
index 000000000..e22cf2edb
--- /dev/null
+++ b/Linphone/Assets.xcassets/arrow-bend-up-left-bold.imageset/arrow-bend-up-left-bold.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/Linphone/Assets.xcassets/arrow-bend-up-right-bold.imageset/Contents.json b/Linphone/Assets.xcassets/arrow-bend-up-right-bold.imageset/Contents.json
new file mode 100644
index 000000000..48d90733f
--- /dev/null
+++ b/Linphone/Assets.xcassets/arrow-bend-up-right-bold.imageset/Contents.json
@@ -0,0 +1,21 @@
+{
+ "images" : [
+ {
+ "filename" : "arrow-bend-up-right-bold.svg",
+ "idiom" : "universal",
+ "scale" : "1x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "2x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "3x"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Linphone/Assets.xcassets/arrow-bend-up-right-bold.imageset/arrow-bend-up-right-bold.svg b/Linphone/Assets.xcassets/arrow-bend-up-right-bold.imageset/arrow-bend-up-right-bold.svg
new file mode 100644
index 000000000..42532ea02
--- /dev/null
+++ b/Linphone/Assets.xcassets/arrow-bend-up-right-bold.imageset/arrow-bend-up-right-bold.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/Linphone/Assets.xcassets/arrow-clockwise.imageset/Contents.json b/Linphone/Assets.xcassets/arrow-clockwise.imageset/Contents.json
new file mode 100644
index 000000000..707dc5691
--- /dev/null
+++ b/Linphone/Assets.xcassets/arrow-clockwise.imageset/Contents.json
@@ -0,0 +1,21 @@
+{
+ "images" : [
+ {
+ "filename" : "arrow-clockwise.svg",
+ "idiom" : "universal",
+ "scale" : "1x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "2x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "3x"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Linphone/Assets.xcassets/arrow-clockwise.imageset/arrow-clockwise.svg b/Linphone/Assets.xcassets/arrow-clockwise.imageset/arrow-clockwise.svg
new file mode 100644
index 000000000..a8c631b3e
--- /dev/null
+++ b/Linphone/Assets.xcassets/arrow-clockwise.imageset/arrow-clockwise.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/Linphone/Assets.xcassets/arrow-right-fill.imageset/Contents.json b/Linphone/Assets.xcassets/arrow-right-fill.imageset/Contents.json
new file mode 100644
index 000000000..8ce3f8e70
--- /dev/null
+++ b/Linphone/Assets.xcassets/arrow-right-fill.imageset/Contents.json
@@ -0,0 +1,21 @@
+{
+ "images" : [
+ {
+ "filename" : "arrow-right-fill.svg",
+ "idiom" : "universal",
+ "scale" : "1x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "2x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "3x"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Linphone/Assets.xcassets/arrow-right-fill.imageset/arrow-right-fill.svg b/Linphone/Assets.xcassets/arrow-right-fill.imageset/arrow-right-fill.svg
new file mode 100644
index 000000000..fb031f4b7
--- /dev/null
+++ b/Linphone/Assets.xcassets/arrow-right-fill.imageset/arrow-right-fill.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/Linphone/Assets.xcassets/arrows-merge.imageset/Contents.json b/Linphone/Assets.xcassets/arrows-merge.imageset/Contents.json
new file mode 100644
index 000000000..9b143aad9
--- /dev/null
+++ b/Linphone/Assets.xcassets/arrows-merge.imageset/Contents.json
@@ -0,0 +1,21 @@
+{
+ "images" : [
+ {
+ "filename" : "arrows-merge.svg",
+ "idiom" : "universal",
+ "scale" : "1x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "2x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "3x"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Linphone/Assets.xcassets/arrows-merge.imageset/arrows-merge.svg b/Linphone/Assets.xcassets/arrows-merge.imageset/arrows-merge.svg
new file mode 100644
index 000000000..9bd183b23
--- /dev/null
+++ b/Linphone/Assets.xcassets/arrows-merge.imageset/arrows-merge.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/Linphone/Assets.xcassets/backspace-fill.imageset/Contents.json b/Linphone/Assets.xcassets/backspace-fill.imageset/Contents.json
new file mode 100644
index 000000000..dccf64ee3
--- /dev/null
+++ b/Linphone/Assets.xcassets/backspace-fill.imageset/Contents.json
@@ -0,0 +1,21 @@
+{
+ "images" : [
+ {
+ "filename" : "backspace-fill.svg",
+ "idiom" : "universal",
+ "scale" : "1x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "2x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "3x"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Linphone/Assets.xcassets/backspace-fill.imageset/backspace-fill.svg b/Linphone/Assets.xcassets/backspace-fill.imageset/backspace-fill.svg
new file mode 100644
index 000000000..580b7f307
--- /dev/null
+++ b/Linphone/Assets.xcassets/backspace-fill.imageset/backspace-fill.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/Linphone/Assets.xcassets/bell-ringing.imageset/Contents.json b/Linphone/Assets.xcassets/bell-ringing.imageset/Contents.json
new file mode 100644
index 000000000..d406bf8d4
--- /dev/null
+++ b/Linphone/Assets.xcassets/bell-ringing.imageset/Contents.json
@@ -0,0 +1,21 @@
+{
+ "images" : [
+ {
+ "filename" : "bell-ringing.svg",
+ "idiom" : "universal",
+ "scale" : "1x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "2x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "3x"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Linphone/Assets.xcassets/bell-ringing.imageset/bell-ringing.svg b/Linphone/Assets.xcassets/bell-ringing.imageset/bell-ringing.svg
new file mode 100644
index 000000000..0d7b4de7d
--- /dev/null
+++ b/Linphone/Assets.xcassets/bell-ringing.imageset/bell-ringing.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/Linphone/Assets.xcassets/bell-simple-slash.imageset/Contents.json b/Linphone/Assets.xcassets/bell-simple-slash.imageset/Contents.json
new file mode 100644
index 000000000..6584d4567
--- /dev/null
+++ b/Linphone/Assets.xcassets/bell-simple-slash.imageset/Contents.json
@@ -0,0 +1,21 @@
+{
+ "images" : [
+ {
+ "filename" : "bell-simple-slash.svg",
+ "idiom" : "universal",
+ "scale" : "1x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "2x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "3x"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Linphone/Assets.xcassets/bell-simple-slash.imageset/bell-simple-slash.svg b/Linphone/Assets.xcassets/bell-simple-slash.imageset/bell-simple-slash.svg
new file mode 100644
index 000000000..89c649e7a
--- /dev/null
+++ b/Linphone/Assets.xcassets/bell-simple-slash.imageset/bell-simple-slash.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/Linphone/Assets.xcassets/bell-simple.imageset/Contents.json b/Linphone/Assets.xcassets/bell-simple.imageset/Contents.json
new file mode 100644
index 000000000..5fe23f349
--- /dev/null
+++ b/Linphone/Assets.xcassets/bell-simple.imageset/Contents.json
@@ -0,0 +1,21 @@
+{
+ "images" : [
+ {
+ "filename" : "bell-simple.svg",
+ "idiom" : "universal",
+ "scale" : "1x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "2x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "3x"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Linphone/Assets.xcassets/bell-simple.imageset/bell-simple.svg b/Linphone/Assets.xcassets/bell-simple.imageset/bell-simple.svg
new file mode 100644
index 000000000..1c026c3b7
--- /dev/null
+++ b/Linphone/Assets.xcassets/bell-simple.imageset/bell-simple.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/Linphone/Assets.xcassets/bell-slash.imageset/Contents.json b/Linphone/Assets.xcassets/bell-slash.imageset/Contents.json
new file mode 100644
index 000000000..1f035fcee
--- /dev/null
+++ b/Linphone/Assets.xcassets/bell-slash.imageset/Contents.json
@@ -0,0 +1,21 @@
+{
+ "images" : [
+ {
+ "filename" : "bell-slash.svg",
+ "idiom" : "universal",
+ "scale" : "1x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "2x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "3x"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Linphone/Assets.xcassets/bell-slash.imageset/bell-slash.svg b/Linphone/Assets.xcassets/bell-slash.imageset/bell-slash.svg
new file mode 100644
index 000000000..9e4cec690
--- /dev/null
+++ b/Linphone/Assets.xcassets/bell-slash.imageset/bell-slash.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/Linphone/Assets.xcassets/bell.imageset/Contents.json b/Linphone/Assets.xcassets/bell.imageset/Contents.json
new file mode 100644
index 000000000..b0db29476
--- /dev/null
+++ b/Linphone/Assets.xcassets/bell.imageset/Contents.json
@@ -0,0 +1,21 @@
+{
+ "images" : [
+ {
+ "filename" : "bell.svg",
+ "idiom" : "universal",
+ "scale" : "1x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "2x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "3x"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Linphone/Assets.xcassets/bell.imageset/bell.svg b/Linphone/Assets.xcassets/bell.imageset/bell.svg
new file mode 100644
index 000000000..7a51a424b
--- /dev/null
+++ b/Linphone/Assets.xcassets/bell.imageset/bell.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/Linphone/Assets.xcassets/bluetooth.imageset/Contents.json b/Linphone/Assets.xcassets/bluetooth.imageset/Contents.json
new file mode 100644
index 000000000..a7c7ca0fc
--- /dev/null
+++ b/Linphone/Assets.xcassets/bluetooth.imageset/Contents.json
@@ -0,0 +1,21 @@
+{
+ "images" : [
+ {
+ "filename" : "bluetooth.svg",
+ "idiom" : "universal",
+ "scale" : "1x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "2x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "3x"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Linphone/Assets.xcassets/bluetooth.imageset/bluetooth.svg b/Linphone/Assets.xcassets/bluetooth.imageset/bluetooth.svg
new file mode 100644
index 000000000..c1133c92f
--- /dev/null
+++ b/Linphone/Assets.xcassets/bluetooth.imageset/bluetooth.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/Linphone/Assets.xcassets/calendar-blank.imageset/Contents.json b/Linphone/Assets.xcassets/calendar-blank.imageset/Contents.json
new file mode 100644
index 000000000..5a415f801
--- /dev/null
+++ b/Linphone/Assets.xcassets/calendar-blank.imageset/Contents.json
@@ -0,0 +1,21 @@
+{
+ "images" : [
+ {
+ "filename" : "calendar-blank.svg",
+ "idiom" : "universal",
+ "scale" : "1x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "2x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "3x"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Linphone/Assets.xcassets/calendar-blank.imageset/calendar-blank.svg b/Linphone/Assets.xcassets/calendar-blank.imageset/calendar-blank.svg
new file mode 100644
index 000000000..81024d312
--- /dev/null
+++ b/Linphone/Assets.xcassets/calendar-blank.imageset/calendar-blank.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/Linphone/Assets.xcassets/calendar.imageset/Contents.json b/Linphone/Assets.xcassets/calendar.imageset/Contents.json
new file mode 100644
index 000000000..14f636381
--- /dev/null
+++ b/Linphone/Assets.xcassets/calendar.imageset/Contents.json
@@ -0,0 +1,21 @@
+{
+ "images" : [
+ {
+ "filename" : "calendar.svg",
+ "idiom" : "universal",
+ "scale" : "1x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "2x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "3x"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Linphone/Assets.xcassets/calendar.imageset/calendar.svg b/Linphone/Assets.xcassets/calendar.imageset/calendar.svg
new file mode 100644
index 000000000..5caacdbef
--- /dev/null
+++ b/Linphone/Assets.xcassets/calendar.imageset/calendar.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/Linphone/Assets.xcassets/camera-rotate.imageset/Contents.json b/Linphone/Assets.xcassets/camera-rotate.imageset/Contents.json
new file mode 100644
index 000000000..61ef9a849
--- /dev/null
+++ b/Linphone/Assets.xcassets/camera-rotate.imageset/Contents.json
@@ -0,0 +1,21 @@
+{
+ "images" : [
+ {
+ "filename" : "camera-rotate.svg",
+ "idiom" : "universal",
+ "scale" : "1x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "2x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "3x"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Linphone/Assets.xcassets/camera-rotate.imageset/camera-rotate.svg b/Linphone/Assets.xcassets/camera-rotate.imageset/camera-rotate.svg
new file mode 100644
index 000000000..742615869
--- /dev/null
+++ b/Linphone/Assets.xcassets/camera-rotate.imageset/camera-rotate.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/Linphone/Assets.xcassets/camera.imageset/Contents.json b/Linphone/Assets.xcassets/camera.imageset/Contents.json
new file mode 100644
index 000000000..5375d722d
--- /dev/null
+++ b/Linphone/Assets.xcassets/camera.imageset/Contents.json
@@ -0,0 +1,21 @@
+{
+ "images" : [
+ {
+ "filename" : "camera.svg",
+ "idiom" : "universal",
+ "scale" : "1x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "2x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "3x"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Linphone/Assets.xcassets/camera.imageset/camera.svg b/Linphone/Assets.xcassets/camera.imageset/camera.svg
new file mode 100644
index 000000000..7a8d0ac40
--- /dev/null
+++ b/Linphone/Assets.xcassets/camera.imageset/camera.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/Linphone/Assets.xcassets/caret-down.imageset/Contents.json b/Linphone/Assets.xcassets/caret-down.imageset/Contents.json
new file mode 100644
index 000000000..cc33c146a
--- /dev/null
+++ b/Linphone/Assets.xcassets/caret-down.imageset/Contents.json
@@ -0,0 +1,21 @@
+{
+ "images" : [
+ {
+ "filename" : "caret-down.svg",
+ "idiom" : "universal",
+ "scale" : "1x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "2x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "3x"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Linphone/Assets.xcassets/caret-down.imageset/caret-down.svg b/Linphone/Assets.xcassets/caret-down.imageset/caret-down.svg
new file mode 100644
index 000000000..5b5218a2f
--- /dev/null
+++ b/Linphone/Assets.xcassets/caret-down.imageset/caret-down.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/Linphone/Assets.xcassets/caret-left.imageset/Contents.json b/Linphone/Assets.xcassets/caret-left.imageset/Contents.json
new file mode 100644
index 000000000..a5f91ffff
--- /dev/null
+++ b/Linphone/Assets.xcassets/caret-left.imageset/Contents.json
@@ -0,0 +1,21 @@
+{
+ "images" : [
+ {
+ "filename" : "caret-left.svg",
+ "idiom" : "universal",
+ "scale" : "1x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "2x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "3x"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Linphone/Assets.xcassets/caret-left.imageset/caret-left.svg b/Linphone/Assets.xcassets/caret-left.imageset/caret-left.svg
new file mode 100644
index 000000000..178311847
--- /dev/null
+++ b/Linphone/Assets.xcassets/caret-left.imageset/caret-left.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/Linphone/Assets.xcassets/caret-right.imageset/Contents.json b/Linphone/Assets.xcassets/caret-right.imageset/Contents.json
new file mode 100644
index 000000000..e4a5b260d
--- /dev/null
+++ b/Linphone/Assets.xcassets/caret-right.imageset/Contents.json
@@ -0,0 +1,21 @@
+{
+ "images" : [
+ {
+ "filename" : "caret-right.svg",
+ "idiom" : "universal",
+ "scale" : "1x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "2x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "3x"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Linphone/Assets.xcassets/caret-right.imageset/caret-right.svg b/Linphone/Assets.xcassets/caret-right.imageset/caret-right.svg
new file mode 100644
index 000000000..e291c0ebe
--- /dev/null
+++ b/Linphone/Assets.xcassets/caret-right.imageset/caret-right.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/Linphone/Assets.xcassets/caret-up.imageset/Contents.json b/Linphone/Assets.xcassets/caret-up.imageset/Contents.json
new file mode 100644
index 000000000..c7fea1d89
--- /dev/null
+++ b/Linphone/Assets.xcassets/caret-up.imageset/Contents.json
@@ -0,0 +1,21 @@
+{
+ "images" : [
+ {
+ "filename" : "caret-up.svg",
+ "idiom" : "universal",
+ "scale" : "1x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "2x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "3x"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Linphone/Assets.xcassets/caret-up.imageset/caret-up.svg b/Linphone/Assets.xcassets/caret-up.imageset/caret-up.svg
new file mode 100644
index 000000000..27a7d9701
--- /dev/null
+++ b/Linphone/Assets.xcassets/caret-up.imageset/caret-up.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/Linphone/Assets.xcassets/cell-signal-full.imageset/Contents.json b/Linphone/Assets.xcassets/cell-signal-full.imageset/Contents.json
new file mode 100644
index 000000000..cc8c45142
--- /dev/null
+++ b/Linphone/Assets.xcassets/cell-signal-full.imageset/Contents.json
@@ -0,0 +1,21 @@
+{
+ "images" : [
+ {
+ "filename" : "cell-signal-full.svg",
+ "idiom" : "universal",
+ "scale" : "1x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "2x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "3x"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Linphone/Assets.xcassets/cell-signal-full.imageset/cell-signal-full.svg b/Linphone/Assets.xcassets/cell-signal-full.imageset/cell-signal-full.svg
new file mode 100644
index 000000000..2149b9e0d
--- /dev/null
+++ b/Linphone/Assets.xcassets/cell-signal-full.imageset/cell-signal-full.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/Linphone/Assets.xcassets/cell-signal-high.imageset/Contents.json b/Linphone/Assets.xcassets/cell-signal-high.imageset/Contents.json
new file mode 100644
index 000000000..daffc78c7
--- /dev/null
+++ b/Linphone/Assets.xcassets/cell-signal-high.imageset/Contents.json
@@ -0,0 +1,21 @@
+{
+ "images" : [
+ {
+ "filename" : "cell-signal-high.svg",
+ "idiom" : "universal",
+ "scale" : "1x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "2x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "3x"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Linphone/Assets.xcassets/cell-signal-high.imageset/cell-signal-high.svg b/Linphone/Assets.xcassets/cell-signal-high.imageset/cell-signal-high.svg
new file mode 100644
index 000000000..0db07907d
--- /dev/null
+++ b/Linphone/Assets.xcassets/cell-signal-high.imageset/cell-signal-high.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/Linphone/Assets.xcassets/cell-signal-low.imageset/Contents.json b/Linphone/Assets.xcassets/cell-signal-low.imageset/Contents.json
new file mode 100644
index 000000000..19bbefaf2
--- /dev/null
+++ b/Linphone/Assets.xcassets/cell-signal-low.imageset/Contents.json
@@ -0,0 +1,21 @@
+{
+ "images" : [
+ {
+ "filename" : "cell-signal-low.svg",
+ "idiom" : "universal",
+ "scale" : "1x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "2x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "3x"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Linphone/Assets.xcassets/cell-signal-low.imageset/cell-signal-low.svg b/Linphone/Assets.xcassets/cell-signal-low.imageset/cell-signal-low.svg
new file mode 100644
index 000000000..dd093bcc8
--- /dev/null
+++ b/Linphone/Assets.xcassets/cell-signal-low.imageset/cell-signal-low.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/Linphone/Assets.xcassets/cell-signal-medium.imageset/Contents.json b/Linphone/Assets.xcassets/cell-signal-medium.imageset/Contents.json
new file mode 100644
index 000000000..88c54ffd4
--- /dev/null
+++ b/Linphone/Assets.xcassets/cell-signal-medium.imageset/Contents.json
@@ -0,0 +1,21 @@
+{
+ "images" : [
+ {
+ "filename" : "cell-signal-medium.svg",
+ "idiom" : "universal",
+ "scale" : "1x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "2x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "3x"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Linphone/Assets.xcassets/cell-signal-medium.imageset/cell-signal-medium.svg b/Linphone/Assets.xcassets/cell-signal-medium.imageset/cell-signal-medium.svg
new file mode 100644
index 000000000..2a986fce4
--- /dev/null
+++ b/Linphone/Assets.xcassets/cell-signal-medium.imageset/cell-signal-medium.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/Linphone/Assets.xcassets/cell-signal-none.imageset/Contents.json b/Linphone/Assets.xcassets/cell-signal-none.imageset/Contents.json
new file mode 100644
index 000000000..763d945f9
--- /dev/null
+++ b/Linphone/Assets.xcassets/cell-signal-none.imageset/Contents.json
@@ -0,0 +1,21 @@
+{
+ "images" : [
+ {
+ "filename" : "cell-signal-none.svg",
+ "idiom" : "universal",
+ "scale" : "1x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "2x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "3x"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Linphone/Assets.xcassets/cell-signal-none.imageset/cell-signal-none.svg b/Linphone/Assets.xcassets/cell-signal-none.imageset/cell-signal-none.svg
new file mode 100644
index 000000000..2b1d4ba4f
--- /dev/null
+++ b/Linphone/Assets.xcassets/cell-signal-none.imageset/cell-signal-none.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/Linphone/Assets.xcassets/chat-dots.imageset/Contents.json b/Linphone/Assets.xcassets/chat-dots.imageset/Contents.json
new file mode 100644
index 000000000..539c622ea
--- /dev/null
+++ b/Linphone/Assets.xcassets/chat-dots.imageset/Contents.json
@@ -0,0 +1,21 @@
+{
+ "images" : [
+ {
+ "filename" : "chat-dots.svg",
+ "idiom" : "universal",
+ "scale" : "1x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "2x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "3x"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Linphone/Assets.xcassets/chat-dots.imageset/chat-dots.svg b/Linphone/Assets.xcassets/chat-dots.imageset/chat-dots.svg
new file mode 100644
index 000000000..481d876d2
--- /dev/null
+++ b/Linphone/Assets.xcassets/chat-dots.imageset/chat-dots.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/Linphone/Assets.xcassets/chat-teardrop-text-slash.imageset/Contents.json b/Linphone/Assets.xcassets/chat-teardrop-text-slash.imageset/Contents.json
new file mode 100644
index 000000000..7f5561e86
--- /dev/null
+++ b/Linphone/Assets.xcassets/chat-teardrop-text-slash.imageset/Contents.json
@@ -0,0 +1,21 @@
+{
+ "images" : [
+ {
+ "filename" : "chat-teardrop-text-slash.svg",
+ "idiom" : "universal",
+ "scale" : "1x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "2x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "3x"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Linphone/Assets.xcassets/chat-teardrop-text-slash.imageset/chat-teardrop-text-slash.svg b/Linphone/Assets.xcassets/chat-teardrop-text-slash.imageset/chat-teardrop-text-slash.svg
new file mode 100644
index 000000000..27d2bfb1b
--- /dev/null
+++ b/Linphone/Assets.xcassets/chat-teardrop-text-slash.imageset/chat-teardrop-text-slash.svg
@@ -0,0 +1,4 @@
+
diff --git a/Linphone/Assets.xcassets/chat-teardrop-text.imageset/Contents.json b/Linphone/Assets.xcassets/chat-teardrop-text.imageset/Contents.json
new file mode 100644
index 000000000..0a273d5fb
--- /dev/null
+++ b/Linphone/Assets.xcassets/chat-teardrop-text.imageset/Contents.json
@@ -0,0 +1,21 @@
+{
+ "images" : [
+ {
+ "filename" : "chat-teardrop-text.svg",
+ "idiom" : "universal",
+ "scale" : "1x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "2x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "3x"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Linphone/Assets.xcassets/chat-teardrop-text.imageset/chat-teardrop-text.svg b/Linphone/Assets.xcassets/chat-teardrop-text.imageset/chat-teardrop-text.svg
new file mode 100644
index 000000000..d07e384d6
--- /dev/null
+++ b/Linphone/Assets.xcassets/chat-teardrop-text.imageset/chat-teardrop-text.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/Linphone/Assets.xcassets/chat-text.imageset/Contents.json b/Linphone/Assets.xcassets/chat-text.imageset/Contents.json
new file mode 100644
index 000000000..1fb54e400
--- /dev/null
+++ b/Linphone/Assets.xcassets/chat-text.imageset/Contents.json
@@ -0,0 +1,21 @@
+{
+ "images" : [
+ {
+ "filename" : "chat-text.svg",
+ "idiom" : "universal",
+ "scale" : "1x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "2x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "3x"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Linphone/Assets.xcassets/chat-text.imageset/chat-text.svg b/Linphone/Assets.xcassets/chat-text.imageset/chat-text.svg
new file mode 100644
index 000000000..6be649cbe
--- /dev/null
+++ b/Linphone/Assets.xcassets/chat-text.imageset/chat-text.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/Linphone/Assets.xcassets/check-square-offset.imageset/Contents.json b/Linphone/Assets.xcassets/check-square-offset.imageset/Contents.json
new file mode 100644
index 000000000..02d20b1ef
--- /dev/null
+++ b/Linphone/Assets.xcassets/check-square-offset.imageset/Contents.json
@@ -0,0 +1,21 @@
+{
+ "images" : [
+ {
+ "filename" : "check-square-offset.svg",
+ "idiom" : "universal",
+ "scale" : "1x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "2x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "3x"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Linphone/Assets.xcassets/check-square-offset.imageset/check-square-offset.svg b/Linphone/Assets.xcassets/check-square-offset.imageset/check-square-offset.svg
new file mode 100644
index 000000000..518a1dc54
--- /dev/null
+++ b/Linphone/Assets.xcassets/check-square-offset.imageset/check-square-offset.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/Linphone/Assets.xcassets/check.imageset/Contents.json b/Linphone/Assets.xcassets/check.imageset/Contents.json
new file mode 100644
index 000000000..17203ccbd
--- /dev/null
+++ b/Linphone/Assets.xcassets/check.imageset/Contents.json
@@ -0,0 +1,21 @@
+{
+ "images" : [
+ {
+ "filename" : "check.svg",
+ "idiom" : "universal",
+ "scale" : "1x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "2x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "3x"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Linphone/Assets.xcassets/check.imageset/check.svg b/Linphone/Assets.xcassets/check.imageset/check.svg
new file mode 100644
index 000000000..2e308611c
--- /dev/null
+++ b/Linphone/Assets.xcassets/check.imageset/check.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/Linphone/Assets.xcassets/checks.imageset/Contents.json b/Linphone/Assets.xcassets/checks.imageset/Contents.json
new file mode 100644
index 000000000..79cbf5b7a
--- /dev/null
+++ b/Linphone/Assets.xcassets/checks.imageset/Contents.json
@@ -0,0 +1,21 @@
+{
+ "images" : [
+ {
+ "filename" : "checks.svg",
+ "idiom" : "universal",
+ "scale" : "1x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "2x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "3x"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Linphone/Assets.xcassets/checks.imageset/checks.svg b/Linphone/Assets.xcassets/checks.imageset/checks.svg
new file mode 100644
index 000000000..11d157d02
--- /dev/null
+++ b/Linphone/Assets.xcassets/checks.imageset/checks.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/Linphone/Assets.xcassets/clock-countdown.imageset/Contents.json b/Linphone/Assets.xcassets/clock-countdown.imageset/Contents.json
new file mode 100644
index 000000000..c3c8e0139
--- /dev/null
+++ b/Linphone/Assets.xcassets/clock-countdown.imageset/Contents.json
@@ -0,0 +1,21 @@
+{
+ "images" : [
+ {
+ "filename" : "clock-countdown.svg",
+ "idiom" : "universal",
+ "scale" : "1x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "2x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "3x"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Linphone/Assets.xcassets/clock-countdown.imageset/clock-countdown.svg b/Linphone/Assets.xcassets/clock-countdown.imageset/clock-countdown.svg
new file mode 100644
index 000000000..c59988986
--- /dev/null
+++ b/Linphone/Assets.xcassets/clock-countdown.imageset/clock-countdown.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/Linphone/Assets.xcassets/clock.imageset/Contents.json b/Linphone/Assets.xcassets/clock.imageset/Contents.json
new file mode 100644
index 000000000..c78a16f5e
--- /dev/null
+++ b/Linphone/Assets.xcassets/clock.imageset/Contents.json
@@ -0,0 +1,21 @@
+{
+ "images" : [
+ {
+ "filename" : "clock.svg",
+ "idiom" : "universal",
+ "scale" : "1x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "2x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "3x"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Linphone/Assets.xcassets/clock.imageset/clock.svg b/Linphone/Assets.xcassets/clock.imageset/clock.svg
new file mode 100644
index 000000000..18f1a5b97
--- /dev/null
+++ b/Linphone/Assets.xcassets/clock.imageset/clock.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/Linphone/Assets.xcassets/confirm_sms_code_illu.imageset/Contents.json b/Linphone/Assets.xcassets/confirm_sms_code_illu.imageset/Contents.json
new file mode 100644
index 000000000..eed2e9663
--- /dev/null
+++ b/Linphone/Assets.xcassets/confirm_sms_code_illu.imageset/Contents.json
@@ -0,0 +1,21 @@
+{
+ "images" : [
+ {
+ "filename" : "confirm_sms_code_illu.png",
+ "idiom" : "universal",
+ "scale" : "1x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "2x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "3x"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Linphone/Assets.xcassets/confirm_sms_code_illu.imageset/confirm_sms_code_illu.png b/Linphone/Assets.xcassets/confirm_sms_code_illu.imageset/confirm_sms_code_illu.png
new file mode 100644
index 000000000..e25974c02
Binary files /dev/null and b/Linphone/Assets.xcassets/confirm_sms_code_illu.imageset/confirm_sms_code_illu.png differ
diff --git a/Linphone/Assets.xcassets/conversation.imageset/Contents.json b/Linphone/Assets.xcassets/conversation.imageset/Contents.json
new file mode 100644
index 000000000..949fb1205
--- /dev/null
+++ b/Linphone/Assets.xcassets/conversation.imageset/Contents.json
@@ -0,0 +1,21 @@
+{
+ "images" : [
+ {
+ "filename" : "conversation.svg",
+ "idiom" : "universal",
+ "scale" : "1x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "2x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "3x"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Linphone/Assets.xcassets/conversation.imageset/conversation.svg b/Linphone/Assets.xcassets/conversation.imageset/conversation.svg
new file mode 100644
index 000000000..1d534708b
--- /dev/null
+++ b/Linphone/Assets.xcassets/conversation.imageset/conversation.svg
@@ -0,0 +1,8 @@
+
diff --git a/Linphone/Assets.xcassets/copy.imageset/Contents.json b/Linphone/Assets.xcassets/copy.imageset/Contents.json
new file mode 100644
index 000000000..be85778ce
--- /dev/null
+++ b/Linphone/Assets.xcassets/copy.imageset/Contents.json
@@ -0,0 +1,21 @@
+{
+ "images" : [
+ {
+ "filename" : "copy.svg",
+ "idiom" : "universal",
+ "scale" : "1x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "2x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "3x"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Linphone/Assets.xcassets/copy.imageset/copy.svg b/Linphone/Assets.xcassets/copy.imageset/copy.svg
new file mode 100644
index 000000000..f371da500
--- /dev/null
+++ b/Linphone/Assets.xcassets/copy.imageset/copy.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/Linphone/Assets.xcassets/current-dot.imageset/Contents.json b/Linphone/Assets.xcassets/current-dot.imageset/Contents.json
new file mode 100644
index 000000000..dafa79dae
--- /dev/null
+++ b/Linphone/Assets.xcassets/current-dot.imageset/Contents.json
@@ -0,0 +1,21 @@
+{
+ "images" : [
+ {
+ "filename" : "current-dot.svg",
+ "idiom" : "universal",
+ "scale" : "1x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "2x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "3x"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Linphone/Assets.xcassets/current-dot.imageset/current-dot.svg b/Linphone/Assets.xcassets/current-dot.imageset/current-dot.svg
new file mode 100644
index 000000000..e28783f66
--- /dev/null
+++ b/Linphone/Assets.xcassets/current-dot.imageset/current-dot.svg
@@ -0,0 +1,4 @@
+
+
\ No newline at end of file
diff --git a/Linphone/Assets.xcassets/detective.imageset/Contents.json b/Linphone/Assets.xcassets/detective.imageset/Contents.json
new file mode 100644
index 000000000..3b2845e51
--- /dev/null
+++ b/Linphone/Assets.xcassets/detective.imageset/Contents.json
@@ -0,0 +1,21 @@
+{
+ "images" : [
+ {
+ "filename" : "detective.svg",
+ "idiom" : "universal",
+ "scale" : "1x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "2x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "3x"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Linphone/Assets.xcassets/detective.imageset/detective.svg b/Linphone/Assets.xcassets/detective.imageset/detective.svg
new file mode 100644
index 000000000..7eb7f87d2
--- /dev/null
+++ b/Linphone/Assets.xcassets/detective.imageset/detective.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/Linphone/Assets.xcassets/dialer.imageset/Contents.json b/Linphone/Assets.xcassets/dialer.imageset/Contents.json
new file mode 100644
index 000000000..117f088cd
--- /dev/null
+++ b/Linphone/Assets.xcassets/dialer.imageset/Contents.json
@@ -0,0 +1,21 @@
+{
+ "images" : [
+ {
+ "filename" : "dialer.svg",
+ "idiom" : "universal",
+ "scale" : "1x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "2x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "3x"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Linphone/Assets.xcassets/dialer.imageset/dialer.svg b/Linphone/Assets.xcassets/dialer.imageset/dialer.svg
new file mode 100644
index 000000000..71705dfdf
--- /dev/null
+++ b/Linphone/Assets.xcassets/dialer.imageset/dialer.svg
@@ -0,0 +1,3 @@
+
diff --git a/Linphone/Assets.xcassets/door.imageset/Contents.json b/Linphone/Assets.xcassets/door.imageset/Contents.json
new file mode 100644
index 000000000..d54a1df16
--- /dev/null
+++ b/Linphone/Assets.xcassets/door.imageset/Contents.json
@@ -0,0 +1,21 @@
+{
+ "images" : [
+ {
+ "filename" : "door.svg",
+ "idiom" : "universal",
+ "scale" : "1x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "2x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "3x"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Linphone/Assets.xcassets/door.imageset/door.svg b/Linphone/Assets.xcassets/door.imageset/door.svg
new file mode 100644
index 000000000..8952e8d49
--- /dev/null
+++ b/Linphone/Assets.xcassets/door.imageset/door.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/Linphone/Assets.xcassets/dot.imageset/Contents.json b/Linphone/Assets.xcassets/dot.imageset/Contents.json
new file mode 100644
index 000000000..ad32b6795
--- /dev/null
+++ b/Linphone/Assets.xcassets/dot.imageset/Contents.json
@@ -0,0 +1,21 @@
+{
+ "images" : [
+ {
+ "filename" : "dot.svg",
+ "idiom" : "universal",
+ "scale" : "1x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "2x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "3x"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Linphone/Assets.xcassets/dot.imageset/dot.svg b/Linphone/Assets.xcassets/dot.imageset/dot.svg
new file mode 100644
index 000000000..fe8bdc248
--- /dev/null
+++ b/Linphone/Assets.xcassets/dot.imageset/dot.svg
@@ -0,0 +1,4 @@
+
+
\ No newline at end of file
diff --git a/Linphone/Assets.xcassets/dots-three-vertical.imageset/Contents.json b/Linphone/Assets.xcassets/dots-three-vertical.imageset/Contents.json
new file mode 100644
index 000000000..b45f32020
--- /dev/null
+++ b/Linphone/Assets.xcassets/dots-three-vertical.imageset/Contents.json
@@ -0,0 +1,21 @@
+{
+ "images" : [
+ {
+ "filename" : "dots-three-vertical.svg",
+ "idiom" : "universal",
+ "scale" : "1x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "2x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "3x"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Linphone/Assets.xcassets/dots-three-vertical.imageset/dots-three-vertical.svg b/Linphone/Assets.xcassets/dots-three-vertical.imageset/dots-three-vertical.svg
new file mode 100644
index 000000000..00e6090fb
--- /dev/null
+++ b/Linphone/Assets.xcassets/dots-three-vertical.imageset/dots-three-vertical.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/Linphone/Assets.xcassets/download-simple.imageset/Contents.json b/Linphone/Assets.xcassets/download-simple.imageset/Contents.json
new file mode 100644
index 000000000..cc9e90c47
--- /dev/null
+++ b/Linphone/Assets.xcassets/download-simple.imageset/Contents.json
@@ -0,0 +1,21 @@
+{
+ "images" : [
+ {
+ "filename" : "download-simple.svg",
+ "idiom" : "universal",
+ "scale" : "1x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "2x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "3x"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Linphone/Assets.xcassets/download-simple.imageset/download-simple.svg b/Linphone/Assets.xcassets/download-simple.imageset/download-simple.svg
new file mode 100644
index 000000000..60d202b45
--- /dev/null
+++ b/Linphone/Assets.xcassets/download-simple.imageset/download-simple.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/Linphone/Assets.xcassets/ear.imageset/Contents.json b/Linphone/Assets.xcassets/ear.imageset/Contents.json
new file mode 100644
index 000000000..d092470f2
--- /dev/null
+++ b/Linphone/Assets.xcassets/ear.imageset/Contents.json
@@ -0,0 +1,21 @@
+{
+ "images" : [
+ {
+ "filename" : "ear.svg",
+ "idiom" : "universal",
+ "scale" : "1x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "2x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "3x"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Linphone/Assets.xcassets/ear.imageset/ear.svg b/Linphone/Assets.xcassets/ear.imageset/ear.svg
new file mode 100644
index 000000000..15ff6531c
--- /dev/null
+++ b/Linphone/Assets.xcassets/ear.imageset/ear.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/Linphone/Assets.xcassets/earth.imageset/Contents.json b/Linphone/Assets.xcassets/earth.imageset/Contents.json
new file mode 100644
index 000000000..ce928664a
--- /dev/null
+++ b/Linphone/Assets.xcassets/earth.imageset/Contents.json
@@ -0,0 +1,21 @@
+{
+ "images" : [
+ {
+ "filename" : "earth.svg",
+ "idiom" : "universal",
+ "scale" : "1x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "2x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "3x"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Linphone/Assets.xcassets/earth.imageset/earth.svg b/Linphone/Assets.xcassets/earth.imageset/earth.svg
new file mode 100644
index 000000000..6bdba5060
--- /dev/null
+++ b/Linphone/Assets.xcassets/earth.imageset/earth.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/Linphone/Assets.xcassets/envelope-simple-open.imageset/Contents.json b/Linphone/Assets.xcassets/envelope-simple-open.imageset/Contents.json
new file mode 100644
index 000000000..0524ea689
--- /dev/null
+++ b/Linphone/Assets.xcassets/envelope-simple-open.imageset/Contents.json
@@ -0,0 +1,21 @@
+{
+ "images" : [
+ {
+ "filename" : "envelope-simple-open.svg",
+ "idiom" : "universal",
+ "scale" : "1x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "2x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "3x"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Linphone/Assets.xcassets/envelope-simple-open.imageset/envelope-simple-open.svg b/Linphone/Assets.xcassets/envelope-simple-open.imageset/envelope-simple-open.svg
new file mode 100644
index 000000000..5c826c63b
--- /dev/null
+++ b/Linphone/Assets.xcassets/envelope-simple-open.imageset/envelope-simple-open.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/Linphone/Assets.xcassets/envelope-simple.imageset/Contents.json b/Linphone/Assets.xcassets/envelope-simple.imageset/Contents.json
new file mode 100644
index 000000000..7f415c5bd
--- /dev/null
+++ b/Linphone/Assets.xcassets/envelope-simple.imageset/Contents.json
@@ -0,0 +1,21 @@
+{
+ "images" : [
+ {
+ "filename" : "envelope-simple.svg",
+ "idiom" : "universal",
+ "scale" : "1x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "2x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "3x"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Linphone/Assets.xcassets/envelope-simple.imageset/envelope-simple.svg b/Linphone/Assets.xcassets/envelope-simple.imageset/envelope-simple.svg
new file mode 100644
index 000000000..ada3da40b
--- /dev/null
+++ b/Linphone/Assets.xcassets/envelope-simple.imageset/envelope-simple.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/Linphone/Assets.xcassets/eye-slash.imageset/Contents.json b/Linphone/Assets.xcassets/eye-slash.imageset/Contents.json
new file mode 100644
index 000000000..ea1110e56
--- /dev/null
+++ b/Linphone/Assets.xcassets/eye-slash.imageset/Contents.json
@@ -0,0 +1,21 @@
+{
+ "images" : [
+ {
+ "filename" : "eye-slash.svg",
+ "idiom" : "universal",
+ "scale" : "1x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "2x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "3x"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Linphone/Assets.xcassets/eye-slash.imageset/eye-slash.svg b/Linphone/Assets.xcassets/eye-slash.imageset/eye-slash.svg
new file mode 100644
index 000000000..aadbe3ca5
--- /dev/null
+++ b/Linphone/Assets.xcassets/eye-slash.imageset/eye-slash.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/Linphone/Assets.xcassets/eye.imageset/Contents.json b/Linphone/Assets.xcassets/eye.imageset/Contents.json
new file mode 100644
index 000000000..d5696cab8
--- /dev/null
+++ b/Linphone/Assets.xcassets/eye.imageset/Contents.json
@@ -0,0 +1,21 @@
+{
+ "images" : [
+ {
+ "filename" : "eye.svg",
+ "idiom" : "universal",
+ "scale" : "1x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "2x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "3x"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Linphone/Assets.xcassets/eye.imageset/eye.svg b/Linphone/Assets.xcassets/eye.imageset/eye.svg
new file mode 100644
index 000000000..61b225273
--- /dev/null
+++ b/Linphone/Assets.xcassets/eye.imageset/eye.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/Linphone/Assets.xcassets/file-audio.imageset/Contents.json b/Linphone/Assets.xcassets/file-audio.imageset/Contents.json
new file mode 100644
index 000000000..8c55b02f2
--- /dev/null
+++ b/Linphone/Assets.xcassets/file-audio.imageset/Contents.json
@@ -0,0 +1,21 @@
+{
+ "images" : [
+ {
+ "filename" : "file-audio.svg",
+ "idiom" : "universal",
+ "scale" : "1x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "2x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "3x"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Linphone/Assets.xcassets/file-audio.imageset/file-audio.svg b/Linphone/Assets.xcassets/file-audio.imageset/file-audio.svg
new file mode 100644
index 000000000..a2bb0c309
--- /dev/null
+++ b/Linphone/Assets.xcassets/file-audio.imageset/file-audio.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/Linphone/Assets.xcassets/file-pdf.imageset/Contents.json b/Linphone/Assets.xcassets/file-pdf.imageset/Contents.json
new file mode 100644
index 000000000..f946df429
--- /dev/null
+++ b/Linphone/Assets.xcassets/file-pdf.imageset/Contents.json
@@ -0,0 +1,21 @@
+{
+ "images" : [
+ {
+ "filename" : "file-pdf.svg",
+ "idiom" : "universal",
+ "scale" : "1x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "2x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "3x"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Linphone/Assets.xcassets/file-pdf.imageset/file-pdf.svg b/Linphone/Assets.xcassets/file-pdf.imageset/file-pdf.svg
new file mode 100644
index 000000000..63fc1ae2e
--- /dev/null
+++ b/Linphone/Assets.xcassets/file-pdf.imageset/file-pdf.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/Linphone/Assets.xcassets/file-text.imageset/Contents.json b/Linphone/Assets.xcassets/file-text.imageset/Contents.json
new file mode 100644
index 000000000..f52ed3162
--- /dev/null
+++ b/Linphone/Assets.xcassets/file-text.imageset/Contents.json
@@ -0,0 +1,21 @@
+{
+ "images" : [
+ {
+ "filename" : "file-text.svg",
+ "idiom" : "universal",
+ "scale" : "1x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "2x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "3x"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Linphone/Assets.xcassets/file-text.imageset/file-text.svg b/Linphone/Assets.xcassets/file-text.imageset/file-text.svg
new file mode 100644
index 000000000..05971352c
--- /dev/null
+++ b/Linphone/Assets.xcassets/file-text.imageset/file-text.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/Linphone/Assets.xcassets/file.imageset/Contents.json b/Linphone/Assets.xcassets/file.imageset/Contents.json
new file mode 100644
index 000000000..d33e50463
--- /dev/null
+++ b/Linphone/Assets.xcassets/file.imageset/Contents.json
@@ -0,0 +1,21 @@
+{
+ "images" : [
+ {
+ "filename" : "file.svg",
+ "idiom" : "universal",
+ "scale" : "1x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "2x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "3x"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Linphone/Assets.xcassets/file.imageset/file.svg b/Linphone/Assets.xcassets/file.imageset/file.svg
new file mode 100644
index 000000000..85a754433
--- /dev/null
+++ b/Linphone/Assets.xcassets/file.imageset/file.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/Linphone/Assets.xcassets/forward.imageset/Contents.json b/Linphone/Assets.xcassets/forward.imageset/Contents.json
new file mode 100644
index 000000000..9e6669929
--- /dev/null
+++ b/Linphone/Assets.xcassets/forward.imageset/Contents.json
@@ -0,0 +1,21 @@
+{
+ "images" : [
+ {
+ "filename" : "forward.svg",
+ "idiom" : "universal",
+ "scale" : "1x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "2x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "3x"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Linphone/Assets.xcassets/forward.imageset/forward.svg b/Linphone/Assets.xcassets/forward.imageset/forward.svg
new file mode 100644
index 000000000..f703e66a8
--- /dev/null
+++ b/Linphone/Assets.xcassets/forward.imageset/forward.svg
@@ -0,0 +1,5 @@
+
diff --git a/Linphone/Assets.xcassets/funnel.imageset/Contents.json b/Linphone/Assets.xcassets/funnel.imageset/Contents.json
new file mode 100644
index 000000000..a6781a2c1
--- /dev/null
+++ b/Linphone/Assets.xcassets/funnel.imageset/Contents.json
@@ -0,0 +1,21 @@
+{
+ "images" : [
+ {
+ "filename" : "funnel.svg",
+ "idiom" : "universal",
+ "scale" : "1x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "2x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "3x"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Linphone/Assets.xcassets/funnel.imageset/funnel.svg b/Linphone/Assets.xcassets/funnel.imageset/funnel.svg
new file mode 100644
index 000000000..8ae63bc99
--- /dev/null
+++ b/Linphone/Assets.xcassets/funnel.imageset/funnel.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/Linphone/Assets.xcassets/gear.imageset/Contents.json b/Linphone/Assets.xcassets/gear.imageset/Contents.json
new file mode 100644
index 000000000..90c5ae20b
--- /dev/null
+++ b/Linphone/Assets.xcassets/gear.imageset/Contents.json
@@ -0,0 +1,21 @@
+{
+ "images" : [
+ {
+ "filename" : "gear.svg",
+ "idiom" : "universal",
+ "scale" : "1x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "2x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "3x"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Linphone/Assets.xcassets/gear.imageset/gear.svg b/Linphone/Assets.xcassets/gear.imageset/gear.svg
new file mode 100644
index 000000000..2781afab4
--- /dev/null
+++ b/Linphone/Assets.xcassets/gear.imageset/gear.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/Linphone/Assets.xcassets/globe-hemisphere-west.imageset/Contents.json b/Linphone/Assets.xcassets/globe-hemisphere-west.imageset/Contents.json
new file mode 100644
index 000000000..3af8b9f04
--- /dev/null
+++ b/Linphone/Assets.xcassets/globe-hemisphere-west.imageset/Contents.json
@@ -0,0 +1,21 @@
+{
+ "images" : [
+ {
+ "filename" : "globe-hemisphere-west.svg",
+ "idiom" : "universal",
+ "scale" : "1x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "2x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "3x"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Linphone/Assets.xcassets/globe-hemisphere-west.imageset/globe-hemisphere-west.svg b/Linphone/Assets.xcassets/globe-hemisphere-west.imageset/globe-hemisphere-west.svg
new file mode 100644
index 000000000..61c122a7e
--- /dev/null
+++ b/Linphone/Assets.xcassets/globe-hemisphere-west.imageset/globe-hemisphere-west.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/Linphone/Assets.xcassets/green-check.imageset/Contents.json b/Linphone/Assets.xcassets/green-check.imageset/Contents.json
new file mode 100644
index 000000000..f4e39fa87
--- /dev/null
+++ b/Linphone/Assets.xcassets/green-check.imageset/Contents.json
@@ -0,0 +1,21 @@
+{
+ "images" : [
+ {
+ "filename" : "green-check.svg",
+ "idiom" : "universal",
+ "scale" : "1x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "2x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "3x"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Linphone/Assets.xcassets/green-check.imageset/green-check.svg b/Linphone/Assets.xcassets/green-check.imageset/green-check.svg
new file mode 100644
index 000000000..1d42925ba
--- /dev/null
+++ b/Linphone/Assets.xcassets/green-check.imageset/green-check.svg
@@ -0,0 +1,3 @@
+
diff --git a/Linphone/Assets.xcassets/headset.imageset/Contents.json b/Linphone/Assets.xcassets/headset.imageset/Contents.json
new file mode 100644
index 000000000..f302ef8ae
--- /dev/null
+++ b/Linphone/Assets.xcassets/headset.imageset/Contents.json
@@ -0,0 +1,21 @@
+{
+ "images" : [
+ {
+ "filename" : "headset.svg",
+ "idiom" : "universal",
+ "scale" : "1x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "2x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "3x"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Linphone/Assets.xcassets/headset.imageset/headset.svg b/Linphone/Assets.xcassets/headset.imageset/headset.svg
new file mode 100644
index 000000000..40e5e753e
--- /dev/null
+++ b/Linphone/Assets.xcassets/headset.imageset/headset.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/Linphone/Assets.xcassets/heart-fill.imageset/Contents.json b/Linphone/Assets.xcassets/heart-fill.imageset/Contents.json
new file mode 100644
index 000000000..2d59cc5d7
--- /dev/null
+++ b/Linphone/Assets.xcassets/heart-fill.imageset/Contents.json
@@ -0,0 +1,21 @@
+{
+ "images" : [
+ {
+ "filename" : "heart-fill.svg",
+ "idiom" : "universal",
+ "scale" : "1x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "2x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "3x"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Linphone/Assets.xcassets/heart-fill.imageset/heart-fill.svg b/Linphone/Assets.xcassets/heart-fill.imageset/heart-fill.svg
new file mode 100644
index 000000000..04ca5ba3c
--- /dev/null
+++ b/Linphone/Assets.xcassets/heart-fill.imageset/heart-fill.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/Linphone/Assets.xcassets/heart.imageset/Contents.json b/Linphone/Assets.xcassets/heart.imageset/Contents.json
new file mode 100644
index 000000000..0b277d7d6
--- /dev/null
+++ b/Linphone/Assets.xcassets/heart.imageset/Contents.json
@@ -0,0 +1,21 @@
+{
+ "images" : [
+ {
+ "filename" : "heart.svg",
+ "idiom" : "universal",
+ "scale" : "1x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "2x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "3x"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Linphone/Assets.xcassets/heart.imageset/heart.svg b/Linphone/Assets.xcassets/heart.imageset/heart.svg
new file mode 100644
index 000000000..89f2caf8e
--- /dev/null
+++ b/Linphone/Assets.xcassets/heart.imageset/heart.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/Linphone/Assets.xcassets/illus-belledonne.imageset/Contents.json b/Linphone/Assets.xcassets/illus-belledonne.imageset/Contents.json
new file mode 100644
index 000000000..867b2dc3e
--- /dev/null
+++ b/Linphone/Assets.xcassets/illus-belledonne.imageset/Contents.json
@@ -0,0 +1,21 @@
+{
+ "images" : [
+ {
+ "filename" : "illus-belledonne.svg",
+ "idiom" : "universal",
+ "scale" : "1x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "2x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "3x"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Linphone/Assets.xcassets/illus-belledonne.imageset/illus-belledonne.svg b/Linphone/Assets.xcassets/illus-belledonne.imageset/illus-belledonne.svg
new file mode 100644
index 000000000..2980979dc
--- /dev/null
+++ b/Linphone/Assets.xcassets/illus-belledonne.imageset/illus-belledonne.svg
@@ -0,0 +1,65 @@
+
diff --git a/Linphone/Assets.xcassets/image-broken.imageset/Contents.json b/Linphone/Assets.xcassets/image-broken.imageset/Contents.json
new file mode 100644
index 000000000..c850a9b36
--- /dev/null
+++ b/Linphone/Assets.xcassets/image-broken.imageset/Contents.json
@@ -0,0 +1,21 @@
+{
+ "images" : [
+ {
+ "filename" : "image-broken.svg",
+ "idiom" : "universal",
+ "scale" : "1x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "2x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "3x"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Linphone/Assets.xcassets/image-broken.imageset/image-broken.svg b/Linphone/Assets.xcassets/image-broken.imageset/image-broken.svg
new file mode 100644
index 000000000..c51df7fe6
--- /dev/null
+++ b/Linphone/Assets.xcassets/image-broken.imageset/image-broken.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/Linphone/Assets.xcassets/in-progress.imageset/Contents.json b/Linphone/Assets.xcassets/in-progress.imageset/Contents.json
new file mode 100644
index 000000000..c8a089b4c
--- /dev/null
+++ b/Linphone/Assets.xcassets/in-progress.imageset/Contents.json
@@ -0,0 +1,21 @@
+{
+ "images" : [
+ {
+ "filename" : "in_progress.svg",
+ "idiom" : "universal",
+ "scale" : "1x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "2x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "3x"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Linphone/Assets.xcassets/in-progress.imageset/in_progress.svg b/Linphone/Assets.xcassets/in-progress.imageset/in_progress.svg
new file mode 100644
index 000000000..0738d048a
--- /dev/null
+++ b/Linphone/Assets.xcassets/in-progress.imageset/in_progress.svg
@@ -0,0 +1,5 @@
+
diff --git a/Linphone/Assets.xcassets/incoming-call-missed.imageset/Contents.json b/Linphone/Assets.xcassets/incoming-call-missed.imageset/Contents.json
new file mode 100644
index 000000000..6482c5867
--- /dev/null
+++ b/Linphone/Assets.xcassets/incoming-call-missed.imageset/Contents.json
@@ -0,0 +1,21 @@
+{
+ "images" : [
+ {
+ "filename" : "incoming_call_missed.svg",
+ "idiom" : "universal",
+ "scale" : "1x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "2x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "3x"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Linphone/Assets.xcassets/incoming-call-missed.imageset/incoming_call_missed.svg b/Linphone/Assets.xcassets/incoming-call-missed.imageset/incoming_call_missed.svg
new file mode 100644
index 000000000..4faa85b70
--- /dev/null
+++ b/Linphone/Assets.xcassets/incoming-call-missed.imageset/incoming_call_missed.svg
@@ -0,0 +1,3 @@
+
diff --git a/Linphone/Assets.xcassets/incoming-call-rejected.imageset/Contents.json b/Linphone/Assets.xcassets/incoming-call-rejected.imageset/Contents.json
new file mode 100644
index 000000000..7ea4c1b04
--- /dev/null
+++ b/Linphone/Assets.xcassets/incoming-call-rejected.imageset/Contents.json
@@ -0,0 +1,21 @@
+{
+ "images" : [
+ {
+ "filename" : "incoming_call_rejected.svg",
+ "idiom" : "universal",
+ "scale" : "1x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "2x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "3x"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Linphone/Assets.xcassets/incoming-call-rejected.imageset/incoming_call_rejected.svg b/Linphone/Assets.xcassets/incoming-call-rejected.imageset/incoming_call_rejected.svg
new file mode 100644
index 000000000..c64a80ebd
--- /dev/null
+++ b/Linphone/Assets.xcassets/incoming-call-rejected.imageset/incoming_call_rejected.svg
@@ -0,0 +1,3 @@
+
diff --git a/Linphone/Assets.xcassets/incoming-call.imageset/Contents.json b/Linphone/Assets.xcassets/incoming-call.imageset/Contents.json
new file mode 100644
index 000000000..479ca7f96
--- /dev/null
+++ b/Linphone/Assets.xcassets/incoming-call.imageset/Contents.json
@@ -0,0 +1,21 @@
+{
+ "images" : [
+ {
+ "filename" : "incoming_call.svg",
+ "idiom" : "universal",
+ "scale" : "1x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "2x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "3x"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Linphone/Assets.xcassets/incoming-call.imageset/incoming_call.svg b/Linphone/Assets.xcassets/incoming-call.imageset/incoming_call.svg
new file mode 100644
index 000000000..5dab38385
--- /dev/null
+++ b/Linphone/Assets.xcassets/incoming-call.imageset/incoming_call.svg
@@ -0,0 +1,3 @@
+
diff --git a/Linphone/Assets.xcassets/info.imageset/Contents.json b/Linphone/Assets.xcassets/info.imageset/Contents.json
new file mode 100644
index 000000000..b5faab124
--- /dev/null
+++ b/Linphone/Assets.xcassets/info.imageset/Contents.json
@@ -0,0 +1,21 @@
+{
+ "images" : [
+ {
+ "filename" : "info.svg",
+ "idiom" : "universal",
+ "scale" : "1x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "2x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "3x"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Linphone/Assets.xcassets/info.imageset/info.svg b/Linphone/Assets.xcassets/info.imageset/info.svg
new file mode 100644
index 000000000..2f26d80a1
--- /dev/null
+++ b/Linphone/Assets.xcassets/info.imageset/info.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/Linphone/Assets.xcassets/keyboard.imageset/Contents.json b/Linphone/Assets.xcassets/keyboard.imageset/Contents.json
new file mode 100644
index 000000000..a9ae5f1ed
--- /dev/null
+++ b/Linphone/Assets.xcassets/keyboard.imageset/Contents.json
@@ -0,0 +1,21 @@
+{
+ "images" : [
+ {
+ "filename" : "keyboard.svg",
+ "idiom" : "universal",
+ "scale" : "1x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "2x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "3x"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Linphone/Assets.xcassets/keyboard.imageset/keyboard.svg b/Linphone/Assets.xcassets/keyboard.imageset/keyboard.svg
new file mode 100644
index 000000000..e7aa36dd0
--- /dev/null
+++ b/Linphone/Assets.xcassets/keyboard.imageset/keyboard.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/Linphone/Assets.xcassets/linphone.imageset/Contents.json b/Linphone/Assets.xcassets/linphone.imageset/Contents.json
new file mode 100644
index 000000000..ff043ddb2
--- /dev/null
+++ b/Linphone/Assets.xcassets/linphone.imageset/Contents.json
@@ -0,0 +1,21 @@
+{
+ "images" : [
+ {
+ "filename" : "linphone.svg",
+ "idiom" : "universal",
+ "scale" : "1x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "2x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "3x"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Linphone/Assets.xcassets/linphone.imageset/linphone.svg b/Linphone/Assets.xcassets/linphone.imageset/linphone.svg
new file mode 100644
index 000000000..005da361d
--- /dev/null
+++ b/Linphone/Assets.xcassets/linphone.imageset/linphone.svg
@@ -0,0 +1,6 @@
+
diff --git a/Linphone/Assets.xcassets/lock-key.imageset/Contents.json b/Linphone/Assets.xcassets/lock-key.imageset/Contents.json
new file mode 100644
index 000000000..309129c73
--- /dev/null
+++ b/Linphone/Assets.xcassets/lock-key.imageset/Contents.json
@@ -0,0 +1,21 @@
+{
+ "images" : [
+ {
+ "filename" : "lock-key.svg",
+ "idiom" : "universal",
+ "scale" : "1x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "2x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "3x"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Linphone/Assets.xcassets/lock-key.imageset/lock-key.svg b/Linphone/Assets.xcassets/lock-key.imageset/lock-key.svg
new file mode 100644
index 000000000..e033ef858
--- /dev/null
+++ b/Linphone/Assets.xcassets/lock-key.imageset/lock-key.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/Linphone/Assets.xcassets/lock_simple.imageset/Contents.json b/Linphone/Assets.xcassets/lock_simple.imageset/Contents.json
new file mode 100644
index 000000000..2f3e708ff
--- /dev/null
+++ b/Linphone/Assets.xcassets/lock_simple.imageset/Contents.json
@@ -0,0 +1,21 @@
+{
+ "images" : [
+ {
+ "filename" : "lock_simple.svg",
+ "idiom" : "universal",
+ "scale" : "1x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "2x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "3x"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Linphone/Assets.xcassets/lock_simple.imageset/lock_simple.svg b/Linphone/Assets.xcassets/lock_simple.imageset/lock_simple.svg
new file mode 100644
index 000000000..dcd3fb3e9
--- /dev/null
+++ b/Linphone/Assets.xcassets/lock_simple.imageset/lock_simple.svg
@@ -0,0 +1,3 @@
+
diff --git a/Linphone/Assets.xcassets/magnifying-glass.imageset/Contents.json b/Linphone/Assets.xcassets/magnifying-glass.imageset/Contents.json
new file mode 100644
index 000000000..c684c02fd
--- /dev/null
+++ b/Linphone/Assets.xcassets/magnifying-glass.imageset/Contents.json
@@ -0,0 +1,21 @@
+{
+ "images" : [
+ {
+ "filename" : "magnifying-glass.svg",
+ "idiom" : "universal",
+ "scale" : "1x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "2x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "3x"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Linphone/Assets.xcassets/magnifying-glass.imageset/magnifying-glass.svg b/Linphone/Assets.xcassets/magnifying-glass.imageset/magnifying-glass.svg
new file mode 100644
index 000000000..39a3b251d
--- /dev/null
+++ b/Linphone/Assets.xcassets/magnifying-glass.imageset/magnifying-glass.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/Linphone/Assets.xcassets/media-encryption-srtp.imageset/Contents.json b/Linphone/Assets.xcassets/media-encryption-srtp.imageset/Contents.json
new file mode 100644
index 000000000..0417d3217
--- /dev/null
+++ b/Linphone/Assets.xcassets/media-encryption-srtp.imageset/Contents.json
@@ -0,0 +1,21 @@
+{
+ "images" : [
+ {
+ "filename" : "media_encryption_srtp.svg",
+ "idiom" : "universal",
+ "scale" : "1x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "2x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "3x"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Linphone/Assets.xcassets/media-encryption-srtp.imageset/media_encryption_srtp.svg b/Linphone/Assets.xcassets/media-encryption-srtp.imageset/media_encryption_srtp.svg
new file mode 100644
index 000000000..37df36fc3
--- /dev/null
+++ b/Linphone/Assets.xcassets/media-encryption-srtp.imageset/media_encryption_srtp.svg
@@ -0,0 +1,6 @@
+
diff --git a/Linphone/Assets.xcassets/media-encryption-zrtp-pq.imageset/Contents.json b/Linphone/Assets.xcassets/media-encryption-zrtp-pq.imageset/Contents.json
new file mode 100644
index 000000000..2296357bc
--- /dev/null
+++ b/Linphone/Assets.xcassets/media-encryption-zrtp-pq.imageset/Contents.json
@@ -0,0 +1,21 @@
+{
+ "images" : [
+ {
+ "filename" : "media_encryption_zrtp_pq.svg",
+ "idiom" : "universal",
+ "scale" : "1x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "2x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "3x"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Linphone/Assets.xcassets/media-encryption-zrtp-pq.imageset/media_encryption_zrtp_pq.svg b/Linphone/Assets.xcassets/media-encryption-zrtp-pq.imageset/media_encryption_zrtp_pq.svg
new file mode 100644
index 000000000..23acb2599
--- /dev/null
+++ b/Linphone/Assets.xcassets/media-encryption-zrtp-pq.imageset/media_encryption_zrtp_pq.svg
@@ -0,0 +1,6 @@
+
diff --git a/Linphone/Assets.xcassets/meeting_plus.imageset/Contents.json b/Linphone/Assets.xcassets/meeting_plus.imageset/Contents.json
new file mode 100644
index 000000000..f79f71a2d
--- /dev/null
+++ b/Linphone/Assets.xcassets/meeting_plus.imageset/Contents.json
@@ -0,0 +1,21 @@
+{
+ "images" : [
+ {
+ "filename" : "meeting_plus.svg",
+ "idiom" : "universal",
+ "scale" : "1x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "2x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "3x"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Linphone/Assets.xcassets/meeting_plus.imageset/meeting_plus.svg b/Linphone/Assets.xcassets/meeting_plus.imageset/meeting_plus.svg
new file mode 100644
index 000000000..19079d429
--- /dev/null
+++ b/Linphone/Assets.xcassets/meeting_plus.imageset/meeting_plus.svg
@@ -0,0 +1,4 @@
+
diff --git a/Linphone/Assets.xcassets/meetings.imageset/Contents.json b/Linphone/Assets.xcassets/meetings.imageset/Contents.json
new file mode 100644
index 000000000..0a516178d
--- /dev/null
+++ b/Linphone/Assets.xcassets/meetings.imageset/Contents.json
@@ -0,0 +1,21 @@
+{
+ "images" : [
+ {
+ "filename" : "meetings.svg",
+ "idiom" : "universal",
+ "scale" : "1x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "2x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "3x"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Linphone/Assets.xcassets/meetings.imageset/meetings.svg b/Linphone/Assets.xcassets/meetings.imageset/meetings.svg
new file mode 100644
index 000000000..a56cd653b
--- /dev/null
+++ b/Linphone/Assets.xcassets/meetings.imageset/meetings.svg
@@ -0,0 +1,8 @@
+
diff --git a/Linphone/Assets.xcassets/microphone-slash.imageset/Contents.json b/Linphone/Assets.xcassets/microphone-slash.imageset/Contents.json
new file mode 100644
index 000000000..2333803cf
--- /dev/null
+++ b/Linphone/Assets.xcassets/microphone-slash.imageset/Contents.json
@@ -0,0 +1,21 @@
+{
+ "images" : [
+ {
+ "filename" : "microphone-slash.svg",
+ "idiom" : "universal",
+ "scale" : "1x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "2x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "3x"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Linphone/Assets.xcassets/microphone-slash.imageset/microphone-slash.svg b/Linphone/Assets.xcassets/microphone-slash.imageset/microphone-slash.svg
new file mode 100644
index 000000000..406de1e82
--- /dev/null
+++ b/Linphone/Assets.xcassets/microphone-slash.imageset/microphone-slash.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/Linphone/Assets.xcassets/microphone-stage.imageset/Contents.json b/Linphone/Assets.xcassets/microphone-stage.imageset/Contents.json
new file mode 100644
index 000000000..810efc4ce
--- /dev/null
+++ b/Linphone/Assets.xcassets/microphone-stage.imageset/Contents.json
@@ -0,0 +1,21 @@
+{
+ "images" : [
+ {
+ "filename" : "microphone-stage.svg",
+ "idiom" : "universal",
+ "scale" : "1x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "2x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "3x"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Linphone/Assets.xcassets/microphone-stage.imageset/microphone-stage.svg b/Linphone/Assets.xcassets/microphone-stage.imageset/microphone-stage.svg
new file mode 100644
index 000000000..dd4ba119d
--- /dev/null
+++ b/Linphone/Assets.xcassets/microphone-stage.imageset/microphone-stage.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/Linphone/Assets.xcassets/microphone.imageset/Contents.json b/Linphone/Assets.xcassets/microphone.imageset/Contents.json
new file mode 100644
index 000000000..49b7a33fb
--- /dev/null
+++ b/Linphone/Assets.xcassets/microphone.imageset/Contents.json
@@ -0,0 +1,21 @@
+{
+ "images" : [
+ {
+ "filename" : "microphone.svg",
+ "idiom" : "universal",
+ "scale" : "1x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "2x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "3x"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Linphone/Assets.xcassets/microphone.imageset/microphone.svg b/Linphone/Assets.xcassets/microphone.imageset/microphone.svg
new file mode 100644
index 000000000..36f7b4e0a
--- /dev/null
+++ b/Linphone/Assets.xcassets/microphone.imageset/microphone.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/Linphone/Assets.xcassets/mountain.imageset/Contents.json b/Linphone/Assets.xcassets/mountain.imageset/Contents.json
new file mode 100644
index 000000000..101c38e7e
--- /dev/null
+++ b/Linphone/Assets.xcassets/mountain.imageset/Contents.json
@@ -0,0 +1,21 @@
+{
+ "images" : [
+ {
+ "filename" : "mountain.svg",
+ "idiom" : "universal",
+ "scale" : "1x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "2x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "3x"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Linphone/Assets.xcassets/mountain.imageset/mountain.svg b/Linphone/Assets.xcassets/mountain.imageset/mountain.svg
new file mode 100644
index 000000000..fdb0ecf8d
--- /dev/null
+++ b/Linphone/Assets.xcassets/mountain.imageset/mountain.svg
@@ -0,0 +1,14 @@
+
diff --git a/Linphone/Assets.xcassets/mountains.imageset/Contents.json b/Linphone/Assets.xcassets/mountains.imageset/Contents.json
new file mode 100644
index 000000000..fbcb6f1f0
--- /dev/null
+++ b/Linphone/Assets.xcassets/mountains.imageset/Contents.json
@@ -0,0 +1,21 @@
+{
+ "images" : [
+ {
+ "filename" : "mountains.svg",
+ "idiom" : "universal",
+ "scale" : "1x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "2x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "3x"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Linphone/Assets.xcassets/mountains.imageset/mountains.svg b/Linphone/Assets.xcassets/mountains.imageset/mountains.svg
new file mode 100644
index 000000000..fdb0ecf8d
--- /dev/null
+++ b/Linphone/Assets.xcassets/mountains.imageset/mountains.svg
@@ -0,0 +1,14 @@
+
diff --git a/Linphone/Assets.xcassets/not-trusted.imageset/Contents.json b/Linphone/Assets.xcassets/not-trusted.imageset/Contents.json
new file mode 100644
index 000000000..919aa20b8
--- /dev/null
+++ b/Linphone/Assets.xcassets/not-trusted.imageset/Contents.json
@@ -0,0 +1,21 @@
+{
+ "images" : [
+ {
+ "filename" : "not_trusted.svg",
+ "idiom" : "universal",
+ "scale" : "1x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "2x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "3x"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Linphone/Assets.xcassets/not-trusted.imageset/not_trusted.svg b/Linphone/Assets.xcassets/not-trusted.imageset/not_trusted.svg
new file mode 100644
index 000000000..b16dc774d
--- /dev/null
+++ b/Linphone/Assets.xcassets/not-trusted.imageset/not_trusted.svg
@@ -0,0 +1,13 @@
+
diff --git a/Linphone/Assets.xcassets/note.imageset/Contents.json b/Linphone/Assets.xcassets/note.imageset/Contents.json
new file mode 100644
index 000000000..e7ac1e9ef
--- /dev/null
+++ b/Linphone/Assets.xcassets/note.imageset/Contents.json
@@ -0,0 +1,21 @@
+{
+ "images" : [
+ {
+ "filename" : "note.svg",
+ "idiom" : "universal",
+ "scale" : "1x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "2x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "3x"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Linphone/Assets.xcassets/note.imageset/note.svg b/Linphone/Assets.xcassets/note.imageset/note.svg
new file mode 100644
index 000000000..a5378aa7a
--- /dev/null
+++ b/Linphone/Assets.xcassets/note.imageset/note.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/Linphone/Assets.xcassets/notebook.imageset/Contents.json b/Linphone/Assets.xcassets/notebook.imageset/Contents.json
new file mode 100644
index 000000000..6a15bef13
--- /dev/null
+++ b/Linphone/Assets.xcassets/notebook.imageset/Contents.json
@@ -0,0 +1,21 @@
+{
+ "images" : [
+ {
+ "filename" : "notebook.svg",
+ "idiom" : "universal",
+ "scale" : "1x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "2x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "3x"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Linphone/Assets.xcassets/notebook.imageset/notebook.svg b/Linphone/Assets.xcassets/notebook.imageset/notebook.svg
new file mode 100644
index 000000000..6acc44dff
--- /dev/null
+++ b/Linphone/Assets.xcassets/notebook.imageset/notebook.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/Linphone/Assets.xcassets/open-source.imageset/Contents.json b/Linphone/Assets.xcassets/open-source.imageset/Contents.json
new file mode 100644
index 000000000..a65928f62
--- /dev/null
+++ b/Linphone/Assets.xcassets/open-source.imageset/Contents.json
@@ -0,0 +1,21 @@
+{
+ "images" : [
+ {
+ "filename" : "open_source.svg",
+ "idiom" : "universal",
+ "scale" : "1x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "2x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "3x"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Linphone/Assets.xcassets/open-source.imageset/open_source.svg b/Linphone/Assets.xcassets/open-source.imageset/open_source.svg
new file mode 100644
index 000000000..5f9b9ae0b
--- /dev/null
+++ b/Linphone/Assets.xcassets/open-source.imageset/open_source.svg
@@ -0,0 +1,3 @@
+
diff --git a/Linphone/Assets.xcassets/outgoing-call-missed.imageset/Contents.json b/Linphone/Assets.xcassets/outgoing-call-missed.imageset/Contents.json
new file mode 100644
index 000000000..52b286e00
--- /dev/null
+++ b/Linphone/Assets.xcassets/outgoing-call-missed.imageset/Contents.json
@@ -0,0 +1,21 @@
+{
+ "images" : [
+ {
+ "filename" : "outgoing_call_missed.svg",
+ "idiom" : "universal",
+ "scale" : "1x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "2x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "3x"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Linphone/Assets.xcassets/outgoing-call-missed.imageset/outgoing_call_missed.svg b/Linphone/Assets.xcassets/outgoing-call-missed.imageset/outgoing_call_missed.svg
new file mode 100644
index 000000000..a5433e0d3
--- /dev/null
+++ b/Linphone/Assets.xcassets/outgoing-call-missed.imageset/outgoing_call_missed.svg
@@ -0,0 +1,3 @@
+
diff --git a/Linphone/Assets.xcassets/outgoing-call-rejected.imageset/Contents.json b/Linphone/Assets.xcassets/outgoing-call-rejected.imageset/Contents.json
new file mode 100644
index 000000000..2af078bf0
--- /dev/null
+++ b/Linphone/Assets.xcassets/outgoing-call-rejected.imageset/Contents.json
@@ -0,0 +1,21 @@
+{
+ "images" : [
+ {
+ "filename" : "outgoing_call_rejected.svg",
+ "idiom" : "universal",
+ "scale" : "1x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "2x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "3x"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Linphone/Assets.xcassets/outgoing-call-rejected.imageset/outgoing_call_rejected.svg b/Linphone/Assets.xcassets/outgoing-call-rejected.imageset/outgoing_call_rejected.svg
new file mode 100644
index 000000000..39fa5aeac
--- /dev/null
+++ b/Linphone/Assets.xcassets/outgoing-call-rejected.imageset/outgoing_call_rejected.svg
@@ -0,0 +1,3 @@
+
diff --git a/Linphone/Assets.xcassets/outgoing-call.imageset/Contents.json b/Linphone/Assets.xcassets/outgoing-call.imageset/Contents.json
new file mode 100644
index 000000000..3423e59e1
--- /dev/null
+++ b/Linphone/Assets.xcassets/outgoing-call.imageset/Contents.json
@@ -0,0 +1,21 @@
+{
+ "images" : [
+ {
+ "filename" : "outgoing_call.svg",
+ "idiom" : "universal",
+ "scale" : "1x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "2x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "3x"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Linphone/Assets.xcassets/outgoing-call.imageset/outgoing_call.svg b/Linphone/Assets.xcassets/outgoing-call.imageset/outgoing_call.svg
new file mode 100644
index 000000000..21bb7c7da
--- /dev/null
+++ b/Linphone/Assets.xcassets/outgoing-call.imageset/outgoing_call.svg
@@ -0,0 +1,3 @@
+
diff --git a/Linphone/Assets.xcassets/paper-plane-tilt.imageset/Contents.json b/Linphone/Assets.xcassets/paper-plane-tilt.imageset/Contents.json
new file mode 100644
index 000000000..74767b869
--- /dev/null
+++ b/Linphone/Assets.xcassets/paper-plane-tilt.imageset/Contents.json
@@ -0,0 +1,21 @@
+{
+ "images" : [
+ {
+ "filename" : "paper-plane-tilt.svg",
+ "idiom" : "universal",
+ "scale" : "1x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "2x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "3x"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Linphone/Assets.xcassets/paper-plane-tilt.imageset/paper-plane-tilt.svg b/Linphone/Assets.xcassets/paper-plane-tilt.imageset/paper-plane-tilt.svg
new file mode 100644
index 000000000..73f7dedd3
--- /dev/null
+++ b/Linphone/Assets.xcassets/paper-plane-tilt.imageset/paper-plane-tilt.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/Linphone/Assets.xcassets/paperclip.imageset/Contents.json b/Linphone/Assets.xcassets/paperclip.imageset/Contents.json
new file mode 100644
index 000000000..f901e0f92
--- /dev/null
+++ b/Linphone/Assets.xcassets/paperclip.imageset/Contents.json
@@ -0,0 +1,21 @@
+{
+ "images" : [
+ {
+ "filename" : "paperclip.svg",
+ "idiom" : "universal",
+ "scale" : "1x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "2x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "3x"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Linphone/Assets.xcassets/paperclip.imageset/paperclip.svg b/Linphone/Assets.xcassets/paperclip.imageset/paperclip.svg
new file mode 100644
index 000000000..8ed525881
--- /dev/null
+++ b/Linphone/Assets.xcassets/paperclip.imageset/paperclip.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/Linphone/Assets.xcassets/pause-fill.imageset/Contents.json b/Linphone/Assets.xcassets/pause-fill.imageset/Contents.json
new file mode 100644
index 000000000..abc6d6ada
--- /dev/null
+++ b/Linphone/Assets.xcassets/pause-fill.imageset/Contents.json
@@ -0,0 +1,21 @@
+{
+ "images" : [
+ {
+ "filename" : "pause-fill.svg",
+ "idiom" : "universal",
+ "scale" : "1x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "2x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "3x"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Linphone/Assets.xcassets/pause-fill.imageset/pause-fill.svg b/Linphone/Assets.xcassets/pause-fill.imageset/pause-fill.svg
new file mode 100644
index 000000000..784dd71dd
--- /dev/null
+++ b/Linphone/Assets.xcassets/pause-fill.imageset/pause-fill.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/Linphone/Assets.xcassets/pause.imageset/Contents.json b/Linphone/Assets.xcassets/pause.imageset/Contents.json
new file mode 100644
index 000000000..61f53a7c2
--- /dev/null
+++ b/Linphone/Assets.xcassets/pause.imageset/Contents.json
@@ -0,0 +1,21 @@
+{
+ "images" : [
+ {
+ "filename" : "pause.svg",
+ "idiom" : "universal",
+ "scale" : "1x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "2x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "3x"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Linphone/Assets.xcassets/pause.imageset/pause.svg b/Linphone/Assets.xcassets/pause.imageset/pause.svg
new file mode 100644
index 000000000..2ba53b7c4
--- /dev/null
+++ b/Linphone/Assets.xcassets/pause.imageset/pause.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/Linphone/Assets.xcassets/pencil-simple.imageset/Contents.json b/Linphone/Assets.xcassets/pencil-simple.imageset/Contents.json
new file mode 100644
index 000000000..fb2e28212
--- /dev/null
+++ b/Linphone/Assets.xcassets/pencil-simple.imageset/Contents.json
@@ -0,0 +1,21 @@
+{
+ "images" : [
+ {
+ "filename" : "pencil-simple.svg",
+ "idiom" : "universal",
+ "scale" : "1x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "2x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "3x"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Linphone/Assets.xcassets/pencil-simple.imageset/pencil-simple.svg b/Linphone/Assets.xcassets/pencil-simple.imageset/pencil-simple.svg
new file mode 100644
index 000000000..ceb292bbf
--- /dev/null
+++ b/Linphone/Assets.xcassets/pencil-simple.imageset/pencil-simple.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/Linphone/Assets.xcassets/phone-call.imageset/Contents.json b/Linphone/Assets.xcassets/phone-call.imageset/Contents.json
new file mode 100644
index 000000000..caac2f414
--- /dev/null
+++ b/Linphone/Assets.xcassets/phone-call.imageset/Contents.json
@@ -0,0 +1,21 @@
+{
+ "images" : [
+ {
+ "filename" : "phone-call.svg",
+ "idiom" : "universal",
+ "scale" : "1x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "2x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "3x"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Linphone/Assets.xcassets/phone-call.imageset/phone-call.svg b/Linphone/Assets.xcassets/phone-call.imageset/phone-call.svg
new file mode 100644
index 000000000..abba91e83
--- /dev/null
+++ b/Linphone/Assets.xcassets/phone-call.imageset/phone-call.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/Linphone/Assets.xcassets/phone-disconnect.imageset/Contents.json b/Linphone/Assets.xcassets/phone-disconnect.imageset/Contents.json
new file mode 100644
index 000000000..b0f43cd14
--- /dev/null
+++ b/Linphone/Assets.xcassets/phone-disconnect.imageset/Contents.json
@@ -0,0 +1,21 @@
+{
+ "images" : [
+ {
+ "filename" : "phone-disconnect.svg",
+ "idiom" : "universal",
+ "scale" : "1x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "2x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "3x"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Linphone/Assets.xcassets/phone-disconnect.imageset/phone-disconnect.svg b/Linphone/Assets.xcassets/phone-disconnect.imageset/phone-disconnect.svg
new file mode 100644
index 000000000..5e42636c4
--- /dev/null
+++ b/Linphone/Assets.xcassets/phone-disconnect.imageset/phone-disconnect.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/Linphone/Assets.xcassets/phone-list.imageset/Contents.json b/Linphone/Assets.xcassets/phone-list.imageset/Contents.json
new file mode 100644
index 000000000..93d7f6f6b
--- /dev/null
+++ b/Linphone/Assets.xcassets/phone-list.imageset/Contents.json
@@ -0,0 +1,21 @@
+{
+ "images" : [
+ {
+ "filename" : "phone-list.svg",
+ "idiom" : "universal",
+ "scale" : "1x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "2x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "3x"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Linphone/Assets.xcassets/phone-list.imageset/phone-list.svg b/Linphone/Assets.xcassets/phone-list.imageset/phone-list.svg
new file mode 100644
index 000000000..d070e2710
--- /dev/null
+++ b/Linphone/Assets.xcassets/phone-list.imageset/phone-list.svg
@@ -0,0 +1,6 @@
+
diff --git a/Linphone/Assets.xcassets/phone-plus.imageset/Contents.json b/Linphone/Assets.xcassets/phone-plus.imageset/Contents.json
new file mode 100644
index 000000000..409aac10b
--- /dev/null
+++ b/Linphone/Assets.xcassets/phone-plus.imageset/Contents.json
@@ -0,0 +1,21 @@
+{
+ "images" : [
+ {
+ "filename" : "phone-plus.svg",
+ "idiom" : "universal",
+ "scale" : "1x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "2x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "3x"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Linphone/Assets.xcassets/phone-plus.imageset/phone-plus.svg b/Linphone/Assets.xcassets/phone-plus.imageset/phone-plus.svg
new file mode 100644
index 000000000..e9bad66df
--- /dev/null
+++ b/Linphone/Assets.xcassets/phone-plus.imageset/phone-plus.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/Linphone/Assets.xcassets/phone-transfer.imageset/Contents.json b/Linphone/Assets.xcassets/phone-transfer.imageset/Contents.json
new file mode 100644
index 000000000..702f535c8
--- /dev/null
+++ b/Linphone/Assets.xcassets/phone-transfer.imageset/Contents.json
@@ -0,0 +1,21 @@
+{
+ "images" : [
+ {
+ "filename" : "phone-transfer.svg",
+ "idiom" : "universal",
+ "scale" : "1x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "2x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "3x"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Linphone/Assets.xcassets/phone-transfer.imageset/phone-transfer.svg b/Linphone/Assets.xcassets/phone-transfer.imageset/phone-transfer.svg
new file mode 100644
index 000000000..c63342fd6
--- /dev/null
+++ b/Linphone/Assets.xcassets/phone-transfer.imageset/phone-transfer.svg
@@ -0,0 +1,4 @@
+
diff --git a/Linphone/Assets.xcassets/phone.imageset/Contents.json b/Linphone/Assets.xcassets/phone.imageset/Contents.json
new file mode 100644
index 000000000..1c2ef1a6a
--- /dev/null
+++ b/Linphone/Assets.xcassets/phone.imageset/Contents.json
@@ -0,0 +1,21 @@
+{
+ "images" : [
+ {
+ "filename" : "phone.svg",
+ "idiom" : "universal",
+ "scale" : "1x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "2x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "3x"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Linphone/Assets.xcassets/phone.imageset/phone.svg b/Linphone/Assets.xcassets/phone.imageset/phone.svg
new file mode 100644
index 000000000..ac3ff5a1c
--- /dev/null
+++ b/Linphone/Assets.xcassets/phone.imageset/phone.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/Linphone/Assets.xcassets/picture-in-picture.imageset/Contents.json b/Linphone/Assets.xcassets/picture-in-picture.imageset/Contents.json
new file mode 100644
index 000000000..44e023b13
--- /dev/null
+++ b/Linphone/Assets.xcassets/picture-in-picture.imageset/Contents.json
@@ -0,0 +1,21 @@
+{
+ "images" : [
+ {
+ "filename" : "picture-in-picture.svg",
+ "idiom" : "universal",
+ "scale" : "1x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "2x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "3x"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Linphone/Assets.xcassets/picture-in-picture.imageset/picture-in-picture.svg b/Linphone/Assets.xcassets/picture-in-picture.imageset/picture-in-picture.svg
new file mode 100644
index 000000000..4a7ab8304
--- /dev/null
+++ b/Linphone/Assets.xcassets/picture-in-picture.imageset/picture-in-picture.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/Linphone/Assets.xcassets/play-fill.imageset/Contents.json b/Linphone/Assets.xcassets/play-fill.imageset/Contents.json
new file mode 100644
index 000000000..3d4192c3e
--- /dev/null
+++ b/Linphone/Assets.xcassets/play-fill.imageset/Contents.json
@@ -0,0 +1,21 @@
+{
+ "images" : [
+ {
+ "filename" : "play-fill.svg",
+ "idiom" : "universal",
+ "scale" : "1x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "2x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "3x"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Linphone/Assets.xcassets/play-fill.imageset/play-fill.svg b/Linphone/Assets.xcassets/play-fill.imageset/play-fill.svg
new file mode 100644
index 000000000..fb0a6d7fb
--- /dev/null
+++ b/Linphone/Assets.xcassets/play-fill.imageset/play-fill.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/Linphone/Assets.xcassets/play.imageset/Contents.json b/Linphone/Assets.xcassets/play.imageset/Contents.json
new file mode 100644
index 000000000..b17e13df5
--- /dev/null
+++ b/Linphone/Assets.xcassets/play.imageset/Contents.json
@@ -0,0 +1,21 @@
+{
+ "images" : [
+ {
+ "filename" : "play.svg",
+ "idiom" : "universal",
+ "scale" : "1x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "2x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "3x"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Linphone/Assets.xcassets/play.imageset/play.svg b/Linphone/Assets.xcassets/play.imageset/play.svg
new file mode 100644
index 000000000..9df4f4c24
--- /dev/null
+++ b/Linphone/Assets.xcassets/play.imageset/play.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/Linphone/Assets.xcassets/plus-circle.imageset/Contents.json b/Linphone/Assets.xcassets/plus-circle.imageset/Contents.json
new file mode 100644
index 000000000..1775f9ba2
--- /dev/null
+++ b/Linphone/Assets.xcassets/plus-circle.imageset/Contents.json
@@ -0,0 +1,21 @@
+{
+ "images" : [
+ {
+ "filename" : "plus-circle.svg",
+ "idiom" : "universal",
+ "scale" : "1x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "2x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "3x"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Linphone/Assets.xcassets/plus-circle.imageset/plus-circle.svg b/Linphone/Assets.xcassets/plus-circle.imageset/plus-circle.svg
new file mode 100644
index 000000000..0365c1a4e
--- /dev/null
+++ b/Linphone/Assets.xcassets/plus-circle.imageset/plus-circle.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/Linphone/Assets.xcassets/plus.imageset/Contents.json b/Linphone/Assets.xcassets/plus.imageset/Contents.json
new file mode 100644
index 000000000..16eddb498
--- /dev/null
+++ b/Linphone/Assets.xcassets/plus.imageset/Contents.json
@@ -0,0 +1,21 @@
+{
+ "images" : [
+ {
+ "filename" : "plus.svg",
+ "idiom" : "universal",
+ "scale" : "1x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "2x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "3x"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Linphone/Assets.xcassets/plus.imageset/plus.svg b/Linphone/Assets.xcassets/plus.imageset/plus.svg
new file mode 100644
index 000000000..79c378c6b
--- /dev/null
+++ b/Linphone/Assets.xcassets/plus.imageset/plus.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/Linphone/Assets.xcassets/presence-busy.imageset/Contents.json b/Linphone/Assets.xcassets/presence-busy.imageset/Contents.json
new file mode 100644
index 000000000..227036d8d
--- /dev/null
+++ b/Linphone/Assets.xcassets/presence-busy.imageset/Contents.json
@@ -0,0 +1,21 @@
+{
+ "images" : [
+ {
+ "filename" : "presence-busy.svg",
+ "idiom" : "universal",
+ "scale" : "1x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "2x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "3x"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Linphone/Assets.xcassets/presence-busy.imageset/presence-busy.svg b/Linphone/Assets.xcassets/presence-busy.imageset/presence-busy.svg
new file mode 100644
index 000000000..0f24966ca
--- /dev/null
+++ b/Linphone/Assets.xcassets/presence-busy.imageset/presence-busy.svg
@@ -0,0 +1,5 @@
+
diff --git a/Linphone/Assets.xcassets/presence-online.imageset/Contents.json b/Linphone/Assets.xcassets/presence-online.imageset/Contents.json
new file mode 100644
index 000000000..606200f2a
--- /dev/null
+++ b/Linphone/Assets.xcassets/presence-online.imageset/Contents.json
@@ -0,0 +1,21 @@
+{
+ "images" : [
+ {
+ "filename" : "presence-online.svg",
+ "idiom" : "universal",
+ "scale" : "1x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "2x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "3x"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Linphone/Assets.xcassets/presence-online.imageset/presence-online.svg b/Linphone/Assets.xcassets/presence-online.imageset/presence-online.svg
new file mode 100644
index 000000000..ae3b6ed5e
--- /dev/null
+++ b/Linphone/Assets.xcassets/presence-online.imageset/presence-online.svg
@@ -0,0 +1,5 @@
+
diff --git a/Linphone/Assets.xcassets/profil-picture-default.imageset/Contents.json b/Linphone/Assets.xcassets/profil-picture-default.imageset/Contents.json
new file mode 100644
index 000000000..68bdd056b
--- /dev/null
+++ b/Linphone/Assets.xcassets/profil-picture-default.imageset/Contents.json
@@ -0,0 +1,21 @@
+{
+ "images" : [
+ {
+ "filename" : "profil-picture-default.svg",
+ "idiom" : "universal",
+ "scale" : "1x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "2x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "3x"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Linphone/Assets.xcassets/profil-picture-default.imageset/profil-picture-default.svg b/Linphone/Assets.xcassets/profil-picture-default.imageset/profil-picture-default.svg
new file mode 100644
index 000000000..557a5087a
--- /dev/null
+++ b/Linphone/Assets.xcassets/profil-picture-default.imageset/profil-picture-default.svg
@@ -0,0 +1,18 @@
+
diff --git a/Linphone/Assets.xcassets/profile-image-example.imageset/Contents.json b/Linphone/Assets.xcassets/profile-image-example.imageset/Contents.json
new file mode 100644
index 000000000..7d497f83a
--- /dev/null
+++ b/Linphone/Assets.xcassets/profile-image-example.imageset/Contents.json
@@ -0,0 +1,21 @@
+{
+ "images" : [
+ {
+ "filename" : "profile-image-example.png",
+ "idiom" : "universal",
+ "scale" : "1x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "2x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "3x"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Linphone/Assets.xcassets/profile-image-example.imageset/profile-image-example.png b/Linphone/Assets.xcassets/profile-image-example.imageset/profile-image-example.png
new file mode 100644
index 000000000..d1e23f999
Binary files /dev/null and b/Linphone/Assets.xcassets/profile-image-example.imageset/profile-image-example.png differ
diff --git a/Linphone/Assets.xcassets/profile-mode.imageset/Contents.json b/Linphone/Assets.xcassets/profile-mode.imageset/Contents.json
new file mode 100644
index 000000000..528acc575
--- /dev/null
+++ b/Linphone/Assets.xcassets/profile-mode.imageset/Contents.json
@@ -0,0 +1,21 @@
+{
+ "images" : [
+ {
+ "filename" : "profile-mode.png",
+ "idiom" : "universal",
+ "scale" : "1x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "2x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "3x"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Linphone/Assets.xcassets/profile-mode.imageset/profile-mode.png b/Linphone/Assets.xcassets/profile-mode.imageset/profile-mode.png
new file mode 100644
index 000000000..740a909fb
Binary files /dev/null and b/Linphone/Assets.xcassets/profile-mode.imageset/profile-mode.png differ
diff --git a/Linphone/Assets.xcassets/qr-code.imageset/Contents.json b/Linphone/Assets.xcassets/qr-code.imageset/Contents.json
new file mode 100644
index 000000000..12ceab544
--- /dev/null
+++ b/Linphone/Assets.xcassets/qr-code.imageset/Contents.json
@@ -0,0 +1,21 @@
+{
+ "images" : [
+ {
+ "filename" : "qr-code.svg",
+ "idiom" : "universal",
+ "scale" : "1x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "2x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "3x"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Linphone/Assets.xcassets/qr-code.imageset/qr-code.svg b/Linphone/Assets.xcassets/qr-code.imageset/qr-code.svg
new file mode 100644
index 000000000..2d6248f02
--- /dev/null
+++ b/Linphone/Assets.xcassets/qr-code.imageset/qr-code.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/Linphone/Assets.xcassets/question.imageset/Contents.json b/Linphone/Assets.xcassets/question.imageset/Contents.json
new file mode 100644
index 000000000..893d8a3b3
--- /dev/null
+++ b/Linphone/Assets.xcassets/question.imageset/Contents.json
@@ -0,0 +1,21 @@
+{
+ "images" : [
+ {
+ "filename" : "question.svg",
+ "idiom" : "universal",
+ "scale" : "1x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "2x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "3x"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Linphone/Assets.xcassets/question.imageset/question.svg b/Linphone/Assets.xcassets/question.imageset/question.svg
new file mode 100644
index 000000000..6d8013ceb
--- /dev/null
+++ b/Linphone/Assets.xcassets/question.imageset/question.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/Linphone/Assets.xcassets/radio-button-fill.imageset/Contents.json b/Linphone/Assets.xcassets/radio-button-fill.imageset/Contents.json
new file mode 100644
index 000000000..7de01f0e6
--- /dev/null
+++ b/Linphone/Assets.xcassets/radio-button-fill.imageset/Contents.json
@@ -0,0 +1,21 @@
+{
+ "images" : [
+ {
+ "filename" : "radio-button-fill.svg",
+ "idiom" : "universal",
+ "scale" : "1x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "2x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "3x"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Linphone/Assets.xcassets/radio-button-fill.imageset/radio-button-fill.svg b/Linphone/Assets.xcassets/radio-button-fill.imageset/radio-button-fill.svg
new file mode 100644
index 000000000..1bc27f1d2
--- /dev/null
+++ b/Linphone/Assets.xcassets/radio-button-fill.imageset/radio-button-fill.svg
@@ -0,0 +1,10 @@
+
diff --git a/Linphone/Assets.xcassets/radio-button.imageset/Contents.json b/Linphone/Assets.xcassets/radio-button.imageset/Contents.json
new file mode 100644
index 000000000..61335ddfb
--- /dev/null
+++ b/Linphone/Assets.xcassets/radio-button.imageset/Contents.json
@@ -0,0 +1,21 @@
+{
+ "images" : [
+ {
+ "filename" : "radio-button.svg",
+ "idiom" : "universal",
+ "scale" : "1x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "2x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "3x"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Linphone/Assets.xcassets/radio-button.imageset/radio-button.svg b/Linphone/Assets.xcassets/radio-button.imageset/radio-button.svg
new file mode 100644
index 000000000..d9001baba
--- /dev/null
+++ b/Linphone/Assets.xcassets/radio-button.imageset/radio-button.svg
@@ -0,0 +1,10 @@
+
diff --git a/Linphone/Assets.xcassets/record-fill.imageset/Contents.json b/Linphone/Assets.xcassets/record-fill.imageset/Contents.json
new file mode 100644
index 000000000..81bfe4345
--- /dev/null
+++ b/Linphone/Assets.xcassets/record-fill.imageset/Contents.json
@@ -0,0 +1,21 @@
+{
+ "images" : [
+ {
+ "filename" : "record-fill.svg",
+ "idiom" : "universal",
+ "scale" : "1x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "2x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "3x"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Linphone/Assets.xcassets/record-fill.imageset/record-fill.svg b/Linphone/Assets.xcassets/record-fill.imageset/record-fill.svg
new file mode 100644
index 000000000..72d18e999
--- /dev/null
+++ b/Linphone/Assets.xcassets/record-fill.imageset/record-fill.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/Linphone/Assets.xcassets/reply-reversed.imageset/Contents.json b/Linphone/Assets.xcassets/reply-reversed.imageset/Contents.json
new file mode 100644
index 000000000..6d84f517c
--- /dev/null
+++ b/Linphone/Assets.xcassets/reply-reversed.imageset/Contents.json
@@ -0,0 +1,21 @@
+{
+ "images" : [
+ {
+ "filename" : "reply-reversed.svg",
+ "idiom" : "universal",
+ "scale" : "1x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "2x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "3x"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Linphone/Assets.xcassets/reply-reversed.imageset/reply-reversed.svg b/Linphone/Assets.xcassets/reply-reversed.imageset/reply-reversed.svg
new file mode 100644
index 000000000..a65d6cf6b
--- /dev/null
+++ b/Linphone/Assets.xcassets/reply-reversed.imageset/reply-reversed.svg
@@ -0,0 +1,5 @@
+
diff --git a/Linphone/Assets.xcassets/reply.imageset/Contents.json b/Linphone/Assets.xcassets/reply.imageset/Contents.json
new file mode 100644
index 000000000..f0dd5187e
--- /dev/null
+++ b/Linphone/Assets.xcassets/reply.imageset/Contents.json
@@ -0,0 +1,21 @@
+{
+ "images" : [
+ {
+ "filename" : "reply.svg",
+ "idiom" : "universal",
+ "scale" : "1x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "2x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "3x"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Linphone/Assets.xcassets/reply.imageset/reply.svg b/Linphone/Assets.xcassets/reply.imageset/reply.svg
new file mode 100644
index 000000000..41b7e5a2c
--- /dev/null
+++ b/Linphone/Assets.xcassets/reply.imageset/reply.svg
@@ -0,0 +1,5 @@
+
diff --git a/Linphone/Assets.xcassets/screencast.imageset/Contents.json b/Linphone/Assets.xcassets/screencast.imageset/Contents.json
new file mode 100644
index 000000000..945a4b5b5
--- /dev/null
+++ b/Linphone/Assets.xcassets/screencast.imageset/Contents.json
@@ -0,0 +1,21 @@
+{
+ "images" : [
+ {
+ "filename" : "screencast.svg",
+ "idiom" : "universal",
+ "scale" : "1x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "2x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "3x"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Linphone/Assets.xcassets/screencast.imageset/screencast.svg b/Linphone/Assets.xcassets/screencast.imageset/screencast.svg
new file mode 100644
index 000000000..c3befc548
--- /dev/null
+++ b/Linphone/Assets.xcassets/screencast.imageset/screencast.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/Linphone/Assets.xcassets/secured.imageset/Contents.json b/Linphone/Assets.xcassets/secured.imageset/Contents.json
new file mode 100644
index 000000000..ce5927e0f
--- /dev/null
+++ b/Linphone/Assets.xcassets/secured.imageset/Contents.json
@@ -0,0 +1,21 @@
+{
+ "images" : [
+ {
+ "filename" : "secured.svg",
+ "idiom" : "universal",
+ "scale" : "1x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "2x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "3x"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Linphone/Assets.xcassets/secured.imageset/secured.svg b/Linphone/Assets.xcassets/secured.imageset/secured.svg
new file mode 100644
index 000000000..212c4e527
--- /dev/null
+++ b/Linphone/Assets.xcassets/secured.imageset/secured.svg
@@ -0,0 +1,10 @@
+
diff --git a/Linphone/Assets.xcassets/security.imageset/Contents.json b/Linphone/Assets.xcassets/security.imageset/Contents.json
new file mode 100644
index 000000000..ef6708f15
--- /dev/null
+++ b/Linphone/Assets.xcassets/security.imageset/Contents.json
@@ -0,0 +1,21 @@
+{
+ "images" : [
+ {
+ "filename" : "security.svg",
+ "idiom" : "universal",
+ "scale" : "1x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "2x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "3x"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Linphone/Assets.xcassets/security.imageset/security.svg b/Linphone/Assets.xcassets/security.imageset/security.svg
new file mode 100644
index 000000000..00fa4a741
--- /dev/null
+++ b/Linphone/Assets.xcassets/security.imageset/security.svg
@@ -0,0 +1,6 @@
+
diff --git a/Linphone/Assets.xcassets/share-network.imageset/Contents.json b/Linphone/Assets.xcassets/share-network.imageset/Contents.json
new file mode 100644
index 000000000..46fe445fb
--- /dev/null
+++ b/Linphone/Assets.xcassets/share-network.imageset/Contents.json
@@ -0,0 +1,21 @@
+{
+ "images" : [
+ {
+ "filename" : "share-network.svg",
+ "idiom" : "universal",
+ "scale" : "1x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "2x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "3x"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Linphone/Assets.xcassets/share-network.imageset/share-network.svg b/Linphone/Assets.xcassets/share-network.imageset/share-network.svg
new file mode 100644
index 000000000..02d8619a1
--- /dev/null
+++ b/Linphone/Assets.xcassets/share-network.imageset/share-network.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/Linphone/Assets.xcassets/shield-warning.imageset/Contents.json b/Linphone/Assets.xcassets/shield-warning.imageset/Contents.json
new file mode 100644
index 000000000..84d820c30
--- /dev/null
+++ b/Linphone/Assets.xcassets/shield-warning.imageset/Contents.json
@@ -0,0 +1,21 @@
+{
+ "images" : [
+ {
+ "filename" : "shield-warning.svg",
+ "idiom" : "universal",
+ "scale" : "1x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "2x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "3x"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Linphone/Assets.xcassets/shield-warning.imageset/shield-warning.svg b/Linphone/Assets.xcassets/shield-warning.imageset/shield-warning.svg
new file mode 100644
index 000000000..dae911b15
--- /dev/null
+++ b/Linphone/Assets.xcassets/shield-warning.imageset/shield-warning.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/Linphone/Assets.xcassets/sign-out.imageset/Contents.json b/Linphone/Assets.xcassets/sign-out.imageset/Contents.json
new file mode 100644
index 000000000..19f1bacac
--- /dev/null
+++ b/Linphone/Assets.xcassets/sign-out.imageset/Contents.json
@@ -0,0 +1,21 @@
+{
+ "images" : [
+ {
+ "filename" : "sign-out.svg",
+ "idiom" : "universal",
+ "scale" : "1x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "2x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "3x"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Linphone/Assets.xcassets/sign-out.imageset/sign-out.svg b/Linphone/Assets.xcassets/sign-out.imageset/sign-out.svg
new file mode 100644
index 000000000..ffd423eec
--- /dev/null
+++ b/Linphone/Assets.xcassets/sign-out.imageset/sign-out.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/Linphone/Assets.xcassets/slideshow.imageset/Contents.json b/Linphone/Assets.xcassets/slideshow.imageset/Contents.json
new file mode 100644
index 000000000..97341936c
--- /dev/null
+++ b/Linphone/Assets.xcassets/slideshow.imageset/Contents.json
@@ -0,0 +1,21 @@
+{
+ "images" : [
+ {
+ "filename" : "slideshow.svg",
+ "idiom" : "universal",
+ "scale" : "1x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "2x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "3x"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Linphone/Assets.xcassets/slideshow.imageset/slideshow.svg b/Linphone/Assets.xcassets/slideshow.imageset/slideshow.svg
new file mode 100644
index 000000000..d52a4aeaa
--- /dev/null
+++ b/Linphone/Assets.xcassets/slideshow.imageset/slideshow.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/Linphone/Assets.xcassets/smiley.imageset/Contents.json b/Linphone/Assets.xcassets/smiley.imageset/Contents.json
new file mode 100644
index 000000000..a7632c4ec
--- /dev/null
+++ b/Linphone/Assets.xcassets/smiley.imageset/Contents.json
@@ -0,0 +1,21 @@
+{
+ "images" : [
+ {
+ "filename" : "smiley.svg",
+ "idiom" : "universal",
+ "scale" : "1x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "2x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "3x"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Linphone/Assets.xcassets/smiley.imageset/smiley.svg b/Linphone/Assets.xcassets/smiley.imageset/smiley.svg
new file mode 100644
index 000000000..cc0711829
--- /dev/null
+++ b/Linphone/Assets.xcassets/smiley.imageset/smiley.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/Linphone/Assets.xcassets/speaker-high.imageset/Contents.json b/Linphone/Assets.xcassets/speaker-high.imageset/Contents.json
new file mode 100644
index 000000000..d1d2b5aba
--- /dev/null
+++ b/Linphone/Assets.xcassets/speaker-high.imageset/Contents.json
@@ -0,0 +1,21 @@
+{
+ "images" : [
+ {
+ "filename" : "speaker-high.svg",
+ "idiom" : "universal",
+ "scale" : "1x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "2x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "3x"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Linphone/Assets.xcassets/speaker-high.imageset/speaker-high.svg b/Linphone/Assets.xcassets/speaker-high.imageset/speaker-high.svg
new file mode 100644
index 000000000..1633bdcc0
--- /dev/null
+++ b/Linphone/Assets.xcassets/speaker-high.imageset/speaker-high.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/Linphone/Assets.xcassets/speaker-slash.imageset/Contents.json b/Linphone/Assets.xcassets/speaker-slash.imageset/Contents.json
new file mode 100644
index 000000000..a924518d6
--- /dev/null
+++ b/Linphone/Assets.xcassets/speaker-slash.imageset/Contents.json
@@ -0,0 +1,21 @@
+{
+ "images" : [
+ {
+ "filename" : "speaker-slash.svg",
+ "idiom" : "universal",
+ "scale" : "1x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "2x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "3x"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Linphone/Assets.xcassets/speaker-slash.imageset/speaker-slash.svg b/Linphone/Assets.xcassets/speaker-slash.imageset/speaker-slash.svg
new file mode 100644
index 000000000..17f3c6a80
--- /dev/null
+++ b/Linphone/Assets.xcassets/speaker-slash.imageset/speaker-slash.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/Linphone/Assets.xcassets/squares-four.imageset/Contents.json b/Linphone/Assets.xcassets/squares-four.imageset/Contents.json
new file mode 100644
index 000000000..9fa7f7893
--- /dev/null
+++ b/Linphone/Assets.xcassets/squares-four.imageset/Contents.json
@@ -0,0 +1,21 @@
+{
+ "images" : [
+ {
+ "filename" : "squares-four.svg",
+ "idiom" : "universal",
+ "scale" : "1x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "2x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "3x"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Linphone/Assets.xcassets/squares-four.imageset/squares-four.svg b/Linphone/Assets.xcassets/squares-four.imageset/squares-four.svg
new file mode 100644
index 000000000..85f5689ef
--- /dev/null
+++ b/Linphone/Assets.xcassets/squares-four.imageset/squares-four.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/Linphone/Assets.xcassets/stop-fill.imageset/Contents.json b/Linphone/Assets.xcassets/stop-fill.imageset/Contents.json
new file mode 100644
index 000000000..4ed79abba
--- /dev/null
+++ b/Linphone/Assets.xcassets/stop-fill.imageset/Contents.json
@@ -0,0 +1,21 @@
+{
+ "images" : [
+ {
+ "filename" : "stop-fill.svg",
+ "idiom" : "universal",
+ "scale" : "1x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "2x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "3x"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Linphone/Assets.xcassets/stop-fill.imageset/stop-fill.svg b/Linphone/Assets.xcassets/stop-fill.imageset/stop-fill.svg
new file mode 100644
index 000000000..91291bef4
--- /dev/null
+++ b/Linphone/Assets.xcassets/stop-fill.imageset/stop-fill.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/Linphone/Assets.xcassets/trash-simple-red.imageset/Contents.json b/Linphone/Assets.xcassets/trash-simple-red.imageset/Contents.json
new file mode 100644
index 000000000..02ba54e4e
--- /dev/null
+++ b/Linphone/Assets.xcassets/trash-simple-red.imageset/Contents.json
@@ -0,0 +1,21 @@
+{
+ "images" : [
+ {
+ "filename" : "trash-simple-red.svg",
+ "idiom" : "universal",
+ "scale" : "1x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "2x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "3x"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Linphone/Assets.xcassets/trash-simple-red.imageset/trash-simple-red.svg b/Linphone/Assets.xcassets/trash-simple-red.imageset/trash-simple-red.svg
new file mode 100644
index 000000000..ea7d36f6a
--- /dev/null
+++ b/Linphone/Assets.xcassets/trash-simple-red.imageset/trash-simple-red.svg
@@ -0,0 +1,3 @@
+
diff --git a/Linphone/Assets.xcassets/trash-simple.imageset/Contents.json b/Linphone/Assets.xcassets/trash-simple.imageset/Contents.json
new file mode 100644
index 000000000..3c4c29331
--- /dev/null
+++ b/Linphone/Assets.xcassets/trash-simple.imageset/Contents.json
@@ -0,0 +1,21 @@
+{
+ "images" : [
+ {
+ "filename" : "trash-simple.svg",
+ "idiom" : "universal",
+ "scale" : "1x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "2x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "3x"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Linphone/Assets.xcassets/trash-simple.imageset/trash-simple.svg b/Linphone/Assets.xcassets/trash-simple.imageset/trash-simple.svg
new file mode 100644
index 000000000..0c2db4850
--- /dev/null
+++ b/Linphone/Assets.xcassets/trash-simple.imageset/trash-simple.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/Linphone/Assets.xcassets/trusted.imageset/Contents.json b/Linphone/Assets.xcassets/trusted.imageset/Contents.json
new file mode 100644
index 000000000..658577d02
--- /dev/null
+++ b/Linphone/Assets.xcassets/trusted.imageset/Contents.json
@@ -0,0 +1,21 @@
+{
+ "images" : [
+ {
+ "filename" : "trusted.svg",
+ "idiom" : "universal",
+ "scale" : "1x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "2x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "3x"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Linphone/Assets.xcassets/trusted.imageset/trusted.svg b/Linphone/Assets.xcassets/trusted.imageset/trusted.svg
new file mode 100644
index 000000000..f39d5a034
--- /dev/null
+++ b/Linphone/Assets.xcassets/trusted.imageset/trusted.svg
@@ -0,0 +1,6 @@
+
diff --git a/Linphone/Assets.xcassets/user-circle-gear.imageset/Contents.json b/Linphone/Assets.xcassets/user-circle-gear.imageset/Contents.json
new file mode 100644
index 000000000..b6ccf2c11
--- /dev/null
+++ b/Linphone/Assets.xcassets/user-circle-gear.imageset/Contents.json
@@ -0,0 +1,21 @@
+{
+ "images" : [
+ {
+ "filename" : "user-circle-gear.svg",
+ "idiom" : "universal",
+ "scale" : "1x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "2x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "3x"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Linphone/Assets.xcassets/user-circle-gear.imageset/user-circle-gear.svg b/Linphone/Assets.xcassets/user-circle-gear.imageset/user-circle-gear.svg
new file mode 100644
index 000000000..5b383bc57
--- /dev/null
+++ b/Linphone/Assets.xcassets/user-circle-gear.imageset/user-circle-gear.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/Linphone/Assets.xcassets/user-circle.imageset/Contents.json b/Linphone/Assets.xcassets/user-circle.imageset/Contents.json
new file mode 100644
index 000000000..6a3349d4a
--- /dev/null
+++ b/Linphone/Assets.xcassets/user-circle.imageset/Contents.json
@@ -0,0 +1,21 @@
+{
+ "images" : [
+ {
+ "filename" : "user-circle.svg",
+ "idiom" : "universal",
+ "scale" : "1x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "2x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "3x"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Linphone/Assets.xcassets/user-circle.imageset/user-circle.svg b/Linphone/Assets.xcassets/user-circle.imageset/user-circle.svg
new file mode 100644
index 000000000..797854dd3
--- /dev/null
+++ b/Linphone/Assets.xcassets/user-circle.imageset/user-circle.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/Linphone/Assets.xcassets/user-plus.imageset/Contents.json b/Linphone/Assets.xcassets/user-plus.imageset/Contents.json
new file mode 100644
index 000000000..bd1b1d0c2
--- /dev/null
+++ b/Linphone/Assets.xcassets/user-plus.imageset/Contents.json
@@ -0,0 +1,21 @@
+{
+ "images" : [
+ {
+ "filename" : "user-plus.svg",
+ "idiom" : "universal",
+ "scale" : "1x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "2x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "3x"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Linphone/Assets.xcassets/user-plus.imageset/user-plus.svg b/Linphone/Assets.xcassets/user-plus.imageset/user-plus.svg
new file mode 100644
index 000000000..9602ea863
--- /dev/null
+++ b/Linphone/Assets.xcassets/user-plus.imageset/user-plus.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/Linphone/Assets.xcassets/user-square.imageset/Contents.json b/Linphone/Assets.xcassets/user-square.imageset/Contents.json
new file mode 100644
index 000000000..bee096e14
--- /dev/null
+++ b/Linphone/Assets.xcassets/user-square.imageset/Contents.json
@@ -0,0 +1,21 @@
+{
+ "images" : [
+ {
+ "filename" : "user-square.svg",
+ "idiom" : "universal",
+ "scale" : "1x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "2x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "3x"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Linphone/Assets.xcassets/user-square.imageset/user-square.svg b/Linphone/Assets.xcassets/user-square.imageset/user-square.svg
new file mode 100644
index 000000000..71c8534fd
--- /dev/null
+++ b/Linphone/Assets.xcassets/user-square.imageset/user-square.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/Linphone/Assets.xcassets/users-three-square.imageset/Contents.json b/Linphone/Assets.xcassets/users-three-square.imageset/Contents.json
new file mode 100644
index 000000000..210e20d8d
--- /dev/null
+++ b/Linphone/Assets.xcassets/users-three-square.imageset/Contents.json
@@ -0,0 +1,21 @@
+{
+ "images" : [
+ {
+ "filename" : "users-three-square.svg",
+ "idiom" : "universal",
+ "scale" : "1x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "2x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "3x"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Linphone/Assets.xcassets/users-three-square.imageset/users-three-square.svg b/Linphone/Assets.xcassets/users-three-square.imageset/users-three-square.svg
new file mode 100644
index 000000000..86d942745
--- /dev/null
+++ b/Linphone/Assets.xcassets/users-three-square.imageset/users-three-square.svg
@@ -0,0 +1,8 @@
+
diff --git a/Linphone/Assets.xcassets/users-three.imageset/Contents.json b/Linphone/Assets.xcassets/users-three.imageset/Contents.json
new file mode 100644
index 000000000..e8bec92c5
--- /dev/null
+++ b/Linphone/Assets.xcassets/users-three.imageset/Contents.json
@@ -0,0 +1,21 @@
+{
+ "images" : [
+ {
+ "filename" : "users-three.svg",
+ "idiom" : "universal",
+ "scale" : "1x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "2x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "3x"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Linphone/Assets.xcassets/users-three.imageset/users-three.svg b/Linphone/Assets.xcassets/users-three.imageset/users-three.svg
new file mode 100644
index 000000000..ba001446a
--- /dev/null
+++ b/Linphone/Assets.xcassets/users-three.imageset/users-three.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/Linphone/Assets.xcassets/users.imageset/Contents.json b/Linphone/Assets.xcassets/users.imageset/Contents.json
new file mode 100644
index 000000000..8e987b946
--- /dev/null
+++ b/Linphone/Assets.xcassets/users.imageset/Contents.json
@@ -0,0 +1,21 @@
+{
+ "images" : [
+ {
+ "filename" : "users.svg",
+ "idiom" : "universal",
+ "scale" : "1x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "2x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "3x"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Linphone/Assets.xcassets/users.imageset/users.svg b/Linphone/Assets.xcassets/users.imageset/users.svg
new file mode 100644
index 000000000..353aca80f
--- /dev/null
+++ b/Linphone/Assets.xcassets/users.imageset/users.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/Linphone/Assets.xcassets/video-camera-slash.imageset/Contents.json b/Linphone/Assets.xcassets/video-camera-slash.imageset/Contents.json
new file mode 100644
index 000000000..6b8eb58cb
--- /dev/null
+++ b/Linphone/Assets.xcassets/video-camera-slash.imageset/Contents.json
@@ -0,0 +1,21 @@
+{
+ "images" : [
+ {
+ "filename" : "video-camera-slash.svg",
+ "idiom" : "universal",
+ "scale" : "1x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "2x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "3x"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Linphone/Assets.xcassets/video-camera-slash.imageset/video-camera-slash.svg b/Linphone/Assets.xcassets/video-camera-slash.imageset/video-camera-slash.svg
new file mode 100644
index 000000000..942e907f3
--- /dev/null
+++ b/Linphone/Assets.xcassets/video-camera-slash.imageset/video-camera-slash.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/Linphone/Assets.xcassets/video-camera.imageset/Contents.json b/Linphone/Assets.xcassets/video-camera.imageset/Contents.json
new file mode 100644
index 000000000..e7745ecc4
--- /dev/null
+++ b/Linphone/Assets.xcassets/video-camera.imageset/Contents.json
@@ -0,0 +1,21 @@
+{
+ "images" : [
+ {
+ "filename" : "video-camera.svg",
+ "idiom" : "universal",
+ "scale" : "1x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "2x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "3x"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Linphone/Assets.xcassets/video-camera.imageset/video-camera.svg b/Linphone/Assets.xcassets/video-camera.imageset/video-camera.svg
new file mode 100644
index 000000000..0383a7c7b
--- /dev/null
+++ b/Linphone/Assets.xcassets/video-camera.imageset/video-camera.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/Linphone/Assets.xcassets/video-conference.imageset/Contents.json b/Linphone/Assets.xcassets/video-conference.imageset/Contents.json
new file mode 100644
index 000000000..6ec75ca93
--- /dev/null
+++ b/Linphone/Assets.xcassets/video-conference.imageset/Contents.json
@@ -0,0 +1,21 @@
+{
+ "images" : [
+ {
+ "filename" : "video-conference.svg",
+ "idiom" : "universal",
+ "scale" : "1x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "2x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "3x"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Linphone/Assets.xcassets/video-conference.imageset/video-conference.svg b/Linphone/Assets.xcassets/video-conference.imageset/video-conference.svg
new file mode 100644
index 000000000..5f7f30001
--- /dev/null
+++ b/Linphone/Assets.xcassets/video-conference.imageset/video-conference.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/Linphone/Assets.xcassets/warning-circle.imageset/Contents.json b/Linphone/Assets.xcassets/warning-circle.imageset/Contents.json
new file mode 100644
index 000000000..5338e468d
--- /dev/null
+++ b/Linphone/Assets.xcassets/warning-circle.imageset/Contents.json
@@ -0,0 +1,21 @@
+{
+ "images" : [
+ {
+ "filename" : "warning-circle.svg",
+ "idiom" : "universal",
+ "scale" : "1x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "2x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "3x"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Linphone/Assets.xcassets/warning-circle.imageset/warning-circle.svg b/Linphone/Assets.xcassets/warning-circle.imageset/warning-circle.svg
new file mode 100644
index 000000000..1b69e522a
--- /dev/null
+++ b/Linphone/Assets.xcassets/warning-circle.imageset/warning-circle.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/Linphone/Assets.xcassets/waveform.imageset/Contents.json b/Linphone/Assets.xcassets/waveform.imageset/Contents.json
new file mode 100644
index 000000000..c9be92b8a
--- /dev/null
+++ b/Linphone/Assets.xcassets/waveform.imageset/Contents.json
@@ -0,0 +1,21 @@
+{
+ "images" : [
+ {
+ "filename" : "waveform.svg",
+ "idiom" : "universal",
+ "scale" : "1x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "2x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "3x"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Linphone/Assets.xcassets/waveform.imageset/waveform.svg b/Linphone/Assets.xcassets/waveform.imageset/waveform.svg
new file mode 100644
index 000000000..ab8d0faff
--- /dev/null
+++ b/Linphone/Assets.xcassets/waveform.imageset/waveform.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/Linphone/Assets.xcassets/wifi-high.imageset/Contents.json b/Linphone/Assets.xcassets/wifi-high.imageset/Contents.json
new file mode 100644
index 000000000..807290f00
--- /dev/null
+++ b/Linphone/Assets.xcassets/wifi-high.imageset/Contents.json
@@ -0,0 +1,21 @@
+{
+ "images" : [
+ {
+ "filename" : "wifi-high.svg",
+ "idiom" : "universal",
+ "scale" : "1x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "2x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "3x"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Linphone/Assets.xcassets/wifi-high.imageset/wifi-high.svg b/Linphone/Assets.xcassets/wifi-high.imageset/wifi-high.svg
new file mode 100644
index 000000000..200030e09
--- /dev/null
+++ b/Linphone/Assets.xcassets/wifi-high.imageset/wifi-high.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/Linphone/Assets.xcassets/wifi-low.imageset/Contents.json b/Linphone/Assets.xcassets/wifi-low.imageset/Contents.json
new file mode 100644
index 000000000..99cd6f07b
--- /dev/null
+++ b/Linphone/Assets.xcassets/wifi-low.imageset/Contents.json
@@ -0,0 +1,21 @@
+{
+ "images" : [
+ {
+ "filename" : "wifi-low.svg",
+ "idiom" : "universal",
+ "scale" : "1x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "2x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "3x"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Linphone/Assets.xcassets/wifi-low.imageset/wifi-low.svg b/Linphone/Assets.xcassets/wifi-low.imageset/wifi-low.svg
new file mode 100644
index 000000000..d8b0173bc
--- /dev/null
+++ b/Linphone/Assets.xcassets/wifi-low.imageset/wifi-low.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/Linphone/Assets.xcassets/wrench.imageset/Contents.json b/Linphone/Assets.xcassets/wrench.imageset/Contents.json
new file mode 100644
index 000000000..67601ecd0
--- /dev/null
+++ b/Linphone/Assets.xcassets/wrench.imageset/Contents.json
@@ -0,0 +1,21 @@
+{
+ "images" : [
+ {
+ "filename" : "wrench.svg",
+ "idiom" : "universal",
+ "scale" : "1x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "2x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "3x"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Linphone/Assets.xcassets/wrench.imageset/wrench.svg b/Linphone/Assets.xcassets/wrench.imageset/wrench.svg
new file mode 100644
index 000000000..71b09f566
--- /dev/null
+++ b/Linphone/Assets.xcassets/wrench.imageset/wrench.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/Linphone/Assets.xcassets/x-circle.imageset/Contents.json b/Linphone/Assets.xcassets/x-circle.imageset/Contents.json
new file mode 100644
index 000000000..b5bc90bda
--- /dev/null
+++ b/Linphone/Assets.xcassets/x-circle.imageset/Contents.json
@@ -0,0 +1,21 @@
+{
+ "images" : [
+ {
+ "filename" : "x-circle.svg",
+ "idiom" : "universal",
+ "scale" : "1x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "2x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "3x"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Linphone/Assets.xcassets/x-circle.imageset/x-circle.svg b/Linphone/Assets.xcassets/x-circle.imageset/x-circle.svg
new file mode 100644
index 000000000..3de6a876d
--- /dev/null
+++ b/Linphone/Assets.xcassets/x-circle.imageset/x-circle.svg
@@ -0,0 +1,3 @@
+
diff --git a/Linphone/Assets.xcassets/x.imageset/Contents.json b/Linphone/Assets.xcassets/x.imageset/Contents.json
new file mode 100644
index 000000000..74ec74c05
--- /dev/null
+++ b/Linphone/Assets.xcassets/x.imageset/Contents.json
@@ -0,0 +1,21 @@
+{
+ "images" : [
+ {
+ "filename" : "x.svg",
+ "idiom" : "universal",
+ "scale" : "1x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "2x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "3x"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Linphone/Assets.xcassets/x.imageset/x.svg b/Linphone/Assets.xcassets/x.imageset/x.svg
new file mode 100644
index 000000000..52756cbd9
--- /dev/null
+++ b/Linphone/Assets.xcassets/x.imageset/x.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/Linphone/Contacts/ContactsManager.swift b/Linphone/Contacts/ContactsManager.swift
new file mode 100644
index 000000000..5b882a94e
--- /dev/null
+++ b/Linphone/Contacts/ContactsManager.swift
@@ -0,0 +1,393 @@
+/*
+ * Copyright (c) 2010-2023 Belledonne Communications SARL.
+ *
+ * This file is part of Linphone
+ *
+ * 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 .
+ */
+
+// swiftlint:disable line_length
+// swiftlint:disable function_parameter_count
+
+import linphonesw
+import Contacts
+import SwiftUI
+import ContactsUI
+import Combine
+
+final class ContactsManager: ObservableObject {
+
+ static let shared = ContactsManager()
+
+ private var coreContext = CoreContext.shared
+
+ private let nativeAddressBookFriendList = "Native address-book"
+ let linphoneAddressBookFriendList = "Linphone address-book"
+
+ var friendList: FriendList?
+ var linphoneFriendList: FriendList?
+
+ @Published var lastSearch: [SearchResult] = []
+ @Published var lastSearchSuggestions: [SearchResult] = []
+ @Published var avatarListModel: [ContactAvatarModel] = []
+
+ private var friendListDelegate: FriendListDelegate?
+
+ private init() {}
+
+ func fetchContacts() {
+ coreContext.doOnCoreQueue { core in
+ if core.globalState == GlobalState.Shutdown || core.globalState == GlobalState.Off {
+ print("\(#function) - Core is being stopped or already destroyed, abort")
+ } else {
+ do {
+ self.friendList = try core.getFriendListByName(name: self.nativeAddressBookFriendList) ?? core.createFriendList()
+ } catch let error {
+ print("\(#function) - Failed to enumerate contacts: \(error)")
+ }
+
+ if let friendList = self.friendList {
+ if friendList.displayName == nil || friendList.displayName!.isEmpty {
+ print("\(#function) - Friend list '\(self.nativeAddressBookFriendList)' didn't exist yet, let's create it")
+ friendList.databaseStorageEnabled = false // We don't want to store local address-book in DB
+ friendList.displayName = self.nativeAddressBookFriendList
+ core.addFriendList(list: friendList)
+ } else {
+ print("\(#function) - Friend list '\(friendList.displayName!) found, removing existing friends if any")
+ friendList.friends.forEach { friend in
+ _ = friendList.removeFriend(linphoneFriend: friend)
+ }
+ }
+ }
+
+ do {
+ self.linphoneFriendList = try core.getFriendListByName(name: self.linphoneAddressBookFriendList) ?? core.createFriendList()
+ } catch let error {
+ print("\(#function) - Failed to enumerate contacts: \(error)")
+ }
+
+ if let linphoneFriendList = self.linphoneFriendList {
+ if linphoneFriendList.displayName == nil || linphoneFriendList.displayName!.isEmpty {
+ print("\(#function) - Friend list \(self.linphoneAddressBookFriendList) didn't exist yet, let's create it")
+ linphoneFriendList.databaseStorageEnabled = true
+ linphoneFriendList.displayName = self.linphoneAddressBookFriendList
+ core.addFriendList(list: linphoneFriendList)
+ }
+ linphoneFriendList.subscriptionsEnabled = true
+ }
+ }
+
+ let store = CNContactStore()
+
+ store.requestAccess(for: .contacts) { (granted, error) in
+ if let error = error {
+ print("\(#function) - failed to request access", error)
+ return
+ }
+ if granted {
+ let keys = [CNContactEmailAddressesKey, CNContactPhoneNumbersKey,
+ CNContactFamilyNameKey, CNContactGivenNameKey, CNContactNicknameKey,
+ CNContactPostalAddressesKey, CNContactIdentifierKey,
+ CNInstantMessageAddressUsernameKey, CNContactInstantMessageAddressesKey,
+ CNContactOrganizationNameKey, CNContactImageDataAvailableKey, CNContactImageDataKey, CNContactThumbnailImageDataKey]
+ let request = CNContactFetchRequest(keysToFetch: keys as [CNKeyDescriptor])
+ do {
+ var contactCounter = 0
+ try store.enumerateContacts(with: request, usingBlock: { (contact, _) in
+ DispatchQueue.main.async {
+ let newContact = Contact(
+ identifier: contact.identifier,
+ firstName: contact.givenName,
+ lastName: contact.familyName,
+ organizationName: contact.organizationName,
+ jobTitle: "",
+ displayName: contact.nickname,
+ sipAddresses: contact.instantMessageAddresses.map { $0.value.service.lowercased() == "SIP".lowercased() ? $0.value.username : "" },
+ phoneNumbers: contact.phoneNumbers.map { PhoneNumber(numLabel: $0.label ?? "", num: $0.value.stringValue)},
+ imageData: ""
+ )
+
+ let imageThumbnail = UIImage(data: contact.thumbnailImageData ?? Data())
+ self.saveImage(
+ image: imageThumbnail
+ ?? self.textToImage(
+ firstName: contact.givenName.isEmpty
+ && contact.familyName.isEmpty
+ && contact.phoneNumbers.first?.value.stringValue != nil
+ ? contact.phoneNumbers.first!.value.stringValue
+ : contact.givenName, lastName: contact.familyName),
+ name: contact.givenName + contact.familyName,
+ prefix: ((imageThumbnail == nil) ? "-default" : ""),
+ contact: newContact, linphoneFriend: false, existingFriend: nil) {
+ if (self.friendList?.friends.count ?? 0) + (self.linphoneFriendList?.friends.count ?? 0) == contactCounter {
+ // Every contact properly added, proceed
+ self.linphoneFriendList?.updateSubscriptions()
+ self.friendList?.updateSubscriptions()
+
+ if let friendListDelegate = self.friendListDelegate {
+ self.friendList?.removeDelegate(delegate: friendListDelegate)
+ }
+ self.friendListDelegate = FriendListDelegateStub(onNewSipAddressDiscovered: { (_: FriendList, linphoneFriend: Friend, sipUri: String) in
+
+ var addedAvatarListModel: [ContactAvatarModel] = []
+ linphoneFriend.phoneNumbers.forEach { phone in
+ let address = core.interpretUrl(url: phone, applyInternationalPrefix: true)
+
+ let presence = linphoneFriend.getPresenceModelForUriOrTel(uriOrTel: address?.asStringUriOnly() ?? "")
+ if address != nil && presence != nil {
+ linphoneFriend.edit()
+ linphoneFriend.addAddress(address: address!)
+ linphoneFriend.done()
+
+ addedAvatarListModel.append(
+ ContactAvatarModel(
+ friend: linphoneFriend,
+ name: linphoneFriend.name ?? "",
+ address: linphoneFriend.address?.clone()?.asStringUriOnly() ?? "",
+ withPresence: true
+ )
+ )
+ }
+ }
+
+ DispatchQueue.main.async {
+ self.avatarListModel += addedAvatarListModel
+ }
+
+ MagicSearchSingleton.shared.searchForContacts(sourceFlags: MagicSearch.Source.Friends.rawValue | MagicSearch.Source.LdapServers.rawValue)
+ })
+ self.friendList?.addDelegate(delegate: self.friendListDelegate!)
+ }
+ }
+ }
+
+ if !(contact.givenName.isEmpty && contact.familyName.isEmpty) {
+ contactCounter += 1
+ }
+ })
+
+ } catch let error {
+ print("\(#function) - Failed to enumerate contact", error)
+ }
+
+ } else {
+ print("\(#function) - access denied")
+ }
+ }
+ }
+ }
+
+ func textToImage(firstName: String, lastName: String) -> UIImage {
+ let lblNameInitialize = UILabel()
+ lblNameInitialize.frame.size = CGSize(width: 200.0, height: 200.0)
+ lblNameInitialize.font = UIFont(name: "NotoSans-ExtraBold", size: 80)
+ lblNameInitialize.textColor = UIColor(Color.grayMain2c600)
+
+ var textToDisplay = ""
+ if firstName.first != nil {
+ textToDisplay += String(firstName.first!)
+ }
+ if lastName.first != nil {
+ textToDisplay += String(lastName.first!)
+ }
+
+ lblNameInitialize.text = textToDisplay.uppercased()
+ lblNameInitialize.textAlignment = .center
+ lblNameInitialize.backgroundColor = UIColor(Color.grayMain2c200)
+ lblNameInitialize.layer.cornerRadius = 10.0
+
+ var IBImgViewUserProfile = UIImage()
+ UIGraphicsBeginImageContext(lblNameInitialize.frame.size)
+ lblNameInitialize.layer.render(in: UIGraphicsGetCurrentContext()!)
+ IBImgViewUserProfile = UIGraphicsGetImageFromCurrentImageContext()!
+ UIGraphicsEndImageContext()
+
+ return IBImgViewUserProfile
+ }
+
+ func saveImage(image: UIImage, name: String, prefix: String, contact: Contact, linphoneFriend: Bool, existingFriend: Friend?, completion: @escaping () -> Void) {
+ guard let data = image.jpegData(compressionQuality: 1) ?? image.pngData() else {
+ return
+ }
+
+ awaitDataWrite(data: data, name: name, prefix: prefix) { _, result in
+ self.saveFriend(result: result, contact: contact, existingFriend: existingFriend) { resultFriend in
+ if resultFriend != nil {
+ if linphoneFriend && existingFriend == nil {
+ _ = self.linphoneFriendList?.addFriend(linphoneFriend: resultFriend!)
+ } else if existingFriend == nil {
+ _ = self.friendList?.addLocalFriend(linphoneFriend: resultFriend!)
+ }
+ }
+ completion()
+ }
+ }
+ }
+
+ func saveFriend(result: String, contact: Contact, existingFriend: Friend?, completion: @escaping (Friend?) -> Void) {
+ self.coreContext.doOnCoreQueue { core in
+ do {
+ let friend = try existingFriend ?? core.createFriend()
+
+ friend.edit()
+ friend.nativeUri = contact.identifier
+ try friend.setName(newValue: contact.firstName + " " + contact.lastName)
+
+ let friendvCard = friend.vcard
+
+ if friendvCard != nil {
+ friendvCard!.givenName = contact.firstName
+ friendvCard!.familyName = contact.lastName
+ }
+
+ friend.organization = contact.organizationName
+
+ var friendAddresses: [Address] = []
+ friend.addresses.forEach({ address in
+ friend.removeAddress(address: address)
+ })
+ contact.sipAddresses.forEach { sipAddress in
+ if !sipAddress.isEmpty {
+ let address = core.interpretUrl(url: sipAddress, applyInternationalPrefix: true)
+
+ if address != nil && ((friendAddresses.firstIndex(where: {$0.asString() == address?.asString()})) == nil) {
+ friend.addAddress(address: address!)
+ friendAddresses.append(address!)
+ }
+ }
+ }
+
+ var friendPhoneNumbers: [PhoneNumber] = []
+ friend.phoneNumbersWithLabel.forEach({ phoneNumber in
+ friend.removePhoneNumberWithLabel(phoneNumber: phoneNumber)
+ })
+ contact.phoneNumbers.forEach { phone in
+ do {
+ if (friendPhoneNumbers.firstIndex(where: {$0.num == phone.num})) == nil {
+ let labelDrop = String(phone.numLabel.dropFirst(4).dropLast(4))
+ let phoneNumber = try Factory.Instance.createFriendPhoneNumber(phoneNumber: phone.num, label: labelDrop)
+ friend.addPhoneNumberWithLabel(phoneNumber: phoneNumber)
+ friendPhoneNumbers.append(phone)
+ }
+ } catch let error {
+ print("\(#function) - Failed to create friend phone number for \(phone.numLabel):", error)
+ }
+ }
+
+ friend.photo = "file:/" + result
+ friend.organization = contact.organizationName
+ friend.jobTitle = contact.jobTitle
+
+ try friend.setSubscribesenabled(newValue: false)
+ try friend.setIncsubscribepolicy(newValue: .SPDeny)
+
+ friend.done()
+
+ completion(friend)
+ } catch let error {
+ print("Failed to enumerate contact", error)
+ completion(nil)
+ }
+ }
+ }
+
+ func getImagePath(friendPhotoPath: String) -> URL {
+ let friendPath = String(friendPhotoPath.dropFirst(6))
+
+ let imagePath = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first!.appendingPathComponent(friendPath)
+
+ return imagePath
+ }
+
+ func awaitDataWrite(data: Data, name: String, prefix: String, completion: @escaping ((), String) -> Void) {
+ let directory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first
+
+ if directory != nil {
+ DispatchQueue.main.async {
+ do {
+ let urlName = URL(string: name + prefix)
+ let imagePath = urlName != nil ? urlName!.absoluteString.replacingOccurrences(of: "%", with: "") : "ImageError"
+
+ let decodedData: () = try data.write(to: directory!.appendingPathComponent(imagePath + ".png"))
+
+ completion(decodedData, imagePath + ".png")
+ } catch {
+ print("Error: ", error)
+ completion((), "")
+ }
+ }
+ }
+ }
+
+ func getFriendWithContact(contact: Contact) -> Friend? {
+ if friendList != nil {
+ let friend = friendList!.friends.first(where: {$0.nativeUri == contact.identifier})
+ if friend == nil && friendList != nil {
+ return linphoneFriendList!.friends.first(where: {$0.nativeUri == contact.identifier})
+ }
+ return friend
+ } else {
+ return nil
+ }
+ }
+
+ func getFriendWithAddress(address: Address?) -> Friend? {
+ if address != nil {
+ let clonedAddress = address!.clone()
+ clonedAddress!.clean()
+ let sipUri = clonedAddress!.asStringUriOnly()
+
+ if self.friendList != nil && !self.friendList!.friends.isEmpty {
+ var friend: Friend?
+ friend = self.friendList!.friends.first(where: {$0.addresses.contains(where: {$0.asStringUriOnly() == sipUri})})
+ if friend == nil && self.linphoneFriendList != nil && !self.linphoneFriendList!.friends.isEmpty {
+ friend = self.linphoneFriendList!.friends.first(where: {$0.addresses.contains(where: {$0.asStringUriOnly() == sipUri})})
+ }
+
+ return friend
+ } else {
+ return nil
+ }
+ } else {
+ return nil
+ }
+ }
+
+ func getFriendWithAddressInCoreQueue(address: Address?, completion: @escaping (Friend?) -> Void) {
+ self.coreContext.doOnCoreQueue { _ in
+ completion(self.getFriendWithAddress(address: address))
+ }
+ }
+}
+
+struct PhoneNumber {
+ var numLabel: String
+ var num: String
+}
+
+struct Contact: Identifiable {
+ var id = UUID()
+ var identifier: String
+ var firstName: String
+ var lastName: String
+ var organizationName: String
+ var jobTitle: String
+ var displayName: String
+ var sipAddresses: [String] = []
+ var phoneNumbers: [PhoneNumber] = []
+ var imageData: String
+}
+
+// swiftlint:enable line_length
+// swiftlint:enable function_parameter_count
diff --git a/Linphone/Core/CoreContext.swift b/Linphone/Core/CoreContext.swift
new file mode 100644
index 000000000..44ddbae7e
--- /dev/null
+++ b/Linphone/Core/CoreContext.swift
@@ -0,0 +1,370 @@
+/*
+ * Copyright (c) 2010-2023 Belledonne Communications SARL.
+ *
+ * This file is part of Linphone
+ *
+ * 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 .
+ */
+
+// swiftlint:disable line_length
+// swiftlint:disable cyclomatic_complexity
+// swiftlint:disable identifier_name
+
+import linphonesw
+import linphone // needed for unwrapped function linphone_core_set_push_and_app_delegate_dispatch_queue
+import Combine
+import UniformTypeIdentifiers
+import Network
+
+#if USE_CRASHLYTICS
+import Firebase
+#endif
+
+final class CoreContext: ObservableObject {
+
+ static let shared = CoreContext()
+ private var sharedMainViewModel = SharedMainViewModel.shared
+
+ var coreVersion: String = Core.getVersion
+ @Published var loggedIn: Bool = false
+ @Published var loggingInProgress: Bool = false
+ @Published var coreIsStarted: Bool = false
+ @Published var accounts: [AccountModel] = []
+ @Published var enteredForeground = false
+
+ private var mCore: Core!
+ private var mIterateSuscription: AnyCancellable?
+
+ var bearerAuthInfoPendingPasswordUpdate: AuthInfo?
+
+ let monitor = NWPathMonitor()
+ var networkStatusIsConnected: Bool = true // updated on core queue
+
+ private var mCoreDelegate: CoreDelegate!
+ private var actionsToPerformOnCoreQueueWhenCoreIsStarted: [((Core) -> Void)] = []
+ private var callStateCallBacks: [((Call.State) -> Void)] = []
+ private var configuringStateCallBacks: [((ConfiguringState) -> Void)] = []
+
+ private init() {
+ do {
+ try initialiseCore()
+ } catch {
+
+ }
+ }
+
+ func doOnCoreQueue(synchronous: Bool = false, lambda: @escaping (Core) -> Void) {
+ if synchronous {
+ coreQueue.sync {
+ lambda(self.mCore)
+ }
+ } else {
+ coreQueue.async {
+ if self.mCore.globalState != .Off {
+ lambda(self.mCore)
+ } else {
+ Log.warn("Doesn't run the asynchronous function because the core is off")
+ }
+ }
+ }
+ }
+
+ func initialiseCore() throws {
+#if USE_CRASHLYTICS
+ FirebaseApp.configure()
+#endif
+ monitor.pathUpdateHandler = { path in
+ let isConnected = path.status == .satisfied
+ if self.networkStatusIsConnected != isConnected {
+ DispatchQueue.main.async {
+ if isConnected {
+ Log.info("Network is now satisfied")
+ ToastViewModel.shared.toastMessage = "Success_toast_network_connected"
+ } else {
+ Log.error("Network is now \(path.status)")
+ ToastViewModel.shared.toastMessage = "Unavailable_network"
+ }
+ ToastViewModel.shared.displayToast = true
+ }
+ self.networkStatusIsConnected = isConnected
+ }
+
+ }
+ monitor.start(queue: coreQueue)
+
+ coreQueue.async {
+ LoggingService.Instance.logLevel = LogLevel.Debug
+ Factory.Instance.logCollectionPath = Factory.Instance.getConfigDir(context: nil)
+ Factory.Instance.enableLogCollection(state: LogCollectionState.Enabled)
+
+ Log.info("Checking if linphonerc file exists already. If not, creating one as a copy of linphonerc-default")
+ if let rcDir = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: Config.appGroupName)?
+ .appendingPathComponent("Library/Preferences/linphone") {
+ let rcFileUrl = rcDir.appendingPathComponent("linphonerc")
+ if !FileManager.default.fileExists(atPath: rcFileUrl.path) {
+ do {
+ try FileManager.default.createDirectory(at: rcDir, withIntermediateDirectories: true)
+ if let pathToDefaultConfig = Bundle.main.path(forResource: "linphonerc-default", ofType: nil) {
+ try FileManager.default.copyItem(at: URL(fileURLWithPath: pathToDefaultConfig), to: rcFileUrl)
+ Log.info("Successfully copied linphonerc-default configuration")
+ }
+ } catch let error {
+ Log.error("Failed to copy default linphonerc file: \(error.localizedDescription)")
+ }
+ } else {
+ Log.info("Found existing linphonerc file, skip copying of linphonerc-default configuration")
+ }
+ }
+
+ Log.info("Initialising core")
+ self.mCore = try? Factory.Instance.createSharedCoreWithConfig(config: Config.get(), systemContext: nil, appGroupId: Config.appGroupName, mainCore: true)
+
+ linphone_core_set_push_and_app_delegate_dispatch_queue(self.mCore.getCobject, Unmanaged.passUnretained(coreQueue).toOpaque())
+ self.mCore.autoIterateEnabled = false
+ self.mCore.callkitEnabled = true
+ self.mCore.pushNotificationEnabled = true
+
+ let appName = Bundle.main.infoDictionary?["CFBundleName"] as? String
+ let version = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String
+
+ self.mCore.setUserAgent(name: "\(appName ?? "Linphone")iOS/\(version ?? "6.0.0") Beta (\(UIDevice.current.localizedModel)) LinphoneSDK", version: self.coreVersion)
+ self.mCore.videoCaptureEnabled = true
+ self.mCore.videoDisplayEnabled = true
+ self.mCore.videoPreviewEnabled = false
+ self.mCore.fecEnabled = true
+ self.mCore.friendListSubscriptionEnabled = true
+ self.mCore.maxSizeForAutoDownloadIncomingFiles = 0
+ self.mCore.config!.setBool(section: "sip", key: "auto_answer_replacing_calls", value: false)
+ self.mCore.config!.setBool(section: "sip", key: "deliver_imdn", value: false)
+
+ self.mCoreDelegate = CoreDelegateStub(onGlobalStateChanged: { (core: Core, state: GlobalState, _: String) in
+ if state == GlobalState.On {
+#if DEBUG
+ let pushEnvironment = ".dev"
+#else
+ let pushEnvironment = ""
+#endif
+ for account in core.accountList where account.params?.pushNotificationConfig?.provider != ("apns" + pushEnvironment) {
+ let newParams = account.params?.clone()
+ Log.info("Account \(String(describing: newParams?.identityAddress?.asStringUriOnly())) - updating apple push provider from \(String(describing: newParams?.pushNotificationConfig?.provider)) to apns\(pushEnvironment)")
+ newParams?.pushNotificationConfig?.provider = "apns" + pushEnvironment
+ account.params = newParams
+ }
+
+ self.actionsToPerformOnCoreQueueWhenCoreIsStarted.forEach { $0(core) }
+ self.actionsToPerformOnCoreQueueWhenCoreIsStarted.removeAll()
+
+ var accountModels: [AccountModel] = []
+ for account in self.mCore.accountList {
+ accountModels.append(AccountModel(account: account, core: self.mCore))
+ }
+ DispatchQueue.main.async {
+ self.coreIsStarted = true
+ self.accounts = accountModels
+ }
+ }
+ }, onCallStateChanged: { (core: Core, call: Call, cstate: Call.State, message: String) in
+ TelecomManager.shared.onCallStateChanged(core: core, call: call, state: cstate, message: message)
+ }, onAuthenticationRequested: { (_: Core, authInfo: AuthInfo, method: AuthMethod) in
+ guard let username = authInfo.username, let server = authInfo.authorizationServer, !server.isEmpty else {
+ Log.error("Authentication requested but either username [\(String(describing: authInfo.username))], domain [\(String(describing: authInfo.domain))] or server [\(String(describing: authInfo.authorizationServer))] is nil or empty!")
+ return
+ }
+ if method == .Bearer {
+ Log.info("Authentication requested method is Bearer, starting Single Sign On activity with server URL \(server) and username \(username)")
+ self.bearerAuthInfoPendingPasswordUpdate = authInfo
+ SingleSignOnManager.shared.setUp(ssoUrl: server, user: username)
+ }
+ }, onTransferStateChanged: { (_: Core, transferred: Call, callState: Call.State) in
+ Log.info("[CoreContext] Transferred call \(transferred.remoteAddress!.asStringUriOnly()) state changed \(callState)")
+ DispatchQueue.main.async {
+ if callState == Call.State.Connected {
+ ToastViewModel.shared.toastMessage = "Success_toast_call_transfer_successful"
+ ToastViewModel.shared.displayToast = true
+ } else if callState == Call.State.OutgoingProgress {
+ ToastViewModel.shared.toastMessage = "Success_toast_call_transfer_in_progress"
+ ToastViewModel.shared.displayToast = true
+ } else if callState == Call.State.End || callState == Call.State.Error {
+ ToastViewModel.shared.toastMessage = "Failed_toast_call_transfer_failed"
+ ToastViewModel.shared.displayToast = true
+ }
+ }
+ }, onConfiguringStatus: { (_: Core, status: ConfiguringState, message: String) in
+ Log.info("New configuration state is \(status) = \(message)\n")
+ var accountModels: [AccountModel] = []
+ for account in self.mCore.accountList {
+ accountModels.append(AccountModel(account: account, core: self.mCore))
+ }
+ DispatchQueue.main.async {
+ if status == ConfiguringState.Successful {
+ ToastViewModel.shared.toastMessage = "Successful"
+ ToastViewModel.shared.displayToast = true
+ self.accounts = accountModels
+ }
+ }
+ }, onLogCollectionUploadStateChanged: { (_: Core, _: Core.LogCollectionUploadState, info: String) in
+ if info.starts(with: "https") {
+ DispatchQueue.main.async {
+ UIPasteboard.general.setValue(info, forPasteboardType: UTType.plainText.identifier)
+ ToastViewModel.shared.toastMessage = "Success_send_logs"
+ ToastViewModel.shared.displayToast = true
+ }
+ }
+ }, onAccountRegistrationStateChanged: { (core: Core, account: Account, state: RegistrationState, message: String) in
+ // If account has been configured correctly, we will go through Progress and Ok states
+ // Otherwise, we will be Failed.
+ Log.info("New registration state is \(state) for user id " +
+ "\( String(describing: account.params?.identityAddress?.asString())) = \(message)\n")
+
+ switch state {
+ case .Ok:
+ ContactsManager.shared.fetchContacts()
+ if self.mCore.consolidatedPresence != ConsolidatedPresence.Online {
+ self.updatePresence(core: self.mCore, presence: ConsolidatedPresence.Online)
+ }
+ case .Cleared:
+ Log.info("[onAccountRegistrationStateChanged] Account \(account.displayName()) registration was cleared. Looking for auth info")
+ if let authInfo = account.findAuthInfo() {
+ Log.info("[onAccountRegistrationStateChanged] Found auth info for account, removing it")
+ core.removeAuthInfo(info: authInfo)
+ } else {
+ Log.warn("[onAccountRegistrationStateChanged] Failed to find matching auth info for account")
+ }
+ case .Failed: // If registration failed, remove account from core
+ if self.networkStatusIsConnected {
+ let params = account.params
+ let clonedParams = params?.clone()
+ clonedParams?.registerEnabled = false
+ account.params = clonedParams
+
+ Log.warn("Registration failed for account \(account.displayName()), deleting it from core")
+ core.removeAccount(account: account)
+ }
+ default:
+ break
+ }
+
+ TelecomManager.shared.onAccountRegistrationStateChanged(core: core, account: account, state: state, message: message)
+
+ DispatchQueue.main.async {
+ if state == .Ok {
+ self.loggingInProgress = false
+ self.loggedIn = true
+ } else if state == .Progress || state == .Refreshing {
+ self.loggingInProgress = true
+ } else if state == .Cleared {
+ self.loggingInProgress = false
+ self.loggedIn = false
+ ToastViewModel.shared.toastMessage = "Success_account_logged_out"
+ ToastViewModel.shared.displayToast = true
+ } else {
+ self.loggingInProgress = false
+ self.loggedIn = false
+ if self.networkStatusIsConnected {
+ // If network is disconnected, a toast message with key "Unavailable_network" should already be displayed
+ ToastViewModel.shared.toastMessage = "Registration_failed"
+ ToastViewModel.shared.displayToast = true
+ }
+
+ }
+ }
+ }, onAccountAdded: { (_: Core, _: Account) in
+ var accountModels: [AccountModel] = []
+ for account in self.mCore.accountList {
+ accountModels.append(AccountModel(account: account, core: self.mCore))
+ }
+ DispatchQueue.main.async {
+ self.accounts = accountModels
+ }
+ }, onAccountRemoved: { (_: Core, _: Account) in
+ var accountModels: [AccountModel] = []
+ for account in self.mCore.accountList {
+ accountModels.append(AccountModel(account: account, core: self.mCore))
+ }
+ DispatchQueue.main.async {
+ self.accounts = accountModels
+ }
+ })
+ self.mCore.addDelegate(delegate: self.mCoreDelegate)
+
+ self.mIterateSuscription = Timer.publish(every: 0.02, on: .main, in: .common)
+ .autoconnect()
+ .receive(on: coreQueue)
+ .sink { _ in
+ self.mCore.iterate()
+ }
+ try? self.mCore.start()
+ }
+ }
+
+ func updatePresence(core: Core, presence: ConsolidatedPresence) {
+ if core.config!.getBool(section: "app", key: "publish_presence", defaultValue: true) {
+ core.consolidatedPresence = presence
+ }
+ }
+
+ func onEnterForeground() {
+ coreQueue.sync {
+ // We can't rely on defaultAccount?.params?.isPublishEnabled
+ // as it will be modified by the SDK when changing the presence status
+
+ try? self.mCore.start()
+ Log.info("App is in foreground, PUBLISHING presence as Online")
+ self.updatePresence(core: self.mCore, presence: ConsolidatedPresence.Online)
+ }
+ }
+
+ func onEnterBackground() {
+ coreQueue.sync {
+ // We can't rely on defaultAccount?.params?.isPublishEnabled
+ // as it will be modified by the SDK when changing the presence status
+ Log.info("App is in background, un-PUBLISHING presence info")
+
+ // We don't use ConsolidatedPresence.Busy but Offline to do an unsubscribe,
+ // Flexisip will handle the Busy status depending on other devices
+ self.updatePresence(core: self.mCore, presence: ConsolidatedPresence.Offline)
+ self.mCore.iterate()
+
+ if self.mCore.currentCall == nil {
+ self.mCore.stop()
+ }
+ }
+ }
+
+ func crashForCrashlytics() {
+ fatalError("Crashing app to test crashlytics")
+ }
+
+ func performActionOnCoreQueueWhenCoreIsStarted(action: @escaping (_ core: Core) -> Void ) {
+ if coreIsStarted {
+ CoreContext.shared.doOnCoreQueue { core in
+ action(core)
+ }
+ } else {
+ actionsToPerformOnCoreQueueWhenCoreIsStarted.append(action)
+ }
+ }
+
+ func addCoreDelegateStub(delegate: CoreDelegateStub) {
+ mCore.addDelegate(delegate: delegate)
+ }
+ func removeCoreDelegateStub(delegate: CoreDelegateStub) {
+ mCore.removeDelegate(delegate: delegate)
+ }
+
+}
+
+// swiftlint:enable line_length
+// swiftlint:enable cyclomatic_complexity
+// swiftlint:enable identifier_name
diff --git a/Linphone/Core/CoreExtension.swift b/Linphone/Core/CoreExtension.swift
new file mode 100644
index 000000000..2a448aef4
--- /dev/null
+++ b/Linphone/Core/CoreExtension.swift
@@ -0,0 +1,44 @@
+/*
+ * Copyright (c) 2010-2023 Belledonne Communications SARL.
+ *
+ * This file is part of linphone
+ *
+ * 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 .
+ */
+
+// Core Extension provides a set of utilies to manage automatically a LinphoneCore no matter if it is from App or an extension.
+// It is based on a singleton pattern and adds.
+
+import UIKit
+import linphonesw
+
+struct CoreError: Error {
+ let message: String
+ init(_ message: String) {
+ self.message = message
+ }
+ public var localizedDescription: String {
+ return message
+ }
+}
+
+extension Core {
+
+ public static func runsInsideExtension() -> Bool { // Tells wether it is run inside app extension or the main app.
+ let bundleUrl: URL = Bundle.main.bundleURL
+ let bundlePathExtension: String = bundleUrl.pathExtension
+ return bundlePathExtension == "appex"
+ }
+
+}
diff --git a/Linphone/Fonts/NotoSans-Bold.ttf b/Linphone/Fonts/NotoSans-Bold.ttf
new file mode 100644
index 000000000..0d1906839
Binary files /dev/null and b/Linphone/Fonts/NotoSans-Bold.ttf differ
diff --git a/Linphone/Fonts/NotoSans-ExtraBold.ttf b/Linphone/Fonts/NotoSans-ExtraBold.ttf
new file mode 100644
index 000000000..a74c69ab1
Binary files /dev/null and b/Linphone/Fonts/NotoSans-ExtraBold.ttf differ
diff --git a/Linphone/Fonts/NotoSans-Light.ttf b/Linphone/Fonts/NotoSans-Light.ttf
new file mode 100644
index 000000000..8fde66280
Binary files /dev/null and b/Linphone/Fonts/NotoSans-Light.ttf differ
diff --git a/Linphone/Fonts/NotoSans-Medium.ttf b/Linphone/Fonts/NotoSans-Medium.ttf
new file mode 100644
index 000000000..faf167c91
Binary files /dev/null and b/Linphone/Fonts/NotoSans-Medium.ttf differ
diff --git a/Linphone/Fonts/NotoSans-Regular.ttf b/Linphone/Fonts/NotoSans-Regular.ttf
new file mode 100644
index 000000000..7552fbe80
Binary files /dev/null and b/Linphone/Fonts/NotoSans-Regular.ttf differ
diff --git a/Linphone/Fonts/NotoSans-SemiBold.ttf b/Linphone/Fonts/NotoSans-SemiBold.ttf
new file mode 100644
index 000000000..b460754c5
Binary files /dev/null and b/Linphone/Fonts/NotoSans-SemiBold.ttf differ
diff --git a/Linphone/Info.plist b/Linphone/Info.plist
new file mode 100644
index 000000000..89d3fd147
--- /dev/null
+++ b/Linphone/Info.plist
@@ -0,0 +1,140 @@
+
+
+
+
+ NSLocalNetworkUsageDescription
+ App requires access to the local network to establish VoIP connections
+ CFBundleURLTypes
+
+
+ CFBundleTypeRole
+ Editor
+ CFBundleURLName
+ org.linphone.phone
+ CFBundleURLSchemes
+
+ linphone-config
+
+
+
+ CFBundleTypeRole
+ Editor
+ CFBundleURLName
+ org.linphone.phone
+ CFBundleURLSchemes
+
+ sip
+
+
+
+ CFBundleTypeRole
+ Editor
+ CFBundleURLName
+ org.linphone.phone
+ CFBundleURLSchemes
+
+ sips
+
+
+
+ CFBundleTypeRole
+ Editor
+ CFBundleURLName
+ org.linphone.phone
+ CFBundleURLSchemes
+
+ sip-linphone
+
+
+
+ CFBundleTypeRole
+ Editor
+ CFBundleURLName
+ org.linphone.phone
+ CFBundleURLSchemes
+
+ sips-linphone
+
+
+
+ CFBundleTypeRole
+ Editor
+ CFBundleURLName
+ org.linphone.phone
+ CFBundleURLSchemes
+
+ linphone-sip
+
+
+
+ CFBundleTypeRole
+ Editor
+ CFBundleURLName
+ org.linphone.phone
+ CFBundleURLSchemes
+
+ linphone-sips
+
+
+
+ CFBundleTypeRole
+ Editor
+ CFBundleURLName
+ org.linphone.phone
+ CFBundleURLSchemes
+
+ tel
+
+
+
+ CFBundleTypeRole
+ Editor
+ CFBundleURLName
+ org.linphone.phone
+ CFBundleURLSchemes
+
+ org.linphone
+
+
+
+ CFBundleTypeRole
+ Editor
+ CFBundleURLName
+ org.linphone.phone
+ CFBundleURLSchemes
+
+ callto
+
+
+
+ ITSAppUsesNonExemptEncryption
+
+ ITSEncryptionExportComplianceCode
+ b5cb085f-772a-4a4f-8c77-5d1332b1f93f
+ NSSupportsSuddenTermination
+
+ UIAppFonts
+
+ NotoSans-Light.ttf
+ NotoSans-Regular.ttf
+ NotoSans-Medium.ttf
+ NotoSans-SemiBold.ttf
+ NotoSans-Bold.ttf
+ NotoSans-ExtraBold.ttf
+
+ UIBackgroundModes
+
+ remote-notification
+ voip
+
+ UILaunchScreen
+
+ UIImageName
+ linphone
+
+ NSCalendarsUsageDescription
+ Deprecated - Prior to iOS 17 full calendar access is required
+ NSCalendarsWriteOnlyAccessUsageDescription
+
+
+
diff --git a/Linphone/Linphone.entitlements b/Linphone/Linphone.entitlements
new file mode 100644
index 000000000..5ac776606
--- /dev/null
+++ b/Linphone/Linphone.entitlements
@@ -0,0 +1,24 @@
+
+
+
+
+ aps-environment
+ development
+ com.apple.developer.aps-environment
+ development
+ com.apple.security.app-sandbox
+
+ com.apple.security.application-groups
+
+ group.belledonne-communications.linphone
+ group.org.linphone.phone.linphoneExtension
+ group.org.linphone.phone.msgNotification
+
+ com.apple.security.files.user-selected.read-only
+
+ keychain-access-groups
+
+ $(AppIdentifierPrefix)org.linphone.phone
+
+
+
diff --git a/Linphone/LinphoneApp.swift b/Linphone/LinphoneApp.swift
new file mode 100644
index 000000000..487346789
--- /dev/null
+++ b/Linphone/LinphoneApp.swift
@@ -0,0 +1,232 @@
+/*
+ * 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 .
+ */
+
+import SwiftUI
+import linphonesw
+import UserNotifications
+
+let accountTokenNotification = Notification.Name("AccountCreationTokenReceived")
+var displayedChatroomPeerAddr: String?
+
+class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCenterDelegate {
+
+ var launchNotificationCallId: String?
+ var launchNotificationPeerAddr: String?
+ var launchNotificationLocalAddr: String?
+
+ var navigationManager: NavigationManager?
+
+ func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
+ let tokenStr = deviceToken.map { String(format: "%02.2hhx", $0) }.joined()
+ Log.info("Received remote push token : \(tokenStr)")
+ CoreContext.shared.doOnCoreQueue { core in
+ Log.warn("Push are disabled for this version, do not forward push token to the core")
+ Log.info("Forwarding remote push token to core")
+ core.didRegisterForRemotePushWithStringifiedToken(deviceTokenStr: tokenStr + ":remote")
+ }
+ }
+
+ func application(_ application: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: Error) {
+ Log.error("Failed to register for push notifications : \(error.localizedDescription)")
+ }
+
+ func application(_ application: UIApplication, didReceiveRemoteNotification userInfo: [AnyHashable: Any], fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void) {
+ Log.info("Received background push notification, payload = \(userInfo.description)")
+ let creationToken = (userInfo["customPayload"] as? NSDictionary)?["token"] as? String
+ if let creationToken = creationToken {
+ NotificationCenter.default.post(name: accountTokenNotification, object: nil, userInfo: ["token": creationToken])
+ }
+
+ completionHandler(UIBackgroundFetchResult.newData)
+ }
+
+ func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
+ // Set up notifications
+ UNUserNotificationCenter.current().delegate = self
+
+ return true
+ }
+
+ // Called when the user interacts with the notification
+ func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void) {
+ let userInfo = response.notification.request.content.userInfo
+
+ if let callId = userInfo["CallId"] as? String, let peerAddr = userInfo["peer_addr"] as? String, let localAddr = userInfo["local_addr"] as? String {
+ if self.navigationManager != nil {
+ self.navigationManager!.selectedCallId = callId
+ self.navigationManager!.peerAddr = peerAddr
+ self.navigationManager!.localAddr = localAddr
+ } else {
+ launchNotificationCallId = callId
+ launchNotificationPeerAddr = peerAddr
+ launchNotificationLocalAddr = localAddr
+ }
+ }
+
+ 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
+ Log.info("applicationWillTerminate - Stopping linphone core")
+ MagicSearchSingleton.shared.destroyMagicSearch()
+ if core.globalState != GlobalState.Off {
+ core.stop()
+ } else {
+ Log.info("applicationWillTerminate - Core already stopped")
+ }
+ }
+ }
+}
+
+@main
+struct LinphoneApp: App {
+
+ @Environment(\.scenePhase) var scenePhase
+ @UIApplicationDelegateAdaptor(AppDelegate.self) var delegate
+ @StateObject var navigationManager = NavigationManager()
+
+ @ObservedObject private var coreContext = CoreContext.shared
+ @ObservedObject private var sharedMainViewModel = SharedMainViewModel.shared
+
+ @State private var contactViewModel: ContactViewModel?
+ @State private var editContactViewModel: EditContactViewModel?
+ @State private var historyViewModel: HistoryViewModel?
+ @State private var historyListViewModel: HistoryListViewModel?
+ @State private var startCallViewModel: StartCallViewModel?
+ @State private var startConversationViewModel: StartConversationViewModel?
+ @State private var callViewModel: CallViewModel?
+ @State private var meetingWaitingRoomViewModel: MeetingWaitingRoomViewModel?
+ @State private var conversationsListViewModel: ConversationsListViewModel?
+ @State private var conversationViewModel: ConversationViewModel?
+ @State private var meetingsListViewModel: MeetingsListViewModel?
+ @State private var meetingViewModel: MeetingViewModel?
+ @State private var conversationForwardMessageViewModel: ConversationForwardMessageViewModel?
+
+ var body: some Scene {
+ WindowGroup {
+ if coreContext.coreIsStarted {
+ if !sharedMainViewModel.welcomeViewDisplayed {
+ ZStack {
+ WelcomeView()
+
+ ToastView()
+ .zIndex(3)
+ }
+ } else if coreContext.accounts.isEmpty || sharedMainViewModel.displayProfileMode {
+ ZStack {
+ AssistantView()
+
+ ToastView()
+ .zIndex(3)
+ }
+ } else if !coreContext.accounts.isEmpty
+ && contactViewModel != nil
+ && editContactViewModel != nil
+ && historyViewModel != nil
+ && historyListViewModel != nil
+ && startCallViewModel != nil
+ && startConversationViewModel != nil
+ && callViewModel != nil
+ && meetingWaitingRoomViewModel != nil
+ && conversationsListViewModel != nil
+ && conversationViewModel != nil
+ && meetingsListViewModel != nil
+ && meetingViewModel != nil
+ && conversationForwardMessageViewModel != nil {
+ ContentView(
+ contactViewModel: contactViewModel!,
+ editContactViewModel: editContactViewModel!,
+ historyViewModel: historyViewModel!,
+ historyListViewModel: historyListViewModel!,
+ startCallViewModel: startCallViewModel!,
+ startConversationViewModel: startConversationViewModel!,
+ callViewModel: callViewModel!,
+ meetingWaitingRoomViewModel: meetingWaitingRoomViewModel!,
+ conversationsListViewModel: conversationsListViewModel!,
+ conversationViewModel: conversationViewModel!,
+ meetingsListViewModel: meetingsListViewModel!,
+ meetingViewModel: meetingViewModel!,
+ conversationForwardMessageViewModel: conversationForwardMessageViewModel!
+ )
+ .environmentObject(navigationManager)
+ .onAppear {
+ // Link the navigation manager to the AppDelegate
+ delegate.navigationManager = navigationManager
+
+ // Check if the app was launched with a notification payload
+ if let callId = delegate.launchNotificationCallId, let peerAddr = delegate.launchNotificationPeerAddr, let localAddr = delegate.launchNotificationLocalAddr {
+ // Notify the app to navigate to the chat room
+ navigationManager.openChatRoom(callId: callId, peerAddr: peerAddr, localAddr: localAddr)
+ }
+ }
+ .onOpenURL { url in
+ URIHandler.handleURL(url: url)
+ }
+ } else {
+ SplashScreen().onOpenURL { url in
+ URIHandler.handleURL(url: url)
+ }
+ }
+ } else {
+ SplashScreen()
+ .onDisappear {
+ contactViewModel = ContactViewModel()
+ editContactViewModel = EditContactViewModel()
+ historyViewModel = HistoryViewModel()
+ historyListViewModel = HistoryListViewModel()
+ startCallViewModel = StartCallViewModel()
+ startConversationViewModel = StartConversationViewModel()
+ callViewModel = CallViewModel()
+ meetingWaitingRoomViewModel = MeetingWaitingRoomViewModel()
+ conversationsListViewModel = ConversationsListViewModel()
+ conversationViewModel = ConversationViewModel()
+ meetingsListViewModel = MeetingsListViewModel()
+ meetingViewModel = MeetingViewModel()
+ conversationForwardMessageViewModel = ConversationForwardMessageViewModel()
+ }.onOpenURL { url in
+ URIHandler.handleURL(url: url)
+ }
+ }
+ }.onChange(of: scenePhase) { newPhase in
+ if newPhase == .active {
+ Log.info("Entering foreground")
+ coreContext.onEnterForeground()
+ } else if newPhase == .inactive {
+ } else if newPhase == .background {
+ Log.info("Entering background")
+ coreContext.onEnterBackground()
+ }
+ }
+ }
+}
diff --git a/Linphone/Localizable.xcstrings b/Linphone/Localizable.xcstrings
new file mode 100644
index 000000000..112190c0e
--- /dev/null
+++ b/Linphone/Localizable.xcstrings
@@ -0,0 +1,2603 @@
+{
+ "sourceLanguage" : "en",
+ "strings" : {
+ "" : {
+
+ },
+ " et " : {
+
+ },
+ " has reacted by " : {
+
+ },
+ " or " : {
+
+ },
+ " to: " : {
+
+ },
+ "." : {
+
+ },
+ "[Forgotten password?](https://subscribe.linphone.org/)" : {
+
+ },
+ "[linphone.org/contact](https://linphone.org/contact)" : {
+
+ },
+ "[nos conditions d’utilisation](https://linphone.org/general-terms)" : {
+
+ },
+ "[notre politique de confidentialité](https://linphone.org/privacy-policy)" : {
+
+ },
+ "*" : {
+
+ },
+ "**%@**" : {
+
+ },
+ "**Camera** : Pour capturer votre vidéo lors des appels vidéo et conférence." : {
+
+ },
+ "**Company :** %@" : {
+
+ },
+ "**Contacts** : Pour vous afficher vos contacts et retrouver qui utilise Linphone." : {
+
+ },
+ "**Job :** %@" : {
+
+ },
+ "**Micro** : Pour permettre à vos correspondants de vous entendre." : {
+
+ },
+ "**Notifications** : Pour vous informer quand vous recevez un message ou un appel." : {
+
+ },
+ "#" : {
+
+ },
+ "%@" : {
+
+ },
+ "%@ meeting" : {
+
+ },
+ "%lld" : {
+
+ },
+ "%lld %@" : {
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "%1$lld %2$@"
+ }
+ }
+ }
+ },
+ "%lld appels" : {
+
+ },
+ "%lld Book (Example)" : {
+ "extractionState" : "manual",
+ "localizations" : {
+ "en" : {
+ "variations" : {
+ "plural" : {
+ "one" : {
+ "stringUnit" : {
+ "state" : "needs_review",
+ "value" : "%lld Book"
+ }
+ },
+ "other" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "%lld Books"
+ }
+ },
+ "zero" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "No Book"
+ }
+ }
+ }
+ }
+ },
+ "fr" : {
+ "variations" : {
+ "plural" : {
+ "one" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "%lld Livre"
+ }
+ },
+ "other" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "%lld Livres"
+ }
+ },
+ "zero" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Pas de Livre"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "%lld selected participants" : {
+
+ },
+ "+" : {
+
+ },
+ "|" : {
+
+ },
+ "❤️" : {
+
+ },
+ "👍" : {
+
+ },
+ "📅 Meeting has been cancelled" : {
+
+ },
+ "📅 Meeting has been modified" : {
+
+ },
+ "📅 You are invited to a meeting" : {
+
+ },
+ "😂" : {
+
+ },
+ "😢" : {
+
+ },
+ "😮" : {
+
+ },
+ "0" : {
+
+ },
+ "1" : {
+
+ },
+ "2" : {
+
+ },
+ "3" : {
+
+ },
+ "4" : {
+
+ },
+ "5" : {
+
+ },
+ "6" : {
+
+ },
+ "7" : {
+
+ },
+ "8" : {
+
+ },
+ "9" : {
+
+ },
+ "A subject and at least one participant is required to create a meeting" : {
+
+ },
+ "Accept all" : {
+
+ },
+ "Account successfully logged out" : {
+
+ },
+ "Active" : {
+
+ },
+ "Add a description" : {
+
+ },
+ "Add a picture" : {
+
+ },
+ "Add participants" : {
+
+ },
+ "Add the contact" : {
+
+ },
+ "Add to calendar" : {
+
+ },
+ "Add to contacts" : {
+
+ },
+ "Add to favourites" : {
+
+ },
+ "Administrateur" : {
+
+ },
+ "All calls will be removed from the history." : {
+
+ },
+ "All contacts" : {
+
+ },
+ "All modifications will be canceled." : {
+
+ },
+ "Annuler" : {
+
+ },
+ "Appel" : {
+
+ },
+ "assistant_account_create" : {
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Create"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Créer"
+ }
+ }
+ }
+ },
+ "assistant_account_creation_sms_confirmation_explanation" : {
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "We have sent a verification code on your phone number %@.
Please enter the verification code below:"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "On vous a envoyé un code de vérification par SMS au numéro %@.
Merci de le saisir ci-dessous :"
+ }
+ }
+ }
+ },
+ "assistant_account_creation_wrong_phone_number" : {
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Wrong number?"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Mauvais numéro ?"
+ }
+ }
+ }
+ },
+ "assistant_account_login" : {
+ "extractionState" : "manual",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Login"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Login en FR"
+ }
+ }
+ }
+ },
+ "assistant_account_register" : {
+ "extractionState" : "manual",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Register"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "S’inscrire"
+ }
+ }
+ }
+ },
+ "assistant_account_register_push_notification_not_received_error" : {
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Push notification with auth token not received in 5 seconds, please try again later"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "La notification poussée avec le jeton d\\'authentification n'a pas été reçue dans les 5 secondes, merci de réessayer plus tard"
+ }
+ }
+ }
+ },
+ "assistant_account_register_unexpected_error" : {
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Unexpected error occurred, please try again later"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Un erreur inattendue est survenue, merci de réessayer plus tard"
+ }
+ }
+ }
+ },
+ "assistant_already_have_an_account" : {
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Already have an account?"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Vous avez déjà un compte ?"
+ }
+ }
+ }
+ },
+ "assistant_create_account_using_email_on_our_web_platform" : {
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Create an account with your email on:"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Créez un compte avec votre email ici :"
+ }
+ }
+ }
+ },
+ "assistant_dialog_confirm_phone_number_message" : {
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Are you sure you want to use %@ phone number?"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Êtes-vous sûr de vouloir utiliser le numéro de téléphone %@ ?"
+ }
+ }
+ }
+ },
+ "assistant_dialog_confirm_phone_number_title" : {
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Confirm phone number"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Confirmez votre numéro de téléphone"
+ }
+ }
+ }
+ },
+ "Attended transfer" : {
+
+ },
+ "Audio" : {
+
+ },
+ "Audio seulement" : {
+
+ },
+ "Block the address" : {
+
+ },
+ "Block the number" : {
+
+ },
+ "Bluetooth" : {
+
+ },
+ "Call failed" : {
+
+ },
+ "Call has been successfully transferred" : {
+
+ },
+ "Call history" : {
+
+ },
+ "Call is being transferred" : {
+
+ },
+ "Call list" : {
+
+ },
+ "Call transfer failed!" : {
+
+ },
+ "call_action_hang_up" : {
+ "extractionState" : "manual",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Hang up"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Raccrocher"
+ }
+ }
+ }
+ },
+ "call_can_be_trusted_toast" : {
+ "extractionState" : "manual",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Authenticated device"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Appareil authentifié"
+ }
+ }
+ }
+ },
+ "call_dialog_zrtp_security_alert_message" : {
+ "extractionState" : "manual",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "This call confidentiality may be compromise!"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "La confidentialité de votre appel peut être compromise !"
+ }
+ }
+ }
+ },
+ "call_dialog_zrtp_security_alert_title" : {
+ "extractionState" : "manual",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Security alert"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Alerte de sécurité"
+ }
+ }
+ }
+ },
+ "call_dialog_zrtp_security_alert_try_again" : {
+ "extractionState" : "manual",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Try again"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Réessayer"
+ }
+ }
+ }
+ },
+ "call_dialog_zrtp_validate_trust_letters_do_not_match" : {
+ "extractionState" : "manual",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Nothing matches"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Aucune correspondance"
+ }
+ }
+ }
+ },
+ "call_dialog_zrtp_validate_trust_local_code_label" : {
+ "extractionState" : "manual",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Your code:"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Votre code :"
+ }
+ }
+ }
+ },
+ "call_dialog_zrtp_validate_trust_message" : {
+ "extractionState" : "manual",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "For your safety, we need to authenticate your correspondent device.
Please exchange your codes:"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Pour garantir le chiffrement, nous avons besoin d’authentifier l’appareil de votre correspondant.
Veuillez échanger vos codes :"
+ }
+ }
+ }
+ },
+ "call_dialog_zrtp_validate_trust_remote_code_label" : {
+ "extractionState" : "manual",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Correspondent code:"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Code correspondant :"
+ }
+ }
+ }
+ },
+ "call_dialog_zrtp_validate_trust_title" : {
+ "extractionState" : "manual",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Validate the device"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Vérification de sécurité"
+ }
+ }
+ }
+ },
+ "call_dialog_zrtp_validate_trust_warning_message" : {
+ "extractionState" : "manual",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "For your safety, we need to re-authenticate your correspondent device.
Please re-exchange your codes:"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Pour garantir le chiffrement, nous avons besoin de réauthentifier l’appareil de votre correspondant.
Veuillez ré-échanger vos codes :"
+ }
+ }
+ }
+ },
+ "call_not_encrypted" : {
+ "extractionState" : "manual",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Call is not encrypted"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Appel non chiffré"
+ }
+ }
+ }
+ },
+ "call_srtp_point_to_point_encrypted" : {
+ "extractionState" : "manual",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Point-to-point encrypted by SRTP"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Appel chiffré de point à point"
+ }
+ }
+ }
+ },
+ "call_waiting_for_encryption_info" : {
+ "extractionState" : "manual",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Waiting for encryption…"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "En attente du chiffrement…"
+ }
+ }
+ }
+ },
+ "call_zrtp_end_to_end_encrypted" : {
+ "extractionState" : "manual",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "End-to-end encrypted by ZRTP"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Appel chiffré de bout en bout"
+ }
+ }
+ }
+ },
+ "call_zrtp_sas_validation_required" : {
+ "extractionState" : "manual",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Validation required"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "needs_review",
+ "value" : "Vérification nécessaire"
+ }
+ }
+ }
+ },
+ "call_zrtp_sas_validation_skip" : {
+ "extractionState" : "manual",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Skip"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Passer"
+ }
+ }
+ }
+ },
+ "Calls" : {
+
+ },
+ "calls_list_dialog_merge_into_conference_label" : {
+ "extractionState" : "manual",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Create conference"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Créer une conférence"
+ }
+ }
+ }
+ },
+ "calls_list_dialog_merge_into_conference_title" : {
+ "extractionState" : "manual",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Merge all calls into conference?"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Fusionner les appels en une conférence ?"
+ }
+ }
+ }
+ },
+ "Cancel" : {
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Cancel"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Annuler"
+ }
+ }
+ }
+ },
+ "Cancel for me only" : {
+
+ },
+ "Cancelled" : {
+
+ },
+ "Categories" : {
+
+ },
+ "Ce mode vous permet d’être interopérable avec d’autres services SIP.\nVos communications seront chiffrées de point à point. " : {
+
+ },
+ "Chiffrement de bout en bout de tous vos échanges, grâce au mode default vos communications sont à l’abri des regards." : {
+
+ },
+ "Chiffrement du média" : {
+
+ },
+ "Clear Logs" : {
+
+ },
+ "Close" : {
+
+ },
+ "Company" : {
+
+ },
+ "Conditions de service" : {
+
+ },
+ "conference_failed_to_create_group_call_toast" : {
+ "extractionState" : "manual",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Failed to create a group call!"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "L'appel de groupe n'a pas pu être créé!"
+ }
+ }
+ }
+ },
+ "Configuration failed" : {
+
+ },
+ "Configuration successfully applied" : {
+
+ },
+ "Confirm" : {
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Confirm"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Confirmer"
+ }
+ }
+ }
+ },
+ "Connexion à la réunion" : {
+
+ },
+ "contact_dialog_pick_phone_number_or_sip_address_title" : {
+ "extractionState" : "manual",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Choose a number or a SIP address"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Choisissez un numéro ou adresse SIP"
+ }
+ }
+ }
+ },
+ "Contacts" : {
+
+ },
+ "contacts_list_empty" : {
+ "extractionState" : "manual",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "No contact for the moment…"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Aucun contact pour le moment…"
+ }
+ }
+ }
+ },
+ "Continue" : {
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Continue"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Continuer"
+ }
+ }
+ }
+ },
+ "conversation_action_mute" : {
+ "extractionState" : "manual",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Mute"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Mettre en sourdine"
+ }
+ }
+ }
+ },
+ "conversation_action_unmute" : {
+ "extractionState" : "manual",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Un-mute"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Enlever la sourdine"
+ }
+ }
+ }
+ },
+ "conversation_composing_label_multiple" : {
+ "extractionState" : "manual",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "%@ are composing…"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "%@ sont en train d'écrire…"
+ }
+ }
+ }
+ },
+ "conversation_composing_label_single" : {
+ "extractionState" : "manual",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "%@ is composing…"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "%@ est en train d'écrire…"
+ }
+ }
+ }
+ },
+ "conversation_dialog_set_subject" : {
+ "extractionState" : "manual",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Set conversation subject"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Nommer la conversation"
+ }
+ }
+ }
+ },
+ "conversation_dialog_subject_hint" : {
+ "extractionState" : "manual",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Conversation subject"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Nom de la conversation"
+ }
+ }
+ }
+ },
+ "conversation_ephemeral_messages_duration_disabled" : {
+
+ },
+ "conversation_ephemeral_messages_duration_one_day" : {
+
+ },
+ "conversation_ephemeral_messages_duration_one_hour" : {
+
+ },
+ "conversation_ephemeral_messages_duration_one_minute" : {
+
+ },
+ "conversation_ephemeral_messages_duration_one_week" : {
+
+ },
+ "conversation_ephemeral_messages_duration_three_days" : {
+
+ },
+ "conversation_event_admin_set" : {
+ "extractionState" : "manual",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "%@ is admin"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "%@ est maintenant administrateur"
+ }
+ }
+ }
+ },
+ "conversation_event_admin_unset" : {
+ "extractionState" : "manual",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "%@ is no longer admin"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "%@ n'est plus administrateur"
+ }
+ }
+ }
+ },
+ "conversation_event_conference_created" : {
+ "extractionState" : "manual",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "You have joined the group"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Vous avez rejoint le groupe"
+ }
+ }
+ }
+ },
+ "conversation_event_conference_destroyed" : {
+ "extractionState" : "manual",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "You have left the group"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Vous avez quitté le groupe"
+ }
+ }
+ }
+ },
+ "conversation_event_device_added" : {
+ "extractionState" : "manual",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "New device for %@"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Nouvel appareil pour %@"
+ }
+ }
+ }
+ },
+ "conversation_event_device_removed" : {
+ "extractionState" : "manual",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Device for %@ removed"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Appareil supprimé pour %@"
+ }
+ }
+ }
+ },
+ "conversation_event_ephemeral_messages_disabled" : {
+ "extractionState" : "manual",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Ephemeral messages have been disabled"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Les messages éphémères ont été désactivés"
+ }
+ }
+ }
+ },
+ "conversation_event_ephemeral_messages_enabled" : {
+ "extractionState" : "manual",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Ephemeral messages have been enabled"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Les messages éphémères ont été activés"
+ }
+ }
+ }
+ },
+ "conversation_event_ephemeral_messages_lifetime_changed" : {
+ "extractionState" : "manual",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Ephemeral lifetime is now %@"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "La durée des messages éphémères est de %@"
+ }
+ }
+ }
+ },
+ "conversation_event_participant_added" : {
+ "extractionState" : "manual",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "%@ has joined"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "%@ a rejoint le groupe"
+ }
+ }
+ }
+ },
+ "conversation_event_participant_removed" : {
+ "extractionState" : "manual",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "%@ has left"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "%@ a quitté le groupe"
+ }
+ }
+ }
+ },
+ "conversation_event_subject_changed" : {
+ "extractionState" : "manual",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "New subject: %@"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Le groupe a été renommé : %@"
+ }
+ }
+ }
+ },
+ "conversation_failed_to_create_toast" : {
+ "extractionState" : "manual",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Failed to create conversation!"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Échec de la création de la conversation !"
+ }
+ }
+ }
+ },
+ "conversation_forward_message_title" : {
+ "extractionState" : "manual",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Forward message to…"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Transférer à…"
+ }
+ }
+ }
+ },
+ "conversation_info_confirm_start_group_call_dialog_message" : {
+ "extractionState" : "manual",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "All participants will receive a call."
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Tous les participants de la conversation recevront un appel."
+ }
+ }
+ }
+ },
+ "conversation_info_confirm_start_group_call_dialog_title" : {
+ "extractionState" : "manual",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Start a group call?"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Démarrer un appel de groupe ?"
+ }
+ }
+ }
+ },
+ "conversation_invalid_participant_due_to_security_mode_toast" : {
+ "extractionState" : "manual",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Can't create conversation with a participant not on the same domain due to security restrictions!"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Pour des raisons de sécurité, la création d'une conversation avec un participant d'un domaine tiers est désactivé."
+ }
+ }
+ }
+ },
+ "conversation_menu_configure_ephemeral_messages" : {
+ "extractionState" : "manual",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Ephemeral messages"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Messages éphémères"
+ }
+ }
+ }
+ },
+ "conversation_menu_go_to_info" : {
+ "extractionState" : "manual",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Conversation info"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Informations"
+ }
+ }
+ }
+ },
+ "conversation_message_forward_cancelled_toast" : {
+ "extractionState" : "manual",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Message forward was cancelled"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Transfert du message abandonné"
+ }
+ }
+ }
+ },
+ "conversation_message_forwarded_toast" : {
+ "extractionState" : "manual",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Message was forwarded"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Le message a été transféré"
+ }
+ }
+ }
+ },
+ "conversation_message_meeting_cancelled_label" : {
+ "extractionState" : "manual",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Meeting has been cancelled!"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "La réunion a été annulée"
+ }
+ }
+ }
+ },
+ "conversation_message_meeting_updated_label" : {
+ "extractionState" : "manual",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Meeting has been updated"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "La réunion a été mise à jour"
+ }
+ }
+ }
+ },
+ "conversation_reply_to_message_title" : {
+ "extractionState" : "manual",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Replying to: "
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "En réponse à : "
+ }
+ }
+ }
+ },
+ "Conversations" : {
+
+ },
+ "conversations_list_empty" : {
+ "extractionState" : "manual",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "No conversation for the moment…"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Aucune conversation pour le moment…"
+ }
+ }
+ }
+ },
+ "Copy address" : {
+
+ },
+ "Copy number" : {
+
+ },
+ "Copy SIP address" : {
+
+ },
+ "Could not send ICS invitations to meeting to any participant" : {
+
+ },
+ "D'accord" : {
+
+ },
+ "Default" : {
+
+ },
+ "Default mode" : {
+
+ },
+ "Delete" : {
+
+ },
+ "Delete %@?" : {
+
+ },
+ "Delete all history" : {
+
+ },
+ "Delete history" : {
+
+ },
+ "Delete this contact" : {
+
+ },
+ "Delete this meeting" : {
+
+ },
+ "Demande d’autorisations" : {
+
+ },
+ "Deny all" : {
+
+ },
+ "Description" : {
+
+ },
+ "Dialer" : {
+
+ },
+ "Display Name" : {
+
+ },
+ "Disposition" : {
+
+ },
+ "Do you really want to delete all calls history?" : {
+
+ },
+ "Domain" : {
+
+ },
+ "Don’t save modifications?" : {
+
+ },
+ "drawer_menu_account_connection_status_cleared" : {
+ "extractionState" : "manual",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Disabled"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Désactivé"
+ }
+ }
+ }
+ },
+ "drawer_menu_account_connection_status_connected" : {
+ "extractionState" : "manual",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Connected"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Connecté"
+ }
+ }
+ }
+ },
+ "drawer_menu_account_connection_status_failed" : {
+ "extractionState" : "manual",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Error"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Erreur"
+ }
+ }
+ }
+ },
+ "drawer_menu_account_connection_status_progress" : {
+ "extractionState" : "manual",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Connecting…"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "En cours de connexion…"
+ }
+ }
+ }
+ },
+ "drawer_menu_account_connection_status_refreshing" : {
+ "extractionState" : "manual",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Refreshing ..."
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "En cours de rafraîchissement…"
+ }
+ }
+ }
+ },
+ "drawer_menu_add_account" : {
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Add an account"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "needs_review",
+ "value" : "Ajouter un compte"
+ }
+ }
+ }
+ },
+ "drawer_menu_no_account_configured_yet" : {
+ "extractionState" : "manual",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "No account configured yet"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Aucun compte configuré"
+ }
+ }
+ }
+ },
+ "Earpiece" : {
+
+ },
+ "Edit" : {
+
+ },
+ "Edit contact" : {
+
+ },
+ "Edit Contact" : {
+
+ },
+ "Edit picture" : {
+
+ },
+ "En attente d'autres participants..." : {
+
+ },
+ "En continuant, vous acceptez ces conditions, " : {
+
+ },
+ "En pause" : {
+
+ },
+ "Error" : {
+
+ },
+ "Error Name" : {
+
+ },
+ "Etes-vous sûr de vouloir supprimer %@ ?" : {
+
+ },
+ "Faire la validation à nouveau" : {
+
+ },
+ "Favourites" : {
+
+ },
+ "First Name" : {
+
+ },
+ "First name*" : {
+
+ },
+ "GC_MSG" : {
+ "extractionState" : "manual",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "You have been added to a chat room"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Vous avez été ajouté à une conversation"
+ }
+ }
+ }
+ },
+ "Hang up call" : {
+
+ },
+ "Headphones" : {
+
+ },
+ "help_title" : {
+ "extractionState" : "manual",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Help"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Aide"
+ }
+ }
+ }
+ },
+ "History has been deleted" : {
+
+ },
+ "history_call_start_create_group_call" : {
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Create a group call"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Démarrer un appel de groupe"
+ }
+ }
+ }
+ },
+ "history_call_start_search_bar_filter_hint" : {
+ "extractionState" : "manual",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Search contact or history call"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Cherchez un contact ou une suggestion"
+ }
+ }
+ }
+ },
+ "history_call_start_title" : {
+ "extractionState" : "manual",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "New call"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Nouvel appel"
+ }
+ }
+ }
+ },
+ "history_group_call_start_dialog_set_subject" : {
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Set group call subject"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Nommer l'appel de groupe"
+ }
+ }
+ }
+ },
+ "history_group_call_start_dialog_subject_hint" : {
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Group call subject"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Nom de l'appel de groupe"
+ }
+ }
+ }
+ },
+ "history_list_empty_history" : {
+ "extractionState" : "manual",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "No call for the moment…"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Aucun appel dans votre historique…"
+ }
+ }
+ }
+ },
+ "history_list_empty_with_filter_history" : {
+ "extractionState" : "manual",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "No entries match your search"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "needs_review",
+ "value" : "Aucune entrée ne correspond à votre recherche"
+ }
+ }
+ }
+ },
+ "I prefere create an account" : {
+
+ },
+ "I understand" : {
+
+ },
+ "IM_MSG" : {
+ "extractionState" : "manual",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "You have received a message"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Vous avez reçu un message"
+ }
+ }
+ }
+ },
+ "Information" : {
+
+ },
+ "Interoperable" : {
+
+ },
+ "Interoperable mode" : {
+
+ },
+ "Invalid QR code!" : {
+
+ },
+ "Invalide URI" : {
+
+ },
+ "Invitation" : {
+
+ },
+ "Job title" : {
+
+ },
+ "Join the meeting now" : {
+
+ },
+ "Joining..." : {
+
+ },
+ "Last name" : {
+
+ },
+ "Linphone" : {
+
+ },
+ "list_filter_no_result_found" : {
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "No result found…"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Aucun résultat…"
+ }
+ }
+ }
+ },
+ "Log out" : {
+
+ },
+ "Login" : {
+
+ },
+ "Logout" : {
+
+ },
+ "Logs cleared" : {
+
+ },
+ "Logs URL copied into clipboard" : {
+
+ },
+ "Marquer comme non lu" : {
+
+ },
+ "Meeting added to iPhone calendar" : {
+
+ },
+ "meeting_waiting_room_join" : {
+ "extractionState" : "manual",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Join"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Rejoindre"
+ }
+ }
+ }
+ },
+ "Meetings" : {
+
+ },
+ "meetings_list_empty" : {
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "No meeting for the moment…"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Aucune réunion pour le moment…"
+ }
+ }
+ }
+ },
+ "menu_copy_chat_message" : {
+ "extractionState" : "manual",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Copy"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Copier le texte"
+ }
+ }
+ }
+ },
+ "menu_delete_selected_item" : {
+ "extractionState" : "manual",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Delete"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Supprimer"
+ }
+ }
+ }
+ },
+ "menu_forward_chat_message" : {
+ "extractionState" : "manual",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Forward"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Transférer"
+ }
+ }
+ }
+ },
+ "menu_reply_to_chat_message" : {
+ "extractionState" : "manual",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Reply"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Répondre"
+ }
+ }
+ }
+ },
+ "menu_resend_chat_message" : {
+ "extractionState" : "manual",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Re-send"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Ré-envoyer"
+ }
+ }
+ }
+ },
+ "Message" : {
+
+ },
+ "Message copied into clipboard" : {
+
+ },
+ "Message received" : {
+
+ },
+ "message_delivery_info_error_title" : {
+ "extractionState" : "manual",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Error"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "En erreur"
+ }
+ }
+ }
+ },
+ "message_delivery_info_read_title" : {
+ "extractionState" : "manual",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Read"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Lu"
+ }
+ }
+ }
+ },
+ "message_delivery_info_received_title" : {
+ "extractionState" : "manual",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Received"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Reçu"
+ }
+ }
+ }
+ },
+ "message_delivery_info_sent_title" : {
+ "extractionState" : "manual",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Sent"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Envoyé"
+ }
+ }
+ }
+ },
+ "message_forwarded_label" : {
+ "extractionState" : "manual",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Forwarded"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Transféré"
+ }
+ }
+ }
+ },
+ "message_reaction_click_to_remove_label" : {
+ "extractionState" : "manual",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Click to remove"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Cliquez pour supprimer"
+ }
+ }
+ }
+ },
+ "message_reactions_info_all_title" : {
+ "extractionState" : "manual",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Reactions"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Réactions"
+ }
+ }
+ }
+ },
+ "Messages" : {
+
+ },
+ "Mettre en sourdine" : {
+
+ },
+ "Missed call" : {
+
+ },
+ "Mosaïque" : {
+
+ },
+ "Network is not reachable" : {
+
+ },
+ "Network is now reachable again" : {
+
+ },
+ "New call" : {
+
+ },
+ "New contact" : {
+
+ },
+ "new_conversation_create_group" : {
+ "extractionState" : "manual",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Create a group conversation"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Créer une conversation de groupe"
+ }
+ }
+ }
+ },
+ "new_conversation_title" : {
+ "extractionState" : "manual",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "New conversation"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Créer une conversation"
+ }
+ }
+ }
+ },
+ "Next" : {
+
+ },
+ "No meeting today" : {
+
+ },
+ "No participant for the moment..." : {
+
+ },
+ "Non" : {
+
+ },
+ "Not account yet?" : {
+
+ },
+ "Ok" : {
+
+ },
+ "Open source" : {
+
+ },
+ "Opération en cours..." : {
+
+ },
+ "Organizer" : {
+
+ },
+ "Other actions" : {
+
+ },
+ "Oui" : {
+
+ },
+ "Partage d'écran" : {
+
+ },
+ "Partager le lien" : {
+
+ },
+ "Participant actif" : {
+
+ },
+ "Participants" : {
+
+ },
+ "password" : {
+ "extractionState" : "manual",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Password"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Password en FR"
+ }
+ }
+ }
+ },
+ "Pause" : {
+
+ },
+ "Paused" : {
+
+ },
+ "Paused by remote" : {
+
+ },
+ "Personnalize your profil mode" : {
+
+ },
+ "Phone :" : {
+
+ },
+ "Phone (%@) :" : {
+
+ },
+ "Phone number" : {
+
+ },
+ "Plus tard" : {
+
+ },
+ "Pour vous permettre de vous profitez pleinement de Linphone nous avons besoin des autorisations suivantes :" : {
+
+ },
+ "QR code validated!" : {
+
+ },
+ "Quitter la conversation" : {
+
+ },
+ "Réactiver les notifications" : {
+
+ },
+ "Record" : {
+
+ },
+ "recordings_title" : {
+ "extractionState" : "manual",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Recordings"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Enregistrements"
+ }
+ }
+ }
+ },
+ "Register" : {
+ "localizations" : {
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "S’inscrire"
+ }
+ }
+ }
+ },
+ "Remove from favourites" : {
+
+ },
+ "Remove picture" : {
+
+ },
+ "Resume" : {
+
+ },
+ "Resuming" : {
+
+ },
+ "Say something..." : {
+
+ },
+ "Scan QR code" : {
+
+ },
+ "Search contact" : {
+
+ },
+ "Sécurisé" : {
+
+ },
+ "See all" : {
+
+ },
+ "See contact" : {
+
+ },
+ "See Linphone contact" : {
+
+ },
+ "Select %@ %@" : {
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "Select %1$@ %2$@"
+ }
+ }
+ }
+ },
+ "Send cancellation notifications" : {
+
+ },
+ "Send invitations to participants" : {
+
+ },
+ "Send Logs" : {
+
+ },
+ "Send notification to participants ?" : {
+
+ },
+ "settings_title" : {
+ "extractionState" : "manual",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Settings"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Paramètres"
+ }
+ }
+ }
+ },
+ "Share" : {
+
+ },
+ "SIP address" : {
+
+ },
+ "SIP address :" : {
+
+ },
+ "SIP address copied into clipboard" : {
+
+ },
+ "sip.linphone.org" : {
+
+ },
+ "Skip" : {
+
+ },
+ "Speaker" : {
+
+ },
+ "Start" : {
+
+ },
+ "Subject" : {
+
+ },
+ "subscribe.linphone.org" : {
+
+ },
+ "Successfully removed meeting" : {
+
+ },
+ "Suggestions" : {
+
+ },
+ "Supprimer la conversation" : {
+
+ },
+ "Supprimer un participant" : {
+
+ },
+ "TCP" : {
+
+ },
+ "Temp Help" : {
+
+ },
+ "The meeting will be cancelled" : {
+
+ },
+ "The user name or password is incorrects" : {
+
+ },
+ "This contact will be deleted definitively." : {
+
+ },
+ "Time Zone: %@" : {
+
+ },
+ "TLS" : {
+
+ },
+ "to Linphone" : {
+
+ },
+ "Transfer" : {
+
+ },
+ "Transfer call to" : {
+
+ },
+ "Transport" : {
+
+ },
+ "UDP" : {
+
+ },
+ "Unable to call, invalid address" : {
+
+ },
+ "Unable to retrieve configuration, invalid address" : {
+
+ },
+ "Une application de communication **sécurisée**, **open source** et **française**." : {
+
+ },
+ "Une application open source et un **service gratuit** depuis **2001**." : {
+
+ },
+ "Use a SIP account" : {
+
+ },
+ "Use SIP Account" : {
+
+ },
+ "username" : {
+ "extractionState" : "manual",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Username"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Username en FR"
+ }
+ }
+ }
+ },
+ "Username error" : {
+
+ },
+ "Vidéo" : {
+
+ },
+ "Video Call" : {
+
+ },
+ "Vos communications sont en sécurité grâce aux **Chiffrement de bout en bout**." : {
+
+ },
+ "Vous allez rejoindre la réunion dans quelques instants..." : {
+
+ },
+ "Welcome" : {
+
+ },
+ "You will change this mode later" : {
+
+ }
+ },
+ "version" : "1.0"
+}
\ No newline at end of file
diff --git a/Linphone/Preview Content/Preview Assets.xcassets/Contents.json b/Linphone/Preview Content/Preview Assets.xcassets/Contents.json
new file mode 100644
index 000000000..73c00596a
--- /dev/null
+++ b/Linphone/Preview Content/Preview Assets.xcassets/Contents.json
@@ -0,0 +1,6 @@
+{
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Linphone/Ressources/assistant_linphone_default_values b/Linphone/Ressources/assistant_linphone_default_values
new file mode 100644
index 000000000..54453d542
--- /dev/null
+++ b/Linphone/Ressources/assistant_linphone_default_values
@@ -0,0 +1,37 @@
+
+
+
+ 1
+ 0
+ 1
+ 120
+ sip:voip-metrics@sip.linphone.org;transport=tls
+ 1
+ 180
+ 31536000
+ sip:?@sip.linphone.org
+ <sip:sip.linphone.org;transport=tls>
+ <sip:sip.linphone.org;transport=tls>
+ 1
+ nat_policy_default_values
+ sip.linphone.org
+ sip:conference-factory@sip.linphone.org
+ sip:videoconference-factory@sip.linphone.org
+ 1
+ 1
+ 1
+ 1
+ https://lime.linphone.org/lime-server/lime-server.php
+
+
+ stun.linphone.org
+ stun,ice
+
+
+
+
diff --git a/Linphone/Ressources/assistant_third_party_default_values b/Linphone/Ressources/assistant_third_party_default_values
new file mode 100644
index 000000000..bd9c9b79f
--- /dev/null
+++ b/Linphone/Ressources/assistant_third_party_default_values
@@ -0,0 +1,33 @@
+
+
+
+ 0
+ 0
+ 0
+ -1
+
+ 0
+ 0
+ 3600
+
+
+
+ 1
+
+
+
+
+ 0
+ 0
+ 0
+
+
+
+ stun.linphone.org
+ stun,ice
+
+
+
diff --git a/Linphone/Ressources/linphonerc-default b/Linphone/Ressources/linphonerc-default
new file mode 100644
index 000000000..80f2f4736
--- /dev/null
+++ b/Linphone/Ressources/linphonerc-default
@@ -0,0 +1,46 @@
+
+## Start of default rc
+
+[sip]
+contact="Linphone iPhone"
+use_info=0
+use_ipv6=1
+keepalive_period=30000
+sip_port=-1
+sip_tcp_port=-1
+sip_tls_port=-1
+media_encryption=none
+update_presence_model_timestamp_before_publish_expires_refresh=1
+
+[net]
+#Because dynamic bitrate adaption can increase bitrate, we must allow "no limit"
+download_bw=0
+upload_bw=0
+
+[video]
+size=vga
+automatically_accept=1
+automatically_initiate=0
+automatically_accept_direction=2 #receive only
+
+[app]
+tunnel=disabled
+auto_download_incoming_voice_recordings=1
+auto_download_incoming_icalendars=1
+
+
+[tunnel]
+host=
+port=443
+
+[misc]
+log_collection_upload_server_url=https://www.linphone.org:444/lft.php
+file_transfer_server_url=https://www.linphone.org:444/lft.php
+version_check_url_root=https://www.linphone.org/releases
+max_calls=10
+conference_layout=1
+
+[fec]
+fec_enabled=1
+
+## End of default rc
diff --git a/Linphone/Ressources/linphonerc-factory b/Linphone/Ressources/linphonerc-factory
new file mode 100644
index 000000000..429ef55c8
--- /dev/null
+++ b/Linphone/Ressources/linphonerc-factory
@@ -0,0 +1,56 @@
+
+## Start of factory rc
+
+# This file shall not contain path referencing package name, in order to be portable when app is renamed.
+# Paths to resources must be set from LinphoneManager, after creating LinphoneCore.
+
+[net]
+mtu=1300
+force_ice_disablement=0
+
+[rtp]
+accept_any_encryption=1
+
+[sip]
+guess_hostname=1
+register_only_when_network_is_up=1
+auto_net_state_mon=1
+auto_answer_replacing_calls=0
+ping_with_options=0
+use_cpim=1
+zrtp_key_agreements_suites=MS_ZRTP_KEY_AGREEMENT_K255_KYB512
+chat_messages_aggregation_delay=1000
+chat_messages_aggregation=1
+update_presence_model_timestamp_before_publish_expires_refresh=1
+rls_uri=sips:rls@sip.linphone.org
+
+[sound]
+#remove this property for any application that is not Linphone public version itself
+ec_calibrator_cool_tones=1
+
+[video]
+auto_resize_preview_to_keep_ratio=1
+max_conference_size=vga
+automatically_accept=1
+automatically_initiate=0
+
+[misc]
+enable_basic_to_client_group_chat_room_migration=0
+enable_simple_group_chat_message_state=0
+aggregate_imdn=1
+notify_each_friend_individually_when_presence_received=0
+store_friends=0
+
+[app]
+record_aware=1
+
+[account_creator]
+url=https://subscribe.linphone.org/api/
+
+[lime]
+lime_update_threshold=86400
+
+[alerts]
+alerts_enabled=1
+
+## End of factory rc
diff --git a/Linphone/SplashScreen.swift b/Linphone/SplashScreen.swift
new file mode 100644
index 000000000..e15c4aaac
--- /dev/null
+++ b/Linphone/SplashScreen.swift
@@ -0,0 +1,43 @@
+/*
+ * Copyright (c) 2010-2023 Belledonne Communications SARL.
+ *
+ * This file is part of Linphone
+ *
+ * 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 .
+ */
+
+import SwiftUI
+
+struct SplashScreen: View {
+
+ var body: some View {
+ GeometryReader { _ in
+ VStack {
+ Spacer()
+ HStack {
+ Spacer()
+ Image("linphone")
+ Spacer()
+ }
+ Spacer()
+ }
+
+ }
+ .ignoresSafeArea(.all)
+ }
+}
+
+#Preview {
+ SplashScreen()
+}
diff --git a/Linphone/TelecomManager/ProviderDelegate.swift b/Linphone/TelecomManager/ProviderDelegate.swift
new file mode 100644
index 000000000..3c3e59499
--- /dev/null
+++ b/Linphone/TelecomManager/ProviderDelegate.swift
@@ -0,0 +1,410 @@
+/*
+ * Copyright (c) 2010-2020 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 .
+ */
+// swiftlint:disable line_length
+
+import Foundation
+import CallKit
+import UIKit
+import linphonesw
+import AVFoundation
+import os
+import SwiftUI
+
+class CallInfo {
+ var callId: String = ""
+ var toAddr: Address?
+ var isOutgoing = false
+ var sasEnabled = false
+ var connected = false
+ var reason: Reason = Reason.None
+ var displayName: String?
+ var videoEnabled = false
+ var isConference = false
+
+ static func newIncomingCallInfo(callId: String) -> CallInfo {
+ let callInfo = CallInfo()
+ callInfo.callId = callId
+ return callInfo
+ }
+
+ static func newOutgoingCallInfo(addr: Address, isSas: Bool, displayName: String, isVideo: Bool, isConference: Bool) -> CallInfo {
+ let callInfo = CallInfo()
+ callInfo.isOutgoing = true
+ callInfo.sasEnabled = isSas
+ callInfo.toAddr = addr
+ callInfo.displayName = displayName
+ callInfo.videoEnabled = isVideo
+ callInfo.isConference = isConference
+ return callInfo
+ }
+}
+
+/*
+ * A delegate to support callkit.
+ */
+class ProviderDelegate: NSObject {
+ let provider: CXProvider
+ var uuids: [String: UUID] = [:]
+ var callInfos: [UUID: CallInfo] = [:]
+
+ override init() {
+ provider = CXProvider(configuration: ProviderDelegate.providerConfiguration)
+ super.init()
+ provider.setDelegate(self, queue: nil)
+ }
+
+ static var providerConfiguration: CXProviderConfiguration {
+ let providerConfiguration = CXProviderConfiguration()
+ // providerConfiguration.ringtoneSound = ConfigManager.instance().lpConfigBoolForKey(key: "use_device_ringtone") ? nil : "notes_of_the_optimistic.caf"
+ providerConfiguration.supportsVideo = true
+ providerConfiguration.iconTemplateImageData = UIImage(named: "linphone")?.pngData()
+ providerConfiguration.supportedHandleTypes = [.generic, .phoneNumber, .emailAddress]
+
+ providerConfiguration.maximumCallsPerCallGroup = 10
+ providerConfiguration.maximumCallGroups = 10
+
+ // not show app's calls in tel's history
+ // providerConfiguration.includesCallsInRecents = YES;
+
+ return providerConfiguration
+ }
+
+ func reportIncomingCall(call: Call?, uuid: UUID, handle: String, hasVideo: Bool, displayName: String) {
+ let update = CXCallUpdate()
+ update.remoteHandle = CXHandle(type: .generic, value: handle)
+ update.hasVideo = hasVideo
+ update.localizedCallerName = displayName
+
+ let callInfo = callInfos[uuid]
+ let callId = callInfo?.callId ?? ""
+
+ /*
+ if (ConfigManager.instance().config?.hasEntry(section: "app", key: "max_calls") == 1) { // moved from misc to app section intentionally upon app start or remote configuration
+ if let maxCalls = ConfigManager.instance().config?.getInt(section: "app",key: "max_calls",defaultValue: 10), Core.get().callsNb > maxCalls {
+ Log.directLog(BCTBX_LOG_MESSAGE, text: "CallKit: declining call, as max calls (\(maxCalls)) reached call-id: [\(String(describing: callId))] and UUID: [\(uuid.description)]")
+ decline(uuid: uuid)
+
+ CoreContext.shared.doOnCoreQueue(synchronous: true) { core in
+ try? call?.decline(reason: .Busy)
+ }
+ return
+ }
+ }
+ */
+
+ Log.info("CallKit: report new incoming call with call-id: [\(callId)] and UUID: [\(uuid.description)]")
+ // TelecomManager.instance().setHeldOtherCalls(exceptCallid: callId ?? "") // ALREADY COMMENTED ON LINPHONE-IPHONE 5.2
+ provider.reportNewIncomingCall(with: uuid, update: update) { error in
+ if error == nil {
+ if TelecomManager.shared.endCallkit {
+ CoreContext.shared.doOnCoreQueue(synchronous: true) { core in
+ let call = core.getCallByCallid(callId: callId)
+ if call?.state == .PushIncomingReceived {
+ try? call?.terminate()
+ }
+ }
+ }
+ } else {
+ Log.error("CallKit: cannot complete incoming call with call-id: [\(callId)] and UUID: [\(uuid.description)] from [\(handle)] caused by [\(error!.localizedDescription)]")
+ let code = (error as NSError?)?.code
+ switch code {
+ case CXErrorCodeIncomingCallError.filteredByDoNotDisturb.rawValue:
+ callInfo?.reason = Reason.Busy // This answer is only for this device. Using Reason.DoNotDisturb will make all other end point stop ringing.
+ case CXErrorCodeIncomingCallError.filteredByBlockList.rawValue:
+ callInfo?.reason = Reason.DoNotDisturb
+ default:
+ callInfo?.reason = Reason.Unknown
+ }
+ self.callInfos.updateValue(callInfo!, forKey: uuid)
+ CoreContext.shared.doOnCoreQueue(synchronous: true) { _ in
+ try? call?.decline(reason: callInfo!.reason)
+ }
+ }
+ }
+ }
+
+ func updateCall(uuid: UUID, handle: String, hasVideo: Bool = false, displayName: String) {
+ let update = CXCallUpdate()
+ update.remoteHandle = CXHandle(type: .generic, value: handle)
+ update.localizedCallerName = displayName
+ update.hasVideo = hasVideo
+ provider.reportCall(with: uuid, updated: update)
+ }
+
+ func reportOutgoingCallStartedConnecting(uuid: UUID) {
+ provider.reportOutgoingCall(with: uuid, startedConnectingAt: nil)
+ }
+
+ func reportOutgoingCallConnected(uuid: UUID) {
+ provider.reportOutgoingCall(with: uuid, connectedAt: nil)
+ }
+
+ func endCall(uuid: UUID) {
+ provider.reportCall(with: uuid, endedAt: .init(), reason: .failed)
+ }
+
+ func decline(uuid: UUID) {
+ provider.reportCall(with: uuid, endedAt: .init(), reason: .unanswered)
+ }
+
+ func endCallNotExist(uuid: UUID, timeout: DispatchTime) {
+ DispatchQueue.main.asyncAfter(deadline: timeout) {
+ CoreContext.shared.doOnCoreQueue(synchronous: true) { core in
+ let callId = TelecomManager.shared.providerDelegate.callInfos[uuid]?.callId
+ if callId == nil {
+ // callkit already ended
+ return
+ }
+ if core.getCallByCallid(callId: callId ?? "") == nil {
+ Log.info("CallKit: terminate call with call-id: \(String(describing: callId)) and UUID: \(uuid) which does not exist.")
+ self.endCall(uuid: uuid)
+ }
+ }
+ }
+ }
+}
+
+// MARK: - CXProviderDelegate
+extension ProviderDelegate: CXProviderDelegate {
+ func provider(_ provider: CXProvider, perform action: CXEndCallAction) {
+
+ let uuid = action.callUUID
+ let callId = callInfos[uuid]?.callId
+
+ // remove call infos first, otherwise CXEndCallAction will be called more than onece
+ if callId != nil {
+ uuids.removeValue(forKey: callId!)
+ }
+ callInfos.removeValue(forKey: uuid)
+
+ CoreContext.shared.doOnCoreQueue { core in
+ if let call = core.getCallByCallid(callId: callId ?? "") {
+ TelecomManager.shared.terminateCall(call: call)
+ Log.info("CallKit: Call ended with call-id: \(String(describing: callId)) an UUID: \(uuid.description).")
+ }
+ action.fulfill()
+ }
+ }
+
+ func provider(_ provider: CXProvider, perform action: CXAnswerCallAction) {
+ let uuid = action.callUUID
+ let callInfo = callInfos[uuid]
+ let callId = callInfo?.callId ?? ""
+
+ if TelecomManager.shared.callInProgress == false {
+ DispatchQueue.main.async {
+ withAnimation {
+ TelecomManager.shared.callInProgress = true
+ TelecomManager.shared.callDisplayed = true
+ }
+ }
+ }
+ CoreContext.shared.doOnCoreQueue { core in
+ Log.info("CallKit: answer call with call-id: \(String(describing: callId)) and UUID: \(uuid.description).")
+
+ let call = core.getCallByCallid(callId: callId)
+
+ let callLogIsNil = call?.callLog != nil
+
+ let videoEnabledTmp = call?.params?.videoEnabled
+ let wasConferenceTmp = call?.callLog?.wasConference()
+
+ DispatchQueue.main.async {
+ if UIApplication.shared.applicationState != .active {
+ TelecomManager.shared.backgroundContextCall = call
+ if callLogIsNil {
+ TelecomManager.shared.backgroundContextCameraIsEnabled = videoEnabledTmp == true || wasConferenceTmp == true
+ } else {
+ TelecomManager.shared.backgroundContextCameraIsEnabled = videoEnabledTmp == true
+ }
+
+ if #available(iOS 16.0, *) {
+ if call?.cameraEnabled == true {
+ call?.cameraEnabled = AVCaptureSession().isMultitaskingCameraAccessSupported
+ }
+ } else {
+ call?.cameraEnabled = false // Disable camera while app is not on foreground
+ }
+ }
+ }
+ TelecomManager.shared.callkitAudioSessionActivated = false
+ core.configureAudioSession()
+
+ if call != nil {
+ TelecomManager.shared.acceptCall(core: core, call: call!, hasVideo: call!.params?.videoEnabled ?? false)
+ }
+
+ action.fulfill()
+ }
+ }
+
+ func provider(_ provider: CXProvider, perform action: CXSetHeldCallAction) {
+ let uuid = action.callUUID
+ let callId = callInfos[uuid]?.callId ?? ""
+
+ CoreContext.shared.doOnCoreQueue { core in
+ let call = core.getCallByCallid(callId: callId)
+
+ if call == nil {
+ Log.error("CXSetHeldCallAction: no call !")
+ action.fail()
+ return
+ }
+
+ do {
+ if call?.conference != nil && action.isOnHold {
+ _ = call?.conference?.leave()
+ Log.info("CallKit: call-id: [\(callId)] leaving conference")
+ NotificationCenter.default.post(name: Notification.Name("LinphoneCallUpdate"), object: self)
+ action.fulfill()
+ } else {
+ let state = action.isOnHold ? "Paused" : "Resumed"
+ Log.info("CallKit: Call with call-id: [\(callId)] and UUID: [\(uuid)] paused status changed to: [\(state)]")
+ if action.isOnHold {
+ TelecomManager.shared.speakerBeforePause = AudioRouteUtils.isSpeakerAudioRouteCurrentlyUsed(core: core, call: call)
+ try call!.pause()
+ // fullfill() the action now to indicate to Callkit that this call is no longer active, even if the
+ // SIP transaction is not completed yet. At this stage, the media streams are off.
+ // If callkit is not aware that the pause action is completed, it will terminate this call if we
+ // attempt to resume another one.
+ action.fulfill()
+ } else {
+ if call != nil && call?.conference != nil && core.callsNb > 1 {
+ _ = call!.conference!.enter()
+ TelecomManager.shared.actionToFulFill = action
+ } else {
+ try call!.resume()
+ // We'll notify callkit that the action is fulfilled when receiving the 200Ok, which is the point
+ // where we actually start the media streams.
+ TelecomManager.shared.actionToFulFill = action
+ // HORRIBLE HACK HERE - PLEASE APPLE FIX THIS !!
+ // When resuming a SIP call after a native call has ended remotely, didActivate: audioSession
+ // is never called.
+ // It looks like in this case, it is implicit.
+ // As a result we have to notify the Core that the AudioSession is active.
+ // The SpeakerBox demo application written by Apple exhibits this behavior.
+ // https://developer.apple.com/documentation/callkit/making_and_receiving_voip_calls_with_callkit
+ // We can clearly see there that startAudio() is called immediately in the CXSetHeldCallAction
+ // handler, while it is called from didActivate: audioSession otherwise.
+ // Callkit's design is not consistent, or its documentation imcomplete, wich is somewhat disapointing.
+ //
+
+ Log.info("Assuming AudioSession is active when executing a CXSetHeldCallAction with isOnHold=false.")
+ core.activateAudioSession(activated: true)
+ TelecomManager.shared.callkitAudioSessionActivated = true
+ }
+ }
+ }
+ } catch {
+ Log.error("CallKit: Call set held (paused or resumed) \(uuid) failed because \(error)")
+ action.fail()
+ }
+ }
+ }
+
+ func provider(_ provider: CXProvider, perform action: CXStartCallAction) {
+ let uuid = action.callUUID
+ let callInfo = callInfos[uuid]
+ let update = CXCallUpdate()
+ update.remoteHandle = action.handle
+ update.localizedCallerName = callInfo?.displayName
+ self.provider.reportCall(with: action.callUUID, updated: update)
+
+ let addr = callInfo?.toAddr
+ if addr == nil {
+ Log.info("CallKit: can not call a null address!")
+ action.fail()
+ } else {
+ CoreContext.shared.doOnCoreQueue { core in
+ do {
+ core.configureAudioSession()
+ try TelecomManager.shared.doCall(core: core, addr: addr!, isSas: callInfo?.sasEnabled ?? false, isVideo: callInfo?.videoEnabled ?? false, isConference: callInfo?.isConference ?? false)
+ action.fulfill()
+ } catch {
+ Log.info("CallKit: Call started failed because \(error)")
+ action.fail()
+ }
+ }
+ }
+ }
+
+ func provider(_ provider: CXProvider, perform action: CXSetGroupCallAction) {
+ CoreContext.shared.doOnCoreQueue { core in
+ Log.info("CallKit: Call grouped callUUid : \(action.callUUID) with callUUID: \(String(describing: action.callUUIDToGroupWith)).")
+ TelecomManager.shared.addAllToLocalConference(core: core)
+ action.fulfill()
+ }
+ }
+
+ func provider(_ provider: CXProvider, perform action: CXSetMutedCallAction) {
+ let uuid = action.callUUID
+ let callId = callInfos[uuid]?.callId
+ CoreContext.shared.doOnCoreQueue { core in
+ Log.info( "CallKit: Call muted with call-id: \(String(describing: callId)) an UUID: \(uuid.description).")
+ core.micEnabled = !core.micEnabled
+ action.fulfill()
+ }
+ }
+
+ func provider(_ provider: CXProvider, perform action: CXPlayDTMFCallAction) {
+ let uuid = action.callUUID
+ let callId = callInfos[uuid]?.callId ?? ""
+
+ CoreContext.shared.doOnCoreQueue { core in
+ Log.info("CallKit: Call send dtmf with call-id: \(callId) an UUID: \(uuid.description).")
+ if let call = core.getCallByCallid(callId: callId) {
+ let digit = (action.digits.cString(using: String.Encoding.utf8)?[0])!
+ do {
+ try call.sendDtmf(dtmf: digit)
+ } catch {
+ Log.error("CallKit: Call send dtmf \(uuid) failed because \(error)")
+ }
+ }
+ action.fulfill()
+ }
+ }
+
+ func provider(_ provider: CXProvider, timedOutPerforming action: CXAction) {
+ let uuid = action.uuid
+ let callId = callInfos[uuid]?.callId
+ Log.error("CallKit: Call time out with call-id: \(String(describing: callId)) an UUID: \(uuid.description).")
+ action.fulfill()
+ }
+
+ func providerDidReset(_ provider: CXProvider) {
+ Log.info("CallKit: did reset.")
+ }
+
+ func provider(_ provider: CXProvider, didActivate audioSession: AVAudioSession) {
+ CoreContext.shared.doOnCoreQueue { core in
+ Log.info("CallKit: audio session activated.")
+ core.activateAudioSession(activated: true)
+ TelecomManager.shared.callkitAudioSessionActivated = true
+ }
+ }
+
+ func provider(_ provider: CXProvider, didDeactivate audioSession: AVAudioSession) {
+ CoreContext.shared.doOnCoreQueue { core in
+ Log.info("CallKit: audio session deactivated.")
+ core.activateAudioSession(activated: false)
+ TelecomManager.shared.callkitAudioSessionActivated = nil
+ }
+ }
+}
+// swiftlint:enable line_length
diff --git a/Linphone/TelecomManager/TelecomManager.swift b/Linphone/TelecomManager/TelecomManager.swift
new file mode 100644
index 000000000..0736f6fc5
--- /dev/null
+++ b/Linphone/TelecomManager/TelecomManager.swift
@@ -0,0 +1,712 @@
+/*
+ * Copyright (c) 2010-2020 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 .
+ */
+// swiftlint:disable cyclomatic_complexity
+// swiftlint:disable line_length
+// swiftlint:disable type_body_length
+
+import Foundation
+import linphonesw
+import UserNotifications
+import os
+import CallKit
+import AVFoundation
+import SwiftUI
+
+class CallAppData: NSObject {
+ var batteryWarningShown = false
+ var videoRequested = false /*set when user has requested for video*/
+ var isConference = false
+
+}
+
+class TelecomManager: ObservableObject {
+ static let shared = TelecomManager()
+ static var uuidReplacedCall: String?
+
+ let providerDelegate: ProviderDelegate // to support callkit
+ let callController: CXCallController // to support callkit
+
+ @Published var callInProgress: Bool = false
+ @Published var callDisplayed: Bool = true
+ @Published var callStarted: Bool = false
+ @Published var isNotVerifiedCounter: Int = 0
+ @Published var outgoingCallStarted: Bool = false
+ @Published var remoteConfVideo: Bool = false
+ @Published var isRecordingByRemote: Bool = false
+ @Published var isPausedByRemote: Bool = false
+ @Published var refreshCallViewModel: Bool = false
+ @Published var remainingCall: Bool = false
+ @Published var callConnected: Bool = false
+ @Published var meetingWaitingRoomDisplayed: Bool = false
+ @Published var meetingWaitingRoomSelected: Address?
+ @Published var meetingWaitingRoomName: String = ""
+
+ var actionToFulFill: CXCallAction?
+ var callkitAudioSessionActivated: Bool?
+ var nextCallIsTransfer: Bool = false
+ var speakerBeforePause: Bool = false
+ var endCallkit: Bool = false
+ var endCallKitReplacedCall: Bool = true
+
+ var backgroundContextCall: Call?
+ var backgroundContextCameraIsEnabled: Bool = false
+
+ var referedFromCall: String?
+ var referedToCall: String?
+ var actionsToPerformOnceWhenCoreIsOn: [(() -> Void)] = []
+
+ private init() {
+ providerDelegate = ProviderDelegate()
+ callController = CXCallController()
+ }
+
+ func addAllToLocalConference(core: Core) {
+ // TODO
+ }
+
+ static func getAppData(sCall: Call) -> CallAppData? {
+ if sCall.userData == nil {
+ return nil
+ }
+ return Unmanaged.fromOpaque(sCall.userData!).takeUnretainedValue()
+ }
+ static func setAppData(sCall: Call, appData: CallAppData?) {
+ if sCall.userData != nil {
+ Unmanaged.fromOpaque(sCall.userData!).release()
+ }
+ if appData == nil {
+ sCall.userData = nil
+ } else {
+ sCall.userData = UnsafeMutableRawPointer(Unmanaged.passRetained(appData!).toOpaque())
+ }
+ }
+
+ func startCallCallKit(core: Core, addr: Address?, isSas: Bool, isVideo: Bool, isConference: Bool = false) throws {
+ if addr == nil {
+ Log.info("Can not start a call with null address!")
+ return
+ }
+
+ if TelecomManager.callKitEnabled(core: core) {// && !nextCallIsTransfer != true {
+ let uuid = UUID()
+ let name = addr?.asStringUriOnly() ?? "Unknown"
+ let handle = CXHandle(type: .generic, value: addr?.asStringUriOnly() ?? "")
+ let startCallAction = CXStartCallAction(call: uuid, handle: handle)
+ let transaction = CXTransaction(action: startCallAction)
+
+ let callInfo = CallInfo.newOutgoingCallInfo(addr: addr!, isSas: isSas, displayName: name, isVideo: isVideo, isConference: isConference)
+ providerDelegate.callInfos.updateValue(callInfo, forKey: uuid)
+ providerDelegate.uuids.updateValue(uuid, forKey: "")
+
+ setHeldOtherCalls(core: core, exceptCallid: "")
+ requestTransaction(transaction, action: "startCall")
+ DispatchQueue.main.async {
+ withAnimation {
+ self.callDisplayed = true
+ }
+ }
+ } else {
+ try doCall(core: core, addr: addr!, isSas: isSas, isVideo: isVideo, isConference: isConference)
+ }
+ }
+
+ func setHeldOtherCalls(core: Core, exceptCallid: String) {
+ for call in core.calls {
+ if call.callLog?.callId != exceptCallid && call.state != .Paused && call.state != .Pausing && call.state != .PausedByRemote {
+ setHeld(call: call, hold: true)
+ } else if call.callLog?.callId == exceptCallid && (call.state == .Paused || call.state == .Pausing || call.state == .PausedByRemote) {
+ setHeld(call: call, hold: true)
+ }
+ }
+ }
+
+ func setHeld(call: Call, hold: Bool) {
+
+#if targetEnvironment(simulator)
+ if hold {
+ try?call.pause()
+ } else {
+ try?call.resume()
+ }
+#else
+ let callid = call.callLog?.callId ?? ""
+ let uuid = providerDelegate.uuids["\(callid)"]
+ if uuid == nil {
+ Log.error("Can not find correspondant call to set held.")
+ return
+ }
+ let setHeldAction = CXSetHeldCallAction(call: uuid!, onHold: hold)
+ let transaction = CXTransaction(action: setHeldAction)
+ requestTransaction(transaction, action: "setHeld")
+#endif
+ }
+
+ func startCall(core: Core, addr: String, isSas: Bool = false, isVideo: Bool, isConference: Bool = false) {
+ do {
+ let address = try Factory.Instance.createAddress(addr: addr)
+ try startCallCallKit(core: core, addr: address, isSas: isSas, isVideo: isVideo, isConference: isConference)
+ } catch {
+ Log.error("[TelecomManager] unable to create address for a new outgoing call : \(addr) \(error) ")
+ }
+ }
+
+ func doCallOrJoinConf(address: Address, isVideo: Bool = false, isConference: Bool = false) {
+ if address.asStringUriOnly().hasPrefix("sip:conference-focus@sip.linphone.org") {
+ do {
+ let meetingAddress = try Factory.Instance.createAddress(addr: address.asStringUriOnly())
+
+ DispatchQueue.main.async {
+ withAnimation {
+ self.meetingWaitingRoomDisplayed = true
+ self.meetingWaitingRoomSelected = meetingAddress
+ }
+ }
+ } catch {}
+ } else {
+ doCallWithCore(
+ addr: address, isVideo: isVideo, isConference: isConference
+ )
+ }
+ }
+
+ func doCallWithCore(addr: Address, isVideo: Bool, isConference: Bool) {
+ CoreContext.shared.doOnCoreQueue { core in
+ do {
+ try self.startCallCallKit(core: core, addr: addr, isSas: false, isVideo: isVideo, isConference: isConference)
+ } catch {
+ Log.error("[TelecomManager] unable to create address for a new outgoing call : \(addr) \(error) ")
+ }
+ }
+ }
+
+ 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")
+
+ let paths = NSSearchPathForDirectoriesInDomains(.cachesDirectory, .userDomainMask, true)
+ let writablePath = paths[0]
+ return writablePath.appending("/\(filePath)")
+ }
+
+ func doCall(core: Core, addr: Address, isSas: Bool, isVideo: Bool, isConference: Bool = false) throws {
+ // let displayName = FastAddressBook.displayName(for: addr.getCobject)
+
+ let lcallParams = try core.createCallParams(call: nil)
+ /*
+ if ConfigManager.instance().lpConfigBoolForKey(key: "edge_opt_preference") && AppManager.network() == .network_2g {
+ Log.directLog(BCTBX_LOG_MESSAGE, text: "Enabling low bandwidth mode")
+ lcallParams.lowBandwidthEnabled = true
+ }
+
+ if (displayName != nil) {
+ try addr.setDisplayname(newValue: displayName!)
+ }
+
+ if(ConfigManager.instance().lpConfigBoolForKey(key: "override_domain_with_default_one")) {
+ try addr.setDomain(newValue: ConfigManager.instance().lpConfigStringForKey(key: "domain", section: "assistant"))
+ }
+ */
+
+ if nextCallIsTransfer {
+ let call = core.currentCall
+ try call?.transferTo(referTo: addr)
+ nextCallIsTransfer = false
+ } else {
+ // We set the record file name here because we can't do it after the call is started.
+ // let writablePath = AppManager.recordingFilePathFromCall(address: addr.username! )
+ // Log.directLog(BCTBX_LOG_DEBUG, text: "record file path: \(writablePath)")
+ // lcallParams.recordFile = writablePath
+
+ lcallParams.recordFile = makeRecordFilePath()
+
+ if isSas {
+ lcallParams.mediaEncryption = .ZRTP
+ }
+
+ if isConference {
+ lcallParams.videoEnabled = true
+ lcallParams.videoDirection = isVideo && core.videoPreviewEnabled ? MediaDirection.SendRecv : MediaDirection.RecvOnly
+ /* if (ConferenceWaitingRoomViewModel.sharedModel.joinLayout.value! != .AudioOnly) {
+ lcallParams.videoEnabled = true
+ lcallParams.videoDirection = ConferenceWaitingRoomViewModel.sharedModel.isVideoEnabled.value == true ? .SendRecv : .RecvOnly
+ lcallParams.conferenceVideoLayout = ConferenceWaitingRoomViewModel.sharedModel.joinLayout.value! == .Grid ? .Grid : .ActiveSpeaker
+ } else {
+ lcallParams.videoEnabled = false
+ }*/
+ } else {
+ lcallParams.videoEnabled = true
+ lcallParams.videoDirection = isVideo ? MediaDirection.SendRecv : MediaDirection.Inactive
+ }
+
+ if let call = core.inviteAddressWithParams(addr: addr, params: lcallParams) {
+ // The LinphoneCallAppData object should be set on call creation with callback
+ // - (void)onCall:StateChanged:withMessage:. If not, we are in big trouble and expect it to crash
+ // We are NOT responsible for creating the AppData.
+ if let data = TelecomManager.getAppData(sCall: call) {
+ data.isConference = isConference
+ data.videoRequested = lcallParams.videoEnabled
+ TelecomManager.setAppData(sCall: call, appData: data)
+ } else {
+ Log.error("New call instanciated but app data was not set. Expect it to crash.")
+ /* will be used later to notify user if video was not activated because of the linphone core*/
+ }
+ }
+
+ DispatchQueue.main.async {
+ self.outgoingCallStarted = true
+ self.callStarted = true
+ self.isNotVerifiedCounter = 0
+ if self.callInProgress == false {
+ withAnimation {
+ self.callInProgress = true
+ self.callDisplayed = true
+ }
+ }
+ }
+ }
+ }
+
+ func acceptCall(core: Core, call: Call, hasVideo: Bool) {
+ do {
+ let callParams = try core.createCallParams(call: call)
+ callParams.recordFile = makeRecordFilePath()
+ callParams.videoEnabled = hasVideo
+ /*if (ConfigManager.instance().lpConfigBoolForKey(key: "edge_opt_preference")) {
+ let low_bandwidth = (AppManager.network() == .network_2g)
+ if (low_bandwidth) {
+ Log.directLog(BCTBX_LOG_MESSAGE, text: "Low bandwidth mode")
+ }
+ callParams.lowBandwidthEnabled = low_bandwidth
+ }*/
+
+ // We set the record file name here because we can't do it after the call is started.
+ // let address = call.callLog?.fromAddress
+ // let writablePath = AppManager.recordingFilePathFromCall(address: address?.username ?? "")
+ // Log.directLog(BCTBX_LOG_MESSAGE, text: "Record file path: \(String(describing: writablePath))")
+ // callParams.recordFile = writablePath
+
+ /*
+ if let chatView : ChatConversationView = PhoneMainView.instance().VIEW(ChatConversationView.compositeViewDescription()), chatView.isVoiceRecording {
+ Log.directLog(BCTBX_LOG_MESSAGE, text: "Voice recording in progress, stopping it befoce accepting the call.")
+ chatView.stopVoiceRecording()
+ }*/
+
+ if call.callLog?.wasConference() == true {
+ // Prevent incoming group call to start in audio only layout
+ // Do the same as the conference waiting room
+ callParams.videoEnabled = true
+ callParams.videoDirection = core.videoActivationPolicy?.automaticallyInitiate == true ? .SendRecv : .RecvOnly
+ Log.info("[Context] Enabling video on call params to prevent audio-only layout when answering")
+ }
+
+ try call.acceptWithParams(params: callParams)
+
+ DispatchQueue.main.async {
+ self.callStarted = true
+ self.isNotVerifiedCounter = 0
+ if self.callDisplayed {
+ self.callDisplayed = core.calls.count <= 1
+ }
+ }
+
+ if core.calls.count > 1 {
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
+ self.callDisplayed = true
+ }
+ }
+ } catch {
+ Log.error("accept call failed \(error)")
+ }
+ }
+
+ func terminateCall(call: Call) {
+ do {
+ try call.terminate()
+ Log.info("Call terminated")
+ } catch {
+ Log.error("Failed to terminate call failed because \(error)")
+ }
+ }
+
+ func displayIncomingCall(call: Call?, handle: String, hasVideo: Bool, callId: String, displayName: String) {
+ let uuid = UUID()
+ let callInfo = CallInfo.newIncomingCallInfo(callId: callId)
+
+ providerDelegate.callInfos.updateValue(callInfo, forKey: uuid)
+ providerDelegate.uuids.updateValue(uuid, forKey: callId)
+ providerDelegate.reportIncomingCall(call: call, uuid: uuid, handle: handle, hasVideo: hasVideo, displayName: displayName)
+ }
+
+ func incomingDisplayName(call: Call, completion: @escaping (String) -> Void) {
+ CoreContext.shared.doOnCoreQueue { _ in
+ ContactsManager.shared.getFriendWithAddressInCoreQueue(address: call.remoteAddress!) { friendResult in
+ if call.remoteAddress != nil {
+ if friendResult != nil && friendResult!.address != nil && friendResult!.address!.displayName != nil {
+ completion(friendResult!.address!.displayName!)
+ } else {
+ if call.remoteAddress!.displayName != nil {
+ completion(call.remoteAddress!.displayName!)
+ } else if call.remoteAddress!.username != nil {
+ completion(call.remoteAddress!.username!)
+ }
+ }
+
+ } else {
+ completion("IncomingDisplayName")
+ }
+ }
+ }
+ }
+
+ static func callKitEnabled(core: Core) -> Bool {
+#if !targetEnvironment(simulator)
+ return core.callkitEnabled
+#else
+ return false
+#endif
+ }
+
+ func requestTransaction(_ transaction: CXTransaction, action: String) {
+ callController.request(transaction) { error in
+ if let error = error {
+ Log.error("CallKit: Requested transaction \(action) failed because: \(error)")
+ } else {
+ Log.info("CallKit: Requested transaction \(action) successfully")
+ }
+ }
+ }
+
+ func onAccountRegistrationStateChanged(core: Core, account: Account, state: RegistrationState, message: String) {
+ if core.accountList.count == 1 && (state == .Failed || state == .Cleared) {
+ // terminate callkit immediately when registration failed or cleared, supporting single account configuration
+ for call in providerDelegate.uuids {
+ let callId = providerDelegate.callInfos[call.value]?.callId
+ if callId != nil {
+ let call = core.getCallByCallid(callId: callId!)
+ if call != nil && call?.state != .PushIncomingReceived {
+ // sometimes (for example) due to network, registration failed, in this case, keep the call
+ continue
+ }
+ }
+ providerDelegate.endCall(uuid: call.value)
+ }
+ endCallkit = true
+ } else {
+ endCallkit = false
+ }
+ }
+
+ func updateRemoteConfVideo(remConfVideoEnabled: Bool) {
+ if self.remoteConfVideo != remConfVideoEnabled {
+ DispatchQueue.main.async {
+ self.remoteConfVideo.toggle()
+ Log.info("[Call] Remote video is \(remConfVideoEnabled ? "activated" : "not activated")")
+ }
+ }
+ }
+
+ func onCallStateChanged(core: Core, call: Call, state cstate: Call.State, message: String) {
+ let callLog = call.callLog
+ let callId = callLog?.callId ?? ""
+ if cstate == .PushIncomingReceived {
+ Log.info("PushIncomingReceived in core delegate, display callkit call")
+ TelecomManager.shared.displayIncomingCall(call: call, handle: "Calling", hasVideo: false, callId: callId, displayName: "Calling")
+ } else {
+ // let oldRemoteConfVideo = self.remoteConfVideo
+
+ if call.conference != nil {
+ if call.conference!.activeSpeakerParticipantDevice != nil {
+ let direction = call.conference?.activeSpeakerParticipantDevice!.getStreamCapability(streamType: StreamType.Video)
+ updateRemoteConfVideo(remConfVideoEnabled: direction == .SendRecv || direction == .SendOnly)
+ } else if call.conference!.participantList.first != nil && call.conference!.participantDeviceList.first != nil
+ && call.conference!.participantList.first?.address != nil
+ && call.conference!.participantList.first!.address!.clone()!.equal(address2: (call.conference!.me?.address)!) {
+ let direction = call.conference!.participantDeviceList.first!.getStreamCapability(streamType: StreamType.Video)
+ updateRemoteConfVideo(remConfVideoEnabled: direction == .SendRecv || direction == .SendOnly)
+ } else if call.conference!.participantList.last != nil && call.conference!.participantDeviceList.last != nil
+ && call.conference!.participantList.last?.address != nil {
+ let direction = call.conference!.participantDeviceList.last!.getStreamCapability(streamType: StreamType.Video)
+ updateRemoteConfVideo(remConfVideoEnabled: direction == .SendRecv || direction == .SendOnly)
+ } else {
+ updateRemoteConfVideo(remConfVideoEnabled: false)
+ }
+ } else {
+ var remConfVideoEnabled = false
+ if call.currentParams != nil {
+ remConfVideoEnabled = call.currentParams!.videoEnabled && call.currentParams!.videoDirection == .SendRecv || call.currentParams!.videoDirection == .RecvOnly
+ }
+ updateRemoteConfVideo(remConfVideoEnabled: remConfVideoEnabled)
+ }
+
+ if self.remoteConfVideo {
+ Log.info("[Call] Remote video is activated")
+ }
+
+ let isRecordingByRemoteTmp = call.remoteParams?.isRecording ?? false
+
+ if isRecordingByRemoteTmp && ToastViewModel.shared.toastMessage.isEmpty {
+
+ var displayName = ""
+ let friend = ContactsManager.shared.getFriendWithAddress(address: call.remoteAddress!)
+ if friend != nil && friend!.address != nil && friend!.address!.displayName != nil {
+ displayName = friend!.address!.displayName!
+ } else {
+ if call.remoteAddress!.displayName != nil {
+ displayName = call.remoteAddress!.displayName!
+ } else if call.remoteAddress!.username != nil {
+ displayName = call.remoteAddress!.username!
+ }
+ }
+
+ DispatchQueue.main.async {
+ self.isRecordingByRemote = isRecordingByRemoteTmp
+ ToastViewModel.shared.toastMessage = "\(displayName) is recording"
+ ToastViewModel.shared.displayToast = true
+ }
+
+ Log.info("[Call] Call is recording by \(call.remoteAddress!.asStringUriOnly())")
+ }
+
+ if !isRecordingByRemoteTmp && ToastViewModel.shared.toastMessage.contains("is recording") {
+ DispatchQueue.main.async {
+ self.isRecordingByRemote = isRecordingByRemoteTmp
+ ToastViewModel.shared.toastMessage = ""
+ ToastViewModel.shared.displayToast = false
+ }
+ Log.info("[Call] Recording is stopped by \(call.remoteAddress!.asStringUriOnly())")
+ }
+
+ if cstate == Call.State.PausedByRemote {
+ DispatchQueue.main.async {
+ self.isPausedByRemote = true
+ }
+ } else {
+ DispatchQueue.main.async {
+ self.isPausedByRemote = false
+ }
+ }
+
+ if cstate == Call.State.Connected {
+ DispatchQueue.main.async {
+ self.callConnected = true
+ self.meetingWaitingRoomSelected = nil
+ self.meetingWaitingRoomDisplayed = false
+ }
+ }
+
+ if call.userData == nil {
+ let appData = CallAppData()
+ TelecomManager.setAppData(sCall: call, appData: appData)
+ }
+
+ switch cstate {
+ case .IncomingReceived:
+ let addr = call.remoteAddress
+ incomingDisplayName(call: call) { displayNameResult in
+ let displayName = displayNameResult
+ #if targetEnvironment(simulator)
+ DispatchQueue.main.async {
+ self.outgoingCallStarted = false
+ self.callStarted = false
+ if self.callInProgress == false {
+ withAnimation {
+ self.callInProgress = true
+ self.callDisplayed = true
+ }
+ }
+ }
+ #endif
+ if TelecomManager.callKitEnabled(core: core) {
+ let uuid = self.providerDelegate.uuids["\(callId)"]
+ TelecomManager.uuidReplacedCall = callId
+
+ if uuid != nil {
+ // Tha app is now registered, updated the call already existed.
+ self.providerDelegate.updateCall(uuid: uuid!, handle: addr!.asStringUriOnly(), hasVideo: self.remoteConfVideo, displayName: displayName)
+ } else {
+ let videoEnabled = call.remoteParams?.videoEnabled ?? false
+ let isConference = call.callLog?.wasConference() ?? false
+ let videoDir = call.remoteParams?.videoDirection != MediaDirection.Inactive
+ self.displayIncomingCall(call: call, handle: addr!.asStringUriOnly(), hasVideo: videoEnabled && videoDir && !isConference, callId: callId, displayName: displayName)
+ }
+ }
+ }
+ case .StreamsRunning:
+ if TelecomManager.callKitEnabled(core: core) {
+
+ DispatchQueue.main.async {
+ self.outgoingCallStarted = false
+ }
+
+ let uuid = providerDelegate.uuids["\(callId)"]
+ if uuid != nil {
+ let callInfo = providerDelegate.callInfos[uuid!]
+ if callInfo != nil && callInfo!.isOutgoing && !callInfo!.connected {
+ Log.info("CallKit: outgoing call connected with uuid \(uuid!) and callId \(callId)")
+ providerDelegate.reportOutgoingCallConnected(uuid: uuid!)
+ callInfo!.connected = true
+ providerDelegate.callInfos.updateValue(callInfo!, forKey: uuid!)
+ }
+ }
+ }
+
+ actionToFulFill?.fulfill()
+ actionToFulFill = nil
+ case .Paused:
+ actionToFulFill?.fulfill()
+ actionToFulFill = nil
+ case .OutgoingInit,
+ .OutgoingProgress,
+ .OutgoingRinging,
+ .OutgoingEarlyMedia:
+
+ if TelecomManager.callKitEnabled(core: core) {
+ let uuid = providerDelegate.uuids[""]
+ if uuid != nil {
+ let callInfo = providerDelegate.callInfos[uuid!]
+ callInfo!.callId = callId
+ providerDelegate.callInfos.updateValue(callInfo!, forKey: uuid!)
+ providerDelegate.uuids.removeValue(forKey: "")
+ providerDelegate.uuids.updateValue(uuid!, forKey: callId)
+
+ Log.info("CallKit: outgoing call started connecting with uuid \(uuid!) and callId \(callId)")
+ providerDelegate.reportOutgoingCallStartedConnecting(uuid: uuid!)
+ } else {
+ referedToCall = callId
+ }
+ }
+ case .End,
+ .Error:
+
+ UIDevice.current.isProximityMonitoringEnabled = false
+ if core.callsNb == 0 {
+ core.outputAudioDevice = core.defaultOutputAudioDevice
+ }
+
+ // if core.callsNb == 0 {
+ self.incomingDisplayName(call: call) { displayNameResult in
+ var displayName = "Unknown"
+ if call.dir == .Incoming {
+ displayName = displayNameResult
+ } else { // if let addr = call.remoteAddress, let contactName = FastAddressBook.displayName(for: addr.getCobject) {
+ displayName = "TODOContactName"
+ }
+ DispatchQueue.main.async {
+ if core.callsNb == 0 {
+ do {
+ try core.setVideodevice(newValue: "AV Capture: com.apple.avfoundation.avcapturedevice.built-in_video:1")
+ } catch _ {
+
+ }
+ withAnimation {
+ self.outgoingCallStarted = false
+ self.callInProgress = false
+ self.callDisplayed = false
+ self.callStarted = false
+ self.callConnected = false
+ }
+ } else {
+ if core.calls.last != nil {
+ self.setHeld(call: core.calls.last!, hold: false)
+
+ DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
+ self.remainingCall = true
+ DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
+ self.remainingCall = false
+ }
+ }
+ }
+ }
+
+ if UIApplication.shared.applicationState != .active && (callLog == nil || callLog?.status == .Missed || callLog?.status == .Aborted || callLog?.status == .EarlyAborted) {
+ // Configure the notification's payload.
+ let content = UNMutableNotificationContent()
+ content.title = NSString.localizedUserNotificationString(forKey: NSLocalizedString("Missed call", comment: ""), arguments: nil)
+ content.body = NSString.localizedUserNotificationString(forKey: displayName, arguments: nil)
+
+ // Deliver the notification.
+ let request = UNNotificationRequest(identifier: "call_request", content: content, trigger: nil) // Schedule the notification.
+ let center = UNUserNotificationCenter.current()
+ center.add(request) { (error: Error?) in
+ if error != nil {
+ Log.info("Error while adding notification request : \(error!.localizedDescription)")
+ }
+ }
+ }
+ }
+ }
+ // }
+
+ if TelecomManager.callKitEnabled(core: core) {
+ var uuid = providerDelegate.uuids["\(callId)"]
+ if callId == referedToCall {
+ // refered call ended before connecting
+ Log.info("Callkit: end refered to call: \(String(describing: referedToCall))")
+ referedFromCall = nil
+ referedToCall = nil
+ }
+ if uuid == nil {
+ // the call not yet connected
+ uuid = providerDelegate.uuids[""]
+ }
+ if uuid != nil {
+ if callId == referedFromCall {
+ Log.info("Callkit: end refered from call: \(String(describing: referedFromCall))")
+ referedFromCall = nil
+ let callInfo = providerDelegate.callInfos[uuid!]
+ callInfo!.callId = referedToCall ?? ""
+ providerDelegate.callInfos.updateValue(callInfo!, forKey: uuid!)
+ providerDelegate.uuids.removeValue(forKey: callId)
+ providerDelegate.uuids.updateValue(uuid!, forKey: callInfo!.callId)
+ referedToCall = nil
+ break
+ }
+ if endCallKitReplacedCall {
+ let transaction = CXTransaction(action: CXEndCallAction(call: uuid!))
+ requestTransaction(transaction, action: "endCall")
+ } else {
+ endCallKitReplacedCall = true
+ }
+
+ }
+ }
+ case .Released:
+ TelecomManager.setAppData(sCall: call, appData: nil)
+ case .Referred:
+ referedFromCall = call.callLog?.callId
+ default:
+ break
+ }
+ }
+ // post Notification kLinphoneCallUpdate
+ NotificationCenter.default.post(name: Notification.Name("LinphoneCallUpdate"), object: self, userInfo: [
+ AnyHashable("call"): NSValue.init(pointer: UnsafeRawPointer(call.getCobject)),
+ AnyHashable("state"): NSNumber(value: cstate.rawValue),
+ AnyHashable("message"): message
+ ])
+ }
+}
+
+// swiftlint:enable type_body_length
+// swiftlint:enable cyclomatic_complexity
+// swiftlint:enable line_length
diff --git a/Linphone/UI/Assistant/AssistantView.swift b/Linphone/UI/Assistant/AssistantView.swift
new file mode 100644
index 000000000..6c4418bcf
--- /dev/null
+++ b/Linphone/UI/Assistant/AssistantView.swift
@@ -0,0 +1,38 @@
+/*
+ * Copyright (c) 2010-2023 Belledonne Communications SARL.
+ *
+ * This file is part of Linphone
+ *
+ * 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 .
+ */
+
+import SwiftUI
+
+struct AssistantView: View {
+
+ @ObservedObject private var sharedMainViewModel = SharedMainViewModel.shared
+ @ObservedObject private var coreContext = CoreContext.shared
+
+ var body: some View {
+ if sharedMainViewModel.displayProfileMode && coreContext.loggedIn {
+ ProfileModeFragment()
+ } else {
+ LoginFragment(accountLoginViewModel: AccountLoginViewModel())
+ }
+ }
+}
+
+#Preview {
+ LoginFragment(accountLoginViewModel: AccountLoginViewModel())
+}
diff --git a/Linphone/UI/Assistant/Fragments/LoginFragment.swift b/Linphone/UI/Assistant/Fragments/LoginFragment.swift
new file mode 100644
index 000000000..a53421ab8
--- /dev/null
+++ b/Linphone/UI/Assistant/Fragments/LoginFragment.swift
@@ -0,0 +1,342 @@
+/*
+ * 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 .
+ */
+
+import SwiftUI
+
+struct LoginFragment: View {
+
+ @ObservedObject private var coreContext = CoreContext.shared
+ @ObservedObject private var sharedMainViewModel = SharedMainViewModel.shared
+ @ObservedObject var accountLoginViewModel: AccountLoginViewModel
+
+ @State private var isSecured: Bool = true
+
+ @FocusState var isNameFocused: Bool
+ @FocusState var isPasswordFocused: Bool
+
+ @State private var isShowPopup = false
+
+ @State private var linkActive = ""
+
+ @State private var isLinkSIPActive = false
+ @State private var isLinkREGActive = false
+
+ var isShowBack = false
+
+ var onBackPressed: (() -> Void)?
+
+ var body: some View {
+ NavigationView {
+ ZStack {
+ GeometryReader { geometry in
+ ScrollView(.vertical) {
+ VStack {
+ ZStack {
+ Image("mountain")
+ .resizable()
+ .scaledToFill()
+ .frame(width: geometry.size.width, height: 100)
+ .clipped()
+
+ if isShowBack {
+ VStack(alignment: .leading) {
+ HStack {
+ Image("caret-left")
+ .renderingMode(.template)
+ .resizable()
+ .foregroundStyle(Color.grayMain2c500)
+ .frame(width: 25, height: 25, alignment: .leading)
+ .padding(.all, 10)
+ .padding(.top, -75)
+ .padding(.leading, -10)
+ .onTapGesture {
+ withAnimation {
+ onBackPressed?()
+ }
+ }
+
+ Spacer()
+ }
+ .padding(.leading)
+ }
+ .frame(width: geometry.size.width)
+ }
+
+ Text("assistant_account_login")
+ .default_text_style_white_800(styleSize: 20)
+ .padding(.top, 20)
+ }
+ .padding(.top, 35)
+ .padding(.bottom, 10)
+
+ VStack(alignment: .leading) {
+ Text(String(localized: "username")+"*")
+ .default_text_style_700(styleSize: 15)
+ .padding(.bottom, -5)
+
+ TextField("username", text: $accountLoginViewModel.username)
+ .default_text_style(styleSize: 15)
+ .disableAutocorrection(true)
+ .autocapitalization(.none)
+ .disabled(coreContext.loggedIn)
+ .frame(height: 25)
+ .padding(.horizontal, 20)
+ .padding(.vertical, 15)
+ .cornerRadius(60)
+ .overlay(
+ RoundedRectangle(cornerRadius: 60)
+ .inset(by: 0.5)
+ .stroke(isNameFocused ? Color.orangeMain500 : Color.gray200, lineWidth: 1)
+ )
+ .padding(.bottom)
+ .focused($isNameFocused)
+
+ Text(String(localized: "password")+"*")
+ .default_text_style_700(styleSize: 15)
+ .padding(.bottom, -5)
+
+ ZStack(alignment: .trailing) {
+ Group {
+ if isSecured {
+ SecureField("password", text: $accountLoginViewModel.passwd)
+ .default_text_style(styleSize: 15)
+ .frame(height: 25)
+ .focused($isPasswordFocused)
+ } else {
+ TextField("password", text: $accountLoginViewModel.passwd)
+ .default_text_style(styleSize: 15)
+ .disableAutocorrection(true)
+ .autocapitalization(.none)
+ .frame(height: 25)
+ .focused($isPasswordFocused)
+ }
+ }
+
+ Button(action: {
+ isSecured.toggle()
+ }, label: {
+ Image(self.isSecured ? "eye-slash" : "eye")
+ .renderingMode(.template)
+ .resizable()
+ .foregroundStyle(Color.grayMain2c500)
+ .frame(width: 20, height: 20)
+ })
+ }
+ .disabled(coreContext.loggedIn)
+ .padding(.horizontal, 20)
+ .padding(.vertical, 15)
+ .cornerRadius(60)
+ .overlay(
+ RoundedRectangle(cornerRadius: 60)
+ .inset(by: 0.5)
+ .stroke(isPasswordFocused ? Color.orangeMain500 : Color.gray200, lineWidth: 1)
+ )
+ .padding(.bottom)
+
+ Button(action: {
+ sharedMainViewModel.changeDisplayProfileMode()
+ self.accountLoginViewModel.login()
+ coreContext.loggingInProgress = true
+ }, label: {
+ Text(coreContext.loggedIn ? "Log out" : "assistant_account_login")
+ .default_text_style_white_600(styleSize: 20)
+ .frame(height: 35)
+ .frame(maxWidth: .infinity)
+ })
+ .padding(.horizontal, 20)
+ .padding(.vertical, 10)
+ .background((accountLoginViewModel.username.isEmpty || accountLoginViewModel.passwd.isEmpty) ? Color.orangeMain100 : Color.orangeMain500)
+ .cornerRadius(60)
+ .disabled(accountLoginViewModel.username.isEmpty || accountLoginViewModel.passwd.isEmpty)
+ .padding(.bottom)
+
+ HStack {
+ Text("[Forgotten password?](https://subscribe.linphone.org/)")
+ .underline()
+ .tint(Color.grayMain2c600)
+ .default_text_style_600(styleSize: 15)
+ .foregroundStyle(Color.grayMain2c500)
+ }
+ .frame(maxWidth: .infinity)
+ .padding(.bottom, 30)
+
+ HStack {
+ VStack {
+ Divider()
+ }
+ Text(" or ")
+ .default_text_style(styleSize: 15)
+ .foregroundStyle(Color.grayMain2c500)
+ VStack {
+ Divider()
+ }
+ }
+ .padding(.bottom, 10)
+
+ NavigationLink(destination: {
+ QrCodeScannerFragment()
+ }, label: {
+ HStack {
+ Image("qr-code")
+ .renderingMode(.template)
+ .resizable()
+ .foregroundStyle(Color.orangeMain500)
+ .frame(width: 20, height: 20)
+
+ Text("Scan QR code")
+ .default_text_style_orange_600(styleSize: 20)
+ .frame(height: 35)
+ }
+ .frame(maxWidth: .infinity)
+
+ })
+ .padding(.horizontal, 20)
+ .padding(.vertical, 10)
+ .cornerRadius(60)
+ .overlay(
+ RoundedRectangle(cornerRadius: 60)
+ .inset(by: 0.5)
+ .stroke(Color.orangeMain500, lineWidth: 1)
+ )
+ .padding(.bottom)
+
+ NavigationLink(isActive: $isLinkSIPActive, destination: {
+ ThirdPartySipAccountWarningFragment(accountLoginViewModel: accountLoginViewModel)
+ }, label: {
+ Text("Use SIP Account")
+ .default_text_style_orange_600(styleSize: 20)
+ .frame(height: 35)
+ .frame(maxWidth: .infinity)
+
+ })
+ .disabled(!sharedMainViewModel.generalTermsAccepted)
+ .padding(.horizontal, 20)
+ .padding(.vertical, 10)
+ .cornerRadius(60)
+ .overlay(
+ RoundedRectangle(cornerRadius: 60)
+ .inset(by: 0.5)
+ .stroke(Color.orangeMain500, lineWidth: 1)
+ )
+ .padding(.bottom)
+ .simultaneousGesture(
+ TapGesture().onEnded {
+ self.linkActive = "SIP"
+ if !sharedMainViewModel.generalTermsAccepted {
+ withAnimation {
+ self.isShowPopup.toggle()
+ }
+ } else {
+ self.isLinkSIPActive = true
+ }
+ }
+ )
+
+ Spacer()
+
+ HStack(alignment: .center) {
+
+ Spacer()
+
+ Text("Not account yet?")
+ .default_text_style(styleSize: 15)
+ .foregroundStyle(Color.grayMain2c700)
+ .padding(.horizontal, 10)
+
+ NavigationLink(destination: RegisterFragment(registerViewModel: RegisterViewModel()), isActive: $isLinkREGActive, label: {Text("Register")
+ .default_text_style_white_600(styleSize: 20)
+ .frame(height: 35)
+ })
+ .disabled(!sharedMainViewModel.generalTermsAccepted)
+ .padding(.horizontal, 20)
+ .padding(.vertical, 10)
+ .background(Color.orangeMain500)
+ .cornerRadius(60)
+ .padding(.horizontal, 10)
+ .simultaneousGesture(
+ TapGesture().onEnded {
+ self.linkActive = "REG"
+ if !sharedMainViewModel.generalTermsAccepted {
+ withAnimation {
+ self.isShowPopup.toggle()
+ }
+ } else {
+ self.isLinkREGActive = true
+ }
+ }
+ )
+
+ Spacer()
+ }
+ .padding(.bottom)
+ }
+ .frame(maxWidth: sharedMainViewModel.maxWidth)
+ .padding(.horizontal, 20)
+ }
+ .frame(minHeight: geometry.size.height)
+ }
+
+ if self.isShowPopup {
+ let contentPopup1 = Text("En continuant, vous acceptez ces conditions, ")
+ let contentPopup2 = Text("[notre politique de confidentialité](https://linphone.org/privacy-policy)").underline()
+ let contentPopup3 = Text(" et ")
+ let contentPopup4 = Text("[nos conditions d’utilisation](https://linphone.org/general-terms)").underline()
+ let contentPopup5 = Text(".")
+ PopupView(isShowPopup: $isShowPopup,
+ title: Text("Conditions de service"),
+ content: contentPopup1 + contentPopup2 + contentPopup3 + contentPopup4 + contentPopup5,
+ titleFirstButton: Text("Deny all"),
+ actionFirstButton: {self.isShowPopup.toggle()},
+ titleSecondButton: Text("Accept all"),
+ actionSecondButton: {acceptGeneralTerms()})
+ .background(.black.opacity(0.65))
+ .onTapGesture {
+ self.isShowPopup.toggle()
+ }
+ }
+ }
+
+ if coreContext.loggingInProgress {
+ PopupLoadingView()
+ .background(.black.opacity(0.65))
+ }
+ }
+ .navigationTitle("")
+ .navigationBarHidden(true)
+ }
+ .navigationViewStyle(StackNavigationViewStyle())
+ }
+
+ func acceptGeneralTerms() {
+ sharedMainViewModel.changeGeneralTerms()
+ self.isShowPopup.toggle()
+ switch linkActive {
+ case "SIP":
+ self.isLinkSIPActive = true
+ case "REG":
+ self.isLinkREGActive = true
+ default:
+ print("Link Not Active")
+ }
+ }
+}
+
+#Preview {
+ LoginFragment(accountLoginViewModel: AccountLoginViewModel())
+}
diff --git a/Linphone/UI/Assistant/Fragments/PermissionsFragment.swift b/Linphone/UI/Assistant/Fragments/PermissionsFragment.swift
new file mode 100644
index 000000000..370ae5a38
--- /dev/null
+++ b/Linphone/UI/Assistant/Fragments/PermissionsFragment.swift
@@ -0,0 +1,209 @@
+/*
+ * 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 .
+ */
+
+import SwiftUI
+
+struct PermissionsFragment: View {
+
+ @ObservedObject private var sharedMainViewModel = SharedMainViewModel.shared
+
+ var permissionManager = PermissionManager.shared
+
+ @Environment(\.dismiss) var dismiss
+
+ var body: some View {
+ GeometryReader { geometry in
+ ScrollView(.vertical) {
+ VStack {
+ ZStack {
+ Image("mountain")
+ .resizable()
+ .scaledToFill()
+ .frame(width: geometry.size.width, height: 100)
+ .clipped()
+
+ VStack(alignment: .leading) {
+ HStack {
+ Image("caret-left")
+ .renderingMode(.template)
+ .resizable()
+ .foregroundStyle(Color.grayMain2c500)
+ .frame(width: 25, height: 25, alignment: .leading)
+ .padding(.all, 10)
+ .padding(.top, -75)
+ .padding(.leading, -10)
+ .onTapGesture {
+ withAnimation {
+ dismiss()
+ }
+ }
+
+ Spacer()
+ }
+ .padding(.leading)
+ }
+ .frame(width: geometry.size.width)
+
+ Text("Demande d’autorisations")
+ .default_text_style_white_800(styleSize: 20)
+ .padding(.top, 20)
+ }
+ .padding(.top, 35)
+ .padding(.bottom, 10)
+
+ Text("Pour vous permettre de vous profitez pleinement de Linphone nous avons besoin des autorisations suivantes :")
+ .default_text_style(styleSize: 15)
+ .multilineTextAlignment(.center)
+
+ Spacer()
+
+ VStack(alignment: .leading) {
+ HStack {
+ HStack(alignment: .center) {
+ Image("bell-ringing")
+ .renderingMode(.template)
+ .resizable()
+ .foregroundStyle(Color.grayMain2c500)
+ .frame(width: 20, height: 20, alignment: .leading)
+ }
+ .padding(16)
+ .background(Color.grayMain2c200)
+ .cornerRadius(40)
+
+ Text("**Notifications** : Pour vous informer quand vous recevez un message ou un appel.")
+ .default_text_style(styleSize: 15)
+ .padding(.leading, 10)
+ }
+ .padding(.bottom)
+
+ HStack {
+ HStack(alignment: .center) {
+ Image("address-book")
+ .renderingMode(.template)
+ .resizable()
+ .foregroundStyle(Color.grayMain2c500)
+ .frame(width: 20, height: 20, alignment: .leading)
+ }
+ .padding(16)
+ .background(Color.grayMain2c200)
+ .cornerRadius(40)
+
+ Text("**Contacts** : Pour vous afficher vos contacts et retrouver qui utilise Linphone.")
+ .default_text_style(styleSize: 15)
+ .padding(.leading, 10)
+ }
+ .padding(.bottom)
+
+ HStack {
+ HStack(alignment: .center) {
+ Image("microphone")
+ .renderingMode(.template)
+ .resizable()
+ .foregroundStyle(Color.grayMain2c500)
+ .frame(width: 20, height: 20, alignment: .leading)
+ }
+ .padding(16)
+ .background(Color.grayMain2c200)
+ .cornerRadius(40)
+
+ Text("**Micro** : Pour permettre à vos correspondants de vous entendre.")
+ .default_text_style(styleSize: 15)
+ .padding(.leading, 10)
+ }
+ .padding(.bottom)
+
+ HStack {
+ HStack(alignment: .center) {
+ Image("video-camera")
+ .renderingMode(.template)
+ .resizable()
+ .foregroundStyle(Color.grayMain2c500)
+ .frame(width: 20, height: 20, alignment: .leading)
+ }
+ .padding(16)
+ .background(Color.grayMain2c200)
+ .cornerRadius(40)
+
+ Text("**Camera** : Pour capturer votre vidéo lors des appels vidéo et conférence.")
+ .default_text_style(styleSize: 15)
+ .padding(.leading, 10)
+ }
+ .padding(.bottom)
+ }
+ .frame(maxWidth: sharedMainViewModel.maxWidth)
+ .frame(maxHeight: .infinity)
+ .padding(.horizontal, 20)
+
+ Spacer()
+
+ Button(action: {
+ withAnimation {
+ sharedMainViewModel.changeWelcomeView()
+ }
+ }, label: {
+ Text("Plus tard")
+ .default_text_style_orange_600(styleSize: 20)
+ .frame(height: 35)
+ .frame(maxWidth: .infinity)
+ })
+ .padding(.horizontal, 20)
+ .padding(.vertical, 10)
+ .cornerRadius(60)
+ .overlay(
+ RoundedRectangle(cornerRadius: 60)
+ .inset(by: 0.5)
+ .stroke(Color.orangeMain500, lineWidth: 1)
+ )
+ .frame(maxWidth: sharedMainViewModel.maxWidth)
+ .padding(.horizontal)
+
+ Button {
+ permissionManager.getPermissions()
+ } label: {
+ Text("D'accord")
+ .default_text_style_white_600(styleSize: 20)
+ .frame(height: 35)
+ .frame(maxWidth: .infinity)
+ }
+ .padding(.horizontal, 20)
+ .padding(.vertical, 10)
+ .background(Color.orangeMain500)
+ .cornerRadius(60)
+ .frame(maxWidth: sharedMainViewModel.maxWidth)
+ .padding(.horizontal)
+ .padding(.bottom, geometry.safeAreaInsets.bottom.isEqual(to: 0.0) ? 20 : 0)
+ }
+ .frame(minHeight: geometry.size.height)
+ }
+ }
+ .navigationViewStyle(StackNavigationViewStyle())
+ .navigationBarHidden(true)
+ .onReceive(permissionManager.$allPermissionsHaveBeenDisplayed, perform: { (granted) in
+ if granted {
+ withAnimation {
+ sharedMainViewModel.changeWelcomeView()
+ }
+ }
+ })
+ }
+}
+
+#Preview {
+ PermissionsFragment()
+}
diff --git a/Linphone/UI/Assistant/Fragments/ProfileModeFragment.swift b/Linphone/UI/Assistant/Fragments/ProfileModeFragment.swift
new file mode 100644
index 000000000..ff100b8cd
--- /dev/null
+++ b/Linphone/UI/Assistant/Fragments/ProfileModeFragment.swift
@@ -0,0 +1,175 @@
+/*
+ * 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 .
+ */
+
+import SwiftUI
+
+struct ProfileModeFragment: View {
+
+ @ObservedObject private var sharedMainViewModel = SharedMainViewModel.shared
+
+ @State var options: Int = 1
+ @State private var isShowPopup = false
+ @State private var isShowPopupForDefault = true
+
+ var body: some View {
+ GeometryReader { geometry in
+ ScrollView(.vertical) {
+ VStack {
+ ZStack {
+ Image("mountain")
+ .resizable()
+ .scaledToFill()
+ .frame(width: geometry.size.width, height: 100)
+ .clipped()
+ Text("Personnalize your profil mode")
+ .default_text_style_white_800(styleSize: 20)
+ .padding(.top, -10)
+ Text("You will change this mode later")
+ .default_text_style_white(styleSize: 15)
+ .padding(.top, 40)
+ }
+ .padding(.top, 35)
+ .padding(.bottom, 10)
+
+ VStack(spacing: 10) {
+ Button(action: {
+ options = 1
+ }, label: {
+ HStack {
+ Image(options == 1 ? "radio-button-fill" : "radio-button")
+ Text("Default")
+ .profile_mode_text_style_gray_800(styleSize: 16)
+ Image("info")
+ .resizable()
+ .frame(width: 25, height: 25)
+ .padding(.all, 10)
+ .onTapGesture {
+ withAnimation {
+ self.isShowPopupForDefault = true
+ self.isShowPopup.toggle()
+ }
+ }
+ Spacer()
+ }
+ })
+
+ HStack {
+ Text("Chiffrement de bout en bout de tous vos échanges, grâce au mode default vos communications sont à l’abri des regards.")
+ .profile_mode_text_style_gray(styleSize: 15)
+ }
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .padding(.horizontal, 16)
+ .padding(.vertical, 20)
+ .background(Color.gray100)
+ .cornerRadius(15)
+ .padding(.bottom, 5)
+
+ Image("profile-mode")
+ .resizable()
+ .frame(width: 150, height: 60)
+ .padding()
+
+ Button(action: {
+ options = 2
+ }, label: {
+ HStack {
+ Image(options == 2 ? "radio-button-fill" : "radio-button")
+ Text("Interoperable")
+ .profile_mode_text_style_gray_800(styleSize: 16)
+ Image("info")
+ .resizable()
+ .frame(width: 25, height: 25)
+ .padding(.all, 10)
+ .onTapGesture {
+ withAnimation {
+ self.isShowPopupForDefault = false
+ self.isShowPopup.toggle()
+ }
+ }
+ Spacer()
+ }
+ })
+
+ HStack {
+ Text("Ce mode vous permet d’être interopérable avec d’autres services SIP.\nVos communications seront chiffrées de point à point. ")
+ .profile_mode_text_style_gray(styleSize: 15)
+ }
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .padding(.horizontal, 16)
+ .padding(.vertical, 20)
+ .background(Color.gray100)
+ .cornerRadius(15)
+ }
+ .frame(maxWidth: sharedMainViewModel.maxWidth)
+ .padding()
+
+ Spacer()
+
+ Button(action: {
+ sharedMainViewModel.changeHideProfileMode()
+ }, label: {
+ Text("Continue")
+ .default_text_style_white_600(styleSize: 20)
+ .frame(height: 35)
+ .frame(maxWidth: .infinity)
+ })
+ .padding(.horizontal, 20)
+ .padding(.vertical, 10)
+ .background(Color.orangeMain500)
+ .cornerRadius(60)
+ .padding(.horizontal)
+ .padding(.bottom, geometry.safeAreaInsets.bottom.isEqual(to: 0.0) ? 20 : 0)
+ .frame(maxWidth: sharedMainViewModel.maxWidth)
+ }
+ .frame(minHeight: geometry.size.height)
+ }
+ .onAppear {
+ UserDefaults.standard.set(false, forKey: "display_profile_mode")
+ // Skip this view
+ sharedMainViewModel.changeHideProfileMode()
+ }
+
+ if self.isShowPopup {
+ PopupView(isShowPopup: $isShowPopup,
+ title: Text(isShowPopupForDefault ? "Default mode" : "Interoperable mode"),
+ content: Text(
+ isShowPopupForDefault
+ ? "Texte explicatif du default mode : lorem ipsum dolor sit amet, consectetur adipiscing elit."
+ + "Etiam velit sapien, egestas sit amet dictum eget, condimentum a ligula."
+ : "Texte explicatif du interoperable mode : lorem ipsum dolor sit amet, consectetur adipiscing elit."
+ + " Etiam velit sapien, egestas sit amet dictum eget, condimentum a ligula."),
+ titleFirstButton: nil,
+ actionFirstButton: {},
+ titleSecondButton: Text("Close"),
+ actionSecondButton: {
+ self.isShowPopup.toggle()
+ }
+ )
+ .background(.black.opacity(0.65))
+ .onTapGesture {
+ self.isShowPopup.toggle()
+ }
+ }
+ }
+ }
+}
+
+#Preview {
+ ProfileModeFragment()
+}
diff --git a/Linphone/UI/Assistant/Fragments/QrCodeScannerFragment.swift b/Linphone/UI/Assistant/Fragments/QrCodeScannerFragment.swift
new file mode 100644
index 000000000..a3a739dfc
--- /dev/null
+++ b/Linphone/UI/Assistant/Fragments/QrCodeScannerFragment.swift
@@ -0,0 +1,74 @@
+/*
+ * Copyright (c) 2010-2023 Belledonne Communications SARL.
+ *
+ * This file is part of Linphone
+ *
+ * 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 .
+ */
+
+import SwiftUI
+
+struct QrCodeScannerFragment: View {
+
+ @ObservedObject private var coreContext = CoreContext.shared
+ @ObservedObject private var sharedMainViewModel = SharedMainViewModel.shared
+
+ @Environment(\.dismiss) var dismiss
+
+ @State var scanResult = "Scan a QR code"
+
+ var body: some View {
+ ZStack(alignment: .top) {
+ QRScanner(result: $scanResult)
+
+ Text(scanResult)
+ .default_text_style_white_800(styleSize: 20)
+ .padding(.top, 175)
+
+ HStack {
+ Button {
+ dismiss()
+ } label: {
+ Image("caret-left")
+ .renderingMode(.template)
+ .resizable()
+ .foregroundStyle(Color.white)
+ .frame(width: 25, height: 25, alignment: .leading)
+ .padding(.all, 10)
+ .padding(.leading, -10)
+ }
+ .padding()
+ .padding(.top, 50)
+
+ Spacer()
+ }
+ }
+ .edgesIgnoringSafeArea(.all)
+ .navigationBarHidden(true)
+
+ /*
+ if $isShowToast {
+ ZStack {
+
+ }.onAppear {
+ dismiss()
+ }
+ }
+ */
+ }
+}
+
+#Preview {
+ QrCodeScannerFragment()
+}
diff --git a/Linphone/UI/Assistant/Fragments/RegisterCodeConfirmationFragment.swift b/Linphone/UI/Assistant/Fragments/RegisterCodeConfirmationFragment.swift
new file mode 100644
index 000000000..374c362c6
--- /dev/null
+++ b/Linphone/UI/Assistant/Fragments/RegisterCodeConfirmationFragment.swift
@@ -0,0 +1,200 @@
+/*
+ * 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 .
+ */
+
+import SwiftUI
+
+// swiftlint:disable line_length
+struct RegisterCodeConfirmationFragment: View {
+ @ObservedObject private var sharedMainViewModel = SharedMainViewModel.shared
+ @ObservedObject var registerViewModel: RegisterViewModel
+
+ @Environment(\.dismiss) var dismiss
+
+ @FocusState var isFocused: Bool
+
+ let textLimit = 4
+ let textBoxWidth = UIScreen.main.bounds.width / 5
+ let textBoxHeight = (UIScreen.main.bounds.width / 5) + 20
+ let spaceBetweenBoxes: CGFloat = 10
+ let paddingOfBox: CGFloat = 1
+ var textFieldOriginalWidth: CGFloat {
+ (textBoxWidth*4)+(spaceBetweenBoxes*3)+((paddingOfBox*2)*3)
+ }
+
+ var body: some View {
+ NavigationView {
+ GeometryReader { geometry in
+ ZStack {
+ ScrollView(.vertical) {
+ VStack {
+ ZStack {
+ Image("mountain")
+ .resizable()
+ .scaledToFill()
+ .frame(width: geometry.size.width, height: 100)
+ .clipped()
+
+ VStack(alignment: .leading) {
+ HStack {
+ Image("caret-left")
+ .renderingMode(.template)
+ .resizable()
+ .foregroundStyle(Color.grayMain2c500)
+ .frame(width: 25, height: 25, alignment: .leading)
+ .padding(.all, 10)
+ .padding(.top, -75)
+ .padding(.leading, -10)
+ .onTapGesture {
+ withAnimation {
+ dismiss()
+ }
+ }
+
+ Spacer()
+ }
+ .padding(.leading)
+ }
+ .frame(width: geometry.size.width)
+
+ Text("assistant_account_register")
+ .default_text_style_white_800(styleSize: 20)
+ .padding(.top, 20)
+ }
+ .padding(.top, 35)
+ .padding(.bottom, 10)
+
+ ZStack {
+ VStack {
+ Spacer()
+ HStack {
+ Spacer()
+ Image("confirm_sms_code_illu")
+ .padding(.bottom, -geometry.safeAreaInsets.bottom)
+ }
+ }
+ VStack(alignment: .center) {
+ Spacer()
+
+ Text(String(format: NSLocalizedString("assistant_account_creation_sms_confirmation_explanation", comment: ""), registerViewModel.phoneNumber))
+ .default_text_style(styleSize: 15)
+ .foregroundStyle(Color.grayMain2c700)
+ .padding(.horizontal, 10)
+ .frame(maxWidth: .infinity, alignment: .center)
+ .multilineTextAlignment(.center)
+
+ VStack {
+ ZStack {
+
+ HStack(spacing: spaceBetweenBoxes) {
+ otpText(text: registerViewModel.otp1, focused: registerViewModel.otpField.isEmpty)
+ otpText(text: registerViewModel.otp2, focused: registerViewModel.otpField.count == 1)
+ otpText(text: registerViewModel.otp3, focused: registerViewModel.otpField.count == 2)
+ otpText(text: registerViewModel.otp4, focused: registerViewModel.otpField.count == 3)
+ }
+
+ TextField("", text: $registerViewModel.otpField)
+ .default_text_style_600(styleSize: 80)
+ .frame(width: isFocused ? 0 : textFieldOriginalWidth, height: textBoxHeight)
+ .textContentType(.oneTimeCode)
+ .foregroundColor(.clear)
+ .accentColor(.clear)
+ .background(.clear)
+ .keyboardType(.numberPad)
+ .focused($isFocused)
+ .onChange(of: registerViewModel.otpField) { _ in
+ limitText(textLimit)
+ if registerViewModel.otpField.count > 3 {
+ registerViewModel.validateCode()
+ }
+ }
+ }
+ }
+ .frame(maxWidth: .infinity, alignment: .center)
+ .padding(.vertical, 20)
+
+ Button(action: {
+ dismiss()
+ }, label: {
+ Text("assistant_account_creation_wrong_phone_number")
+ .default_text_style_orange_600(styleSize: 15)
+ .frame(height: 35)
+ })
+ .padding(.horizontal, 15)
+ .padding(.vertical, 5)
+ .cornerRadius(60)
+ .overlay(
+ RoundedRectangle(cornerRadius: 60)
+ .inset(by: 0.5)
+ .stroke(Color.orangeMain500, lineWidth: 1)
+ )
+ .padding(.bottom)
+ .frame(maxWidth: .infinity)
+
+ Spacer()
+ Spacer()
+ }
+ .frame(maxWidth: sharedMainViewModel.maxWidth)
+ .padding(.horizontal, 20)
+ }
+ }
+ .frame(minHeight: geometry.size.height)
+ .onAppear {
+ registerViewModel.otpField = ""
+ }
+ }
+
+ if registerViewModel.createInProgress {
+ PopupLoadingView()
+ .background(.black.opacity(0.65))
+ }
+ }
+ }
+ .navigationTitle("")
+ .navigationBarHidden(true)
+ }
+ .navigationViewStyle(StackNavigationViewStyle())
+ .navigationTitle("")
+ .navigationBarHidden(true)
+ }
+
+ private func otpText(text: String, focused: Bool) -> some View {
+
+ return Text(text)
+ .foregroundStyle(isFocused && focused ? Color.orangeMain500 : Color.grayMain2c600)
+ .default_text_style_600(styleSize: 40)
+ .frame(width: textBoxWidth, height: textBoxHeight)
+ .overlay(
+ RoundedRectangle(cornerRadius: 20)
+ .inset(by: 0.5)
+ .stroke(isFocused && focused ? Color.orangeMain500 : Color.grayMain2c600, lineWidth: 1)
+ )
+ .padding(paddingOfBox)
+ }
+
+ func limitText(_ upper: Int) {
+ if registerViewModel.otpField.count > upper {
+ registerViewModel.otpField = String(registerViewModel.otpField.prefix(upper))
+ }
+ }
+}
+
+#Preview {
+ RegisterCodeConfirmationFragment(registerViewModel: RegisterViewModel())
+}
+// swiftlint:enable line_length
diff --git a/Linphone/UI/Assistant/Fragments/RegisterFragment.swift b/Linphone/UI/Assistant/Fragments/RegisterFragment.swift
new file mode 100644
index 000000000..f5d1e5f74
--- /dev/null
+++ b/Linphone/UI/Assistant/Fragments/RegisterFragment.swift
@@ -0,0 +1,349 @@
+/*
+ * 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 .
+ */
+
+// swiftlint:disable line_length
+
+import SwiftUI
+
+struct RegisterFragment: View {
+ @ObservedObject private var sharedMainViewModel = SharedMainViewModel.shared
+ @ObservedObject var registerViewModel: RegisterViewModel
+
+ @Environment(\.dismiss) var dismiss
+
+ @State private var isSecured: Bool = true
+
+ @FocusState var isNameFocused: Bool
+ @FocusState var isPhoneNumberFocused: Bool
+ @FocusState var isPasswordFocused: Bool
+
+ @State private var isShowPopup = false
+
+ var body: some View {
+ NavigationView {
+ GeometryReader { geometry in
+ ZStack {
+ ScrollView(.vertical) {
+ VStack {
+ ZStack {
+ Image("mountain")
+ .resizable()
+ .scaledToFill()
+ .frame(width: geometry.size.width, height: 100)
+ .clipped()
+
+ VStack(alignment: .leading) {
+ HStack {
+ Image("caret-left")
+ .renderingMode(.template)
+ .resizable()
+ .foregroundStyle(Color.grayMain2c500)
+ .frame(width: 25, height: 25, alignment: .leading)
+ .padding(.all, 10)
+ .padding(.top, -75)
+ .padding(.leading, -10)
+ .onTapGesture {
+ withAnimation {
+ dismiss()
+ }
+ }
+
+ Spacer()
+ }
+ .padding(.leading)
+ }
+ .frame(width: geometry.size.width)
+
+ Text("assistant_account_register")
+ .default_text_style_white_800(styleSize: 20)
+ .padding(.top, 20)
+ }
+ .padding(.top, 35)
+ .padding(.bottom, 10)
+
+ VStack(alignment: .leading) {
+ Text(String(localized: "username")+"*")
+ .default_text_style_700(styleSize: 15)
+ .padding(.bottom, -5)
+
+ TextField("username", text: $registerViewModel.username)
+ .default_text_style(styleSize: 15)
+ .disableAutocorrection(true)
+ .autocapitalization(.none)
+ .frame(height: 25)
+ .padding(.horizontal, 20)
+ .padding(.vertical, 15)
+ .cornerRadius(60)
+ .overlay(
+ RoundedRectangle(cornerRadius: 60)
+ .inset(by: 0.5)
+ .stroke(isNameFocused ? Color.orangeMain500 : (!registerViewModel.usernameError.isEmpty ? Color.redDanger500 : Color.gray200), lineWidth: 1)
+ )
+ .focused($isNameFocused)
+ .onChange(of: registerViewModel.username) { _ in
+ if !registerViewModel.usernameError.isEmpty {
+ registerViewModel.usernameError = ""
+ }
+ }
+
+ Text(registerViewModel.usernameError)
+ .foregroundStyle(Color.redDanger500)
+ .default_text_style_600(styleSize: 15)
+ .padding(.bottom)
+
+ Text(String(localized: "Phone number")+"*")
+ .default_text_style_700(styleSize: 15)
+ .padding(.bottom, -5)
+
+ HStack {
+ Menu {
+ Picker("", selection: $registerViewModel.dialPlanValueSelected) {
+ ForEach(Array(registerViewModel.dialPlansLabelList.enumerated()), id: \.offset) { index, dialPlan in
+ Text(dialPlan).tag(registerViewModel.dialPlansShortLabelList[index])
+ }
+ }
+ } label: {
+ HStack {
+ Text(registerViewModel.dialPlanValueSelected)
+
+ Image("caret-down")
+ .renderingMode(.template)
+ .resizable()
+ .foregroundStyle(Color.blue)
+ .frame(width: 15, height: 15)
+ }
+ }
+ .padding(.trailing, 5)
+
+ Divider()
+
+ TextField("Phone number", text: $registerViewModel.phoneNumber)
+ .default_text_style(styleSize: 15)
+ .disableAutocorrection(true)
+ .autocapitalization(.none)
+ .padding(.leading, 5)
+ .keyboardType(.numberPad)
+ .onChange(of: registerViewModel.phoneNumber) { _ in
+ if !registerViewModel.phoneNumberError.isEmpty {
+ registerViewModel.phoneNumberError = ""
+ }
+ }
+ }
+ .frame(height: 25)
+ .padding(.horizontal, 20)
+ .padding(.vertical, 15)
+ .cornerRadius(60)
+ .overlay(
+ RoundedRectangle(cornerRadius: 60)
+ .inset(by: 0.5)
+ .stroke(isPhoneNumberFocused ? Color.orangeMain500 : (!registerViewModel.phoneNumberError.isEmpty ? Color.redDanger500 : Color.gray200), lineWidth: 1)
+ )
+ .focused($isPhoneNumberFocused)
+
+ Text(registerViewModel.phoneNumberError)
+ .foregroundStyle(Color.redDanger500)
+ .default_text_style_600(styleSize: 15)
+ .padding(.bottom)
+
+ Text(String(localized: "password")+"*")
+ .default_text_style_700(styleSize: 15)
+ .padding(.bottom, -5)
+
+ ZStack(alignment: .trailing) {
+ Group {
+ if isSecured {
+ SecureField("password", text: $registerViewModel.passwd)
+ .default_text_style(styleSize: 15)
+ .frame(height: 25)
+ .focused($isPasswordFocused)
+ .onChange(of: registerViewModel.passwd) { _ in
+ if !registerViewModel.passwordError.isEmpty {
+ registerViewModel.passwordError = ""
+ }
+ }
+ } else {
+ TextField("password", text: $registerViewModel.passwd)
+ .default_text_style(styleSize: 15)
+ .disableAutocorrection(true)
+ .autocapitalization(.none)
+ .frame(height: 25)
+ .focused($isPasswordFocused)
+ .onChange(of: registerViewModel.passwd) { _ in
+ if !registerViewModel.passwordError.isEmpty {
+ registerViewModel.passwordError = ""
+ }
+ }
+ }
+ }
+
+ Button(action: {
+ isSecured.toggle()
+ }, label: {
+ Image(self.isSecured ? "eye-slash" : "eye")
+ .renderingMode(.template)
+ .resizable()
+ .foregroundStyle(Color.grayMain2c500)
+ .frame(width: 20, height: 20)
+ })
+ }
+ .padding(.horizontal, 20)
+ .padding(.vertical, 15)
+ .cornerRadius(60)
+ .overlay(
+ RoundedRectangle(cornerRadius: 60)
+ .inset(by: 0.5)
+ .stroke(isPasswordFocused ? Color.orangeMain500 : (!registerViewModel.passwordError.isEmpty ? Color.redDanger500 : Color.gray200), lineWidth: 1)
+ )
+
+ Text(registerViewModel.passwordError)
+ .foregroundStyle(Color.redDanger500)
+ .default_text_style_600(styleSize: 15)
+ .padding(.bottom)
+
+ NavigationLink(isActive: $registerViewModel.isLinkActive, destination: {
+ RegisterCodeConfirmationFragment(registerViewModel: registerViewModel)
+ }, label: {
+ Text("assistant_account_create")
+ .default_text_style_white_600(styleSize: 20)
+ .frame(height: 35)
+ .frame(maxWidth: .infinity)
+
+ })
+ .padding(.horizontal, 20)
+ .padding(.vertical, 10)
+ .background((registerViewModel.username.isEmpty || registerViewModel.phoneNumber.isEmpty || registerViewModel.passwd.isEmpty) ? Color.orangeMain100 : Color.orangeMain500)
+ .cornerRadius(60)
+ .disabled(!registerViewModel.isLinkActive)
+ .padding(.bottom)
+ .simultaneousGesture(
+ TapGesture().onEnded {
+ if !(registerViewModel.username.isEmpty || registerViewModel.phoneNumber.isEmpty || registerViewModel.passwd.isEmpty) {
+ withAnimation {
+ self.isShowPopup = true
+ }
+ }
+ }
+ )
+
+ Spacer()
+
+ Text("assistant_create_account_using_email_on_our_web_platform")
+ .default_text_style(styleSize: 15)
+ .foregroundStyle(Color.grayMain2c700)
+ .padding(.horizontal, 10)
+ .frame(maxWidth: .infinity, alignment: .center)
+
+ Button(action: {
+ UIApplication.shared.open(URL(string: "https://subscribe.linphone.org/register/email")!)
+ }, label: {
+ Text("subscribe.linphone.org")
+ .default_text_style_orange_600(styleSize: 15)
+ .frame(height: 35)
+ })
+ .padding(.horizontal, 15)
+ .padding(.vertical, 5)
+ .cornerRadius(60)
+ .overlay(
+ RoundedRectangle(cornerRadius: 60)
+ .inset(by: 0.5)
+ .stroke(Color.orangeMain500, lineWidth: 1)
+ )
+ .padding(.bottom)
+ .frame(maxWidth: .infinity)
+
+ Spacer()
+
+ HStack(alignment: .center) {
+
+ Spacer()
+
+ Text("assistant_already_have_an_account")
+ .default_text_style(styleSize: 15)
+ .foregroundStyle(Color.grayMain2c700)
+ .padding(.horizontal, 10)
+
+ Button(action: {
+ dismiss()
+ }, label: {
+ Text("Login")
+ .default_text_style_white_600(styleSize: 20)
+ .frame(height: 35)
+ })
+ .padding(.horizontal, 20)
+ .padding(.vertical, 10)
+ .background(Color.orangeMain500)
+ .cornerRadius(60)
+ .padding(.horizontal, 10)
+
+ Spacer()
+ }
+ .padding(.bottom)
+ }
+ .frame(maxWidth: sharedMainViewModel.maxWidth)
+ .padding(.horizontal, 20)
+ }
+ .frame(minHeight: geometry.size.height)
+ }
+
+ if self.isShowPopup {
+ let titlePopup = Text("assistant_dialog_confirm_phone_number_title")
+ let contentPopup = Text(String(format: NSLocalizedString("assistant_dialog_confirm_phone_number_message", comment: ""), registerViewModel.phoneNumber))
+
+ PopupView(
+ isShowPopup: $isShowPopup,
+ title: titlePopup,
+ content: contentPopup,
+ titleFirstButton: Text("Cancel"),
+ actionFirstButton: {
+ self.isShowPopup = false
+ },
+ titleSecondButton: Text("Continue"),
+ actionSecondButton: {
+ self.isShowPopup = false
+ registerViewModel.createInProgress = true
+ registerViewModel.startAccountCreation()
+ registerViewModel.phoneNumberConfirmedByUser()
+ }
+ )
+ .background(.black.opacity(0.65))
+ .onTapGesture {
+ self.isShowPopup = false
+ }
+ }
+
+ if registerViewModel.createInProgress {
+ PopupLoadingView()
+ .background(.black.opacity(0.65))
+ }
+ }
+ }
+ .navigationTitle("")
+ .navigationBarHidden(true)
+ }
+ .navigationViewStyle(StackNavigationViewStyle())
+ .navigationTitle("")
+ .navigationBarHidden(true)
+ }
+}
+
+#Preview {
+ RegisterFragment(registerViewModel: RegisterViewModel())
+}
+
+// swiftlint:enable line_length
diff --git a/Linphone/UI/Assistant/Fragments/ThirdPartySipAccountLoginFragment.swift b/Linphone/UI/Assistant/Fragments/ThirdPartySipAccountLoginFragment.swift
new file mode 100644
index 000000000..9069efd75
--- /dev/null
+++ b/Linphone/UI/Assistant/Fragments/ThirdPartySipAccountLoginFragment.swift
@@ -0,0 +1,245 @@
+/*
+ * 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 .
+ */
+
+import SwiftUI
+
+struct ThirdPartySipAccountLoginFragment: View {
+
+ @ObservedObject private var sharedMainViewModel = SharedMainViewModel.shared
+ @ObservedObject private var coreContext = CoreContext.shared
+ @ObservedObject var accountLoginViewModel: AccountLoginViewModel
+
+ @Environment(\.dismiss) var dismiss
+
+ @State private var isSecured: Bool = true
+
+ @FocusState var isNameFocused: Bool
+ @FocusState var isPasswordFocused: Bool
+ @FocusState var isDomainFocused: Bool
+ @FocusState var isDisplayNameFocused: Bool
+
+ var body: some View {
+ GeometryReader { geometry in
+ ScrollView(.vertical) {
+ VStack {
+ ZStack {
+ Image("mountain")
+ .resizable()
+ .scaledToFill()
+ .frame(width: geometry.size.width, height: 100)
+ .clipped()
+
+ VStack(alignment: .leading) {
+ HStack {
+ Image("caret-left")
+ .renderingMode(.template)
+ .resizable()
+ .foregroundStyle(Color.grayMain2c500)
+ .frame(width: 25, height: 25, alignment: .leading)
+ .padding(.all, 10)
+ .padding(.top, -75)
+ .padding(.leading, -10)
+ .onTapGesture {
+ withAnimation {
+ accountLoginViewModel.domain = "sip.linphone.org"
+ accountLoginViewModel.transportType = "TLS"
+ dismiss()
+ }
+ }
+
+ Spacer()
+ }
+ .padding(.leading)
+ }
+ .frame(width: geometry.size.width)
+
+ Text("Use a SIP account")
+ .default_text_style_white_800(styleSize: 20)
+ .padding(.top, 20)
+ }
+ .padding(.top, 35)
+ .padding(.bottom, 10)
+
+ VStack(alignment: .leading) {
+ Text(String(localized: "username")+"*")
+ .default_text_style_700(styleSize: 15)
+ .padding(.bottom, -5)
+
+ TextField("username", text: $accountLoginViewModel.username)
+ .default_text_style(styleSize: 15)
+ .disableAutocorrection(true)
+ .autocapitalization(.none)
+ .disabled(coreContext.loggedIn)
+ .frame(height: 25)
+ .padding(.horizontal, 20)
+ .padding(.vertical, 15)
+ .cornerRadius(60)
+ .overlay(
+ RoundedRectangle(cornerRadius: 60)
+ .inset(by: 0.5)
+ .stroke(isNameFocused ? Color.orangeMain500 : Color.gray200, lineWidth: 1)
+ )
+ .padding(.bottom)
+ .focused($isNameFocused)
+
+ Text(String(localized: "password")+"*")
+ .default_text_style_700(styleSize: 15)
+ .padding(.bottom, -5)
+
+ ZStack(alignment: .trailing) {
+ Group {
+ if isSecured {
+ SecureField("password", text: $accountLoginViewModel.passwd)
+ .default_text_style(styleSize: 15)
+ .frame(height: 25)
+ .focused($isPasswordFocused)
+ } else {
+ TextField("password", text: $accountLoginViewModel.passwd)
+ .default_text_style(styleSize: 15)
+ .disableAutocorrection(true)
+ .autocapitalization(.none)
+ .frame(height: 25)
+ .focused($isPasswordFocused)
+ }
+ }
+ Button(action: {
+ isSecured.toggle()
+ }, label: {
+ Image(self.isSecured ? "eye-slash" : "eye")
+ .renderingMode(.template)
+ .resizable()
+ .foregroundStyle(Color.grayMain2c500)
+ .frame(width: 20, height: 20)
+ })
+ }
+ .disabled(coreContext.loggedIn)
+ .padding(.horizontal, 20)
+ .padding(.vertical, 15)
+ .cornerRadius(60)
+ .overlay(
+ RoundedRectangle(cornerRadius: 60)
+ .inset(by: 0.5)
+ .stroke(isPasswordFocused ? Color.orangeMain500 : Color.gray200, lineWidth: 1)
+ )
+ .padding(.bottom)
+
+ Text(String(localized: "Domain")+"*")
+ .default_text_style_700(styleSize: 15)
+ .padding(.bottom, -5)
+
+ TextField("sip.linphone.org", text: $accountLoginViewModel.domain)
+ .default_text_style(styleSize: 15)
+ .disableAutocorrection(true)
+ .autocapitalization(.none)
+ .disabled(coreContext.loggedIn)
+ .frame(height: 25)
+ .padding(.horizontal, 20)
+ .padding(.vertical, 15)
+ .cornerRadius(60)
+ .overlay(
+ RoundedRectangle(cornerRadius: 60)
+ .inset(by: 0.5)
+ .stroke(isDomainFocused ? Color.orangeMain500 : Color.gray200, lineWidth: 1)
+ )
+ .padding(.bottom)
+ .focused($isDomainFocused)
+
+ Text(String(localized: "Display Name"))
+ .default_text_style_700(styleSize: 15)
+ .padding(.bottom, -5)
+
+ TextField("Display Name", text: $accountLoginViewModel.displayName)
+ .default_text_style(styleSize: 15)
+ .disableAutocorrection(true)
+ .autocapitalization(.none)
+ .disabled(coreContext.loggedIn)
+ .frame(height: 25)
+ .padding(.horizontal, 20)
+ .padding(.vertical, 15)
+ .cornerRadius(60)
+ .overlay(
+ RoundedRectangle(cornerRadius: 60)
+ .inset(by: 0.5)
+ .stroke(isDisplayNameFocused ? Color.orangeMain500 : Color.gray200, lineWidth: 1)
+ )
+ .padding(.bottom)
+ .focused($isDisplayNameFocused)
+
+ Text(String(localized: "Transport"))
+ .default_text_style_700(styleSize: 15)
+ .padding(.bottom, -5)
+
+ Menu {
+ Button("TLS") {accountLoginViewModel.transportType = "TLS"}
+ Button("TCP") {accountLoginViewModel.transportType = "TCP"}
+ Button("UDP") {accountLoginViewModel.transportType = "UDP"}
+ } label: {
+ Text(accountLoginViewModel.transportType)
+ .default_text_style(styleSize: 15)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ Image("caret-down")
+ .renderingMode(.template)
+ .resizable()
+ .foregroundStyle(Color.grayMain2c500)
+ .frame(width: 20, height: 20)
+ }
+ .frame(height: 25)
+ .padding(.horizontal, 20)
+ .padding(.vertical, 15)
+ .cornerRadius(60)
+ .overlay(
+ RoundedRectangle(cornerRadius: 60)
+ .inset(by: 0.5)
+ .stroke(Color.gray200, lineWidth: 1)
+ )
+ .padding(.bottom)
+
+ Spacer()
+
+ Button(action: {
+ self.accountLoginViewModel.login()
+ }, label: {
+ Text(coreContext.loggedIn ? "Log out" : "assistant_account_login")
+ .default_text_style_white_600(styleSize: 20)
+ .frame(height: 35)
+ .frame(maxWidth: .infinity)
+ })
+ .padding(.horizontal, 20)
+ .padding(.vertical, 10)
+ .background(
+ (accountLoginViewModel.username.isEmpty || accountLoginViewModel.passwd.isEmpty || accountLoginViewModel.domain.isEmpty)
+ ? Color.orangeMain100
+ : Color.orangeMain500)
+ .cornerRadius(60)
+ .disabled(accountLoginViewModel.username.isEmpty || accountLoginViewModel.passwd.isEmpty || accountLoginViewModel.domain.isEmpty)
+ .padding(.bottom, geometry.safeAreaInsets.bottom.isEqual(to: 0.0) ? 20 : 0)
+ }
+ .frame(maxWidth: sharedMainViewModel.maxWidth)
+ .padding(.horizontal, 20)
+ }
+ .frame(minHeight: geometry.size.height)
+ }
+ }
+ .navigationBarHidden(true)
+ }
+}
+
+#Preview {
+ ThirdPartySipAccountLoginFragment(accountLoginViewModel: AccountLoginViewModel())
+}
diff --git a/Linphone/UI/Assistant/Fragments/ThirdPartySipAccountWarningFragment.swift b/Linphone/UI/Assistant/Fragments/ThirdPartySipAccountWarningFragment.swift
new file mode 100644
index 000000000..b63164bd5
--- /dev/null
+++ b/Linphone/UI/Assistant/Fragments/ThirdPartySipAccountWarningFragment.swift
@@ -0,0 +1,187 @@
+/*
+ * 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 .
+ */
+
+import SwiftUI
+
+struct ThirdPartySipAccountWarningFragment: View {
+
+ @ObservedObject private var sharedMainViewModel = SharedMainViewModel.shared
+ @ObservedObject private var coreContext = CoreContext.shared
+ @ObservedObject var accountLoginViewModel: AccountLoginViewModel
+
+ @Environment(\.dismiss) var dismiss
+
+ var body: some View {
+ NavigationView {
+ GeometryReader { geometry in
+ ScrollView(.vertical) {
+ VStack {
+ ZStack {
+ Image("mountain")
+ .resizable()
+ .scaledToFill()
+ .frame(width: geometry.size.width, height: 100)
+ .clipped()
+
+ VStack(alignment: .leading) {
+ HStack {
+ Image("caret-left")
+ .renderingMode(.template)
+ .resizable()
+ .foregroundStyle(Color.grayMain2c500)
+ .frame(width: 25, height: 25, alignment: .leading)
+ .padding(.all, 10)
+ .padding(.top, -75)
+ .padding(.leading, -10)
+ .onTapGesture {
+ withAnimation {
+ dismiss()
+ }
+ }
+
+ Spacer()
+ }
+ .padding(.leading)
+ }
+ .frame(width: geometry.size.width)
+
+ Text("Use a SIP account")
+ .default_text_style_white_800(styleSize: 20)
+ .padding(.top, 20)
+ }
+ .padding(.top, 35)
+ .padding(.bottom, 10)
+
+ Spacer()
+
+ VStack(alignment: .leading) {
+ HStack {
+ Spacer()
+ HStack(alignment: .center) {
+ Image("chat-teardrop-text-slash")
+ .renderingMode(.template)
+ .resizable()
+ .foregroundStyle(Color.grayMain2c500)
+ .frame(width: 20, height: 20, alignment: .leading)
+ }
+ .padding(16)
+ .background(Color.grayMain2c200)
+ .cornerRadius(40)
+ .padding(.horizontal)
+
+ HStack(alignment: .center) {
+ Image("video-camera-slash")
+ .renderingMode(.template)
+ .resizable()
+ .foregroundStyle(Color.grayMain2c500)
+ .frame(width: 20, height: 20, alignment: .leading)
+ }
+ .padding(16)
+ .background(Color.grayMain2c200)
+ .cornerRadius(40)
+ .padding(.horizontal)
+
+ Spacer()
+ }
+ .padding(.bottom, 40)
+
+ Text("Some features require a Linphone account, such as group messaging, video conferences...\n\n"
+ + "These features are hidden when you register with a third party SIP account.\n\n"
+ + "To enable it in a commercial projet, please contact us. ")
+ .default_text_style(styleSize: 15)
+ .multilineTextAlignment(.center)
+ .padding(.bottom)
+
+ HStack {
+ Spacer()
+
+ HStack {
+ Text("[linphone.org/contact](https://linphone.org/contact)")
+ .tint(Color.orangeMain500)
+ .default_text_style_orange_600(styleSize: 15)
+ .frame(height: 35)
+ }
+ .padding(.horizontal, 15)
+ .cornerRadius(60)
+ .overlay(
+ RoundedRectangle(cornerRadius: 60)
+ .inset(by: 0.5)
+ .stroke(Color.orangeMain500, lineWidth: 1)
+ )
+
+ Spacer()
+ }
+ .padding(.vertical)
+ }
+ .frame(maxWidth: sharedMainViewModel.maxWidth)
+ .padding(.horizontal, 20)
+
+ Spacer()
+
+ Button(action: {
+ dismiss()
+ }, label: {
+ Text("I prefere create an account")
+ .default_text_style_orange_600(styleSize: 20)
+ .frame(height: 35)
+ .frame(maxWidth: .infinity)
+ })
+ .padding(.horizontal, 20)
+ .padding(.vertical, 10)
+ .cornerRadius(60)
+ .overlay(
+ RoundedRectangle(cornerRadius: 60)
+ .inset(by: 0.5)
+ .stroke(Color.orangeMain500, lineWidth: 1)
+ )
+ .frame(maxWidth: sharedMainViewModel.maxWidth)
+ .padding(.horizontal)
+
+ NavigationLink(destination: {
+ ThirdPartySipAccountLoginFragment(accountLoginViewModel: accountLoginViewModel)
+ }, label: {
+ Text("I understand")
+ .default_text_style_white_600(styleSize: 20)
+ .frame(height: 35)
+ .frame(maxWidth: .infinity)
+
+ })
+ .padding(.horizontal, 20)
+ .padding(.vertical, 10)
+ .background(Color.orangeMain500)
+ .cornerRadius(60)
+ .frame(maxWidth: sharedMainViewModel.maxWidth)
+ .padding(.horizontal)
+ .padding(.bottom, geometry.safeAreaInsets.bottom.isEqual(to: 0.0) ? 20 : 0)
+ }
+ .frame(minHeight: geometry.size.height)
+ }
+ }
+ .navigationTitle("")
+ .navigationBarHidden(true)
+ }
+ .navigationViewStyle(StackNavigationViewStyle())
+ .navigationTitle("")
+ .navigationBarHidden(true)
+ }
+}
+
+#Preview {
+ ThirdPartySipAccountWarningFragment(accountLoginViewModel: AccountLoginViewModel())
+}
diff --git a/Linphone/UI/Assistant/Viewmodel/AccountLoginViewModel.swift b/Linphone/UI/Assistant/Viewmodel/AccountLoginViewModel.swift
new file mode 100644
index 000000000..ebebd6c32
--- /dev/null
+++ b/Linphone/UI/Assistant/Viewmodel/AccountLoginViewModel.swift
@@ -0,0 +1,166 @@
+/*
+ * Copyright (c) 2010-2023 Belledonne Communications SARL.
+ *
+ * This file is part of Linphone
+ *
+ * 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 .
+ */
+
+import linphonesw
+import SwiftUI
+
+class AccountLoginViewModel: ObservableObject {
+
+ private var coreContext = CoreContext.shared
+
+ @Published var username: String = ""
+ @Published var passwd: String = ""
+ @Published var domain: String = "sip.linphone.org"
+ @Published var displayName: String = ""
+ @Published var transportType: String = "TLS"
+
+ init() {}
+
+ func login() {
+ coreContext.doOnCoreQueue { core in
+ guard self.coreContext.networkStatusIsConnected else {
+ DispatchQueue.main.async {
+ self.coreContext.loggingInProgress = false
+ ToastViewModel.shared.toastMessage = "Unavailable_network"
+ ToastViewModel.shared.displayToast = true
+ }
+ return
+ }
+ do {
+ let usernameWithDomain = self.username.split(separator: "@")
+
+ if usernameWithDomain.count > 1 {
+ DispatchQueue.main.async {
+ self.domain = String(usernameWithDomain.last ?? "")
+ self.username = String(usernameWithDomain.first ?? "")
+ }
+ }
+
+ if self.domain != "sip.linphone.org" {
+ if let assistantLinphone = Bundle.main.path(forResource: "assistant_third_party_default_values", ofType: nil) {
+ core.loadConfigFromXml(xmlUri: assistantLinphone)
+ }
+ } else {
+ if let assistantLinphone = Bundle.main.path(forResource: "assistant_linphone_default_values", ofType: nil) {
+ core.loadConfigFromXml(xmlUri: assistantLinphone)
+ }
+ }
+
+ // Get the transport protocol to use.
+ // TLS is strongly recommended
+ // Only use UDP if you don't have the choice
+ var transport: TransportType
+ if self.transportType == "TLS" {
+ transport = TransportType.Tls
+ } else if self.transportType == "TCP" {
+ transport = TransportType.Tcp
+ } else { transport = TransportType.Udp }
+
+ // To configure a SIP account, we need an Account object and an AuthInfo object
+ // The first one is how to connect to the proxy server, the second one stores the credentials
+
+ // The auth info can be created from the Factory as it's only a data class
+ // userID is set to null as it's the same as the username in our case
+ // ha1 is set to null as we are using the clear text password. Upon first register, the hash will be computed automatically.
+ // The realm will be determined automatically from the first register, as well as the algorithm
+ let authInfo = try Factory.Instance.createAuthInfo(
+ username: self.username,
+ userid: "",
+ passwd: self.passwd,
+ ha1: "",
+ realm: "",
+ domain: self.domain
+ )
+
+ // Account object replaces deprecated ProxyConfig object
+ // Account object is configured through an AccountParams object that we can obtain from the Core
+
+ let accountParams = try core.createAccountParams()
+
+ // A SIP account is identified by an identity address that we can construct from the username and domain
+ let identity = try Factory.Instance.createAddress(addr: String("sip:" + self.username + "@" + self.domain))
+ try accountParams.setIdentityaddress(newValue: identity)
+
+ // We also need to configure where the proxy server is located
+ let address = try Factory.Instance.createAddress(addr: String("sip:" + self.domain))
+
+ // We use the Address object to easily set the transport protocol
+ try address.setTransport(newValue: transport)
+ try accountParams.setServeraddress(newValue: address)
+ // And we ensure the account will start the registration process
+ accountParams.registerEnabled = true
+ accountParams.pushNotificationAllowed = true
+ accountParams.remotePushNotificationAllowed = true
+#if DEBUG
+ let pushEnvironment = ".dev"
+#else
+ let pushEnvironment = ""
+#endif
+ accountParams.pushNotificationConfig?.provider = "apns" + pushEnvironment
+
+ // Now that our AccountParams is configured, we can create the Account object
+ let account = try core.createAccount(params: accountParams)
+
+ // Now let's add our objects to the Core
+ core.addAuthInfo(info: authInfo)
+ try core.addAccount(account: account)
+
+ // Also set the newly added account as default
+ core.defaultAccount = account
+
+ self.domain = "sip.linphone.org"
+ self.transportType = "TLS"
+
+ } catch { NSLog(error.localizedDescription) }
+ }
+ }
+
+ func unregister() {
+ coreContext.doOnCoreQueue { core in
+ // Here we will disable the registration of our Account
+ if let account = core.defaultAccount {
+
+ let params = account.params
+ // Returned params object is const, so to make changes we first need to clone it
+ let clonedParams = params?.clone()
+
+ // Now let's make our changes
+ clonedParams?.registerEnabled = false
+
+ // And apply them
+ account.params = clonedParams
+ }
+ }
+ }
+
+ func delete() {
+ coreContext.doOnCoreQueue { core in
+ // To completely remove an Account
+ if let account = core.defaultAccount {
+ core.removeAccount(account: account)
+
+ // To remove all accounts use
+ core.clearAccounts()
+
+ // Same for auth info
+ core.clearAllAuthInfo()
+ }
+ }
+ }
+}
diff --git a/Linphone/UI/Assistant/Viewmodel/QRScanner.swift b/Linphone/UI/Assistant/Viewmodel/QRScanner.swift
new file mode 100644
index 000000000..014cf08f3
--- /dev/null
+++ b/Linphone/UI/Assistant/Viewmodel/QRScanner.swift
@@ -0,0 +1,90 @@
+/*
+ * Copyright (c) 2010-2023 Belledonne Communications SARL.
+ *
+ * This file is part of Linphone
+ *
+ * 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 .
+ */
+
+import Foundation
+import SwiftUI
+import AVFoundation
+
+struct QRScanner: UIViewControllerRepresentable {
+
+ @Binding var result: String
+
+ func makeUIViewController(context: Context) -> QRScannerController {
+ let controller = QRScannerController()
+ controller.delegate = context.coordinator
+
+ return controller
+ }
+
+ func makeCoordinator() -> Coordinator {
+ Coordinator($result)
+ }
+
+ func updateUIViewController(_ uiViewController: QRScannerController, context: Context) {
+ }
+}
+
+class Coordinator: NSObject, AVCaptureMetadataOutputObjectsDelegate {
+
+ private var coreContext = CoreContext.shared
+ private var sharedMainViewModel = SharedMainViewModel.shared
+
+ @Binding var scanResult: String
+ private var lastResult: String = ""
+
+ init(_ scanResult: Binding) {
+ self._scanResult = scanResult
+ }
+
+ func metadataOutput(_ output: AVCaptureMetadataOutput, didOutput metadataObjects: [AVMetadataObject], from connection: AVCaptureConnection) {
+
+ // Check if the metadataObjects array is not nil and it contains at least one object.
+ if metadataObjects.isEmpty {
+ scanResult = "Scan a QR code"
+ return
+ }
+
+ // Get the metadata object.
+ guard let metadataObj = metadataObjects[0] as? AVMetadataMachineReadableCodeObject else {
+ return
+ }
+
+ if metadataObj.type == AVMetadataObject.ObjectType.qr,
+ let result = metadataObj.stringValue {
+ if !result.isEmpty && result != lastResult {
+ if let url = NSURL(string: result) {
+ if UIApplication.shared.canOpenURL(url as URL) {
+ lastResult = result
+ coreContext.doOnCoreQueue { core in
+ try? core.setProvisioninguri(newValue: result)
+ core.stop()
+ try? core.start()
+ }
+ } else {
+ ToastViewModel.shared.toastMessage = "Invalide URI"
+ ToastViewModel.shared.displayToast.toggle()
+ }
+ } else {
+ ToastViewModel.shared.toastMessage = "Invalide URI"
+ ToastViewModel.shared.displayToast.toggle()
+ }
+ }
+ }
+ }
+}
diff --git a/Linphone/UI/Assistant/Viewmodel/QRScannerController.swift b/Linphone/UI/Assistant/Viewmodel/QRScannerController.swift
new file mode 100644
index 000000000..1a4042241
--- /dev/null
+++ b/Linphone/UI/Assistant/Viewmodel/QRScannerController.swift
@@ -0,0 +1,76 @@
+/*
+ * Copyright (c) 2010-2023 Belledonne Communications SARL.
+ *
+ * This file is part of Linphone
+ *
+ * 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 .
+ */
+
+import Foundation
+import SwiftUI
+import AVFoundation
+
+class QRScannerController: UIViewController {
+ var captureSession = AVCaptureSession()
+ var videoPreviewLayer: AVCaptureVideoPreviewLayer?
+ var qrCodeFrameView: UIView?
+
+ var delegate: AVCaptureMetadataOutputObjectsDelegate?
+
+ override func viewDidLoad() {
+ super.viewDidLoad()
+
+ // Get the back-facing camera for capturing videos
+ guard let captureDevice = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: .back) else {
+ print("Failed to get the camera device")
+ return
+ }
+
+ let videoInput: AVCaptureDeviceInput
+
+ do {
+ // Get an instance of the AVCaptureDeviceInput class using the previous device object.
+ videoInput = try AVCaptureDeviceInput(device: captureDevice)
+
+ } catch {
+ // If any error occurs, simply print it out and don't continue any more.
+ print(error)
+ return
+ }
+
+ // Set the input device on the capture session.
+ captureSession.addInput(videoInput)
+
+ // Initialize a AVCaptureMetadataOutput object and set it as the output device to the capture session.
+ let captureMetadataOutput = AVCaptureMetadataOutput()
+ captureSession.addOutput(captureMetadataOutput)
+
+ // Set delegate and use the default dispatch queue to execute the call back
+ captureMetadataOutput.setMetadataObjectsDelegate(delegate, queue: DispatchQueue.main)
+ captureMetadataOutput.metadataObjectTypes = [ .qr ]
+
+ // Initialize the video preview layer and add it as a sublayer to the viewPreview view's layer.
+ videoPreviewLayer = AVCaptureVideoPreviewLayer(session: captureSession)
+ videoPreviewLayer?.videoGravity = AVLayerVideoGravity.resizeAspectFill
+ videoPreviewLayer?.frame = view.layer.bounds
+ view.layer.addSublayer(videoPreviewLayer!)
+
+ // Start video capture.
+ DispatchQueue.global(qos: .background).async {
+ self.captureSession.startRunning()
+ }
+
+ }
+
+}
diff --git a/Linphone/UI/Assistant/Viewmodel/RegisterViewModel.swift b/Linphone/UI/Assistant/Viewmodel/RegisterViewModel.swift
new file mode 100644
index 000000000..186a980d7
--- /dev/null
+++ b/Linphone/UI/Assistant/Viewmodel/RegisterViewModel.swift
@@ -0,0 +1,477 @@
+/*
+ * 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 .
+ */
+
+import Foundation
+import linphonesw
+import Combine
+
+// swiftlint:disable line_length
+// swiftlint:disable type_body_length
+class RegisterViewModel: ObservableObject {
+
+ static let TAG = "[RegisterViewModel]"
+ let accountTokenNotification = Notification.Name("AccountCreationTokenReceived")
+
+ private var coreContext = CoreContext.shared
+
+ @Published var username: String = ""
+ @Published var usernameError: String = ""
+ @Published var phoneNumber: String = ""
+ @Published var phoneNumberError: String = ""
+ @Published var passwd: String = ""
+ @Published var passwordError: String = ""
+ @Published var domain: String = "sip.linphone.org"
+ @Published var displayName: String = ""
+ @Published var transportType: String = "TLS"
+
+ @Published var dialPlanValueSelected: String = "🇫🇷 +33"
+ @Published var dialPlansList: [DialPlan] = []
+ @Published var dialPlansLabelList: [String] = []
+ @Published var dialPlansShortLabelList: [String] = []
+
+ private let HASHALGORITHM = "SHA-256"
+
+ private var accountManagerServices: AccountManagerServices?
+ private var accountManagerServicesRequest: AccountManagerServicesRequest?
+ private var accountCreationToken: String?
+ private var accountCreatedAuthInfo: AuthInfo?
+ private var accountCreated: Account?
+ private var normalizedPhoneNumber: String?
+
+ private var requestDelegate: AccountManagerServicesRequestDelegate?
+
+ @Published var isLinkActive: Bool = false
+ @Published var createInProgress: Bool = false
+
+ @Published var otpField = "" {
+ didSet {
+ guard otpField.count <= 5,
+ otpField.last?.isNumber ?? true else {
+ otpField = oldValue
+ return
+ }
+ }
+ }
+ var otp1: String {
+ guard otpField.count >= 1 else {
+ return ""
+ }
+ return String(Array(otpField)[0])
+ }
+ var otp2: String {
+ guard otpField.count >= 2 else {
+ return ""
+ }
+ return String(Array(otpField)[1])
+ }
+ var otp3: String {
+ guard otpField.count >= 3 else {
+ return ""
+ }
+ return String(Array(otpField)[2])
+ }
+ var otp4: String {
+ guard otpField.count >= 4 else {
+ return ""
+ }
+ return String(Array(otpField)[3])
+ }
+
+ init() {
+ getDialPlansList()
+ getAccountCreationToken()
+
+ self.usernameError = ""
+ self.phoneNumberError = ""
+ self.passwordError = ""
+
+ NotificationCenter.default.addObserver(forName: accountTokenNotification, object: nil, queue: nil) { notification in
+ if !(self.username.isEmpty || self.passwd.isEmpty) {
+ if let token = notification.userInfo?["token"] as? String {
+ if !token.isEmpty {
+ self.accountCreationToken = token
+ Log.info(
+ "\(RegisterViewModel.TAG) Extracted token \(self.accountCreationToken ?? "Error token") from push payload, creating account"
+ )
+ self.createAccount()
+ } else {
+ Log.error("\(RegisterViewModel.TAG) Push payload JSON object has an empty 'token'!")
+ self.onFlexiApiTokenRequestError()
+ }
+ }
+ }
+ }
+ }
+ func addDelegate(request: AccountManagerServicesRequest) {
+ coreContext.doOnCoreQueue { core in
+ self.requestDelegate = AccountManagerServicesRequestDelegateStub(onRequestSuccessful: { (request: AccountManagerServicesRequest, data: String) in
+ Log.info("\(RegisterViewModel.TAG) Request \(request) was successful, data is \(data)")
+ switch request.type {
+ case .CreateAccountUsingToken:
+ if !data.isEmpty {
+ self.storeAccountInCore(core: core, identity: data)
+ self.sendCodeBySms()
+ } else {
+ Log.error(
+ "\(RegisterViewModel.TAG) No data found for createAccountUsingToken request, can't continue!"
+ )
+ }
+
+ case .SendPhoneNumberLinkingCodeBySms:
+ DispatchQueue.main.async {
+ self.createInProgress = false
+ self.isLinkActive = true
+ }
+
+ case .LinkPhoneNumberUsingCode:
+ let account = self.accountCreated
+ if account != nil {
+ Log.info( "\(RegisterViewModel.TAG) Account \(account?.params?.identityAddress?.asStringUriOnly() ?? "NIL") has been created & activated, setting it as default")
+
+ if let assistantLinphone = Bundle.main.path(forResource: "assistant_linphone_default_values", ofType: nil) {
+ core.loadConfigFromXml(xmlUri: assistantLinphone)
+ }
+
+ DispatchQueue.main.async {
+ self.createInProgress = false
+ }
+
+ do {
+ try core.addAccount(account: account!)
+ core.defaultAccount = account
+ request.removeDelegate(delegate: self.requestDelegate!)
+ self.requestDelegate = nil
+ } catch {
+ }
+ }
+
+ default: break
+ }
+ }, onRequestError: { (request: AccountManagerServicesRequest, statusCode: Int, errorMessage: String, parameterErrors: Dictionary?) in
+ Log.error(
+ "\(RegisterViewModel.TAG) Request \(request) returned an error with status code \(statusCode) and message \(errorMessage)"
+ )
+
+ if !errorMessage.isEmpty {
+ DispatchQueue.main.async {
+ ToastViewModel.shared.toastMessage = "Error: \(errorMessage)"
+ ToastViewModel.shared.displayToast = true
+ }
+ }
+
+ parameterErrors?.keys.forEach({ parameter in
+ let parameterErrorMessage = parameterErrors?.getString(key: parameter) ?? ""
+
+ switch parameter {
+ case "username":
+ self.usernameError = parameterErrorMessage
+ case "password":
+ self.passwordError = parameterErrorMessage
+ case "phone":
+ self.phoneNumberError = parameterErrorMessage
+ default:
+ break
+ }
+ })
+
+ switch request.type {
+ case .SendAccountCreationTokenByPush:
+ Log.warn("\(RegisterViewModel.TAG) Cancelling job waiting for push notification")
+ default: break
+ }
+
+ DispatchQueue.main.async {
+ self.createInProgress = false
+ }
+ })
+ request.addDelegate(delegate: self.requestDelegate!)
+ }
+ }
+
+ func getDialPlansList() {
+ coreContext.doOnCoreQueue { _ in
+ let dialPlans = Factory.Instance.dialPlans
+ dialPlans.forEach { dialPlan in
+ self.dialPlansList.append(dialPlan)
+ self.dialPlansLabelList.append(
+ "\(dialPlan.flag) \(dialPlan.country) | +\(dialPlan.countryCallingCode)"
+ )
+ self.dialPlansShortLabelList.append(
+ "\(dialPlan.flag) +\(dialPlan.countryCallingCode)"
+ )
+ }
+ }
+ }
+
+ func getAccountCreationToken() {
+ coreContext.doOnCoreQueue { core in
+ do {
+ self.accountManagerServices = try core.createAccountManagerServices()
+ if self.accountManagerServices != nil {
+ self.accountManagerServices!.language = Locale.current.identifier
+ }
+ } catch {
+
+ }
+ }
+ }
+
+ func startAccountCreation() {
+ coreContext.doOnCoreQueue { core in
+ if self.accountCreationToken == nil {
+ Log.info("\(RegisterViewModel.TAG) We don't have a creation token, let's request one")
+ self.requestFlexiApiToken(core: core)
+ } else {
+ let authInfo = self.accountCreatedAuthInfo
+ if authInfo != nil {
+ Log.info("\(RegisterViewModel.TAG) Account has already been created, requesting SMS to be sent")
+ self.sendCodeBySms()
+ } else {
+ Log.info("\(RegisterViewModel.TAG) We've already have a token \(self.accountCreationToken ?? ""), continuing")
+ self.createAccount()
+ }
+ }
+ }
+ }
+
+ func storeAccountInCore(core: Core, identity: String) {
+ do {
+ let passwordValue = passwd
+ let sipIdentity = try Factory.Instance.createAddress(addr: identity)
+
+ // We need to have an AuthInfo for newly created account to authorize phone number linking request
+ let authInfo = try Factory.Instance.createAuthInfo(
+ username: sipIdentity.username ?? "Error username",
+ userid: nil,
+ passwd: passwordValue,
+ ha1: nil,
+ realm: nil,
+ domain: sipIdentity.domain
+ )
+
+ core.addAuthInfo(info: authInfo)
+ Log.info("\(RegisterViewModel.TAG) Auth info for SIP identity \(sipIdentity.asStringUriOnly()) created & added")
+
+ var dialPlan: DialPlan?
+
+ dialPlansList.forEach { dial in
+ let countryCode = dialPlanValueSelected.components(separatedBy: "+")
+ if dial.countryCallingCode == countryCode[1] {
+ dialPlan = dial
+ }
+ }
+
+ let accountParams = try core.createAccountParams()
+ try accountParams.setIdentityaddress(newValue: sipIdentity)
+ if dialPlan != nil {
+ let dialPlanTmp = dialPlan?.internationalCallPrefix ?? "Error international call prefix"
+ let isoCountryCodeTmp = dialPlan?.isoCountryCode ?? "Error iso country code"
+ Log.info(
+ "\(RegisterViewModel.TAG) Setting international prefix \(dialPlanTmp) and country \(isoCountryCodeTmp) to account params"
+ )
+ accountParams.internationalPrefix = dialPlan!.internationalCallPrefix
+ accountParams.internationalPrefixIsoCountryCode = dialPlan!.isoCountryCode
+ }
+ let account = try core.createAccount(params: accountParams)
+
+ Log.info("\(RegisterViewModel.TAG) Account for SIP identity \(sipIdentity.asStringUriOnly()) created & added")
+
+ accountCreatedAuthInfo = authInfo
+ accountCreated = account
+ } catch let error {
+ Log.error("\(RegisterViewModel.TAG) Failed to create address from SIP Identity \(identity)!")
+ Log.error("\(RegisterViewModel.TAG) Error is \(error)")
+ }
+ }
+
+ func requestFlexiApiToken(core: Core) {
+ if !core.isPushNotificationAvailable {
+ Log.error(
+ "\(RegisterViewModel.TAG) Core says push notification aren't available, can't request a token from FlexiAPI"
+ )
+ self.onFlexiApiTokenRequestError()
+ return
+ }
+
+ let pushConfig = core.pushNotificationConfig
+ if pushConfig != nil && self.accountManagerServices != nil {
+#if DEBUG
+ let pushEnvironment = ".dev"
+#else
+ let pushEnvironment = ""
+#endif
+ pushConfig!.provider = "apns\(pushEnvironment)"
+ var formatedPnParam = pushConfig!.param
+ formatedPnParam = formatedPnParam?.replacingOccurrences(of: "voip&remote", with: "remote")
+ pushConfig!.param = formatedPnParam
+
+ let coreRemoteToken = pushConfig!.remoteToken
+ var formatedRemoteToken = ""
+ if coreRemoteToken != nil {
+ formatedRemoteToken = String(coreRemoteToken!.prefix(64))
+ pushConfig!.prid = formatedRemoteToken.uppercased()
+ do {
+ let request = try self.accountManagerServices!.createSendAccountCreationTokenByPushRequest(
+ pnProvider: pushConfig?.provider ?? "",
+ pnParam: pushConfig?.param ?? "",
+ pnPrid: pushConfig?.prid ?? ""
+ )
+ self.addDelegate(request: request)
+ request.submit()
+ } catch {
+ Log.error("\(RegisterViewModel.TAG) Can't create account creation token by push request")
+ }
+ } else {
+ Log.warn("\(RegisterViewModel.TAG) No remote push token available in core for account creator configuration")
+ }
+
+ Log.info("\(RegisterViewModel.TAG) Found push notification info: provider \("apns.dev"), param \(formatedPnParam ?? "error") and prid \(formatedRemoteToken)")
+ } else {
+ Log.error("\(RegisterViewModel.TAG) No push configuration object in Core, shouldn't happen!")
+ self.onFlexiApiTokenRequestError()
+ }
+ }
+
+ func onFlexiApiTokenRequestError() {
+ Log.error("\(RegisterViewModel.TAG) Flexi API token request by push error!")
+
+ DispatchQueue.main.async {
+ ToastViewModel.shared.toastMessage = "Failed_push_notification_not_received_error"
+ ToastViewModel.shared.displayToast = true
+ }
+ }
+
+ func sendCodeBySms() {
+ let account = accountCreated
+ if accountManagerServices != nil && account != nil {
+ let phoneNumberValue = normalizedPhoneNumber
+ if phoneNumberValue == nil || phoneNumberValue!.isEmpty {
+ Log.error("\(RegisterViewModel.TAG) Phone number is null or empty, this shouldn't happen at this step!")
+ return
+ }
+
+ let identity = account!.params!.identityAddress
+ if identity != nil {
+ Log.info("\(RegisterViewModel.TAG) Account \(identity!.asStringUriOnly()) should now be created, asking account manager to send a confirmation code by SMS to \(phoneNumberValue ?? "")")
+ do {
+ let request = try accountManagerServices?.createSendPhoneNumberLinkingCodeBySmsRequest(
+ sipIdentity: identity!,
+ phoneNumber: phoneNumberValue!
+ )
+
+ if request != nil {
+ self.addDelegate(request: request!)
+ request!.submit()
+ }
+ } catch {
+ Log.error("\(RegisterViewModel.TAG) Can't create send phone number linking code by SMS request")
+ }
+ }
+ }
+ }
+
+ func createAccount() {
+ if accountManagerServices != nil {
+ let token = accountCreationToken
+ if token == nil || (token != nil && token!.isEmpty) {
+ Log.error("\(RegisterViewModel.TAG) No account creation token, can't create account!")
+ return
+ }
+
+ if username.isEmpty || passwd.isEmpty {
+ Log.error("\(RegisterViewModel.TAG) Either username \(username) or password is null or empty!")
+ return
+ }
+
+ Log.info( "\(RegisterViewModel.TAG) Account creation token is \(token ?? "Error token"), creating account with username \(username) and algorithm \(HASHALGORITHM)")
+
+ do {
+ let request = try accountManagerServices!.createNewAccountUsingTokenRequest(
+ username: username,
+ password: passwd,
+ algorithm: HASHALGORITHM,
+ token: token!
+ )
+ self.addDelegate(request: request)
+ request.submit()
+ } catch {
+ Log.error("\(RegisterViewModel.TAG) Can't create account using token")
+ }
+ }
+ }
+
+ func phoneNumberConfirmedByUser() {
+ coreContext.doOnCoreQueue { _ in
+ if self.accountManagerServices != nil {
+ var dialPlan: DialPlan?
+
+ for dial in self.dialPlansList {
+ let countryCode = self.dialPlanValueSelected.components(separatedBy: "+")
+ if dial.countryCallingCode == countryCode[1] {
+ dialPlan = dial
+ break
+ }
+ }
+ if dialPlan == nil {
+ Log.error("\(RegisterViewModel.TAG) No dial plan (country) selected!")
+ }
+
+ let number = self.phoneNumber
+ let formattedPhoneNumber = dialPlan?.formatPhoneNumber(phoneNumber: number, escapePlus: false)
+ Log.info( "\(RegisterViewModel.TAG) Formatted phone number \(number) using dial plan \(dialPlan?.country ?? "Error country") is \(formattedPhoneNumber ?? "Error phone number")")
+
+ self.normalizedPhoneNumber = formattedPhoneNumber
+ } else {
+ Log.error("\(RegisterViewModel.TAG) Account manager services hasn't been initialized!")
+
+ DispatchQueue.main.async {
+ ToastViewModel.shared.toastMessage = "Failed_account_register_unexpected_error"
+ ToastViewModel.shared.displayToast = true
+ }
+ }
+ }
+ }
+
+ func validateCode() {
+ createInProgress = true
+ let account = accountCreated
+ if accountManagerServices != nil && account != nil {
+ let code = otpField
+ let identity = account!.params?.identityAddress
+ if identity != nil {
+ Log.info(
+ "\(RegisterViewModel.TAG) Activating account using code \(code) for account \(identity!.asStringUriOnly())"
+ )
+
+ do {
+ let request = try accountManagerServices?.createLinkPhoneNumberToAccountUsingCodeRequest(sipIdentity: identity!, code: code)
+ if request != nil {
+ self.addDelegate(request: request!)
+ request!.submit()
+ }
+ } catch {
+ Log.error("\(RegisterViewModel.TAG) Can't create link phone number to account using code request")
+ }
+ }
+ }
+ }
+}
+
+// swiftlint:enable line_length
+// swiftlint:enable type_body_length
diff --git a/Linphone/UI/Call/CallView.swift b/Linphone/UI/Call/CallView.swift
new file mode 100644
index 000000000..786b561d7
--- /dev/null
+++ b/Linphone/UI/Call/CallView.swift
@@ -0,0 +1,2800 @@
+/*
+ * Copyright (c) 2010-2020 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 .
+ */
+
+import SwiftUI
+import CallKit
+import AVFAudio
+import linphonesw
+import UniformTypeIdentifiers
+
+// swiftlint:disable function_body_length
+// swiftlint:disable type_body_length
+// swiftlint:disable line_length
+// swiftlint:disable file_length
+struct CallView: View {
+
+ @ObservedObject private var coreContext = CoreContext.shared
+ @ObservedObject private var telecomManager = TelecomManager.shared
+ @ObservedObject private var contactsManager = ContactsManager.shared
+
+ @ObservedObject var callViewModel: CallViewModel
+ @ObservedObject var conversationViewModel: ConversationViewModel
+ @ObservedObject var conversationsListViewModel: ConversationsListViewModel
+ @ObservedObject var conversationForwardMessageViewModel: ConversationForwardMessageViewModel
+
+ @State private var addParticipantsViewModel: AddParticipantsViewModel?
+
+ private var idiom: UIUserInterfaceIdiom { UIDevice.current.userInterfaceIdiom }
+ @State private var orientation = UIDevice.current.orientation
+
+ let pub = NotificationCenter.default.publisher(for: AVAudioSession.routeChangeNotification)
+
+ @State var audioRouteSheet: Bool = false
+ @State var changeLayoutSheet: Bool = false
+ @State var mediaEncryptedSheet: Bool = false
+ @State var callStatisticsSheet: Bool = false
+ @State var optionsAudioRoute: Int = 1
+ @State var optionsChangeLayout: Int = 2
+ @State var imageAudioRoute: String = ""
+ @State var angleDegree = 0.0
+ @State var showingDialer = false
+ @State var minBottomSheetHeight: CGFloat = 0.16
+ @State var maxBottomSheetHeight: CGFloat = 0.5
+ @State private var pointingUp: CGFloat = 0.0
+ @State private var currentOffset: CGFloat = 0.0
+ @State var displayVideo = false
+
+ @Binding var fullscreenVideo: Bool
+ @State var isShowCallsListFragment: Bool = false
+ @State var isShowParticipantsListFragment: Bool = false
+ @Binding var isShowStartCallFragment: Bool
+ @Binding var isShowConversationFragment: Bool
+ @Binding var isShowStartCallGroupPopup: Bool
+
+ @State var buttonSize = 60.0
+
+ var body: some View {
+ GeometryReader { geo in
+ ZStack {
+ if #available(iOS 16.4, *), idiom != .pad {
+ innerView(geometry: geo)
+ .sheet(isPresented: $mediaEncryptedSheet, onDismiss: {
+ mediaEncryptedSheet = false
+ }, content: {
+ MediaEncryptedSheetBottomSheet(callViewModel: callViewModel, mediaEncryptedSheet: $mediaEncryptedSheet)
+ .presentationDetents([.medium])
+ })
+ .sheet(isPresented: $callStatisticsSheet, onDismiss: {
+ callStatisticsSheet = false
+ }, content: {
+ CallStatisticsSheetBottomSheet(callViewModel: callViewModel, callStatisticsSheet: $callStatisticsSheet)
+ .presentationDetents(!callViewModel.callStatsModel.isVideoEnabled ? [.fraction(0.3)] : [.medium])
+ })
+ .sheet(isPresented: $audioRouteSheet, onDismiss: {
+ audioRouteSheet = false
+ }, content: {
+ AudioRouteBottomSheet(callViewModel: callViewModel, optionsAudioRoute: $optionsAudioRoute)
+ .presentationDetents([.fraction(0.3)])
+ })
+ .sheet(isPresented: $changeLayoutSheet, onDismiss: {
+ changeLayoutSheet = false
+ }, content: {
+ ChangeLayoutBottomSheet(callViewModel: callViewModel, changeLayoutSheet: $changeLayoutSheet, optionsChangeLayout: $optionsChangeLayout)
+ .presentationDetents([.fraction(0.3)])
+ })
+ .sheet(isPresented: $showingDialer) {
+ DialerBottomSheet(
+ startCallViewModel: StartCallViewModel(),
+ callViewModel: callViewModel,
+ isShowStartCallFragment: $isShowStartCallFragment,
+ showingDialer: $showingDialer,
+ currentCall: callViewModel.currentCall
+ )
+ .presentationDetents([.medium])
+ .presentationBackgroundInteraction(.enabled(upThrough: .medium))
+ }
+ } else if #available(iOS 16.0, *), idiom != .pad {
+ innerView(geometry: geo)
+ .sheet(isPresented: $mediaEncryptedSheet, onDismiss: {
+ mediaEncryptedSheet = false
+ }, content: {
+ MediaEncryptedSheetBottomSheet(callViewModel: callViewModel, mediaEncryptedSheet: $mediaEncryptedSheet)
+ .presentationDetents([.medium])
+ })
+ .sheet(isPresented: $callStatisticsSheet, onDismiss: {
+ callStatisticsSheet = false
+ }, content: {
+ CallStatisticsSheetBottomSheet(callViewModel: callViewModel, callStatisticsSheet: $callStatisticsSheet)
+ .presentationDetents(!callViewModel.callStatsModel.isVideoEnabled ? [.fraction(0.3)] : [.medium])
+ })
+ .sheet(isPresented: $audioRouteSheet, onDismiss: {
+ audioRouteSheet = false
+ }, content: {
+ AudioRouteBottomSheet(callViewModel: callViewModel, optionsAudioRoute: $optionsAudioRoute)
+ .presentationDetents([.fraction(0.3)])
+ })
+ .sheet(isPresented: $changeLayoutSheet, onDismiss: {
+ changeLayoutSheet = false
+ }, content: {
+ ChangeLayoutBottomSheet(callViewModel: callViewModel, changeLayoutSheet: $changeLayoutSheet, optionsChangeLayout: $optionsChangeLayout)
+ .presentationDetents([.fraction(0.3)])
+ })
+ .sheet(isPresented: $showingDialer) {
+ DialerBottomSheet(
+ startCallViewModel: StartCallViewModel(),
+ callViewModel: callViewModel,
+ isShowStartCallFragment: $isShowStartCallFragment,
+ showingDialer: $showingDialer,
+ currentCall: callViewModel.currentCall
+ )
+ .presentationDetents([.medium])
+ }
+ } else {
+ innerView(geometry: geo)
+ .halfSheet(showSheet: $mediaEncryptedSheet) {
+ MediaEncryptedSheetBottomSheet(callViewModel: callViewModel, mediaEncryptedSheet: $mediaEncryptedSheet)
+ } onDismiss: {
+ mediaEncryptedSheet = false
+ }
+ .halfSheet(showSheet: $callStatisticsSheet) {
+ CallStatisticsSheetBottomSheet(callViewModel: callViewModel, callStatisticsSheet: $callStatisticsSheet)
+ } onDismiss: {
+ callStatisticsSheet = false
+ }
+ .halfSheet(showSheet: $audioRouteSheet) {
+ AudioRouteBottomSheet(callViewModel: callViewModel, optionsAudioRoute: $optionsAudioRoute)
+ } onDismiss: {
+ audioRouteSheet = false
+ }
+ .halfSheet(showSheet: $changeLayoutSheet) {
+ ChangeLayoutBottomSheet(callViewModel: callViewModel, changeLayoutSheet: $changeLayoutSheet, optionsChangeLayout: $optionsChangeLayout)
+ } onDismiss: {
+ changeLayoutSheet = false
+ }
+ .halfSheet(showSheet: $showingDialer) {
+ DialerBottomSheet(
+ startCallViewModel: StartCallViewModel(),
+ callViewModel: callViewModel,
+ isShowStartCallFragment: $isShowStartCallFragment,
+ showingDialer: $showingDialer,
+ currentCall: callViewModel.currentCall
+ )
+ } onDismiss: {}
+ }
+
+ if isShowCallsListFragment {
+ CallsListFragment(callViewModel: callViewModel, isShowCallsListFragment: $isShowCallsListFragment)
+ .zIndex(4)
+ .transition(.move(edge: .bottom))
+ }
+
+ if isShowParticipantsListFragment {
+ ParticipantsListFragment(callViewModel: callViewModel, addParticipantsViewModel: addParticipantsViewModel ?? AddParticipantsViewModel(), isShowParticipantsListFragment: $isShowParticipantsListFragment)
+ .zIndex(4)
+ .transition(.move(edge: .bottom))
+ .onAppear {
+ addParticipantsViewModel = AddParticipantsViewModel()
+ }
+ }
+
+ if isShowConversationFragment && conversationViewModel.displayedConversation != nil {
+ ConversationFragment(
+ conversationViewModel: conversationViewModel,
+ conversationsListViewModel: conversationsListViewModel,
+ conversationForwardMessageViewModel: conversationForwardMessageViewModel,
+ isShowConversationFragment: $isShowConversationFragment,
+ isShowStartCallGroupPopup: $isShowStartCallGroupPopup
+ )
+ .frame(maxWidth: .infinity)
+ .background(Color.gray100)
+ .ignoresSafeArea(.keyboard)
+ .zIndex(4)
+ .transition(.move(edge: .bottom))
+ .onDisappear {
+ conversationViewModel.displayedConversation = nil
+ isShowConversationFragment = false
+ }
+ }
+
+ if callViewModel.zrtpPopupDisplayed == true {
+ if idiom != .pad
+ && (orientation == .landscapeLeft
+ || orientation == .landscapeRight
+ || UIScreen.main.bounds.size.width > UIScreen.main.bounds.size.height)
+ && buttonSize != 45 {
+ ZRTPPopup(callViewModel: callViewModel, resizeView: 1.5)
+ .background(.black.opacity(0.65))
+ .frame(maxHeight: geo.size.height)
+ } else {
+ ZRTPPopup(callViewModel: callViewModel, resizeView: buttonSize == 45 ? 1.5 : 1)
+ .background(.black.opacity(0.65))
+ .frame(maxHeight: geo.size.height)
+ }
+ }
+
+ if telecomManager.remainingCall {
+ HStack {}
+ .onAppear {
+ callViewModel.resetCallView()
+ callViewModel.getCallsList()
+ }
+ }
+ }
+ .onAppear {
+ UIApplication.shared.endEditing()
+ fullscreenVideo = false
+ if geo.size.width < 350 || geo.size.height < 350 {
+ buttonSize = 45.0
+ }
+ }
+ }
+ }
+
+ @ViewBuilder
+ func innerView(geometry: GeometryProxy) -> some View {
+ ZStack {
+ VStack {
+ if !fullscreenVideo || (fullscreenVideo && telecomManager.isPausedByRemote) {
+ ZStack {
+ HStack {
+ Button {
+ withAnimation {
+ telecomManager.callDisplayed = false
+ }
+ } label: {
+ Image("caret-left")
+ .renderingMode(.template)
+ .resizable()
+ .foregroundStyle(.white)
+ .frame(width: 25, height: 25, alignment: .leading)
+ .padding(.all, 10)
+ }
+
+ Text(callViewModel.displayName)
+ .default_text_style_white_800(styleSize: 16)
+
+ if !telecomManager.outgoingCallStarted && telecomManager.callInProgress {
+ Text("|")
+ .default_text_style_white_800(styleSize: 16)
+
+ ZStack {
+ Text(callViewModel.timeElapsed.convertDurationToString())
+ .onReceive(callViewModel.timer) { _ in
+ callViewModel.timeElapsed = callViewModel.currentCall?.duration ?? 0
+ }
+ .default_text_style_white_800(styleSize: 16)
+ .if(callViewModel.isPaused || telecomManager.isPausedByRemote) { view in
+ view.hidden()
+ }
+
+ if callViewModel.isPaused {
+ Text("Paused")
+ .default_text_style_white_800(styleSize: 16)
+ } else if telecomManager.isPausedByRemote {
+ Text("Paused by remote")
+ .default_text_style_white_800(styleSize: 16)
+ }
+ }
+ }
+
+ Spacer()
+
+ Button {
+ callStatisticsSheet = true
+ } label: {
+ Image(callViewModel.qualityIcon)
+ .renderingMode(.template)
+ .resizable()
+ .foregroundStyle(.white)
+ .frame(width: 30, height: 30)
+ .padding(.all, 10)
+ }
+
+ if callViewModel.videoDisplayed {
+ Button {
+ callViewModel.switchCamera()
+ } label: {
+ Image("camera-rotate")
+ .renderingMode(.template)
+ .resizable()
+ .foregroundStyle(.white)
+ .frame(width: 30, height: 30)
+ .padding(.horizontal)
+ }
+ }
+ }
+ .frame(height: 40)
+ .zIndex(1)
+
+ if !telecomManager.outgoingCallStarted && telecomManager.callInProgress {
+ if callViewModel.isMediaEncrypted && callViewModel.isRemoteDeviceTrusted && callViewModel.isZrtp {
+ HStack {
+ Image("lock-key")
+ .renderingMode(.template)
+ .resizable()
+ .foregroundStyle(Color.blueInfo500)
+ .frame(width: 15, height: 15, alignment: .leading)
+ .padding(.leading, 50)
+ .padding(.top, 35)
+
+ Text("call_zrtp_end_to_end_encrypted")
+ .foregroundStyle(Color.blueInfo500)
+ .default_text_style_white(styleSize: 12)
+ .padding(.top, 35)
+
+ Spacer()
+ }
+ .onTapGesture {
+ mediaEncryptedSheet = true
+ }
+ .frame(height: 40)
+ .zIndex(1)
+ } else if callViewModel.isMediaEncrypted && !callViewModel.isZrtp {
+ HStack {
+ Image("lock_simple")
+ .renderingMode(.template)
+ .resizable()
+ .foregroundStyle(Color.blueInfo500)
+ .frame(width: 15, height: 15, alignment: .leading)
+ .padding(.leading, 50)
+ .padding(.top, 35)
+
+ Text("call_srtp_point_to_point_encrypted")
+ .foregroundStyle(Color.blueInfo500)
+ .default_text_style_white(styleSize: 12)
+ .padding(.top, 35)
+
+ Spacer()
+ }
+ .onTapGesture {
+ mediaEncryptedSheet = true
+ }
+ .frame(height: 40)
+ .zIndex(1)
+ } else if callViewModel.isMediaEncrypted && (!callViewModel.isRemoteDeviceTrusted && callViewModel.isZrtp) || callViewModel.cacheMismatch {
+ HStack {
+ Image("warning-circle")
+ .renderingMode(.template)
+ .resizable()
+ .foregroundStyle(Color.orangeWarning600)
+ .frame(width: 15, height: 15, alignment: .leading)
+ .padding(.leading, 50)
+ .padding(.top, 35)
+
+ Text("call_zrtp_sas_validation_required")
+ .foregroundStyle(Color.orangeWarning600)
+ .default_text_style_white(styleSize: 12)
+ .padding(.top, 35)
+
+ Spacer()
+ }
+ .onTapGesture {
+ mediaEncryptedSheet = true
+ }
+ .frame(height: 40)
+ .zIndex(1)
+ } else if callViewModel.isNotEncrypted {
+ HStack {
+ Image("lock_simple")
+ .renderingMode(.template)
+ .resizable()
+ .foregroundStyle(.white)
+ .frame(width: 15, height: 15, alignment: .leading)
+ .padding(.leading, 50)
+ .padding(.top, 35)
+
+ Text("call_not_encrypted")
+ .foregroundStyle(.white)
+ .default_text_style_white(styleSize: 12)
+ .padding(.top, 35)
+
+ Spacer()
+ }
+ .onTapGesture {
+ mediaEncryptedSheet = true
+ }
+ .frame(height: 40)
+ .zIndex(1)
+ } else {
+ HStack {
+ ProgressView()
+ .controlSize(.mini)
+ .progressViewStyle(CircularProgressViewStyle(tint: .white))
+ .frame(width: 15, height: 15, alignment: .leading)
+ .padding(.leading, 50)
+ .padding(.top, 35)
+
+ Text("call_waiting_for_encryption_info")
+ .foregroundStyle(.white)
+ .default_text_style_white(styleSize: 12)
+ .padding(.top, 35)
+
+ Spacer()
+ }
+ .frame(height: 40)
+ .zIndex(1)
+ }
+ }
+ }
+ }
+
+ simpleCallView(geometry: geometry)
+
+ Spacer()
+ }
+ .frame(height: geometry.size.height)
+ .frame(maxWidth: .infinity)
+ .background(Color.gray900)
+
+ if !fullscreenVideo || (fullscreenVideo && telecomManager.isPausedByRemote) {
+ if telecomManager.callStarted {
+ let scene = UIApplication.shared.connectedScenes.first as? UIWindowScene
+ let bottomInset = scene?.windows.first?.safeAreaInsets
+
+ BottomSheetView(
+ content: bottomSheetContent(geo: geometry),
+ minHeight: (minBottomSheetHeight * geometry.size.height > 80 ? minBottomSheetHeight * geometry.size.height : 78),
+ maxHeight: (maxBottomSheetHeight * geometry.size.height),
+ currentOffset: $currentOffset,
+ pointingUp: $pointingUp,
+ bottomSafeArea: bottomInset?.bottom ?? 0
+ )
+ .onAppear {
+ currentOffset = (minBottomSheetHeight * geometry.size.height > 80 ? minBottomSheetHeight * geometry.size.height : 78)
+ pointingUp = -(((currentOffset - (minBottomSheetHeight * geometry.size.height > 80 ? minBottomSheetHeight * geometry.size.height : 78)) / ((maxBottomSheetHeight * geometry.size.height) - (minBottomSheetHeight * geometry.size.height > 80 ? minBottomSheetHeight * geometry.size.height : 78))) - 0.5) * 2
+ }
+ .onChange(of: optionsChangeLayout) { _ in
+ currentOffset = (minBottomSheetHeight * geometry.size.height > 80 ? minBottomSheetHeight * geometry.size.height : 78)
+ pointingUp = -(((currentOffset - (minBottomSheetHeight * geometry.size.height > 80 ? minBottomSheetHeight * geometry.size.height : 78)) / ((maxBottomSheetHeight * geometry.size.height) - (minBottomSheetHeight * geometry.size.height > 80 ? minBottomSheetHeight * geometry.size.height : 78))) - 0.5) * 2
+ }
+ .edgesIgnoringSafeArea(.bottom)
+ }
+ }
+ }
+ }
+
+ // swiftlint:disable:next cyclomatic_complexity
+ func simpleCallView(geometry: GeometryProxy) -> some View {
+ ZStack {
+ if callViewModel.isOneOneCall {
+ VStack {
+ Spacer()
+ ZStack {
+
+ if callViewModel.isRemoteDeviceTrusted {
+ Circle()
+ .fill(Color.blueInfo500)
+ .frame(width: 206, height: 206)
+ }
+
+ if callViewModel.avatarModel != nil {
+ Avatar(contactAvatarModel: callViewModel.avatarModel!, avatarSize: 200, hidePresence: true)
+ }
+
+ if callViewModel.isRemoteDeviceTrusted {
+ VStack {
+ Spacer()
+ HStack {
+ Image("trusted")
+ .resizable()
+ .frame(width: 25, height: 25)
+ .padding(.all, 15)
+ Spacer()
+ }
+ }
+ .frame(width: 200, height: 200)
+ }
+ }
+
+ Text(callViewModel.displayName)
+ .padding(.top)
+ .default_text_style_white(styleSize: 22)
+
+ Text(callViewModel.remoteAddressString)
+ .default_text_style_white_300(styleSize: 16)
+
+ Spacer()
+ }
+
+ if telecomManager.remoteConfVideo {
+ LinphoneVideoViewHolder { view in
+ coreContext.doOnCoreQueue { core in
+ core.nativeVideoWindow = view
+ }
+ }
+ .frame(
+ 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()
+ .onTapGesture {
+ if telecomManager.remoteConfVideo {
+ fullscreenVideo.toggle()
+ }
+ }
+ .onAppear {
+ if callViewModel.videoDisplayed {
+ callViewModel.videoDisplayed = false
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
+ callViewModel.videoDisplayed = true
+ }
+ }
+ }
+ .onDisappear {
+ if callViewModel.videoDisplayed {
+ callViewModel.videoDisplayed = false
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
+ callViewModel.videoDisplayed = true
+ }
+ }
+
+ fullscreenVideo = false
+ }
+ }
+
+ if callViewModel.videoDisplayed {
+ HStack {
+ Spacer()
+ VStack {
+ Spacer()
+ LinphoneVideoViewHolder { view in
+ coreContext.doOnCoreQueue { core in
+ core.nativePreviewWindow = view
+ }
+ }
+ .frame(width: angleDegree == 0 ? 120*1.2 : 160*1.2, height: angleDegree == 0 ? 160*1.2 : 120*1.2)
+ .cornerRadius(20)
+ .padding(10)
+ .padding(.trailing, abs(angleDegree/2))
+ }
+ }
+ .frame(
+ maxWidth: fullscreenVideo && !telecomManager.isPausedByRemote ? geometry.size.width : geometry.size.width - 8,
+ maxHeight: fullscreenVideo && !telecomManager.isPausedByRemote ? geometry.size.height + geometry.safeAreaInsets.top + geometry.safeAreaInsets.bottom : geometry.size.height - (minBottomSheetHeight * geometry.size.height > 80 ? minBottomSheetHeight * geometry.size.height : 78) - 40 - 20 + geometry.safeAreaInsets.bottom
+ )
+ }
+
+ if telecomManager.outgoingCallStarted {
+ VStack {
+ ActivityIndicator(color: .white)
+ .frame(width: 20, height: 20)
+ .padding(.top, 60)
+
+ Text(callViewModel.counterToMinutes())
+ .onAppear {
+ callViewModel.timeElapsed = 0
+ }
+ .onReceive(callViewModel.timer) { _ in
+ callViewModel.timeElapsed = callViewModel.currentCall?.duration ?? 0
+
+ }
+ .onDisappear {
+ callViewModel.timeElapsed = 0
+ }
+ .padding(.top)
+ .foregroundStyle(.white)
+
+ Spacer()
+ }
+ .background(.clear)
+ .frame(
+ maxWidth: fullscreenVideo && !telecomManager.isPausedByRemote ? geometry.size.width : geometry.size.width - 8,
+ maxHeight: fullscreenVideo && !telecomManager.isPausedByRemote ? geometry.size.height + geometry.safeAreaInsets.top + geometry.safeAreaInsets.bottom : geometry.size.height - (minBottomSheetHeight * geometry.size.height > 80 ? minBottomSheetHeight * geometry.size.height : 78) - 40 - 20 + geometry.safeAreaInsets.bottom
+ )
+ }
+ } else if callViewModel.isConference && !telecomManager.outgoingCallStarted && callViewModel.activeSpeakerParticipant != nil {
+ let heightValue = (fullscreenVideo && !telecomManager.isPausedByRemote ? geometry.size.height : geometry.size.height - (minBottomSheetHeight * geometry.size.height > 80 ? minBottomSheetHeight * geometry.size.height : 78) - 40 - 20 + geometry.safeAreaInsets.bottom)
+ if optionsChangeLayout == 1 && callViewModel.participantList.count <= 5 {
+ mosaicMode(geometry: geometry, height: heightValue)
+ } else if optionsChangeLayout == 3 {
+ audioOnlyMode(geometry: geometry, height: heightValue)
+ } else {
+ activeSpeakerMode(geometry: geometry)
+ }
+ } else if callViewModel.isConference && !telecomManager.outgoingCallStarted && callViewModel.participantList.isEmpty {
+ VStack {
+ Spacer()
+
+ Text("En attente d'autres participants...")
+ .frame(maxWidth: .infinity, alignment: .center)
+ .foregroundStyle(Color.white)
+ .default_text_style_300(styleSize: 25)
+ .lineLimit(1)
+ .padding(.bottom, 4)
+
+ Button(action: {
+ UIPasteboard.general.setValue(
+ callViewModel.remoteAddressString,
+ forPasteboardType: UTType.plainText.identifier
+ )
+
+ DispatchQueue.main.async {
+ ToastViewModel.shared.toastMessage = "Success_address_copied_into_clipboard"
+ ToastViewModel.shared.displayToast = true
+ }
+ }, label: {
+ HStack {
+ Image("share-network")
+ .renderingMode(.template)
+ .resizable()
+ .foregroundStyle(Color.grayMain2c400)
+ .frame(width: 30, height: 30)
+
+ Text("Partager le lien")
+ .foregroundStyle(Color.grayMain2c400)
+ .default_text_style(styleSize: 25)
+ .frame(height: 40)
+ }
+ })
+ .padding(.horizontal, 20)
+ .padding(.vertical, 10)
+ .cornerRadius(60)
+ .overlay(
+ RoundedRectangle(cornerRadius: 60)
+ .inset(by: 0.5)
+ .stroke(Color.grayMain2c400, lineWidth: 1)
+ )
+
+ Spacer()
+ }
+ .onAppear {
+ fullscreenVideo = false
+ }
+
+ HStack {
+ Spacer()
+ VStack {
+ Spacer()
+ LinphoneVideoViewHolder { view in
+ coreContext.doOnCoreQueue { core in
+ core.nativePreviewWindow = view
+ }
+ }
+ .frame(width: angleDegree == 0 ? 120*1.2 : 160*1.2, height: angleDegree == 0 ? 160*1.2 : 120*1.2)
+ .cornerRadius(20)
+ .padding(10)
+ .padding(.trailing, abs(angleDegree/2))
+ }
+ }
+ .frame(
+ maxWidth: fullscreenVideo && !telecomManager.isPausedByRemote ? geometry.size.width : geometry.size.width - 8,
+ maxHeight: fullscreenVideo && !telecomManager.isPausedByRemote ? geometry.size.height + geometry.safeAreaInsets.top + geometry.safeAreaInsets.bottom : geometry.size.height - (minBottomSheetHeight * geometry.size.height > 80 ? minBottomSheetHeight * geometry.size.height : 78) - 40 - 20 + geometry.safeAreaInsets.bottom
+ )
+ } else if telecomManager.outgoingCallStarted {
+ ProgressView()
+ .progressViewStyle(CircularProgressViewStyle(tint: .white))
+ .frame(width: 60, height: 60, alignment: .center)
+ .onDisappear {
+ callViewModel.resetCallView()
+ }
+ }
+
+ if callViewModel.isRecording {
+ HStack {
+ VStack {
+ Image("record-fill")
+ .renderingMode(.template)
+ .resizable()
+ .foregroundStyle(Color.redDanger500)
+ .frame(width: 32, height: 32)
+ .padding(10)
+ .if(fullscreenVideo && !telecomManager.isPausedByRemote) { view in
+ view.padding(.top, 30)
+ }
+ Spacer()
+ }
+ Spacer()
+ }
+ .frame(
+ maxWidth: fullscreenVideo && !telecomManager.isPausedByRemote ? geometry.size.width : geometry.size.width - 8,
+ maxHeight: fullscreenVideo && !telecomManager.isPausedByRemote ? geometry.size.height + geometry.safeAreaInsets.top + geometry.safeAreaInsets.bottom : geometry.size.height - (minBottomSheetHeight * geometry.size.height > 80 ? minBottomSheetHeight * geometry.size.height : 78) - 40 - 20 + geometry.safeAreaInsets.bottom
+ )
+ }
+ }
+ .frame(
+ maxWidth: fullscreenVideo && !telecomManager.isPausedByRemote ? geometry.size.width : geometry.size.width - 8,
+ maxHeight: fullscreenVideo && !telecomManager.isPausedByRemote ? geometry.size.height + geometry.safeAreaInsets.top + geometry.safeAreaInsets.bottom : geometry.size.height - (minBottomSheetHeight * geometry.size.height > 80 ? minBottomSheetHeight * geometry.size.height : 78) - 40 - 20 + geometry.safeAreaInsets.bottom
+ )
+ .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
+ orientation = newOrientation
+ if orientation == .portrait || orientation == .portraitUpsideDown {
+ angleDegree = 0
+ } else {
+ if orientation == .landscapeLeft {
+ angleDegree = -90
+ } else if orientation == .landscapeRight {
+ angleDegree = 90
+ } else if UIScreen.main.bounds.size.width > UIScreen.main.bounds.size.height {
+ angleDegree = 90
+ }
+ }
+
+ if (oldOrientation != orientation && oldOrientation != .faceUp) || (oldOrientation == .faceUp && (orientation == .landscapeLeft || orientation == .landscapeRight)) {
+ telecomManager.callStarted = false
+
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
+ telecomManager.callStarted = true
+ }
+ }
+
+ callViewModel.orientationUpdate(orientation: orientation)
+ }
+ .onAppear {
+ if orientation == .portrait && orientation == .portraitUpsideDown {
+ angleDegree = 0
+ } else {
+ if orientation == .landscapeLeft {
+ angleDegree = -90
+ } else if orientation == .landscapeRight {
+ angleDegree = 90
+ } else if UIScreen.main.bounds.size.width > UIScreen.main.bounds.size.height {
+ angleDegree = 90
+ }
+ }
+
+ callViewModel.orientationUpdate(orientation: orientation)
+ }
+ .onReceive(telecomManager.$remoteConfVideo, perform: { videoOn in
+ if videoOn {
+ fullscreenVideo = videoOn
+ }
+ })
+ }
+
+ // swiftlint:disable:next cyclomatic_complexity
+ func activeSpeakerMode(geometry: GeometryProxy) -> some View {
+ ZStack {
+ let isLandscapeMode = (orientation == .landscapeLeft || orientation == .landscapeRight || UIScreen.main.bounds.size.width > UIScreen.main.bounds.size.height)
+ if callViewModel.activeSpeakerParticipant!.onPause {
+ VStack {
+ VStack {
+ Spacer()
+
+ Image("pause")
+ .renderingMode(.template)
+ .resizable()
+ .foregroundStyle(.white)
+ .frame(width: 40, height: 40)
+
+ Text("En pause")
+ .frame(maxWidth: .infinity, alignment: .center)
+ .foregroundStyle(Color.white)
+ .default_text_style_500(styleSize: 14)
+ .lineLimit(1)
+ .padding(.horizontal, 10)
+
+ Spacer()
+ }
+ .frame(
+ width: fullscreenVideo && !telecomManager.isPausedByRemote ? geometry.size.width - (isLandscapeMode ? 160 : 0) : geometry.size.width - 8 - (isLandscapeMode ? 160 : 0),
+ height: fullscreenVideo && !telecomManager.isPausedByRemote ? geometry.size.height + geometry.safeAreaInsets.top + geometry.safeAreaInsets.bottom - (!isLandscapeMode ? 160 : 0) - (isLandscapeMode && fullscreenVideo && !telecomManager.isPausedByRemote ? 40 : 0) : geometry.size.height - (minBottomSheetHeight * geometry.size.height > 80 ? minBottomSheetHeight * geometry.size.height : 78) - 40 - 20 - (!isLandscapeMode ? 160 : 0) + geometry.safeAreaInsets.bottom - (isLandscapeMode && fullscreenVideo && !telecomManager.isPausedByRemote ? 40 : 0)
+ )
+
+ Spacer()
+ }
+ .frame(
+ maxWidth: fullscreenVideo && !telecomManager.isPausedByRemote ? geometry.size.width : geometry.size.width - 8,
+ maxHeight: fullscreenVideo && !telecomManager.isPausedByRemote ? geometry.size.height + geometry.safeAreaInsets.top + geometry.safeAreaInsets.bottom : geometry.size.height - (minBottomSheetHeight * geometry.size.height > 80 ? minBottomSheetHeight * geometry.size.height : 78) - 40 - 20 + geometry.safeAreaInsets.bottom
+ )
+ } else {
+ VStack {
+ HStack {
+ VStack {
+ Spacer()
+ HStack {
+ if callViewModel.activeSpeakerParticipant != nil {
+ Avatar(contactAvatarModel: callViewModel.activeSpeakerParticipant!.avatarModel, avatarSize: 200, hidePresence: true)
+ .onAppear {
+ DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
+ displayVideo = true
+ }
+ }
+ }
+ }
+
+ Spacer()
+ }
+ .frame(
+ width: fullscreenVideo && !telecomManager.isPausedByRemote ? geometry.size.width - (isLandscapeMode ? 160 : 0) : geometry.size.width - 8 - (isLandscapeMode ? 160 : 0),
+ height: fullscreenVideo && !telecomManager.isPausedByRemote ? geometry.size.height + geometry.safeAreaInsets.top + geometry.safeAreaInsets.bottom - (!isLandscapeMode ? 160 : 0) - (isLandscapeMode && fullscreenVideo && !telecomManager.isPausedByRemote ? 40 : 0) : geometry.size.height - (minBottomSheetHeight * geometry.size.height > 80 ? minBottomSheetHeight * geometry.size.height : 78) - 40 - 20 - (!isLandscapeMode ? 160 : 0) - (isLandscapeMode && fullscreenVideo && !telecomManager.isPausedByRemote ? 40 : 0) + geometry.safeAreaInsets.bottom
+ )
+
+ if isLandscapeMode {
+ Spacer()
+ }
+ }
+
+ Spacer()
+ }
+ .frame(
+ maxWidth: fullscreenVideo && !telecomManager.isPausedByRemote ? geometry.size.width : geometry.size.width - 8,
+ maxHeight: fullscreenVideo && !telecomManager.isPausedByRemote ? geometry.size.height + geometry.safeAreaInsets.top + geometry.safeAreaInsets.bottom : geometry.size.height - (minBottomSheetHeight * geometry.size.height > 80 ? minBottomSheetHeight * geometry.size.height : 78) - 40 - 20 + geometry.safeAreaInsets.bottom
+ )
+
+ VStack {
+ if telecomManager.remoteConfVideo && !telecomManager.outgoingCallStarted && callViewModel.activeSpeakerParticipant != nil && displayVideo {
+ HStack {
+ VStack {
+ LinphoneVideoViewHolder { view in
+ coreContext.doOnCoreQueue { core in
+ core.nativeVideoWindow = view
+ }
+ }
+ }
+ .frame(
+ width: fullscreenVideo && !telecomManager.isPausedByRemote ? geometry.size.width - (isLandscapeMode ? 160 : 0) : geometry.size.width - 8 - (isLandscapeMode ? 160 : 0),
+ height: fullscreenVideo && !telecomManager.isPausedByRemote ? geometry.size.height + geometry.safeAreaInsets.top + geometry.safeAreaInsets.bottom - (!isLandscapeMode ? 160 : 0) - (isLandscapeMode && fullscreenVideo && !telecomManager.isPausedByRemote ? 40 : 0) : geometry.size.height - (minBottomSheetHeight * geometry.size.height > 80 ? minBottomSheetHeight * geometry.size.height : 78) - 40 - 20 - (!isLandscapeMode ? 160 : 0) - (isLandscapeMode && fullscreenVideo && !telecomManager.isPausedByRemote ? 40 : 0) + geometry.safeAreaInsets.bottom
+ )
+ .cornerRadius(20)
+
+ if isLandscapeMode {
+ Spacer()
+ }
+ }
+ }
+ Spacer()
+ }
+ .frame(
+ width: fullscreenVideo && !telecomManager.isPausedByRemote ? geometry.size.width : geometry.size.width - 8,
+ height: fullscreenVideo && !telecomManager.isPausedByRemote ? geometry.size.height + geometry.safeAreaInsets.top + geometry.safeAreaInsets.bottom : geometry.size.height - (minBottomSheetHeight * geometry.size.height > 80 ? minBottomSheetHeight * geometry.size.height : 78) - 40 - 20 + geometry.safeAreaInsets.bottom
+ )
+ }
+
+ if callViewModel.isConference && !telecomManager.outgoingCallStarted && callViewModel.activeSpeakerParticipant != nil && callViewModel.activeSpeakerParticipant!.isMuted {
+ VStack {
+ HStack {
+ Spacer()
+
+ HStack(alignment: .center) {
+ Image("microphone-slash")
+ .renderingMode(.template)
+ .resizable()
+ .foregroundStyle(Color.grayMain2c800)
+ .frame(width: 20, height: 20)
+ }
+ .padding(5)
+ .background(.white)
+ .cornerRadius(40)
+
+ if isLandscapeMode {
+ Spacer()
+ .frame(width: 160)
+ }
+ }
+ Spacer()
+ }
+ .frame(maxWidth: .infinity)
+ .padding(.all, 20)
+ }
+
+ if callViewModel.isConference {
+ HStack {
+ Spacer()
+ VStack {
+ Spacer()
+
+ Text(callViewModel.activeSpeakerName)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .foregroundStyle(Color.white)
+ .default_text_style_500(styleSize: 20)
+ .lineLimit(1)
+ .padding(.horizontal, 10)
+ .padding(.bottom, 6)
+ .padding(.top, isLandscapeMode && fullscreenVideo && !telecomManager.isPausedByRemote ? -70 : 0)
+
+ if !isLandscapeMode {
+ ScrollView(.horizontal) {
+ HStack {
+ ZStack {
+ VStack {
+ Spacer()
+
+ if callViewModel.myParticipantModel != nil {
+ Avatar(contactAvatarModel: callViewModel.myParticipantModel!.avatarModel, avatarSize: 50, hidePresence: true)
+ }
+
+ Spacer()
+ }
+ .frame(width: 140, height: 140)
+
+ if callViewModel.videoDisplayed {
+ LinphoneVideoViewHolder { view in
+ coreContext.doOnCoreQueue { core in
+ core.nativePreviewWindow = view
+ }
+ }
+ .frame(width: angleDegree == 0 ? 120*1.2 : 160*1.2, height: angleDegree == 0 ? 160*1.2 : 120*1.2)
+ .scaledToFill()
+ .clipped()
+ }
+
+ VStack(alignment: .leading) {
+ Spacer()
+
+ if callViewModel.myParticipantModel != nil {
+ Text(callViewModel.myParticipantModel!.name)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .foregroundStyle(Color.white)
+ .default_text_style_500(styleSize: 14)
+ .lineLimit(1)
+ .padding(.horizontal, 10)
+ .padding(.bottom, 6)
+ }
+ }
+ .frame(width: 140, height: 140)
+ }
+ .frame(width: 140, height: 140)
+ .background(Color.gray600)
+ .overlay(
+ RoundedRectangle(cornerRadius: 20)
+ .stroke(callViewModel.myParticipantModel != nil && callViewModel.myParticipantModel!.isSpeaking ? .white : .clear, lineWidth: 4)
+ )
+ .cornerRadius(20)
+
+ ForEach(0.. 80 ? minBottomSheetHeight * geometry.size.height : 78) - 40 - 20 + geometry.safeAreaInsets.bottom
+ )
+ .padding(.bottom, 10)
+ .padding(.leading, -10)
+
+ if isLandscapeMode {
+ HStack {
+ Spacer()
+ ScrollView(.vertical) {
+ VStack {
+ ZStack {
+ VStack {
+ Spacer()
+
+ if callViewModel.myParticipantModel != nil {
+ Avatar(contactAvatarModel: callViewModel.myParticipantModel!.avatarModel, avatarSize: 50, hidePresence: true)
+ }
+
+ Spacer()
+ }
+ .frame(width: 140, height: 140)
+
+ if callViewModel.videoDisplayed {
+ LinphoneVideoViewHolder { view in
+ coreContext.doOnCoreQueue { core in
+ core.nativePreviewWindow = view
+ }
+ }
+ .frame(width: angleDegree == 0 ? 120*1.2 : 160*1.2, height: angleDegree == 0 ? 160*1.2 : 120*1.2)
+ .scaledToFill()
+ .clipped()
+ }
+
+ VStack(alignment: .leading) {
+ Spacer()
+
+ if callViewModel.myParticipantModel != nil {
+ Text(callViewModel.myParticipantModel!.name)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .foregroundStyle(Color.white)
+ .default_text_style_500(styleSize: 14)
+ .lineLimit(1)
+ .padding(.horizontal, 10)
+ .padding(.bottom, 6)
+ }
+ }
+ .frame(width: 140, height: 140)
+ }
+ .frame(width: 140, height: 140)
+ .background(Color.gray600)
+ .overlay(
+ RoundedRectangle(cornerRadius: 20)
+ .stroke(callViewModel.myParticipantModel != nil && callViewModel.myParticipantModel!.isSpeaking ? .white : .clear, lineWidth: 4)
+ )
+ .cornerRadius(20)
+
+ ForEach(0.. 80 ? minBottomSheetHeight * geometry.size.height : 78) - 40 - 20 + geometry.safeAreaInsets.bottom
+ )
+ .padding(.bottom, 10)
+ .padding(.leading, -10)
+ }
+ }
+ }
+ .padding(.top, fullscreenVideo && !telecomManager.isPausedByRemote && (orientation == .landscapeLeft || orientation == .landscapeRight || UIScreen.main.bounds.size.width > UIScreen.main.bounds.size.height) ? 50 : 10)
+ .frame(
+ maxWidth: fullscreenVideo && !telecomManager.isPausedByRemote ? geometry.size.width : geometry.size.width - 8,
+ maxHeight: fullscreenVideo && !telecomManager.isPausedByRemote ? geometry.size.height + geometry.safeAreaInsets.top + geometry.safeAreaInsets.bottom - ((orientation == .landscapeLeft || orientation == .landscapeRight || UIScreen.main.bounds.size.width > UIScreen.main.bounds.size.height) ? 50 : 10) : geometry.size.height - (minBottomSheetHeight * geometry.size.height > 80 ? minBottomSheetHeight * geometry.size.height : 78) - 40 - 20 + geometry.safeAreaInsets.bottom - ((orientation == .landscapeLeft || orientation == .landscapeRight || UIScreen.main.bounds.size.width > UIScreen.main.bounds.size.height) ? 50 : 10)
+ )
+ .contentShape(Rectangle())
+ .onTapGesture {
+ fullscreenVideo.toggle()
+ }
+ .onAppear {
+ optionsChangeLayout = 2
+ }
+ }
+
+ // swiftlint:disable:next cyclomatic_complexity
+ func mosaicMode(geometry: GeometryProxy, height: Double) -> some View {
+ VStack {
+ if geometry.size.width < geometry.size.height {
+ let maxValue = max(
+ ((geometry.size.width/2) - 10.0) * ceil(Double(callViewModel.participantList.count + 1) / 2.0) > height ? ((height / 3) - 10.0) : ((geometry.size.width/2) - 10.0),
+ ((height / Double(callViewModel.participantList.count + 1)) - 10.0)
+ )
+
+ LazyVGrid(columns: [
+ GridItem(.adaptive(
+ minimum: maxValue
+ ))
+ ], spacing: 10) {
+ if callViewModel.myParticipantModel != nil {
+ ZStack {
+ if callViewModel.myParticipantModel!.isJoining {
+ VStack {
+ Spacer()
+
+ ActivityIndicator(color: .white)
+ .frame(width: maxValue/4, height: maxValue/4)
+ .padding(.bottom, 5)
+
+ Text("Joining...")
+ .frame(maxWidth: .infinity, alignment: .center)
+ .foregroundStyle(Color.white)
+ .default_text_style_500(styleSize: 14)
+ .lineLimit(1)
+ .padding(.horizontal, 10)
+
+ Spacer()
+ }
+ } else if callViewModel.myParticipantModel!.onPause {
+ VStack {
+ Spacer()
+
+ Image("pause")
+ .renderingMode(.template)
+ .resizable()
+ .foregroundStyle(.white)
+ .frame(width: maxValue/4, height: maxValue/4)
+
+ Text("En pause")
+ .frame(maxWidth: .infinity, alignment: .center)
+ .foregroundStyle(Color.white)
+ .default_text_style_500(styleSize: 14)
+ .lineLimit(1)
+ .padding(.horizontal, 10)
+
+ Spacer()
+ }
+ } else {
+ VStack {
+ Spacer()
+
+ if callViewModel.myParticipantModel != nil {
+ Avatar(contactAvatarModel: callViewModel.myParticipantModel!.avatarModel, avatarSize: maxValue/2, hidePresence: true)
+ }
+
+ Spacer()
+ }
+ .frame(width: maxValue, height: maxValue)
+
+ if callViewModel.videoDisplayed {
+ LinphoneVideoViewHolder { view in
+ coreContext.doOnCoreQueue { core in
+ core.nativePreviewWindow = view
+ }
+ }
+ .frame(
+ width: 120 * ceil(maxValue / 120),
+ height: 160 * ceil(maxValue / 120)
+ )
+ .scaledToFill()
+ .clipped()
+ }
+
+ if callViewModel.myParticipantModel!.isMuted {
+ VStack {
+ HStack {
+ Spacer()
+
+ HStack(alignment: .center) {
+ Image("microphone-slash")
+ .renderingMode(.template)
+ .resizable()
+ .foregroundStyle(Color.grayMain2c800)
+ .frame(width: 12, height: 12)
+ }
+ .padding(2)
+ .background(.white)
+ .cornerRadius(40)
+ }
+ Spacer()
+ }
+ .frame(maxWidth: .infinity)
+ .padding(.all, 10)
+ }
+ }
+
+ VStack(alignment: .leading) {
+ Spacer()
+
+ if callViewModel.myParticipantModel != nil {
+ Text(callViewModel.myParticipantModel!.name)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .foregroundStyle(Color.white)
+ .default_text_style_500(styleSize: 14)
+ .lineLimit(1)
+ .padding(.horizontal, 10)
+ .padding(.bottom, 6)
+ }
+ }
+ .frame(width: maxValue, height: maxValue)
+ }
+ .frame(
+ width: maxValue,
+ height: maxValue,
+ alignment: .center
+ )
+ .background(Color.gray600)
+ .overlay(
+ RoundedRectangle(cornerRadius: 20)
+ .stroke(callViewModel.myParticipantModel!.isSpeaking ? .white : .clear, lineWidth: 4)
+ )
+ .cornerRadius(20)
+ }
+
+ ForEach(0.. height ? ((height / 2) - 10.0) : ((geometry.size.width/3) - 10.0),
+ ((geometry.size.width/Double(callViewModel.participantList.count + 1)) - 10.0) > height ? height - 20 : ((geometry.size.width/Double(callViewModel.participantList.count + 1)) - 10.0)
+ )
+
+ LazyHGrid(rows: [
+ GridItem(.adaptive(
+ minimum: maxValue
+ ))
+ ], spacing: 10) {
+ if callViewModel.myParticipantModel != nil {
+ ZStack {
+ if callViewModel.myParticipantModel!.isJoining {
+ VStack {
+ Spacer()
+
+ ActivityIndicator(color: .white)
+ .frame(width: maxValue/4, height: maxValue/4)
+ .padding(.bottom, 5)
+
+ Text("Joining...")
+ .frame(maxWidth: .infinity, alignment: .center)
+ .foregroundStyle(Color.white)
+ .default_text_style_500(styleSize: 14)
+ .lineLimit(1)
+ .padding(.horizontal, 10)
+
+ Spacer()
+ }
+ } else if callViewModel.myParticipantModel!.onPause {
+ VStack {
+ Spacer()
+
+ Image("pause")
+ .renderingMode(.template)
+ .resizable()
+ .foregroundStyle(.white)
+ .frame(width: maxValue/4, height: maxValue/4)
+
+ Text("En pause")
+ .frame(maxWidth: .infinity, alignment: .center)
+ .foregroundStyle(Color.white)
+ .default_text_style_500(styleSize: 14)
+ .lineLimit(1)
+ .padding(.horizontal, 10)
+
+ Spacer()
+ }
+ } else {
+ VStack {
+ Spacer()
+
+ if callViewModel.myParticipantModel != nil {
+ Avatar(contactAvatarModel: callViewModel.myParticipantModel!.avatarModel, avatarSize: maxValue/2, hidePresence: true)
+ }
+
+ Spacer()
+ }
+ .frame(width: maxValue, height: maxValue)
+
+ if callViewModel.videoDisplayed {
+ LinphoneVideoViewHolder { view in
+ coreContext.doOnCoreQueue { core in
+ core.nativePreviewWindow = view
+ }
+ }
+ .frame(
+ width: 160 * ceil(maxValue / 120),
+ height: 120 * ceil(maxValue / 120)
+ )
+ .scaledToFill()
+ .clipped()
+ }
+
+ if callViewModel.myParticipantModel!.isMuted {
+ VStack {
+ HStack {
+ Spacer()
+
+ HStack(alignment: .center) {
+ Image("microphone-slash")
+ .renderingMode(.template)
+ .resizable()
+ .foregroundStyle(Color.grayMain2c800)
+ .frame(width: 12, height: 12)
+ }
+ .padding(2)
+ .background(.white)
+ .cornerRadius(40)
+ }
+ Spacer()
+ }
+ .frame(maxWidth: .infinity)
+ .padding(.all, 10)
+ }
+ }
+
+ VStack(alignment: .leading) {
+ Spacer()
+
+ if callViewModel.myParticipantModel != nil {
+ Text(callViewModel.myParticipantModel!.name)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .foregroundStyle(Color.white)
+ .default_text_style_500(styleSize: 14)
+ .lineLimit(1)
+ .padding(.horizontal, 10)
+ .padding(.bottom, 6)
+ }
+ }
+ .frame(width: maxValue, height: maxValue)
+ }
+ .frame(
+ width: maxValue,
+ height: maxValue,
+ alignment: .center
+ )
+ .background(Color.gray600)
+ .overlay(
+ RoundedRectangle(cornerRadius: 20)
+ .stroke(callViewModel.myParticipantModel!.isSpeaking ? .white : .clear, lineWidth: 4)
+ )
+ .cornerRadius(20)
+ }
+
+ ForEach(0.. 80 ? minBottomSheetHeight * geometry.size.height : 78) - 40 - 20 + geometry.safeAreaInsets.bottom
+ )
+ .contentShape(Rectangle())
+ .onTapGesture {
+ fullscreenVideo.toggle()
+ }
+ }
+
+ func audioOnlyMode(geometry: GeometryProxy, height: Double) -> some View {
+ VStack {
+ let layout = [
+ GridItem(.fixed((geometry.size.width/2)-10)),
+ GridItem(.fixed((geometry.size.width/2)-10))
+ ]
+ ScrollView {
+ LazyVGrid(columns: layout) {
+ if callViewModel.myParticipantModel != nil {
+ HStack {
+ Avatar(contactAvatarModel: callViewModel.myParticipantModel!.avatarModel, avatarSize: 50, hidePresence: true)
+
+ Text(callViewModel.myParticipantModel!.name)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .foregroundStyle(Color.white)
+ .default_text_style_500(styleSize: 14)
+ .lineLimit(1)
+ .padding(.horizontal, 10)
+
+ if callViewModel.myParticipantModel!.isMuted {
+ HStack(alignment: .center) {
+ Image("microphone-slash")
+ .renderingMode(.template)
+ .resizable()
+ .foregroundStyle(Color.grayMain2c800)
+ .frame(width: 20, height: 20)
+ }
+ .padding(2)
+ .background(.white)
+ .cornerRadius(40)
+ }
+
+ if callViewModel.myParticipantModel!.onPause {
+ Image("pause")
+ .renderingMode(.template)
+ .resizable()
+ .foregroundStyle(.white)
+ .frame(width: 25, height: 25)
+ }
+ }
+ .frame(height: 80)
+ .padding(.all, 10)
+ .background(Color.gray600)
+ .overlay(
+ RoundedRectangle(cornerRadius: 20)
+ .stroke(callViewModel.myParticipantModel!.isSpeaking ? .white : .clear, lineWidth: 4)
+ )
+ .cornerRadius(20)
+ }
+
+ ForEach(0.. some View {
+ GeometryReader { _ in
+ VStack(spacing: 0) {
+ Button {
+ withAnimation {
+ if currentOffset < (maxBottomSheetHeight * geo.size.height) {
+ currentOffset = (maxBottomSheetHeight * geo.size.height)
+ } else {
+ currentOffset = (minBottomSheetHeight * geo.size.height > 80 ? minBottomSheetHeight * geo.size.height : 78)
+ }
+
+ pointingUp = -(((currentOffset - (minBottomSheetHeight * geo.size.height > 80 ? minBottomSheetHeight * geo.size.height : 78)) / ((maxBottomSheetHeight * geo.size.height) - (minBottomSheetHeight * geo.size.height > 80 ? minBottomSheetHeight * geo.size.height : 78))) - 0.5) * 2
+ }
+ } label: {
+ ChevronShape(pointingUp: pointingUp)
+ .stroke(style: StrokeStyle(lineWidth: 4, lineCap: .round))
+ .frame(width: 40, height: 6)
+ .foregroundStyle(.white)
+ .contentShape(Rectangle())
+ .padding(.top, 15)
+ }
+
+ HStack(spacing: 12) {
+ Button {
+ callViewModel.terminateCall()
+ } label: {
+ Image("phone-disconnect")
+ .renderingMode(.template)
+ .resizable()
+ .foregroundStyle(.white)
+ .frame(width: 32, height: 32)
+
+ }
+ .frame(width: buttonSize == 60 ? 90 : 70, height: buttonSize)
+ .background(Color.redDanger500)
+ .cornerRadius(40)
+
+ Spacer()
+
+ Button {
+ if optionsChangeLayout == 3 {
+ optionsChangeLayout = 2
+ callViewModel.toggleVideoMode(isAudioOnlyMode: false)
+ } else {
+ callViewModel.displayMyVideo()
+ }
+ } label: {
+ HStack {
+ Image(callViewModel.videoDisplayed ? "video-camera" : "video-camera-slash")
+ .renderingMode(.template)
+ .resizable()
+ .foregroundStyle((callViewModel.isPaused || telecomManager.isPausedByRemote) ? Color.gray500 : .white)
+ .frame(width: 32, height: 32)
+ }
+ }
+ .buttonStyle(PressedButtonStyle(buttonSize: buttonSize))
+ .frame(width: buttonSize, height: buttonSize)
+ .background((callViewModel.isPaused || telecomManager.isPausedByRemote) ? .white : Color.gray500)
+ .cornerRadius(40)
+ .disabled(callViewModel.isPaused || telecomManager.isPausedByRemote)
+
+ Button {
+ callViewModel.toggleMuteMicrophone()
+ } label: {
+ HStack {
+ Image(callViewModel.micMutted ? "microphone-slash" : "microphone")
+ .renderingMode(.template)
+ .resizable()
+ .foregroundStyle(.white)
+ .frame(width: 32, height: 32)
+ }
+ }
+ .buttonStyle(PressedButtonStyle(buttonSize: buttonSize))
+ .frame(width: buttonSize, height: buttonSize)
+ .background(callViewModel.micMutted ? Color.redDanger500 : Color.gray500)
+ .cornerRadius(40)
+
+ Button {
+ if AVAudioSession.sharedInstance().availableInputs != nil
+ && !AVAudioSession.sharedInstance().availableInputs!.filter({ $0.portType.rawValue.contains("Bluetooth") }).isEmpty {
+
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
+ audioRouteSheet = true
+ }
+ } else {
+ do {
+ try AVAudioSession.sharedInstance().overrideOutputAudioPort(AVAudioSession.sharedInstance().currentRoute.outputs.filter({ $0.portType.rawValue == "Speaker" }).isEmpty ? .speaker : .none)
+ } catch _ {
+
+ }
+ }
+
+ } label: {
+ HStack {
+ Image(imageAudioRoute)
+ .renderingMode(.template)
+ .resizable()
+ .foregroundStyle(.white)
+ .frame(width: 32, height: 32)
+ .onAppear(perform: getAudioRouteImage)
+ .onReceive(pub) { _ in
+ self.getAudioRouteImage()
+ }
+ }
+ }
+ .buttonStyle(PressedButtonStyle(buttonSize: buttonSize))
+ .frame(width: buttonSize, height: buttonSize)
+ .background(Color.gray500)
+ .cornerRadius(40)
+ }
+ .frame(height: geo.size.height * 0.15)
+ .padding(.horizontal, 20)
+ .padding(.top, -5)
+
+ if orientation != .landscapeLeft && orientation != .landscapeRight {
+ HStack(spacing: 0) {
+ if callViewModel.isOneOneCall {
+ VStack {
+ Button {
+ if callViewModel.callsCounter < 2 {
+ withAnimation {
+ callViewModel.isTransferInsteadCall = true
+ MagicSearchSingleton.shared.searchForSuggestions()
+ isShowStartCallFragment.toggle()
+ }
+
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
+ telecomManager.callStarted = false
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
+ telecomManager.callStarted = true
+ }
+ }
+ } else {
+ callViewModel.transferClicked()
+ }
+ } label: {
+ HStack {
+ Image("phone-transfer")
+ .renderingMode(.template)
+ .resizable()
+ .foregroundStyle(.white)
+ .frame(width: 32, height: 32)
+ }
+ }
+ .buttonStyle(PressedButtonStyle(buttonSize: buttonSize))
+ .frame(width: buttonSize, height: buttonSize)
+ .background(Color.gray500)
+ .cornerRadius(40)
+
+ Text(callViewModel.callsCounter < 2 ? "Transfer" : "Attended transfer")
+ .foregroundStyle(.white)
+ .default_text_style(styleSize: 15)
+ }
+ .frame(width: geo.size.width * 0.24, height: geo.size.width * 0.24)
+
+ VStack {
+ Button {
+ withAnimation {
+ MagicSearchSingleton.shared.searchForSuggestions()
+ isShowStartCallFragment.toggle()
+ }
+
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
+ telecomManager.callStarted = false
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
+ telecomManager.callStarted = true
+ }
+ }
+ } label: {
+ HStack {
+ Image("phone-plus")
+ .renderingMode(.template)
+ .resizable()
+ .foregroundStyle(.white)
+ .frame(width: 32, height: 32)
+ }
+ }
+ .buttonStyle(PressedButtonStyle(buttonSize: buttonSize))
+ .frame(width: buttonSize, height: buttonSize)
+ .background(Color.gray500)
+ .cornerRadius(40)
+
+ Text("New call")
+ .foregroundStyle(.white)
+ .default_text_style(styleSize: 15)
+ }
+ .frame(width: geo.size.width * 0.24, height: geo.size.width * 0.24)
+ } else {
+ VStack {
+ Button {
+ } label: {
+ HStack {
+ Image("screencast")
+ .renderingMode(.template)
+ .resizable()
+ .foregroundStyle(Color.gray500)
+ .frame(width: 32, height: 32)
+ }
+ }
+ .buttonStyle(PressedButtonStyle(buttonSize: buttonSize))
+ .frame(width: buttonSize, height: buttonSize)
+ .background(.white)
+ .cornerRadius(40)
+ .disabled(true)
+
+ Text("Partage d'écran")
+ .foregroundStyle(.white)
+ .default_text_style(styleSize: 15)
+ }
+ .frame(width: geo.size.width * 0.24, height: geo.size.width * 0.24)
+
+ VStack {
+ Button {
+ withAnimation {
+ isShowParticipantsListFragment.toggle()
+ }
+ } label: {
+ HStack {
+ Image("users")
+ .renderingMode(.template)
+ .resizable()
+ .foregroundStyle(.white)
+ .frame(width: 32, height: 32)
+ }
+ }
+ .buttonStyle(PressedButtonStyle(buttonSize: buttonSize))
+ .frame(width: buttonSize, height: buttonSize)
+ .background(Color.gray500)
+ .cornerRadius(40)
+
+ Text("Participants")
+ .foregroundStyle(.white)
+ .default_text_style(styleSize: 15)
+ }
+ .frame(width: geo.size.width * 0.24, height: geo.size.width * 0.24)
+ }
+ VStack {
+ ZStack {
+ Button {
+ callViewModel.getCallsList()
+ withAnimation {
+ isShowCallsListFragment.toggle()
+ }
+
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
+ telecomManager.callStarted = false
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
+ telecomManager.callStarted = true
+ }
+ }
+ } label: {
+ HStack {
+ Image("phone-list")
+ .renderingMode(.template)
+ .resizable()
+ .foregroundStyle(.white)
+ .frame(width: 32, height: 32)
+ }
+ }
+ .buttonStyle(PressedButtonStyle(buttonSize: buttonSize))
+ .frame(width: buttonSize, height: buttonSize)
+ .background(Color.gray500)
+ .cornerRadius(40)
+
+ if callViewModel.callsCounter > 1 {
+ VStack {
+ HStack {
+ Spacer()
+
+ VStack {
+ Text("\(callViewModel.callsCounter)")
+ .foregroundStyle(.white)
+ .default_text_style(styleSize: 15)
+ }
+ .frame(width: 20, height: 20)
+ .background(Color.redDanger500)
+ .cornerRadius(10)
+ }
+
+ Spacer()
+ }
+ .frame(width: buttonSize, height: buttonSize)
+ }
+ }
+
+ Text("Call list")
+ .foregroundStyle(.white)
+ .default_text_style(styleSize: 15)
+ }
+ .frame(width: geo.size.width * 0.24, height: geo.size.width * 0.24)
+
+ if callViewModel.isOneOneCall {
+ VStack {
+ Button {
+ showingDialer.toggle()
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
+ telecomManager.callStarted = false
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
+ telecomManager.callStarted = true
+ }
+ }
+ } label: {
+ HStack {
+ Image("dialer")
+ .renderingMode(.template)
+ .resizable()
+ .foregroundStyle(.white)
+ .frame(width: 32, height: 32)
+ }
+ }
+ .buttonStyle(PressedButtonStyle(buttonSize: buttonSize))
+ .frame(width: buttonSize, height: buttonSize)
+ .background(Color.gray500)
+ .cornerRadius(40)
+
+ Text("Dialer")
+ .foregroundStyle(.white)
+ .default_text_style(styleSize: 15)
+ }
+ .frame(width: geo.size.width * 0.24, height: geo.size.width * 0.24)
+ } else {
+ VStack {
+ Button {
+ changeLayoutSheet = true
+ } label: {
+ HStack {
+ Image("notebook")
+ .renderingMode(.template)
+ .resizable()
+ .foregroundStyle(.white)
+ .frame(width: 32, height: 32)
+ }
+ }
+ .buttonStyle(PressedButtonStyle(buttonSize: buttonSize))
+ .frame(width: buttonSize, height: buttonSize)
+ .background(Color.gray500)
+ .cornerRadius(40)
+
+ Text("Disposition")
+ .foregroundStyle(.white)
+ .default_text_style(styleSize: 15)
+ }
+ .frame(width: geo.size.width * 0.24, height: geo.size.width * 0.24)
+ }
+ }
+ .frame(height: geo.size.height * 0.15)
+
+ HStack(spacing: 0) {
+ VStack {
+ Button {
+ if callViewModel.isOneOneCall && callViewModel.remoteAddress != nil {
+ callViewModel.createOneToOneChatRoomWith(remote: callViewModel.remoteAddress!)
+ }
+ } label: {
+ HStack {
+ if !callViewModel.operationInProgress {
+ Image("chat-teardrop-text")
+ .renderingMode(.template)
+ .resizable()
+ .foregroundStyle(callViewModel.isOneOneCall ? .white : Color.gray500)
+ .frame(width: 32, height: 32)
+ } else {
+ ProgressView()
+ .controlSize(.mini)
+ .progressViewStyle(CircularProgressViewStyle(tint: .white))
+ .frame(width: 32, height: 32, alignment: .center)
+ .onDisappear {
+ if callViewModel.isOneOneCall && callViewModel.displayedConversation != nil {
+ conversationViewModel.changeDisplayedChatRoom(conversationModel: callViewModel.displayedConversation!)
+ }
+ }
+ }
+ }
+ }
+ .buttonStyle(PressedButtonStyle(buttonSize: buttonSize))
+ .frame(width: buttonSize, height: buttonSize)
+ .background(callViewModel.isOneOneCall ? Color.gray500 : .white)
+ .cornerRadius(40)
+ .disabled(!callViewModel.isOneOneCall)
+
+ Text("Messages")
+ .foregroundStyle(.white)
+ .default_text_style(styleSize: 15)
+ }
+ .frame(width: geo.size.width * 0.24, height: geo.size.width * 0.24)
+
+ VStack {
+ Button {
+ callViewModel.togglePause()
+ } label: {
+ HStack {
+ Image(callViewModel.isPaused ? "play" : "pause")
+ .renderingMode(.template)
+ .resizable()
+ .foregroundStyle(telecomManager.isPausedByRemote ? Color.gray500 : .white)
+ .frame(width: 32, height: 32)
+ }
+ }
+ .buttonStyle(PressedButtonStyle(buttonSize: buttonSize))
+ .frame(width: buttonSize, height: buttonSize)
+ .background(telecomManager.isPausedByRemote ? .white : (callViewModel.isPaused ? Color.greenSuccess500 : Color.gray500))
+ .cornerRadius(40)
+ .disabled(telecomManager.isPausedByRemote)
+
+ Text("Pause")
+ .foregroundStyle(.white)
+ .default_text_style(styleSize: 15)
+ }
+ .frame(width: geo.size.width * 0.24, height: geo.size.width * 0.24)
+
+ if callViewModel.isOneOneCall {
+ VStack {
+ Button {
+ callViewModel.toggleRecording()
+ } label: {
+ HStack {
+ Image("record-fill")
+ .renderingMode(.template)
+ .resizable()
+ .foregroundStyle((callViewModel.isPaused || telecomManager.isPausedByRemote) ? Color.gray500 : .white)
+ .frame(width: 32, height: 32)
+ }
+ }
+ .buttonStyle(PressedButtonStyle(buttonSize: buttonSize))
+ .frame(width: buttonSize, height: buttonSize)
+ .background((callViewModel.isPaused || telecomManager.isPausedByRemote) ? .white : (callViewModel.isRecording ? Color.redDanger500 : Color.gray500))
+ .cornerRadius(40)
+ .disabled(callViewModel.isPaused || telecomManager.isPausedByRemote)
+
+ Text("Record")
+ .foregroundStyle(.white)
+ .default_text_style(styleSize: 15)
+ }
+ .frame(width: geo.size.width * 0.24, height: geo.size.width * 0.24)
+ } else {
+ VStack {
+ Button {
+ } label: {
+ HStack {
+ Image("record-fill")
+ .renderingMode(.template)
+ .resizable()
+ .foregroundStyle(Color.gray500)
+ .frame(width: 32, height: 32)
+ }
+ }
+ .buttonStyle(PressedButtonStyle(buttonSize: buttonSize))
+ .frame(width: buttonSize, height: buttonSize)
+ .background(.white)
+ .cornerRadius(40)
+ .disabled(true)
+
+ Text("Record")
+ .foregroundStyle(.white)
+ .default_text_style(styleSize: 15)
+ }
+ .frame(width: geo.size.width * 0.24, height: geo.size.width * 0.24)
+ }
+
+ VStack {
+ Button {
+ } label: {
+ HStack {
+ Image("video-camera")
+ .renderingMode(.template)
+ .resizable()
+ .foregroundStyle(.white)
+ .frame(width: 32, height: 32)
+ }
+ }
+ .buttonStyle(PressedButtonStyle(buttonSize: buttonSize))
+ .frame(width: buttonSize, height: buttonSize)
+ .background(Color.gray500)
+ .cornerRadius(40)
+
+ Text("Disposition")
+ .foregroundStyle(.white)
+ .default_text_style(styleSize: 15)
+ }
+ .frame(width: geo.size.width * 0.24, height: geo.size.width * 0.24)
+ .hidden()
+ }
+ .frame(height: geo.size.height * 0.15)
+ } else {
+ HStack {
+ if callViewModel.isOneOneCall {
+ VStack {
+ Button {
+ if callViewModel.callsCounter < 2 {
+ withAnimation {
+ callViewModel.isTransferInsteadCall = true
+ MagicSearchSingleton.shared.searchForSuggestions()
+ isShowStartCallFragment.toggle()
+ }
+
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
+ telecomManager.callStarted = false
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
+ telecomManager.callStarted = true
+ }
+ }
+ } else {
+ callViewModel.transferClicked()
+ }
+ } label: {
+ HStack {
+ Image("phone-transfer")
+ .renderingMode(.template)
+ .resizable()
+ .foregroundStyle(.white)
+ .frame(width: 32, height: 32)
+ }
+ }
+ .buttonStyle(PressedButtonStyle(buttonSize: buttonSize))
+ .frame(width: buttonSize, height: buttonSize)
+ .background(Color.gray500)
+ .cornerRadius(40)
+
+ Text(callViewModel.callsCounter < 2 ? "Transfer" : "Attended transfer")
+ .foregroundStyle(.white)
+ .default_text_style(styleSize: 15)
+ }
+ .frame(width: geo.size.width * 0.125, height: geo.size.width * 0.125)
+
+ VStack {
+ Button {
+ withAnimation {
+ MagicSearchSingleton.shared.searchForSuggestions()
+ isShowStartCallFragment.toggle()
+ }
+
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
+ telecomManager.callStarted = false
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
+ telecomManager.callStarted = true
+ }
+ }
+ } label: {
+ HStack {
+ Image("phone-plus")
+ .renderingMode(.template)
+ .resizable()
+ .foregroundStyle(.white)
+ .frame(width: 32, height: 32)
+ }
+ }
+ .buttonStyle(PressedButtonStyle(buttonSize: buttonSize))
+ .frame(width: buttonSize, height: buttonSize)
+ .background(Color.gray500)
+ .cornerRadius(40)
+
+ Text("New call")
+ .foregroundStyle(.white)
+ .default_text_style(styleSize: 15)
+ }
+ .frame(width: geo.size.width * 0.125, height: geo.size.width * 0.125)
+ } else {
+ VStack {
+ VStack {
+ Button {
+ } label: {
+ HStack {
+ Image("screencast")
+ .renderingMode(.template)
+ .resizable()
+ .foregroundStyle(Color.gray500)
+ .frame(width: 32, height: 32)
+ }
+ }
+ .buttonStyle(PressedButtonStyle(buttonSize: buttonSize))
+ .frame(width: buttonSize, height: buttonSize)
+ .background(.white)
+ .cornerRadius(40)
+ .disabled(true)
+
+ Text("Partage d'écran")
+ .foregroundStyle(.white)
+ .default_text_style(styleSize: 15)
+ }
+ }
+ .frame(width: geo.size.width * 0.125, height: geo.size.width * 0.125)
+
+ VStack {
+ Button {
+ withAnimation {
+ isShowParticipantsListFragment.toggle()
+ }
+ } label: {
+ HStack {
+ Image("users")
+ .renderingMode(.template)
+ .resizable()
+ .foregroundStyle(.white)
+ .frame(width: 32, height: 32)
+ }
+ }
+ .buttonStyle(PressedButtonStyle(buttonSize: buttonSize))
+ .frame(width: buttonSize, height: buttonSize)
+ .background(Color.gray500)
+ .cornerRadius(40)
+
+ Text("Participants")
+ .foregroundStyle(.white)
+ .default_text_style(styleSize: 15)
+ }
+ .frame(width: geo.size.width * 0.125, height: geo.size.width * 0.125)
+ }
+
+ VStack {
+ ZStack {
+ Button {
+ callViewModel.getCallsList()
+ withAnimation {
+ isShowCallsListFragment.toggle()
+ }
+
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
+ telecomManager.callStarted = false
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
+ telecomManager.callStarted = true
+ }
+ }
+ } label: {
+ HStack {
+ Image("phone-list")
+ .renderingMode(.template)
+ .resizable()
+ .foregroundStyle(.white)
+ .frame(width: 32, height: 32)
+ }
+ }
+ .buttonStyle(PressedButtonStyle(buttonSize: buttonSize))
+ .frame(width: buttonSize, height: buttonSize)
+ .background(Color.gray500)
+ .cornerRadius(40)
+
+ if callViewModel.callsCounter > 1 {
+ VStack {
+ HStack {
+ Spacer()
+
+ VStack {
+ Text("\(callViewModel.callsCounter)")
+ .foregroundStyle(.white)
+ .default_text_style(styleSize: 15)
+ }
+ .frame(width: 20, height: 20)
+ .background(Color.redDanger500)
+ .cornerRadius(10)
+ }
+
+ Spacer()
+ }
+ .frame(width: buttonSize, height: buttonSize)
+ }
+ }
+
+ Text("Call list")
+ .foregroundStyle(.white)
+ .default_text_style(styleSize: 15)
+ }
+ .frame(width: geo.size.width * 0.125, height: geo.size.width * 0.125)
+
+ if callViewModel.isOneOneCall {
+ VStack {
+ Button {
+ showingDialer.toggle()
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
+ telecomManager.callStarted = false
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
+ telecomManager.callStarted = true
+ }
+ }
+ } label: {
+ HStack {
+ Image("dialer")
+ .renderingMode(.template)
+ .resizable()
+ .foregroundStyle(.white)
+ .frame(width: 32, height: 32)
+ }
+ }
+ .buttonStyle(PressedButtonStyle(buttonSize: buttonSize))
+ .frame(width: buttonSize, height: buttonSize)
+ .background(Color.gray500)
+ .cornerRadius(40)
+
+ Text("Dialer")
+ .foregroundStyle(.white)
+ .default_text_style(styleSize: 15)
+ }
+ .frame(width: geo.size.width * 0.125, height: geo.size.width * 0.125)
+ } else {
+ VStack {
+ Button {
+ changeLayoutSheet = true
+ } label: {
+ HStack {
+ Image("notebook")
+ .renderingMode(.template)
+ .resizable()
+ .foregroundStyle(.white)
+ .frame(width: 32, height: 32)
+ }
+ }
+ .buttonStyle(PressedButtonStyle(buttonSize: buttonSize))
+ .frame(width: buttonSize, height: buttonSize)
+ .background(Color.gray500)
+ .cornerRadius(40)
+
+ Text("Disposition")
+ .foregroundStyle(.white)
+ .default_text_style(styleSize: 15)
+ }
+ .frame(width: geo.size.width * 0.125, height: geo.size.width * 0.125)
+ }
+
+ VStack {
+ Button {
+ if callViewModel.isOneOneCall && callViewModel.remoteAddress != nil {
+ callViewModel.createOneToOneChatRoomWith(remote: callViewModel.remoteAddress!)
+ }
+ } label: {
+ HStack {
+ if !callViewModel.operationInProgress {
+ Image("chat-teardrop-text")
+ .renderingMode(.template)
+ .resizable()
+ .foregroundStyle(callViewModel.isOneOneCall ? .white : Color.gray500)
+ .frame(width: 32, height: 32)
+ } else {
+ ProgressView()
+ .controlSize(.mini)
+ .progressViewStyle(CircularProgressViewStyle(tint: .white))
+ .frame(width: 32, height: 32, alignment: .center)
+ .onDisappear {
+ if callViewModel.isOneOneCall && callViewModel.displayedConversation != nil {
+ conversationViewModel.changeDisplayedChatRoom(conversationModel: callViewModel.displayedConversation!)
+ }
+ }
+ }
+ }
+ }
+ .buttonStyle(PressedButtonStyle(buttonSize: buttonSize))
+ .frame(width: buttonSize, height: buttonSize)
+ .background(callViewModel.isOneOneCall ? Color.gray500 : .white)
+ .cornerRadius(40)
+ .disabled(!callViewModel.isOneOneCall)
+
+ Text("Messages")
+ .foregroundStyle(.white)
+ .default_text_style(styleSize: 15)
+ }
+ .frame(width: geo.size.width * 0.125, height: geo.size.width * 0.125)
+
+ VStack {
+ Button {
+ callViewModel.togglePause()
+ } label: {
+ HStack {
+ Image(callViewModel.isPaused ? "play" : "pause")
+ .renderingMode(.template)
+ .resizable()
+ .foregroundStyle(telecomManager.isPausedByRemote ? Color.gray500 : .white)
+ .frame(width: 32, height: 32)
+ }
+ }
+ .buttonStyle(PressedButtonStyle(buttonSize: buttonSize))
+ .frame(width: buttonSize, height: buttonSize)
+ .background(telecomManager.isPausedByRemote ? .white : (callViewModel.isPaused ? Color.greenSuccess500 : Color.gray500))
+ .cornerRadius(40)
+ .disabled(telecomManager.isPausedByRemote)
+
+ Text("Pause")
+ .foregroundStyle(.white)
+ .default_text_style(styleSize: 15)
+ }
+ .frame(width: geo.size.width * 0.125, height: geo.size.width * 0.125)
+
+ if callViewModel.isOneOneCall {
+ VStack {
+ Button {
+ callViewModel.toggleRecording()
+ } label: {
+ HStack {
+ Image("record-fill")
+ .renderingMode(.template)
+ .resizable()
+ .foregroundStyle((callViewModel.isPaused || telecomManager.isPausedByRemote) ? Color.gray500 : .white)
+ .frame(width: 32, height: 32)
+ }
+ }
+ .buttonStyle(PressedButtonStyle(buttonSize: buttonSize))
+ .frame(width: buttonSize, height: buttonSize)
+ .background((callViewModel.isPaused || telecomManager.isPausedByRemote) ? .white : (callViewModel.isRecording ? Color.redDanger500 : Color.gray500))
+ .cornerRadius(40)
+ .disabled(callViewModel.isPaused || telecomManager.isPausedByRemote)
+
+ Text("Record")
+ .foregroundStyle(.white)
+ .default_text_style(styleSize: 15)
+ }
+ .frame(width: geo.size.width * 0.125, height: geo.size.width * 0.125)
+ } else {
+ VStack {
+ Button {
+ } label: {
+ HStack {
+ Image("record-fill")
+ .renderingMode(.template)
+ .resizable()
+ .foregroundStyle(Color.gray500)
+ .frame(width: 32, height: 32)
+ }
+ }
+ .buttonStyle(PressedButtonStyle(buttonSize: buttonSize))
+ .frame(width: buttonSize, height: buttonSize)
+ .background(.white)
+ .cornerRadius(40)
+ .disabled(true)
+
+ Text("Record")
+ .foregroundStyle(.white)
+ .default_text_style(styleSize: 15)
+ }
+ .frame(width: geo.size.width * 0.125, height: geo.size.width * 0.125)
+ }
+ }
+ .frame(height: geo.size.height * 0.15)
+ .padding(.horizontal, 20)
+ .padding(.top, 30)
+ }
+
+ Spacer()
+ }
+ .background(Color.gray600)
+ .frame(maxHeight: .infinity, alignment: .top)
+ }
+ }
+
+ func getAudioRouteImage() {
+ if !AVAudioSession.sharedInstance().currentRoute.outputs.filter({ $0.portType.rawValue == "Speaker" }).isEmpty {
+ imageAudioRoute = "speaker-high"
+ optionsAudioRoute = 2
+ } else if !AVAudioSession.sharedInstance().currentRoute.outputs.filter({ $0.portType.rawValue.contains("Bluetooth") }).isEmpty {
+ imageAudioRoute = "bluetooth"
+ optionsAudioRoute = 3
+ } else {
+ imageAudioRoute = callViewModel.isHeadPhoneAvailable() ? "headset" : "speaker-slash"
+ optionsAudioRoute = 1
+ }
+ }
+}
+
+struct BottomSheetView: View {
+ let content: Content
+
+ @State var minHeight: CGFloat
+ @State var maxHeight: CGFloat
+
+ @Binding var currentOffset: CGFloat
+ @Binding var pointingUp: CGFloat
+
+ @State var bottomSafeArea: CGFloat
+
+ var body: some View {
+ GeometryReader { geometry in
+ VStack(spacing: 0.0) {
+ content
+ }
+ .frame(
+ width: geometry.size.width,
+ height: maxHeight,
+ alignment: .top
+ )
+ .clipShape(
+ Path(
+ UIBezierPath(
+ roundedRect: CGRect(x: 0.0, y: 0.0, width: geometry.size.width, height: maxHeight),
+ byRoundingCorners: [.topLeft, .topRight],
+ cornerRadii: CGSize(width: 16.0, height: 16.0)
+ )
+ .cgPath
+ )
+ )
+ .frame(
+ height: geometry.size.height,
+ alignment: .bottom
+ )
+ .highPriorityGesture(
+ DragGesture()
+ .onChanged { value in
+ currentOffset -= value.translation.height
+ currentOffset = min(max(currentOffset, minHeight), maxHeight)
+ pointingUp = -(((currentOffset - minHeight) / (maxHeight - minHeight)) - 0.5) * 2
+ }
+ .onEnded { _ in
+ withAnimation {
+ currentOffset = (currentOffset - minHeight <= maxHeight - currentOffset) ? minHeight : maxHeight
+ pointingUp = -(((currentOffset - minHeight) / (maxHeight - minHeight)) - 0.5) * 2
+ }
+ }
+ )
+ .offset(y: maxHeight - currentOffset)
+ }
+ }
+}
+
+struct ChevronShape: Shape {
+ var pointingUp: CGFloat
+
+ var animatableData: CGFloat {
+ get { return pointingUp }
+ set { pointingUp = newValue }
+ }
+
+ func path(in rect: CGRect) -> Path {
+ var path = Path()
+
+ let width = rect.width
+ let height = rect.height
+
+ let horizontalCenter = width / 2
+ let horizontalCenterOffset = width * 0.05
+ let arrowTipStartingPoint = height - pointingUp * height * 0.9
+
+ path.move(to: .init(x: 0, y: height))
+
+ path.addLine(to: .init(x: horizontalCenter - horizontalCenterOffset, y: arrowTipStartingPoint))
+ path.addQuadCurve(to: .init(x: horizontalCenter + horizontalCenterOffset, y: arrowTipStartingPoint), control: .init(x: horizontalCenter, y: height * (1 - pointingUp)))
+
+ path.addLine(to: .init(x: width, y: height))
+
+ return path
+ }
+}
+
+struct PressedButtonStyle: ButtonStyle {
+ var buttonSize: CGFloat
+ func makeBody(configuration: Self.Configuration) -> some View {
+ configuration.label
+ .frame(width: buttonSize, height: buttonSize)
+ .background(configuration.isPressed ? .white : .clear)
+ .cornerRadius(40)
+ }
+}
+
+#Preview {
+ CallView(
+ callViewModel: CallViewModel(),
+ conversationViewModel: ConversationViewModel(),
+ conversationsListViewModel: ConversationsListViewModel(),
+ conversationForwardMessageViewModel: ConversationForwardMessageViewModel(),
+ fullscreenVideo: .constant(false),
+ isShowStartCallFragment: .constant(false),
+ isShowConversationFragment: .constant(false),
+ isShowStartCallGroupPopup: .constant(false)
+ )
+}
+// swiftlint:enable type_body_length
+// swiftlint:enable line_length
+// swiftlint:enable function_body_length
+// swiftlint:enable file_length
diff --git a/Linphone/UI/Call/Fragments/AudioRouteBottomSheet.swift b/Linphone/UI/Call/Fragments/AudioRouteBottomSheet.swift
new file mode 100644
index 000000000..73f292ecb
--- /dev/null
+++ b/Linphone/UI/Call/Fragments/AudioRouteBottomSheet.swift
@@ -0,0 +1,143 @@
+/*
+ * 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 .
+ */
+
+import SwiftUI
+import AVFAudio
+
+struct AudioRouteBottomSheet: View {
+ @Environment(\.dismiss) private var dismiss
+
+ @ObservedObject var callViewModel: CallViewModel
+
+ private var idiom: UIUserInterfaceIdiom { UIDevice.current.userInterfaceIdiom }
+ @State private var orientation = UIDevice.current.orientation
+
+ @Binding var optionsAudioRoute: Int
+
+ var body: some View {
+ VStack(spacing: 0) {
+ Button(action: {
+ optionsAudioRoute = 1
+
+ do {
+ try AVAudioSession.sharedInstance().overrideOutputAudioPort(.none)
+ if callViewModel.isHeadPhoneAvailable() {
+ try AVAudioSession.sharedInstance().setPreferredInput(
+ AVAudioSession.sharedInstance().availableInputs?.filter({ $0.portType.rawValue.contains("Receiver") }).first)
+ } else {
+ try AVAudioSession.sharedInstance().setPreferredInput(AVAudioSession.sharedInstance().availableInputs?.first)
+ }
+ } catch _ {
+
+ }
+ }, label: {
+ HStack {
+ Image(optionsAudioRoute == 1 ? "radio-button-fill" : "radio-button")
+ .renderingMode(.template)
+ .resizable()
+ .foregroundStyle(.white)
+ .frame(width: 25, height: 25, alignment: .leading)
+ .padding(.all, 10)
+
+ Text(!callViewModel.isHeadPhoneAvailable() ? "Earpiece" : "Headphones")
+ .default_text_style_white(styleSize: 15)
+
+ Spacer()
+
+ Image(!callViewModel.isHeadPhoneAvailable() ? "ear" : "headset")
+ .renderingMode(.template)
+ .resizable()
+ .foregroundStyle(.white)
+ .frame(width: 25, height: 25, alignment: .leading)
+ .padding(.all, 10)
+ }
+ })
+ .frame(maxHeight: .infinity)
+
+ Button(action: {
+ optionsAudioRoute = 2
+
+ do {
+ try AVAudioSession.sharedInstance().overrideOutputAudioPort(.speaker)
+ } catch _ {
+
+ }
+ }, label: {
+ HStack {
+ Image(optionsAudioRoute == 2 ? "radio-button-fill" : "radio-button")
+ .renderingMode(.template)
+ .resizable()
+ .foregroundStyle(.white)
+ .frame(width: 25, height: 25, alignment: .leading)
+ .padding(.all, 10)
+
+ Text("Speaker")
+ .default_text_style_white(styleSize: 15)
+
+ Spacer()
+
+ Image("speaker-high")
+ .renderingMode(.template)
+ .resizable()
+ .foregroundStyle(.white)
+ .frame(width: 25, height: 25, alignment: .leading)
+ .padding(.all, 10)
+ }
+ })
+ .frame(maxHeight: .infinity)
+
+ Button(action: {
+ optionsAudioRoute = 3
+
+ do {
+ try AVAudioSession.sharedInstance().overrideOutputAudioPort(.none)
+ try AVAudioSession.sharedInstance().setPreferredInput(
+ AVAudioSession.sharedInstance().availableInputs?.filter({ $0.portType.rawValue.contains("Bluetooth") }).first)
+ } catch _ {
+
+ }
+ }, label: {
+ HStack {
+ Image(optionsAudioRoute == 3 ? "radio-button-fill" : "radio-button")
+ .renderingMode(.template)
+ .resizable()
+ .foregroundStyle(.white)
+ .frame(width: 25, height: 25, alignment: .leading)
+ .padding(.all, 10)
+
+ Text("Bluetooth")
+ .default_text_style_white(styleSize: 15)
+
+ Spacer()
+
+ Image("bluetooth")
+ .renderingMode(.template)
+ .resizable()
+ .foregroundStyle(.white)
+ .frame(width: 25, height: 25, alignment: .leading)
+ .padding(.all, 10)
+ }
+ })
+ .frame(maxHeight: .infinity)
+ }
+ .padding(.horizontal, 20)
+ .background(Color.gray600)
+ .frame(maxHeight: .infinity)
+ }
+}
diff --git a/Linphone/UI/Call/Fragments/CallStatisticsSheetBottomSheet.swift b/Linphone/UI/Call/Fragments/CallStatisticsSheetBottomSheet.swift
new file mode 100644
index 000000000..0ec6bd92e
--- /dev/null
+++ b/Linphone/UI/Call/Fragments/CallStatisticsSheetBottomSheet.swift
@@ -0,0 +1,115 @@
+/*
+ * 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 .
+ */
+
+import SwiftUI
+
+struct CallStatisticsSheetBottomSheet: View {
+ @Environment(\.dismiss) private var dismiss
+
+ @ObservedObject var callViewModel: CallViewModel
+
+ private var idiom: UIUserInterfaceIdiom { UIDevice.current.userInterfaceIdiom }
+ @State private var orientation = UIDevice.current.orientation
+
+ @Binding var callStatisticsSheet: Bool
+
+ var body: some View {
+ VStack {
+ if idiom != .pad && (orientation == .landscapeLeft
+ || orientation == .landscapeRight
+ || UIScreen.main.bounds.size.width > UIScreen.main.bounds.size.height) {
+ Spacer()
+ HStack {
+ Spacer()
+ Button("Close") {
+ callStatisticsSheet = false
+ dismiss()
+ }
+ }
+ .padding(.trailing)
+ } else {
+ Capsule()
+ .fill(Color.grayMain2c300)
+ .frame(width: 75, height: 5)
+ .padding(15)
+ }
+
+ Text("Audio")
+ .default_text_style_white_600(styleSize: 15)
+ .padding(.top, 10)
+
+ Spacer()
+
+ Text(callViewModel.callStatsModel.audioCodec)
+ .default_text_style_white(styleSize: 15)
+
+ Spacer()
+
+ Text(callViewModel.callStatsModel.audioBandwidth)
+ .default_text_style_white(styleSize: 15)
+
+ Spacer()
+
+ Text(callViewModel.callStatsModel.audioLossRate)
+ .default_text_style_white(styleSize: 15)
+
+ Spacer()
+
+ Text(callViewModel.callStatsModel.audioJitterBufferSize)
+ .default_text_style_white(styleSize: 15)
+
+ Spacer()
+
+ if callViewModel.callStatsModel.isVideoEnabled {
+ Text("Vidéo")
+ .default_text_style_white_600(styleSize: 15)
+ .padding(.top, 10)
+
+ Spacer()
+
+ Text(callViewModel.callStatsModel.videoCodec)
+ .default_text_style_white(styleSize: 15)
+
+ Spacer()
+
+ Text(callViewModel.callStatsModel.videoBandwidth)
+ .default_text_style_white(styleSize: 15)
+
+ Spacer()
+
+ Text(callViewModel.callStatsModel.videoLossRate)
+ .default_text_style_white(styleSize: 15)
+
+ Spacer()
+
+ Text(callViewModel.callStatsModel.videoResolution)
+ .default_text_style_white(styleSize: 15)
+
+ Spacer()
+
+ Text(callViewModel.callStatsModel.videoFps)
+ .default_text_style_white(styleSize: 15)
+
+ Spacer()
+ }
+ }
+ .frame(maxWidth: .infinity)
+ .background(Color.gray600)
+ }
+}
diff --git a/Linphone/UI/Call/Fragments/CallsListFragment.swift b/Linphone/UI/Call/Fragments/CallsListFragment.swift
new file mode 100644
index 000000000..dfc84b328
--- /dev/null
+++ b/Linphone/UI/Call/Fragments/CallsListFragment.swift
@@ -0,0 +1,386 @@
+/*
+ * Copyright (c) 2010-2020 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 .
+ */
+
+import SwiftUI
+import linphonesw
+
+// swiftlint:disable type_body_length
+struct CallsListFragment: View {
+
+ @ObservedObject private var coreContext = CoreContext.shared
+ @ObservedObject private var contactsManager = ContactsManager.shared
+
+ private var idiom: UIUserInterfaceIdiom { UIDevice.current.userInterfaceIdiom }
+
+ @ObservedObject var callViewModel: CallViewModel
+
+ @State private var delayedColor = Color.white
+ @State var isShowCallsListBottomSheet: Bool = false
+ @State private var isShowPopup = false
+
+ @Binding var isShowCallsListFragment: Bool
+
+ var body: some View {
+ ZStack {
+ VStack(spacing: 1) {
+ Rectangle()
+ .foregroundColor(delayedColor)
+ .edgesIgnoringSafeArea(.top)
+ .frame(height: 0)
+ .task(delayColor)
+
+ HStack {
+ Image("caret-left")
+ .renderingMode(.template)
+ .resizable()
+ .foregroundStyle(Color.orangeMain500)
+ .frame(width: 25, height: 25, alignment: .leading)
+ .padding(.all, 10)
+ .padding(.top, 2)
+ .padding(.leading, -10)
+ .onTapGesture {
+ delayColorDismiss()
+ withAnimation {
+ isShowCallsListFragment.toggle()
+ }
+ }
+
+ Text("Call list")
+ .multilineTextAlignment(.leading)
+ .default_text_style_orange_800(styleSize: 16)
+
+ Spacer()
+
+ if callViewModel.callsCounter > 1 {
+ Button {
+ self.isShowPopup = true
+ } label: {
+ Image("arrows-merge")
+ .renderingMode(.template)
+ .resizable()
+ .foregroundStyle(Color.orangeMain500)
+ .frame(width: 25, height: 25, alignment: .leading)
+ .padding(.all, 10)
+ }
+ }
+ }
+ .frame(maxWidth: .infinity)
+ .frame(height: 50)
+ .padding(.horizontal)
+ .padding(.bottom, 4)
+ .background(.white)
+
+ if #available(iOS 16.0, *), idiom != .pad {
+ callsList
+ .sheet(isPresented: $isShowCallsListBottomSheet, onDismiss: {
+ }, content: {
+ innerBottomSheet()
+ .presentationDetents([.fraction(0.2)])
+ })
+ } else {
+ callsList
+ .halfSheet(showSheet: $isShowCallsListBottomSheet) {
+ innerBottomSheet()
+ } onDismiss: {}
+ }
+ }
+ .background(.white)
+
+ if self.isShowPopup {
+ PopupView(isShowPopup: $isShowPopup,
+ title: Text("calls_list_dialog_merge_into_conference_title"),
+ content: nil,
+ titleFirstButton: Text("Cancel"),
+ actionFirstButton: {self.isShowPopup.toggle()},
+ titleSecondButton: Text("calls_list_dialog_merge_into_conference_label"),
+ actionSecondButton: {
+ callViewModel.mergeCallsIntoConference()
+ self.isShowPopup.toggle()
+ isShowCallsListFragment.toggle()
+ })
+ .background(.black.opacity(0.65))
+ .onTapGesture {
+ self.isShowPopup.toggle()
+ }
+ }
+ }
+ .navigationBarHidden(true)
+ }
+
+ @ViewBuilder
+ func innerBottomSheet() -> some View {
+ VStack(spacing: 0) {
+ if callViewModel.selectedCall != nil {
+ Button(action: {
+ if callViewModel.currentCall != nil && callViewModel.selectedCall!.callLog!.callId == callViewModel.currentCall!.callLog!.callId {
+ if callViewModel.currentCall!.state == .StreamsRunning {
+ do {
+ try callViewModel.currentCall!.pause()
+
+ DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
+ callViewModel.isPaused = true
+ }
+ } catch {
+
+ }
+ } else {
+ do {
+ try callViewModel.currentCall!.resume()
+
+ DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
+ callViewModel.isPaused = false
+ }
+ } catch {
+
+ }
+ }
+
+ isShowCallsListBottomSheet = false
+ } else {
+ CoreContext.shared.doOnCoreQueue { core in
+ if callViewModel.currentCall!.state == .StreamsRunning {
+ TelecomManager.shared.setHeldOtherCalls(core: core, exceptCallid: "")
+ } else {
+ TelecomManager.shared.setHeldOtherCalls(core: core, exceptCallid: callViewModel.currentCall?.callLog?.callId ?? "")
+ }
+ }
+ TelecomManager.shared.setHeld(call: callViewModel.selectedCall!, hold: false)
+
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
+ callViewModel.resetCallView()
+ }
+ }
+ }, label: {
+ HStack {
+ HStack {
+ Image((callViewModel.selectedCall!.state == .PausedByRemote
+ || callViewModel.selectedCall!.state == .Pausing
+ || callViewModel.selectedCall!.state == .Paused) ? "play" : "pause")
+ .resizable()
+ .frame(width: 30, height: 30)
+
+ }
+ .frame(width: 35, height: 30)
+ .background(.clear)
+ .cornerRadius(40)
+
+ Text((callViewModel.selectedCall!.state == .PausedByRemote
+ || callViewModel.selectedCall!.state == .Pausing
+ || callViewModel.selectedCall!.state == .Paused) ? "Resume" : "Pause")
+ .default_text_style(styleSize: 15)
+
+ Spacer()
+ }
+ })
+ .padding(.horizontal, 30)
+ .frame(maxHeight: .infinity)
+
+ VStack {
+ Divider()
+ }
+ .frame(maxWidth: .infinity)
+
+ Button(action: {
+ do {
+ try callViewModel.selectedCall!.terminate()
+ isShowCallsListBottomSheet = false
+ } catch _ {
+
+ }
+ }, label: {
+ HStack {
+ HStack {
+ Image("phone-disconnect")
+ .renderingMode(.template)
+ .resizable()
+ .foregroundStyle(.white)
+ .frame(width: 20, height: 20)
+
+ }
+ .frame(width: 35, height: 30)
+ .background(Color.redDanger500)
+ .cornerRadius(40)
+
+ Text("Hang up call")
+ .foregroundStyle(Color.redDanger500)
+ .default_text_style_white(styleSize: 15)
+
+ Spacer()
+ }
+ })
+ .padding(.horizontal, 30)
+ .frame(maxHeight: .infinity)
+ }
+ }
+ .frame(maxHeight: .infinity)
+ }
+
+ @Sendable private func delayColor() async {
+ try? await Task.sleep(nanoseconds: 250_000_000)
+ delayedColor = Color.orangeMain500
+ }
+
+ func delayColorDismiss() {
+ Task {
+ try? await Task.sleep(nanoseconds: 80_000_000)
+ delayedColor = .white
+ }
+ }
+
+ var callsList: some View {
+ VStack {
+ List {
+ ForEach(0...
+ */
+
+import SwiftUI
+
+struct ChangeLayoutBottomSheet: View {
+ @Environment(\.dismiss) private var dismiss
+
+ @ObservedObject var callViewModel: CallViewModel
+
+ private var idiom: UIUserInterfaceIdiom { UIDevice.current.userInterfaceIdiom }
+ @State private var orientation = UIDevice.current.orientation
+
+ @Binding var changeLayoutSheet: Bool
+ @Binding var optionsChangeLayout: Int
+
+ var body: some View {
+ VStack(spacing: 0) {
+ Button(action: {
+ optionsChangeLayout = 1
+ callViewModel.toggleVideoMode(isAudioOnlyMode: false)
+ changeLayoutSheet = false
+ dismiss()
+ }, label: {
+ HStack {
+ Image(optionsChangeLayout == 1 ? "radio-button-fill" : "radio-button")
+ .renderingMode(.template)
+ .resizable()
+ .foregroundStyle(callViewModel.participantList.count > 5 ? Color.gray500 : .white)
+ .frame(width: 25, height: 25, alignment: .leading)
+ .padding(.all, 10)
+
+ Text("Mosaïque")
+ .foregroundStyle(callViewModel.participantList.count > 5 ? Color.gray500 : .white)
+ .default_text_style_white(styleSize: 15)
+
+ Spacer()
+
+ Image("squares-four")
+ .renderingMode(.template)
+ .resizable()
+ .foregroundStyle(callViewModel.participantList.count > 5 ? Color.gray500 : .white)
+ .frame(width: 25, height: 25, alignment: .leading)
+ .padding(.all, 10)
+ }
+ })
+ .disabled(callViewModel.participantList.count > 5)
+ .frame(maxHeight: .infinity)
+
+ Button(action: {
+ optionsChangeLayout = 2
+ callViewModel.toggleVideoMode(isAudioOnlyMode: false)
+ changeLayoutSheet = false
+ dismiss()
+ }, label: {
+ HStack {
+ Image(optionsChangeLayout == 2 ? "radio-button-fill" : "radio-button")
+ .renderingMode(.template)
+ .resizable()
+ .foregroundStyle(.white)
+ .frame(width: 25, height: 25, alignment: .leading)
+ .padding(.all, 10)
+
+ Text("Participant actif")
+ .default_text_style_white(styleSize: 15)
+
+ Spacer()
+
+ Image("picture-in-picture")
+ .renderingMode(.template)
+ .resizable()
+ .foregroundStyle(.white)
+ .frame(width: 25, height: 25, alignment: .leading)
+ .padding(.all, 10)
+ }
+ })
+ .frame(maxHeight: .infinity)
+
+ Button(action: {
+ optionsChangeLayout = 3
+ if callViewModel.videoDisplayed {
+ callViewModel.displayMyVideo()
+ }
+ callViewModel.toggleVideoMode(isAudioOnlyMode: true)
+ changeLayoutSheet = false
+ dismiss()
+ }, label: {
+ HStack {
+ Image(optionsChangeLayout == 3 ? "radio-button-fill" : "radio-button")
+ .renderingMode(.template)
+ .resizable()
+ .foregroundStyle(.white)
+ .frame(width: 25, height: 25, alignment: .leading)
+ .padding(.all, 10)
+
+ Text("Audio seulement")
+ .default_text_style_white(styleSize: 15)
+
+ Spacer()
+
+ Image("waveform")
+ .renderingMode(.template)
+ .resizable()
+ .foregroundStyle(.white)
+ .frame(width: 25, height: 25, alignment: .leading)
+ .padding(.all, 10)
+ }
+ })
+ .frame(maxHeight: .infinity)
+ }
+ .padding(.horizontal, 20)
+ .background(Color.gray600)
+ .frame(maxHeight: .infinity)
+ }
+}
diff --git a/Linphone/UI/Call/Fragments/MediaEncryptedSheetBottomSheet.swift b/Linphone/UI/Call/Fragments/MediaEncryptedSheetBottomSheet.swift
new file mode 100644
index 000000000..cbf9832e4
--- /dev/null
+++ b/Linphone/UI/Call/Fragments/MediaEncryptedSheetBottomSheet.swift
@@ -0,0 +1,109 @@
+/*
+ * 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 .
+ */
+
+import SwiftUI
+
+struct MediaEncryptedSheetBottomSheet: View {
+ @Environment(\.dismiss) private var dismiss
+
+ @ObservedObject var callViewModel: CallViewModel
+
+ private var idiom: UIUserInterfaceIdiom { UIDevice.current.userInterfaceIdiom }
+ @State private var orientation = UIDevice.current.orientation
+
+ @Binding var mediaEncryptedSheet: Bool
+
+ var body: some View {
+ VStack {
+ if idiom != .pad && (orientation == .landscapeLeft
+ || orientation == .landscapeRight
+ || UIScreen.main.bounds.size.width > UIScreen.main.bounds.size.height) {
+ Spacer()
+ HStack {
+ Spacer()
+ Button("Close") {
+ mediaEncryptedSheet = false
+ dismiss()
+ }
+ }
+ .padding(.trailing)
+ } else {
+ Capsule()
+ .fill(Color.grayMain2c300)
+ .frame(width: 75, height: 5)
+ .padding(15)
+ }
+
+ Text("Chiffrement du média")
+ .default_text_style_white_600(styleSize: 15)
+ .padding(.top, 10)
+
+ Spacer()
+
+ Text(callViewModel.callMediaEncryptionModel.mediaEncryption)
+ .default_text_style_white(styleSize: 15)
+
+ Spacer()
+
+ Text(callViewModel.callMediaEncryptionModel.zrtpCipher)
+ .default_text_style_white(styleSize: 15)
+
+ Spacer()
+
+ Text(callViewModel.callMediaEncryptionModel.zrtpKeyAgreement)
+ .default_text_style_white(styleSize: 15)
+
+ Spacer()
+
+ Text(callViewModel.callMediaEncryptionModel.zrtpHash)
+ .default_text_style_white(styleSize: 15)
+
+ Spacer()
+
+ Text(callViewModel.callMediaEncryptionModel.zrtpAuthTag)
+ .default_text_style_white(styleSize: 15)
+
+ Spacer()
+
+ Text(callViewModel.callMediaEncryptionModel.zrtpAuthSas)
+ .default_text_style_white(styleSize: 15)
+ .padding(.bottom, 10)
+
+ Spacer()
+
+ Button(action: {
+ callViewModel.showZrtpSasDialogIfPossible()
+ mediaEncryptedSheet = false
+ dismiss()
+ }, label: {
+ Text("Faire la validation à nouveau")
+ .default_text_style_white_600(styleSize: 20)
+ .frame(height: 35)
+ .frame(maxWidth: .infinity)
+ })
+ .padding(.horizontal, 20)
+ .padding(.vertical, 10)
+ .background(Color.orangeMain500)
+ .cornerRadius(60)
+ .padding(.bottom)
+ .padding(.horizontal, 10)
+ }
+ .background(Color.gray600)
+ }
+}
diff --git a/Linphone/UI/Call/Fragments/ParticipantsListFragment.swift b/Linphone/UI/Call/Fragments/ParticipantsListFragment.swift
new file mode 100644
index 000000000..22c3c1ff9
--- /dev/null
+++ b/Linphone/UI/Call/Fragments/ParticipantsListFragment.swift
@@ -0,0 +1,271 @@
+/*
+ * Copyright (c) 2010-2020 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 .
+ */
+
+import SwiftUI
+
+struct ParticipantsListFragment: View {
+
+ @ObservedObject private var coreContext = CoreContext.shared
+ @ObservedObject private var contactsManager = ContactsManager.shared
+
+ private var idiom: UIUserInterfaceIdiom { UIDevice.current.userInterfaceIdiom }
+
+ @ObservedObject var callViewModel: CallViewModel
+
+ @ObservedObject var addParticipantsViewModel: AddParticipantsViewModel
+
+ @State private var delayedColor = Color.white
+
+ @Binding var isShowParticipantsListFragment: Bool
+
+ @State private var isShowPopup = false
+ @State private var indexToRemove = -1
+
+ var body: some View {
+ NavigationView {
+ ZStack {
+ VStack(spacing: 1) {
+ Rectangle()
+ .foregroundColor(delayedColor)
+ .edgesIgnoringSafeArea(.top)
+ .frame(height: 0)
+ .task(delayColor)
+
+ HStack {
+ Image("caret-left")
+ .renderingMode(.template)
+ .resizable()
+ .foregroundStyle(Color.orangeMain500)
+ .frame(width: 25, height: 25, alignment: .leading)
+ .padding(.all, 10)
+ .padding(.top, 2)
+ .padding(.leading, -10)
+ .onTapGesture {
+ delayColorDismiss()
+ withAnimation {
+ isShowParticipantsListFragment.toggle()
+ }
+ }
+
+ Text("\(callViewModel.participantList.count + 1) \(callViewModel.participantList.isEmpty ? "Participant" : "Participants")")
+ .multilineTextAlignment(.leading)
+ .default_text_style_orange_800(styleSize: 16)
+
+ Spacer()
+
+ }
+ .frame(maxWidth: .infinity)
+ .frame(height: 50)
+ .padding(.horizontal)
+ .padding(.bottom, 4)
+ .background(.white)
+
+ participantsList
+
+ HStack {
+ Spacer()
+
+ if callViewModel.myParticipantModel != nil && callViewModel.myParticipantModel!.isAdmin {
+ NavigationLink(destination: {
+ AddParticipantsFragment(addParticipantsViewModel: addParticipantsViewModel, confirmAddParticipantsFunc: callViewModel.addParticipants)
+ .onAppear {
+ addParticipantsViewModel.participantsToAdd = []
+ }
+ }, label: {
+ Image("plus")
+ .resizable()
+ .renderingMode(.template)
+ .frame(width: 25, height: 25)
+ .foregroundStyle(.white)
+ .padding()
+ .background(Color.orangeMain500)
+ .clipShape(Circle())
+ .shadow(color: .black.opacity(0.2), radius: 4)
+
+ })
+ .padding()
+ }
+ }
+ .padding(.trailing, 10)
+ }
+ .background(.white)
+
+ if self.isShowPopup {
+ let contentPopup = Text("Etes-vous sûr de vouloir supprimer \(callViewModel.participantList[indexToRemove].name) ?")
+ PopupView(isShowPopup: $isShowPopup,
+ title: Text("Supprimer un participant"),
+ content: contentPopup,
+ titleFirstButton: Text("Non"),
+ actionFirstButton: {self.isShowPopup.toggle()},
+ titleSecondButton: Text("Oui"),
+ actionSecondButton: {
+ callViewModel.removeParticipant(index: indexToRemove)
+ self.isShowPopup.toggle()
+ indexToRemove = -1
+ })
+ .background(.black.opacity(0.65))
+ .onTapGesture {
+ self.isShowPopup.toggle()
+ indexToRemove = -1
+ }
+ }
+ }
+ .navigationBarHidden(true)
+ }
+ }
+
+ @Sendable private func delayColor() async {
+ try? await Task.sleep(nanoseconds: 250_000_000)
+ delayedColor = Color.orangeMain500
+ }
+
+ func delayColorDismiss() {
+ Task {
+ try? await Task.sleep(nanoseconds: 80_000_000)
+ delayedColor = .white
+ }
+ }
+
+ var participantsList: some View {
+ VStack {
+ List {
+ HStack {
+ HStack {
+ if callViewModel.myParticipantModel != nil {
+ Avatar(contactAvatarModel: callViewModel.myParticipantModel!.avatarModel, avatarSize: 50, hidePresence: true)
+
+ Text(callViewModel.myParticipantModel!.name)
+ .default_text_style(styleSize: 16)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .lineLimit(1)
+
+ Spacer()
+
+ if callViewModel.myParticipantModel!.isAdmin {
+ Text("Administrateur")
+ .foregroundStyle(Color.grayMain2c300)
+ .default_text_style(styleSize: 12)
+ .frame(maxWidth: .infinity, alignment: .trailing)
+ .lineLimit(1)
+ }
+
+ if callViewModel.myParticipantModel!.isAdmin {
+ Toggle("", isOn: .constant(true))
+ .tint(Color.greenSuccess700)
+ .labelsHidden()
+ .padding(.horizontal, 4)
+
+ HStack(alignment: .center, spacing: 10) {
+ Image("x")
+ .renderingMode(.template)
+ .foregroundStyle(Color.grayMain2c400)
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ }
+ .frame(width: 30, height: 30, alignment: .center)
+ .hidden()
+ }
+ }
+ }
+ }
+ .buttonStyle(.borderless)
+ .listRowInsets(EdgeInsets(top: 10, leading: 20, bottom: 10, trailing: 20))
+ .listRowSeparator(.hidden)
+ .background(.white)
+
+ ForEach(0...
+ */
+
+import SwiftUI
+import Foundation
+
+// swiftlint:disable:next type_body_length
+struct ZRTPPopup: View {
+
+ @ObservedObject private var telecomManager = TelecomManager.shared
+ @ObservedObject private var sharedMainViewModel = SharedMainViewModel.shared
+
+ @ObservedObject var callViewModel: CallViewModel
+
+ private var idiom: UIUserInterfaceIdiom { UIDevice.current.userInterfaceIdiom }
+ @State private var orientation = UIDevice.current.orientation
+
+ var resizeView: CGFloat
+
+ var body: some View {
+ if callViewModel.isNotVerified {
+ alertZRTP
+ } else {
+ popupZRTP
+ }
+ }
+
+ var popupZRTP: some View {
+ GeometryReader { geometry in
+ VStack(alignment: .leading) {
+ ZStack(alignment: .top, content: {
+ HStack {
+ Spacer()
+
+ VStack {
+ Image("security")
+ .resizable()
+ .frame(width: 20, height: 20, alignment: .leading)
+
+ Text("call_dialog_zrtp_validate_trust_title")
+ .default_text_style_white_700(styleSize: 16 / resizeView)
+ }
+ .frame(maxWidth: .infinity)
+
+ Spacer()
+ }
+ .padding(.top, 15)
+ .padding(.bottom, 2)
+
+ HStack {
+ Spacer()
+ HStack {
+ Text("call_zrtp_sas_validation_skip")
+ .underline()
+ .tint(.white)
+ .default_text_style_white_600(styleSize: 16 / resizeView)
+ .foregroundStyle(.white)
+ }
+ .onTapGesture {
+ callViewModel.skipZrtpAuthentication()
+ callViewModel.zrtpPopupDisplayed = false
+ }
+ }
+ .padding(.top, 10 / resizeView)
+ .padding(.trailing, 15 / resizeView)
+ })
+
+ VStack(alignment: .center) {
+ VStack {
+ if idiom != .pad && (orientation == .landscapeLeft
+ || orientation == .landscapeRight
+ || UIScreen.main.bounds.size.width > UIScreen.main.bounds.size.height) {
+ HStack {
+ Text("call_dialog_zrtp_validate_trust_message")
+ .default_text_style(styleSize: 16 / resizeView)
+ .multilineTextAlignment(.center)
+ .padding(.bottom, 10 / resizeView)
+
+ VStack {
+ Text("call_dialog_zrtp_validate_trust_local_code_label")
+ .default_text_style(styleSize: 16 / resizeView)
+ .multilineTextAlignment(.center)
+
+ Text(!callViewModel.upperCaseAuthTokenToRead.isEmpty ? callViewModel.upperCaseAuthTokenToRead : "ZZ")
+ .default_text_style_700(styleSize: 22 / resizeView)
+ .padding(.bottom, 20 / resizeView)
+ }
+ }
+ } else {
+ Text(callViewModel.cacheMismatch ? "call_dialog_zrtp_validate_trust_warning_message" : "call_dialog_zrtp_validate_trust_message")
+ .default_text_style(styleSize: 16 / resizeView)
+ .multilineTextAlignment(.center)
+ .padding(.bottom, 10 / resizeView)
+
+ Text("call_dialog_zrtp_validate_trust_local_code_label")
+ .default_text_style(styleSize: 16 / resizeView)
+ .multilineTextAlignment(.center)
+
+ Text(!callViewModel.upperCaseAuthTokenToRead.isEmpty ? callViewModel.upperCaseAuthTokenToRead : "ZZ")
+ .default_text_style_800(styleSize: 22 / resizeView)
+ }
+ }
+ .padding(.bottom, 5)
+
+ VStack {
+ Text("call_dialog_zrtp_validate_trust_remote_code_label")
+ .default_text_style(styleSize: 16 / resizeView)
+ .multilineTextAlignment(.center)
+ .padding(.top, 15 / resizeView)
+ .padding(.bottom, 10 / resizeView)
+
+ if idiom != .pad && (orientation == .landscapeLeft
+ || orientation == .landscapeRight
+ || UIScreen.main.bounds.size.width > UIScreen.main.bounds.size.height) {
+ HStack(spacing: 30) {
+ HStack(alignment: .center) {
+ Text(callViewModel.letters1)
+ .default_text_style(styleSize: 24 / resizeView)
+ .frame(width: 45 / resizeView, height: 45 / resizeView)
+ }
+ .padding(10 / resizeView)
+ .background(.white)
+ .clipShape(Circle())
+ .shadow(color: .gray.opacity(0.4), radius: 4)
+ .onTapGesture {
+ callViewModel.updateZrtpSas(authTokenClicked: callViewModel.letters1)
+ callViewModel.zrtpPopupDisplayed = false
+ }
+
+ HStack(alignment: .center) {
+ Text(callViewModel.letters2)
+ .default_text_style(styleSize: 24 / resizeView)
+ .frame(width: 45 / resizeView, height: 45 / resizeView)
+ }
+ .padding(10 / resizeView)
+ .background(.white)
+ .clipShape(Circle())
+ .shadow(color: .gray.opacity(0.4), radius: 4)
+ .onTapGesture {
+ callViewModel.updateZrtpSas(authTokenClicked: callViewModel.letters2)
+ callViewModel.zrtpPopupDisplayed = false
+ }
+
+ HStack(alignment: .center) {
+ Text(callViewModel.letters3)
+ .default_text_style(styleSize: 24 / resizeView)
+ .frame(width: 45 / resizeView, height: 45 / resizeView)
+ }
+ .padding(10 / resizeView)
+ .background(.white)
+ .clipShape(Circle())
+ .shadow(color: .gray.opacity(0.4), radius: 4)
+ .onTapGesture {
+ callViewModel.updateZrtpSas(authTokenClicked: callViewModel.letters3)
+ callViewModel.zrtpPopupDisplayed = false
+ }
+
+ HStack(alignment: .center) {
+ Text(callViewModel.letters4)
+ .default_text_style(styleSize: 24 / resizeView)
+ .frame(width: 45 / resizeView, height: 45 / resizeView)
+ }
+ .padding(10 / resizeView)
+ .background(.white)
+ .clipShape(Circle())
+ .shadow(color: .gray.opacity(0.4), radius: 4)
+ .onTapGesture {
+ callViewModel.updateZrtpSas(authTokenClicked: callViewModel.letters4)
+ callViewModel.zrtpPopupDisplayed = false
+ }
+ }
+ .padding(.horizontal, 40 / resizeView)
+ .padding(.bottom, 20 / resizeView)
+ } else {
+ HStack(spacing: 30) {
+ HStack(alignment: .center) {
+ Text(callViewModel.letters1)
+ .default_text_style(styleSize: 34 / resizeView)
+ .frame(width: 60 / resizeView, height: 60 / resizeView)
+ }
+ .padding(10 / resizeView)
+ .background(.white)
+ .clipShape(Circle())
+ .shadow(color: .gray.opacity(0.4), radius: 4)
+ .onTapGesture {
+ callViewModel.updateZrtpSas(authTokenClicked: callViewModel.letters1)
+ callViewModel.zrtpPopupDisplayed = false
+ }
+
+ HStack(alignment: .center) {
+ Text(callViewModel.letters2)
+ .default_text_style(styleSize: 34 / resizeView)
+ .frame(width: 60 / resizeView, height: 60 / resizeView)
+ }
+ .padding(10 / resizeView)
+ .background(.white)
+ .clipShape(Circle())
+ .shadow(color: .gray.opacity(0.4), radius: 4)
+ .onTapGesture {
+ callViewModel.updateZrtpSas(authTokenClicked: callViewModel.letters2)
+ callViewModel.zrtpPopupDisplayed = false
+ }
+ }
+ .padding(.horizontal, 40 / resizeView)
+ .padding(.bottom, 20 / resizeView)
+
+ HStack(spacing: 30) {
+ HStack(alignment: .center) {
+ Text(callViewModel.letters3)
+ .default_text_style(styleSize: 34 / resizeView)
+ .frame(width: 60 / resizeView, height: 60 / resizeView)
+ }
+ .padding(10 / resizeView)
+ .background(.white)
+ .clipShape(Circle())
+ .shadow(color: .gray.opacity(0.4), radius: 4)
+ .onTapGesture {
+ callViewModel.updateZrtpSas(authTokenClicked: callViewModel.letters3)
+ callViewModel.zrtpPopupDisplayed = false
+ }
+
+ HStack(alignment: .center) {
+ Text(callViewModel.letters4)
+ .default_text_style(styleSize: 34 / resizeView)
+ .frame(width: 60 / resizeView, height: 60 / resizeView)
+ }
+ .padding(10 / resizeView)
+ .background(.white)
+ .clipShape(Circle())
+ .shadow(color: .gray.opacity(0.4), radius: 4)
+ .onTapGesture {
+ callViewModel.updateZrtpSas(authTokenClicked: callViewModel.letters4)
+ callViewModel.zrtpPopupDisplayed = false
+ }
+ }
+ .padding(.horizontal, 40 / resizeView)
+ .padding(.bottom, 20 / resizeView)
+ }
+ }
+ .padding(.horizontal, 10 / resizeView)
+ .padding(.bottom, 10 / resizeView)
+ .cornerRadius(20)
+ .overlay(
+ RoundedRectangle(cornerRadius: 20)
+ .inset(by: 0.5)
+ .stroke(Color.grayMain2c200, lineWidth: 1)
+ )
+ .padding(.bottom, 10 / resizeView)
+
+ Button(action: {
+ callViewModel.updateZrtpSas(authTokenClicked: "")
+ callViewModel.zrtpPopupDisplayed = false
+ }, label: {
+ Text("call_dialog_zrtp_validate_trust_letters_do_not_match")
+ .foregroundStyle(Color.redDanger500)
+ .default_text_style_orange_600(styleSize: 20 / resizeView)
+ .frame(height: 35 / resizeView)
+ .frame(maxWidth: .infinity)
+ })
+ .padding(.horizontal, 20 / resizeView)
+ .padding(.vertical, 10 / resizeView)
+ .cornerRadius(60)
+ .overlay(
+ RoundedRectangle(cornerRadius: 60)
+ .inset(by: 0.5)
+ .stroke(Color.redDanger500, lineWidth: 1)
+ )
+ .padding(.bottom)
+ }
+ .padding(.top, 20 / resizeView)
+ .padding(.horizontal, 20 / resizeView)
+ .background(.white)
+ .cornerRadius(20)
+ }
+ .background(callViewModel.cacheMismatch ? Color.orangeWarning600 : Color.blueInfo500)
+ .cornerRadius(20)
+ .padding(.horizontal, 2)
+ .frame(maxHeight: .infinity)
+ .shadow(color: callViewModel.cacheMismatch ? Color.orangeWarning600 : Color.blueInfo500, radius: 0, x: 0, y: 2)
+ .frame(maxWidth: sharedMainViewModel.maxWidth * 1.2)
+ .position(x: geometry.size.width / 2, y: geometry.size.height / 2)
+ .onAppear {
+ callViewModel.remoteAuthenticationTokens()
+ }
+ }
+ }
+
+ var alertZRTP: some View {
+ GeometryReader { geometry in
+ VStack(alignment: .leading) {
+ ZStack(alignment: .top, content: {
+ HStack {
+ Spacer()
+
+ VStack {
+ Image("shield-warning")
+ .renderingMode(.template)
+ .resizable()
+ .foregroundStyle(.white)
+ .frame(width: 25, height: 25, alignment: .leading)
+
+ Text("call_dialog_zrtp_security_alert_title")
+ .default_text_style_white_700(styleSize: 16 / resizeView)
+ }
+ .frame(maxWidth: .infinity)
+
+ Spacer()
+ }
+ .padding(.top, 15)
+ .padding(.bottom, 2)
+ })
+
+ VStack(alignment: .center) {
+ VStack {
+ Text("call_dialog_zrtp_security_alert_message")
+ .default_text_style(styleSize: 16 / resizeView)
+ .multilineTextAlignment(.center)
+ .padding(.bottom, 10 / resizeView)
+ }
+ .padding(.bottom, 5)
+
+ if telecomManager.isNotVerifiedCounter <= 1 {
+ Button(action: {
+ callViewModel.isNotVerified = false
+ }, label: {
+ Text("call_dialog_zrtp_security_alert_try_again")
+ .foregroundStyle(Color.redDanger500)
+ .default_text_style_orange_600(styleSize: 20 / resizeView)
+ .frame(height: 35 / resizeView)
+ .frame(maxWidth: .infinity)
+ })
+ .padding(.horizontal, 20 / resizeView)
+ .padding(.vertical, 10 / resizeView)
+ .cornerRadius(60)
+ .overlay(
+ RoundedRectangle(cornerRadius: 60)
+ .inset(by: 0.5)
+ .stroke(Color.redDanger500, lineWidth: 1)
+ )
+ .padding(.bottom)
+ }
+
+ Button(action: {
+ callViewModel.terminateCall()
+ }, label: {
+ HStack {
+ Image("phone-disconnect")
+ .renderingMode(.template)
+ .resizable()
+ .foregroundStyle(.white)
+ .frame(width: 20, height: 20)
+
+ Text("call_action_hang_up")
+ .default_text_style_white_600(styleSize: 20)
+ .frame(height: 35)
+ }
+ .frame(maxWidth: .infinity)
+ })
+ .padding(.horizontal, 20 / resizeView)
+ .padding(.vertical, 10 / resizeView)
+ .background(Color.redDanger500)
+ .cornerRadius(60)
+ .padding(.bottom)
+ }
+ .padding(.top, 20 / resizeView)
+ .padding(.horizontal, 20 / resizeView)
+ .background(.white)
+ .cornerRadius(20)
+ }
+ .background(Color.redDanger500)
+ .cornerRadius(20)
+ .padding(.horizontal, 2)
+ .frame(maxHeight: .infinity)
+ .shadow(color: Color.redDanger500, radius: 0, x: 0, y: 2)
+ .frame(maxWidth: sharedMainViewModel.maxWidth * 1.2)
+ .position(x: geometry.size.width / 2, y: geometry.size.height / 2)
+ .onAppear {
+ callViewModel.remoteAuthenticationTokens()
+ }
+ }
+ }
+}
+
+#Preview {
+ ZRTPPopup(callViewModel: CallViewModel(), resizeView: 1)
+}
diff --git a/Linphone/UI/Call/MeetingWaitingRoomFragment.swift b/Linphone/UI/Call/MeetingWaitingRoomFragment.swift
new file mode 100644
index 000000000..e1fae5807
--- /dev/null
+++ b/Linphone/UI/Call/MeetingWaitingRoomFragment.swift
@@ -0,0 +1,577 @@
+/*
+ * Copyright (c) 2010-2020 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 .
+ */
+
+import SwiftUI
+import linphonesw
+import AVFAudio
+
+// swiftlint:disable type_body_length
+// swiftlint:disable cyclomatic_complexity
+
+struct MeetingWaitingRoomFragment: View {
+
+ @ObservedObject private var coreContext = CoreContext.shared
+ @ObservedObject private var telecomManager = TelecomManager.shared
+
+ @ObservedObject var meetingWaitingRoomViewModel: MeetingWaitingRoomViewModel
+
+ private var idiom: UIUserInterfaceIdiom { UIDevice.current.userInterfaceIdiom }
+ @State private var orientation = UIDevice.current.orientation
+
+ let pub = NotificationCenter.default.publisher(for: AVAudioSession.routeChangeNotification)
+
+ @State var audioRouteSheet: Bool = false
+ @State var options: Int = 1
+ @State var angleDegree = 0.0
+
+ var body: some View {
+ GeometryReader { geometry in
+
+ if #available(iOS 16.0, *), idiom != .pad {
+ innerView(geometry: geometry)
+ .sheet(isPresented: $audioRouteSheet, onDismiss: {
+ audioRouteSheet = false
+ }, content: {
+ innerBottomSheet().presentationDetents([.fraction(0.3)])
+ })
+ .onAppear {
+ meetingWaitingRoomViewModel.enableAVAudioSession()
+ if AVAudioSession.sharedInstance().currentRoute.outputs.filter({
+ $0.portType.rawValue.contains("Bluetooth") || $0.portType.rawValue.contains("Headphones")
+ }).isEmpty {
+ do {
+ try AVAudioSession.sharedInstance().overrideOutputAudioPort(.speaker)
+ } catch _ {
+
+ }
+ }
+ }
+ .onDisappear {
+ meetingWaitingRoomViewModel.disableAVAudioSession()
+ }
+ } else {
+ innerView(geometry: geometry)
+ .halfSheet(showSheet: $audioRouteSheet) {
+ innerBottomSheet()
+ } onDismiss: {
+ audioRouteSheet = false
+ }
+ .onAppear {
+ meetingWaitingRoomViewModel.enableAVAudioSession()
+ if AVAudioSession.sharedInstance().currentRoute.outputs.filter({
+ $0.portType.rawValue.contains("Bluetooth") || $0.portType.rawValue.contains("Headphones")
+ }).isEmpty {
+ do {
+ try AVAudioSession.sharedInstance().overrideOutputAudioPort(.speaker)
+ } catch _ {
+
+ }
+ }
+ }
+ .onDisappear {
+ meetingWaitingRoomViewModel.disableAVAudioSession()
+ }
+ }
+ }
+ }
+
+ @ViewBuilder
+ // swiftlint:disable:next function_body_length
+ func innerView(geometry: GeometryProxy) -> some View {
+ VStack {
+ if #available(iOS 16.0, *) {
+ Rectangle()
+ .foregroundColor(Color.orangeMain500)
+ .edgesIgnoringSafeArea(.top)
+ .frame(height: 0)
+ } else if idiom != .pad && !(orientation == .landscapeLeft || orientation == .landscapeRight
+ || UIScreen.main.bounds.size.width > UIScreen.main.bounds.size.height) {
+ Rectangle()
+ .foregroundColor(Color.orangeMain500)
+ .edgesIgnoringSafeArea(.top)
+ .frame(height: 1)
+ }
+
+ HStack {
+ Button {
+ withAnimation {
+ meetingWaitingRoomViewModel.disableVideoPreview()
+ telecomManager.meetingWaitingRoomSelected = nil
+ telecomManager.meetingWaitingRoomDisplayed = false
+ }
+ } label: {
+ Image("caret-left")
+ .renderingMode(.template)
+ .resizable()
+ .foregroundStyle(Color.orangeMain500)
+ .frame(width: 25, height: 25, alignment: .leading)
+ .padding(.all, 10)
+ }
+
+ Text(telecomManager.meetingWaitingRoomName)
+ .default_text_style_white_800(styleSize: 16)
+
+ Spacer()
+ }
+ .frame(height: 40)
+ .zIndex(1)
+
+ HStack {
+ Button {
+ } label: {
+ Image("caret-left")
+ .renderingMode(.template)
+ .resizable()
+ .foregroundStyle(Color.orangeMain500)
+ .frame(width: 25, height: 25, alignment: .leading)
+ .padding(.all, 10)
+ }
+ .hidden()
+
+ Text(meetingWaitingRoomViewModel.meetingDate)
+ .foregroundStyle(.white)
+ .default_text_style_white(styleSize: 12)
+
+ Spacer()
+ }
+ .frame(height: 40)
+ .padding(.top, -25)
+ .zIndex(1)
+
+ if !telecomManager.callStarted {
+ ZStack {
+ VStack {
+ Spacer()
+
+ if meetingWaitingRoomViewModel.avatarDisplayed {
+ ZStack {
+
+ if meetingWaitingRoomViewModel.isRemoteDeviceTrusted {
+ Circle()
+ .fill(Color.blueInfo500)
+ .frame(width: 206, height: 206)
+ }
+
+ if meetingWaitingRoomViewModel.avatarModel != nil {
+ Avatar(contactAvatarModel: meetingWaitingRoomViewModel.avatarModel!, avatarSize: 200, hidePresence: true)
+ } else {
+ Image("profil-picture-default")
+ .resizable()
+ .frame(width: 200, height: 200)
+ .clipShape(Circle())
+ }
+
+ if meetingWaitingRoomViewModel.isRemoteDeviceTrusted {
+ VStack {
+ Spacer()
+ HStack {
+ Image("trusted")
+ .resizable()
+ .frame(width: 25, height: 25)
+ .padding(.all, 15)
+ Spacer()
+ }
+ }
+ .frame(width: 200, height: 200)
+ }
+ }
+ }
+
+ Spacer()
+ }
+ .frame(
+ width:
+ angleDegree == 0
+ ? 120 * ceil((geometry.size.width - 20) / 120)
+ : 160 * ceil((geometry.size.height - 160) / 160),
+ height:
+ angleDegree == 0
+ ? 160 * ceil((geometry.size.width - 20) / 120)
+ : 120 * ceil((geometry.size.height - 160) / 160)
+ )
+
+ LinphoneVideoViewHolder { view in
+ coreContext.doOnCoreQueue { core in
+ core.nativePreviewWindow = view
+ }
+ }
+ .frame(
+ width:
+ angleDegree == 0
+ ? 120 * ceil((geometry.size.width - 20) / 120)
+ : 160 * ceil((geometry.size.height - 160) / 160),
+ height:
+ angleDegree == 0
+ ? 160 * ceil((geometry.size.width - 20) / 120)
+ : 120 * ceil((geometry.size.height - 160) / 160)
+ )
+
+ VStack {
+ HStack {
+ Spacer()
+
+ if meetingWaitingRoomViewModel.videoDisplayed {
+ Button {
+ meetingWaitingRoomViewModel.switchCamera()
+ } label: {
+ Image("camera-rotate")
+ .renderingMode(.template)
+ .resizable()
+ .foregroundStyle(.white)
+ .frame(width: 30, height: 30)
+ }
+ }
+ }
+
+ Spacer()
+
+ HStack {
+ Text(meetingWaitingRoomViewModel.userName)
+ .foregroundStyle(.white)
+ .default_text_style_white(styleSize: 20)
+ Spacer()
+ }
+ }
+ .padding(.all, 10)
+ .frame(maxWidth: geometry.size.width - 20, maxHeight: geometry.size.height - (angleDegree == 0 ? 250 : 160))
+ }
+ .background(Color.gray600)
+ .frame(maxWidth: geometry.size.width - 20, maxHeight: geometry.size.height - (angleDegree == 0 ? 250 : 160))
+ .cornerRadius(20)
+ .padding(.horizontal, 10)
+ .onDisappear {
+ meetingWaitingRoomViewModel.disableVideoPreview()
+ }
+
+ if angleDegree != 0 {
+ Spacer()
+ }
+
+ HStack {
+ Spacer()
+
+ Button {
+ !meetingWaitingRoomViewModel.videoDisplayed
+ ? meetingWaitingRoomViewModel.enableVideoPreview() : meetingWaitingRoomViewModel.disableVideoPreview()
+ } label: {
+ HStack {
+ Image(meetingWaitingRoomViewModel.videoDisplayed ? "video-camera" : "video-camera-slash")
+ .renderingMode(.template)
+ .resizable()
+ .foregroundStyle(.white)
+ .frame(width: 32, height: 32)
+ }
+ }
+ .buttonStyle(PressedButtonStyle(buttonSize: 60))
+ .frame(width: 60, height: 60)
+ .background(Color.gray500)
+ .cornerRadius(40)
+ .padding(.horizontal, 5)
+
+ Button {
+ meetingWaitingRoomViewModel.toggleMuteMicrophone()
+ } label: {
+ HStack {
+ Image(meetingWaitingRoomViewModel.micMutted ? "microphone-slash" : "microphone")
+ .renderingMode(.template)
+ .resizable()
+ .foregroundStyle(.white)
+ .frame(width: 32, height: 32)
+ }
+ }
+ .buttonStyle(PressedButtonStyle(buttonSize: 60))
+ .frame(width: 60, height: 60)
+ .background(Color.gray500)
+ .cornerRadius(40)
+ .padding(.horizontal, 5)
+
+ Button {
+ if AVAudioSession.sharedInstance().availableInputs != nil
+ && !AVAudioSession.sharedInstance().availableInputs!.filter({ $0.portType.rawValue.contains("Bluetooth") }).isEmpty {
+
+ audioRouteSheet = true
+ } else {
+ do {
+ try AVAudioSession.sharedInstance().overrideOutputAudioPort(
+ AVAudioSession.sharedInstance().currentRoute
+ .outputs.filter({ $0.portType.rawValue == "Speaker" }).isEmpty ? .speaker : .none)
+ } catch _ {}
+ }
+ } label: {
+ HStack {
+ Image(meetingWaitingRoomViewModel.imageAudioRoute)
+ .renderingMode(.template)
+ .resizable()
+ .foregroundStyle(.white)
+ .frame(width: 32, height: 32)
+ .onAppear(perform: getAudioRouteImage)
+ .onReceive(pub) { _ in
+ self.getAudioRouteImage()
+ }
+ }
+ }
+ .buttonStyle(PressedButtonStyle(buttonSize: 60))
+ .frame(width: 60, height: 60)
+ .background(Color.gray500)
+ .cornerRadius(40)
+ .padding(.horizontal, 5)
+
+ Spacer()
+
+ if angleDegree != 0 {
+ Button(action: {
+ meetingWaitingRoomViewModel.joinMeeting()
+ }, label: {
+ Text("meeting_waiting_room_join")
+ .default_text_style_white_600(styleSize: 20)
+ .frame(height: 35)
+ .frame(maxWidth: .infinity)
+ })
+ .padding(.horizontal, 20)
+ .padding(.vertical, 10)
+ .background(Color.orangeMain500)
+ .cornerRadius(60)
+ .padding(.horizontal, 10)
+ .frame(width: (geometry.size.width - 20) / 2)
+ }
+ }
+ .padding(.all, 10)
+
+ if angleDegree == 0 {
+ Spacer()
+
+ Button(action: {
+ meetingWaitingRoomViewModel.joinMeeting()
+ }, label: {
+ Text("meeting_waiting_room_join")
+ .default_text_style_white_600(styleSize: 20)
+ .frame(height: 35)
+ .frame(maxWidth: .infinity)
+ })
+ .padding(.horizontal, 20)
+ .padding(.vertical, 10)
+ .background(Color.orangeMain500)
+ .cornerRadius(60)
+ .padding(.bottom)
+ .padding(.horizontal, 10)
+
+ }
+ } else {
+ VStack {
+ Spacer()
+
+ Text("Connexion à la réunion")
+ .default_text_style_white_600(styleSize: 24)
+ .multilineTextAlignment(.center)
+ .padding(.bottom, 10)
+
+ Text("Vous allez rejoindre la réunion dans quelques instants...")
+ .default_text_style_white(styleSize: 16)
+ .multilineTextAlignment(.center)
+ .padding(.bottom, 20)
+
+ ActivityIndicator(color: Color.orangeMain500)
+ .frame(width: 35, height: 35)
+
+ Spacer()
+
+ Button(action: {
+ meetingWaitingRoomViewModel.cancelMeeting()
+ }, label: {
+ Text("Annuler")
+ .default_text_style_white_600(styleSize: 20)
+ .frame(height: 35)
+ .frame(maxWidth: .infinity)
+ })
+ .padding(.horizontal, 20)
+ .padding(.vertical, 10)
+ .background(Color.orangeMain500)
+ .cornerRadius(60)
+ .padding(.bottom)
+ .padding(.horizontal, 10)
+ }
+ }
+
+ Spacer()
+ }
+ .background(Color.gray900)
+ .onRotate { newOrientation in
+ orientation = newOrientation
+ if orientation == .portrait || orientation == .portraitUpsideDown {
+ angleDegree = 0
+ } else {
+ if orientation == .landscapeLeft {
+ angleDegree = -90
+ } else if orientation == .landscapeRight {
+ angleDegree = 90
+ } else if UIScreen.main.bounds.size.width > UIScreen.main.bounds.size.height {
+ angleDegree = 90
+ }
+ }
+
+ meetingWaitingRoomViewModel.orientationUpdate(orientation: orientation)
+ }
+ .onAppear {
+ if orientation == .portrait || orientation == .portraitUpsideDown {
+ angleDegree = 0
+ } else {
+ if orientation == .landscapeLeft {
+ angleDegree = -90
+ } else if orientation == .landscapeRight {
+ angleDegree = 90
+ } else if UIScreen.main.bounds.size.width > UIScreen.main.bounds.size.height {
+ angleDegree = 90
+ }
+ }
+
+ meetingWaitingRoomViewModel.orientationUpdate(orientation: orientation)
+ }
+ }
+
+ @ViewBuilder
+ func innerBottomSheet() -> some View {
+ VStack(spacing: 0) {
+ Button(action: {
+ options = 1
+
+ do {
+ try AVAudioSession.sharedInstance().overrideOutputAudioPort(.none)
+ if meetingWaitingRoomViewModel.isHeadPhoneAvailable() {
+ try AVAudioSession.sharedInstance().setPreferredInput(AVAudioSession.sharedInstance()
+ .availableInputs?.filter({ $0.portType.rawValue.contains("Receiver") }).first)
+ } else {
+ try AVAudioSession.sharedInstance().setPreferredInput(AVAudioSession.sharedInstance().availableInputs?.first)
+ }
+ } catch _ {
+
+ }
+ }, label: {
+ HStack {
+ Image(options == 1 ? "radio-button-fill" : "radio-button")
+ .renderingMode(.template)
+ .resizable()
+ .foregroundStyle(.white)
+ .frame(width: 25, height: 25, alignment: .leading)
+ .padding(.all, 10)
+
+ Text(!meetingWaitingRoomViewModel.isHeadPhoneAvailable() ? "Earpiece" : "Headphones")
+ .default_text_style_white(styleSize: 15)
+
+ Spacer()
+
+ Image(!meetingWaitingRoomViewModel.isHeadPhoneAvailable() ? "ear" : "headset")
+ .renderingMode(.template)
+ .resizable()
+ .foregroundStyle(.white)
+ .frame(width: 25, height: 25, alignment: .leading)
+ .padding(.all, 10)
+ }
+ })
+ .frame(maxHeight: .infinity)
+
+ Button(action: {
+ options = 2
+
+ do {
+ try AVAudioSession.sharedInstance().overrideOutputAudioPort(.speaker)
+ } catch _ {
+
+ }
+ }, label: {
+ HStack {
+ Image(options == 2 ? "radio-button-fill" : "radio-button")
+ .renderingMode(.template)
+ .resizable()
+ .foregroundStyle(.white)
+ .frame(width: 25, height: 25, alignment: .leading)
+ .padding(.all, 10)
+
+ Text("Speaker")
+ .default_text_style_white(styleSize: 15)
+
+ Spacer()
+
+ Image("speaker-high")
+ .renderingMode(.template)
+ .resizable()
+ .foregroundStyle(.white)
+ .frame(width: 25, height: 25, alignment: .leading)
+ .padding(.all, 10)
+ }
+ })
+ .frame(maxHeight: .infinity)
+
+ Button(action: {
+ options = 3
+
+ do {
+ try AVAudioSession.sharedInstance().overrideOutputAudioPort(.none)
+ try AVAudioSession.sharedInstance().setPreferredInput(AVAudioSession.sharedInstance().availableInputs?
+ .filter({ $0.portType.rawValue.contains("Bluetooth") }).first)
+ } catch _ {
+
+ }
+ }, label: {
+ HStack {
+ Image(options == 3 ? "radio-button-fill" : "radio-button")
+ .renderingMode(.template)
+ .resizable()
+ .foregroundStyle(.white)
+ .frame(width: 25, height: 25, alignment: .leading)
+ .padding(.all, 10)
+
+ Text("Bluetooth")
+ .default_text_style_white(styleSize: 15)
+
+ Spacer()
+
+ Image("bluetooth")
+ .renderingMode(.template)
+ .resizable()
+ .foregroundStyle(.white)
+ .frame(width: 25, height: 25, alignment: .leading)
+ .padding(.all, 10)
+ }
+ })
+ .frame(maxHeight: .infinity)
+ }
+ .padding(.horizontal, 20)
+ .background(Color.gray600)
+ .frame(maxHeight: .infinity)
+ }
+
+ func getAudioRouteImage() {
+ if !AVAudioSession.sharedInstance().currentRoute.outputs.filter({ $0.portType.rawValue == "Speaker" }).isEmpty {
+ meetingWaitingRoomViewModel.imageAudioRoute = "speaker-high"
+ options = 2
+ } else if !AVAudioSession.sharedInstance().currentRoute.outputs.filter({ $0.portType.rawValue.contains("Bluetooth") }).isEmpty {
+ meetingWaitingRoomViewModel.imageAudioRoute = "bluetooth"
+ options = 3
+ } else {
+ meetingWaitingRoomViewModel.imageAudioRoute = meetingWaitingRoomViewModel.isHeadPhoneAvailable() ? "headset" : "speaker-slash"
+ options = 1
+ }
+ }
+}
+
+#Preview {
+ MeetingWaitingRoomFragment(meetingWaitingRoomViewModel: MeetingWaitingRoomViewModel())
+}
+// swiftlint:enable type_body_length
+// swiftlint:enable cyclomatic_complexity
diff --git a/Linphone/UI/Call/Model/CallMediaEncryptionModel.swift b/Linphone/UI/Call/Model/CallMediaEncryptionModel.swift
new file mode 100644
index 000000000..6795591e1
--- /dev/null
+++ b/Linphone/UI/Call/Model/CallMediaEncryptionModel.swift
@@ -0,0 +1,95 @@
+/*
+ * Copyright (c) 2010-2020 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 .
+ */
+
+import Foundation
+import linphonesw
+
+class CallMediaEncryptionModel: ObservableObject {
+ var coreContext = CoreContext.shared
+
+ @Published var mediaEncryption = ""
+
+ @Published var isMediaEncryptionZrtp = false
+ @Published var zrtpCipher = ""
+ @Published var zrtpKeyAgreement = ""
+ @Published var zrtpHash = ""
+ @Published var zrtpAuthTag = ""
+ @Published var zrtpAuthSas = ""
+
+ func update(call: Call) {
+ coreContext.doOnCoreQueue { _ in
+ let stats = call.getStats(type: StreamType.Audio)
+ if stats != nil {
+ // ZRTP stats are only available when authentication token isn't null !
+ if call.currentParams!.mediaEncryption == .ZRTP && call.authenticationToken != nil {
+ let isMediaEncryptionZrtpTmp = true
+
+ var mediaEncryptionTmp = ""
+ if stats!.isZrtpKeyAgreementAlgoPostQuantum {
+ mediaEncryptionTmp = "Media encryption: " + "Post Quantum ZRTP"
+ } else {
+ switch call.currentParams!.mediaEncryption {
+ case .None:
+ mediaEncryptionTmp = "Media encryption: " + "None"
+ case .SRTP:
+ mediaEncryptionTmp = "Media encryption: " + "SRTP"
+ case .ZRTP:
+ mediaEncryptionTmp = "Media encryption: " + "ZRTP"
+ case .DTLS:
+ mediaEncryptionTmp = "Media encryption: " + "DTLS"
+ }
+ }
+
+ let zrtpCipherTmp = "Cipher algorithm: " + stats!.zrtpCipherAlgo
+
+ let zrtpKeyAgreementTmp = "Key agreement algorithm: " + stats!.zrtpKeyAgreementAlgo
+
+ let zrtpHashTmp = "Hash algorithm: " + stats!.zrtpHashAlgo
+
+ let zrtpAuthTagTmp = "Authentication algorithm: " + stats!.zrtpAuthTagAlgo
+
+ let zrtpAuthSasTmp = "SAS algorithm: " + stats!.zrtpSasAlgo
+
+ DispatchQueue.main.async {
+ self.isMediaEncryptionZrtp = isMediaEncryptionZrtpTmp
+
+ self.mediaEncryption = mediaEncryptionTmp
+
+ self.zrtpCipher = zrtpCipherTmp
+
+ self.zrtpKeyAgreement = zrtpKeyAgreementTmp
+
+ self.zrtpHash = zrtpHashTmp
+
+ self.zrtpAuthTag = zrtpAuthTagTmp
+
+ self.zrtpAuthSas = zrtpAuthSasTmp
+ }
+ } else {
+ let mediaEncryptionTmp = "Media encryption: " + call.currentParams!.mediaEncryption.rawValue.description // call.currentParams.mediaEncryption
+
+ DispatchQueue.main.async {
+ self.mediaEncryption = mediaEncryptionTmp
+ }
+ }
+
+ }
+ }
+ }
+}
diff --git a/Linphone/UI/Call/Model/CallStatsModel.swift b/Linphone/UI/Call/Model/CallStatsModel.swift
new file mode 100644
index 000000000..7e19cfe62
--- /dev/null
+++ b/Linphone/UI/Call/Model/CallStatsModel.swift
@@ -0,0 +1,111 @@
+/*
+ * Copyright (c) 2010-2020 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 .
+ */
+
+import Foundation
+import linphonesw
+
+class CallStatsModel: ObservableObject {
+ var coreContext = CoreContext.shared
+
+ @Published var audioCodec = ""
+ @Published var audioBandwidth = ""
+ @Published var audioLossRate = ""
+ @Published var audioJitterBufferSize = ""
+
+ @Published var isVideoEnabled = false
+ @Published var videoCodec = ""
+ @Published var videoBandwidth = ""
+ @Published var videoLossRate = ""
+ @Published var videoResolution = ""
+ @Published var videoFps = ""
+
+ func update(call: Call, stats: CallStats) {
+ coreContext.doOnCoreQueue { _ in
+ if call.params != nil {
+ self.isVideoEnabled = call.params!.videoEnabled && call.currentParams != nil && call.currentParams!.videoDirection != .Inactive
+ switch stats.type {
+ case .Audio:
+ if call.currentParams != nil {
+ let payloadType = call.currentParams!.usedAudioPayloadType
+ let clockRate = (payloadType?.clockRate != nil ? payloadType!.clockRate : 0) / 1000
+ let codecLabel = "Codec: " + "\(payloadType != nil ? payloadType!.mimeType : "")/\(clockRate) kHz"
+
+ if stats.uploadBandwidth.rounded().isNaN || stats.uploadBandwidth.rounded().isInfinite
+ || stats.downloadBandwidth.rounded().isNaN || stats.downloadBandwidth.rounded().isInfinite {
+ return
+ }
+
+ let uploadBandwidth = Int(stats.uploadBandwidth.rounded())
+ let downloadBandwidth = Int(stats.downloadBandwidth.rounded())
+ let bandwidthLabel = "Bandwidth: " + "↑ \(uploadBandwidth) kbits/s ↓ \(downloadBandwidth) kbits/s"
+
+ let senderLossRate = Int(stats.senderLossRate.rounded())
+ let receiverLossRate = Int(stats.receiverLossRate.rounded())
+ let lossRateLabel = "Lossrate: ↑ \(senderLossRate)% ↓ \(receiverLossRate)%"
+
+ let jitterBufferSize = Int(stats.jitterBufferSizeMs.rounded())
+ let jitterBufferSizeLabel = "Jitter buffer: \(jitterBufferSize)ms"
+ DispatchQueue.main.async {
+ self.audioCodec = codecLabel
+ self.audioBandwidth = bandwidthLabel
+ self.audioLossRate = lossRateLabel
+ self.audioJitterBufferSize = jitterBufferSizeLabel
+ }
+ }
+ case .Video:
+ if call.currentParams != nil {
+ let payloadType = call.currentParams!.usedVideoPayloadType
+ let clockRate = (payloadType?.clockRate != nil ? payloadType!.clockRate : 0) / 1000
+ let codecLabel = "Codec: " + "\(payloadType != nil ? payloadType!.mimeType : "null")/\(clockRate) kHz"
+
+ if stats.uploadBandwidth.rounded().isNaN || stats.uploadBandwidth.rounded().isInfinite
+ || stats.downloadBandwidth.rounded().isNaN || stats.downloadBandwidth.rounded().isInfinite {
+ return
+ }
+
+ let uploadBandwidth = Int(stats.uploadBandwidth.rounded())
+ let downloadBandwidth = Int(stats.downloadBandwidth.rounded())
+ let bandwidthLabel = "Bandwidth: " + "↑ \(uploadBandwidth) kbits/s ↓ \(downloadBandwidth) kbits/s"
+
+ let senderLossRate = Int(stats.senderLossRate.rounded())
+ let receiverLossRate = Int(stats.receiverLossRate.rounded())
+ let lossRateLabel = "Lossrate: ↑ \(senderLossRate)% ↓ \(receiverLossRate)%"
+
+ let sentResolution = call.currentParams!.sentVideoDefinition!.name
+ let receivedResolution = call.currentParams!.receivedVideoDefinition!.name
+ let resolutionLabel = "Resolution: " + "↑ \(sentResolution!) ↓ \(receivedResolution!)"
+
+ let sentFps = Int(call.currentParams!.sentFramerate.rounded())
+ let receivedFps = Int(call.currentParams!.receivedFramerate.rounded())
+ let fpsLabel = "FPS: " + "↑ \(sentFps) ↓ \(receivedFps)"
+
+ DispatchQueue.main.async {
+ self.videoCodec = codecLabel
+ self.videoBandwidth = bandwidthLabel
+ self.videoLossRate = lossRateLabel
+ self.videoResolution = resolutionLabel
+ self.videoFps = fpsLabel
+ }
+ }
+ default: break
+ }
+ }
+ }
+ }
+}
diff --git a/Linphone/UI/Call/Model/ParticipantModel.swift b/Linphone/UI/Call/Model/ParticipantModel.swift
new file mode 100644
index 000000000..3a81da4cf
--- /dev/null
+++ b/Linphone/UI/Call/Model/ParticipantModel.swift
@@ -0,0 +1,64 @@
+/*
+ * Copyright (c) 2010-2023 Belledonne Communications SARL.
+ *
+ * This file is part of Linphone
+ *
+ * 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 .
+ */
+
+import Foundation
+import linphonesw
+
+class ParticipantModel: ObservableObject {
+
+ static let TAG = "[Participant Model]"
+
+ let address: Address
+ @Published var sipUri: String
+ @Published var name: String
+ @Published var avatarModel: ContactAvatarModel
+ @Published var isJoining: Bool
+ @Published var onPause: Bool
+ @Published var isMuted: Bool
+ @Published var isAdmin: Bool
+ @Published var isSpeaking: Bool
+
+ init(address: Address, isJoining: Bool = false, onPause: Bool = false, isMuted: Bool = false, isAdmin: Bool = false, isSpeaking: Bool = false) {
+ self.address = address
+
+ self.sipUri = address.asStringUriOnly()
+
+ self.name = ""
+
+ self.avatarModel = ContactAvatarModel(friend: nil, name: "", address: address.asStringUriOnly(), withPresence: false)
+
+ self.isJoining = isJoining
+ self.onPause = onPause
+ self.isMuted = isMuted
+ self.isAdmin = isAdmin
+ self.isSpeaking = isSpeaking
+
+ ContactsManager.shared.getFriendWithAddressInCoreQueue(address: self.address) { friendResult in
+ if let addressFriend = friendResult {
+ self.name = addressFriend.name!
+ } else {
+ self.name = address.displayName != nil ? address.displayName! : address.username!
+ }
+ }
+
+ ContactAvatarModel.getAvatarModelFromAddress(address: self.address) { avatarResult in
+ self.avatarModel = avatarResult
+ }
+ }
+}
diff --git a/Linphone/UI/Call/ViewModel/CallViewModel.swift b/Linphone/UI/Call/ViewModel/CallViewModel.swift
new file mode 100644
index 000000000..a3ce8d591
--- /dev/null
+++ b/Linphone/UI/Call/ViewModel/CallViewModel.swift
@@ -0,0 +1,1351 @@
+/*
+ * Copyright (c) 2010-2020 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 .
+ */
+
+import SwiftUI
+import linphonesw
+import AVFAudio
+import Combine
+
+// swiftlint:disable line_length
+// swiftlint:disable type_body_length
+// swiftlint:disable cyclomatic_complexity
+class CallViewModel: ObservableObject {
+
+ static let TAG = "[CallViewModel]"
+
+ var coreContext = CoreContext.shared
+ var telecomManager = TelecomManager.shared
+
+ @Published var displayName: String = ""
+ @Published var direction: Call.Dir = .Outgoing
+ @Published var remoteAddressString: String = ""
+ @Published var remoteAddress: Address?
+ @Published var avatarModel: ContactAvatarModel?
+ @Published var micMutted: Bool = false
+ @Published var isRecording: Bool = false
+ @Published var isRemoteRecording: Bool = false
+ @Published var isPaused: Bool = false
+ @Published var timeElapsed: Int = 0
+ @Published var zrtpPopupDisplayed: Bool = false
+ @Published var upperCaseAuthTokenToRead = ""
+ @Published var upperCaseAuthTokenToListen = ""
+ @Published var isMediaEncrypted: Bool = false
+ @Published var isNotEncrypted: Bool = false
+ @Published var isZrtp: Bool = false
+ @Published var isRemoteDeviceTrusted: Bool = false
+ @Published var cacheMismatch: Bool = false
+ @Published var isNotVerified: Bool = false
+ @Published var selectedCall: Call?
+ @Published var isTransferInsteadCall: Bool = false
+ @Published var isOneOneCall: Bool = false
+ @Published var isConference: Bool = false
+ @Published var videoDisplayed: Bool = false
+ @Published var participantList: [ParticipantModel] = []
+ @Published var activeSpeakerParticipant: ParticipantModel?
+ @Published var activeSpeakerName: String = ""
+ @Published var myParticipantModel: ParticipantModel?
+ @Published var callMediaEncryptionModel = CallMediaEncryptionModel()
+ @Published var callStatsModel = CallStatsModel()
+
+ @Published var qualityValue: Float = 0.0
+ @Published var qualityIcon = "cell-signal-full"
+
+ private var conferenceDelegate: ConferenceDelegate?
+ private var waitingForConferenceDelegate: ConferenceDelegate?
+
+ @Published var calls: [Call] = []
+ @Published var callsCounter: Int = 0
+ @Published var callsContactAvatarModel: [ContactAvatarModel?] = []
+
+ let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
+
+ var currentCall: Call?
+
+ private var callDelegate: CallDelegate?
+
+ @Published var letters1: String = "AA"
+ @Published var letters2: String = "BB"
+ @Published var letters3: String = "CC"
+ @Published var letters4: String = "DD"
+
+ @Published var operationInProgress: Bool = false
+ @Published var displayedConversation: ConversationModel?
+
+ private var chatRoomDelegate: ChatRoomDelegate?
+
+ init() {
+ do {
+ try AVAudioSession.sharedInstance().setCategory(.playAndRecord, mode: .voiceChat, options: .allowBluetooth)
+ } catch _ {
+
+ }
+ }
+
+ func resetCallView() {
+ DispatchQueue.main.async {
+ self.displayName = ""
+ }
+
+ coreContext.doOnCoreQueue { core in
+ if core.currentCall != nil && core.currentCall!.remoteAddress != nil {
+ if self.callDelegate != nil {
+ self.currentCall?.removeDelegate(delegate: self.callDelegate!)
+ self.callDelegate = nil
+ }
+ if self.conferenceDelegate != nil {
+ self.currentCall?.conference?.removeDelegate(delegate: self.conferenceDelegate!)
+ self.conferenceDelegate = nil
+ }
+ if self.waitingForConferenceDelegate != nil {
+ self.currentCall?.conference?.removeDelegate(delegate: self.waitingForConferenceDelegate!)
+ self.waitingForConferenceDelegate = nil
+ }
+ self.currentCall = core.currentCall
+ let callsCounterTmp = core.calls.count
+
+ var videoDisplayedTmp = false
+ do {
+ let params = try core.createCallParams(call: self.currentCall)
+ videoDisplayedTmp = params.videoEnabled && params.videoDirection == .SendRecv || params.videoDirection == .SendOnly
+ } catch {
+
+ }
+
+ var displayNameTmp = ""
+
+ var isOneOneCallTmp = false
+ if self.currentCall?.remoteAddress != nil {
+ let conf = self.currentCall!.conference
+ let confInfo = core.findConferenceInformationFromUri(uri: self.currentCall!.remoteAddress!)
+ if conf == nil && confInfo == nil {
+ isOneOneCallTmp = true
+ } else {
+ displayNameTmp = confInfo?.subject ?? "Conference-focus"
+ }
+ }
+
+ var isMediaEncryptedTmp = false
+ var isZrtpTmp = false
+ if self.currentCall != nil && self.currentCall!.currentParams != nil {
+ if self.currentCall!.currentParams!.mediaEncryption == .ZRTP ||
+ self.currentCall!.currentParams!.mediaEncryption == .SRTP ||
+ self.currentCall!.currentParams!.mediaEncryption == .DTLS {
+
+ isMediaEncryptedTmp = true
+ isZrtpTmp = self.currentCall!.currentParams!.mediaEncryption == .ZRTP
+ }
+ }
+
+ let directionTmp = self.currentCall!.dir
+
+ let remoteAddressTmp = self.currentCall!.remoteAddress!.clone()
+ remoteAddressTmp!.clean()
+
+ let remoteAddressStringTmp = remoteAddressTmp != nil ? String(remoteAddressTmp!.asStringUriOnly().dropFirst(4)) : ""
+
+ if self.currentCall?.conference != nil {
+ displayNameTmp = self.currentCall?.conference?.subject ?? ""
+ } else if self.currentCall?.remoteAddress != nil {
+ let friend = ContactsManager.shared.getFriendWithAddress(address: self.currentCall!.remoteAddress)
+ if friend != nil && friend!.address != nil && friend!.address!.displayName != nil {
+ displayNameTmp = friend!.address!.displayName!
+ } else {
+ if self.currentCall!.remoteAddress!.displayName != nil {
+ displayNameTmp = self.currentCall!.remoteAddress!.displayName!
+ } else if self.currentCall!.remoteAddress!.username != nil && displayNameTmp.isEmpty {
+ displayNameTmp = self.currentCall!.remoteAddress!.username!
+ }
+ }
+
+ DispatchQueue.main.async {
+ self.displayName = displayNameTmp
+ }
+
+ ContactAvatarModel.getAvatarModelFromAddress(address: self.currentCall!.remoteAddress!) { avatarResult in
+ DispatchQueue.main.async {
+ self.avatarModel = avatarResult
+ }
+ }
+ }
+
+ let micMuttedTmp = self.currentCall!.microphoneMuted || !core.micEnabled
+ let isRecordingTmp = self.currentCall!.params!.isRecording
+ let isPausedTmp = self.isCallPaused()
+ let timeElapsedTmp = self.currentCall?.duration ?? 0
+
+ let authToken = self.currentCall!.localAuthenticationToken
+ let cacheMismatchFlag = self.currentCall!.zrtpCacheMismatchFlag
+ let isDeviceTrusted = !cacheMismatchFlag && self.currentCall!.authenticationTokenVerified && authToken != nil
+ let isRemoteDeviceTrustedTmp = self.telecomManager.callInProgress ? isDeviceTrusted : false
+
+ if self.currentCall != nil {
+ self.callMediaEncryptionModel.update(call: self.currentCall!)
+ if self.currentCall!.audioStats != nil {
+ self.callStatsModel.update(call: self.currentCall!, stats: self.currentCall!.audioStats!)
+ }
+ }
+
+ DispatchQueue.main.async {
+ self.direction = directionTmp
+ self.remoteAddressString = remoteAddressStringTmp
+ self.remoteAddress = remoteAddressTmp
+ self.displayName = displayNameTmp
+
+ self.micMutted = micMuttedTmp
+ self.isRecording = isRecordingTmp
+ self.isPaused = isPausedTmp
+ self.timeElapsed = timeElapsedTmp
+
+ self.isRemoteDeviceTrusted = isRemoteDeviceTrustedTmp
+ self.activeSpeakerParticipant = nil
+
+ self.avatarModel = nil
+ self.isRemoteRecording = false
+ self.zrtpPopupDisplayed = false
+ self.upperCaseAuthTokenToRead = ""
+ self.upperCaseAuthTokenToListen = ""
+ self.isNotVerified = false
+
+ self.updateEncryption(withToast: false)
+ self.isConference = false
+ self.participantList = []
+ self.activeSpeakerParticipant = nil
+ self.activeSpeakerName = ""
+ self.myParticipantModel = nil
+
+ self.videoDisplayed = videoDisplayedTmp
+ self.isOneOneCall = isOneOneCallTmp
+ self.isMediaEncrypted = isMediaEncryptedTmp
+ self.isNotEncrypted = false
+ self.isZrtp = isZrtpTmp
+ self.cacheMismatch = cacheMismatchFlag
+
+ self.getCallsList()
+
+ self.callsCounter = callsCounterTmp
+
+ if self.currentCall?.conference?.state == .Created {
+ self.getConference()
+ } else {
+ self.waitingForCreatedStateConference()
+ }
+ }
+
+ self.callDelegate = CallDelegateStub(onEncryptionChanged: { (_: Call, _: Bool, _: String)in
+ self.updateEncryption(withToast: false)
+ if self.currentCall != nil {
+ self.callMediaEncryptionModel.update(call: self.currentCall!)
+ }
+ }, onAuthenticationTokenVerified: { (_, verified: Bool) in
+ Log.warn("[CallViewModel][ZRTPPopup] Notified that authentication token is \(verified ? "verified" : "not verified!")")
+ if verified {
+ self.updateEncryption(withToast: true)
+ if self.currentCall != nil {
+ self.callMediaEncryptionModel.update(call: self.currentCall!)
+ }
+ } else {
+ if self.telecomManager.isNotVerifiedCounter == 0 {
+ DispatchQueue.main.async {
+ self.isNotVerified = true
+ self.telecomManager.isNotVerifiedCounter += 1
+ }
+ self.showZrtpSasDialogIfPossible()
+ } else {
+ DispatchQueue.main.async {
+ self.isNotVerified = true
+ self.telecomManager.isNotVerifiedCounter += 1
+ self.zrtpPopupDisplayed = true
+ }
+ }
+ }
+ }, onStatsUpdated: { (_: Call, stats: CallStats) in
+ DispatchQueue.main.async {
+ if self.currentCall != nil {
+ self.callStatsModel.update(call: self.currentCall!, stats: stats)
+ }
+ }
+ })
+ self.currentCall!.addDelegate(delegate: self.callDelegate!)
+ self.updateCallQualityIcon()
+ }
+ }
+ }
+
+ func getCallsList() {
+ self.callsContactAvatarModel.removeAll()
+ self.calls.removeAll()
+ coreContext.doOnCoreQueue { core in
+ let callsTmp = core.calls
+ callsTmp.forEach { call in
+ ContactAvatarModel.getAvatarModelFromAddress(address: call.callLog!.remoteAddress!) { avatarResult in
+ DispatchQueue.main.async {
+ self.callsContactAvatarModel.append(avatarResult)
+ self.calls.append(call)
+ }
+ }
+ }
+ }
+ }
+
+ func getConference() {
+ coreContext.doOnCoreQueue { _ in
+ if self.currentCall?.conference != nil {
+ let conf = self.currentCall!.conference!
+
+ let displayNameTmp = conf.subject ?? ""
+
+ var myParticipantModelTmp: ParticipantModel?
+ if conf.me?.address != nil {
+ myParticipantModelTmp = ParticipantModel(address: conf.me!.address!, isJoining: false, onPause: false, isMuted: false, isAdmin: conf.me!.isAdmin)
+ } else if self.currentCall?.callLog?.localAddress != nil {
+ myParticipantModelTmp = ParticipantModel(address: self.currentCall!.callLog!.localAddress!, isJoining: false, onPause: false, isMuted: false, isAdmin: conf.me!.isAdmin)
+ }
+
+ var activeSpeakerParticipantTmp: ParticipantModel?
+ if conf.activeSpeakerParticipantDevice?.address != nil {
+ activeSpeakerParticipantTmp = ParticipantModel(
+ address: conf.activeSpeakerParticipantDevice!.address!,
+ isJoining: false,
+ onPause: conf.activeSpeakerParticipantDevice!.state == .OnHold,
+ isMuted: conf.activeSpeakerParticipantDevice!.isMuted
+ )
+ } else if conf.participantList.first?.address != nil && conf.participantList.first!.address!.clone()!.equal(address2: (conf.me?.address)!) {
+ activeSpeakerParticipantTmp = ParticipantModel(
+ address: conf.participantDeviceList.first!.address!,
+ isJoining: false,
+ onPause: conf.participantDeviceList.first!.state == .OnHold,
+ isMuted: conf.participantDeviceList.first!.isMuted
+ )
+ } else if conf.participantList.last?.address != nil {
+ activeSpeakerParticipantTmp = ParticipantModel(
+ address: conf.participantDeviceList.last!.address!,
+ isJoining: false,
+ onPause: conf.participantDeviceList.last!.state == .OnHold,
+ isMuted: conf.participantDeviceList.last!.isMuted
+ )
+ }
+
+ var activeSpeakerNameTmp = ""
+ if activeSpeakerParticipantTmp != nil {
+ let friend = ContactsManager.shared.getFriendWithAddress(address: activeSpeakerParticipantTmp?.address)
+ if friend != nil && friend!.address != nil && friend!.address!.displayName != nil {
+ activeSpeakerNameTmp = friend!.address!.displayName!
+ } else {
+ if activeSpeakerParticipantTmp!.address.displayName != nil {
+ activeSpeakerNameTmp = activeSpeakerParticipantTmp!.address.displayName!
+ } else if activeSpeakerParticipantTmp!.address.username != nil {
+ activeSpeakerNameTmp = activeSpeakerParticipantTmp!.address.username!
+ }
+ }
+ }
+
+ var participantListTmp: [ParticipantModel] = []
+ conf.participantDeviceList.forEach({ participantDevice in
+ if participantDevice.address != nil && !conf.isMe(uri: participantDevice.address!.clone()!) {
+ if !conf.isMe(uri: participantDevice.address!.clone()!) {
+ let isAdmin = conf.participantList.first(where: {$0.address!.equal(address2: participantDevice.address!.clone()!)})?.isAdmin
+ participantListTmp.append(
+ ParticipantModel(
+ address: participantDevice.address!,
+ isJoining: participantDevice.state == .Joining || participantDevice.state == .Alerting,
+ onPause: participantDevice.state == .OnHold,
+ isMuted: participantDevice.isMuted,
+ isAdmin: isAdmin ?? false
+ )
+ )
+ }
+ }
+ })
+
+ DispatchQueue.main.async {
+ self.displayName = displayNameTmp
+
+ self.isConference = true
+
+ self.myParticipantModel = myParticipantModelTmp
+
+ self.activeSpeakerParticipant = activeSpeakerParticipantTmp
+
+ self.activeSpeakerName = activeSpeakerNameTmp
+
+ self.participantList = participantListTmp
+
+ self.addConferenceCallBacks()
+ }
+ } else if self.currentCall?.remoteContactAddress != nil {
+ self.addConferenceCallBacks()
+ }
+ }
+ }
+
+ func waitingForCreatedStateConference() {
+ if let conference = self.currentCall?.conference {
+ self.waitingForConferenceDelegate = ConferenceDelegateStub(onStateChanged: { (_: Conference, newState: Conference.State) in
+ if newState == .Created {
+ DispatchQueue.main.async {
+ self.getConference()
+ }
+ }
+ })
+ conference.addDelegate(delegate: self.waitingForConferenceDelegate!)
+ }
+ }
+
+ func addConferenceCallBacks() {
+ coreContext.doOnCoreQueue { _ in
+ guard let conference = self.currentCall?.conference else { return }
+
+ self.conferenceDelegate = ConferenceDelegateStub(onParticipantDeviceAdded: { (conference: Conference, participantDevice: ParticipantDevice) in
+ if participantDevice.address != nil {
+ var participantListTmp: [ParticipantModel] = []
+ conference.participantDeviceList.forEach({ pDevice in
+ if pDevice.address != nil && !conference.isMe(uri: pDevice.address!.clone()!) {
+ if !conference.isMe(uri: pDevice.address!.clone()!) {
+ let isAdmin = conference.participantList.first(where: {$0.address!.equal(address2: pDevice.address!.clone()!)})?.isAdmin
+ participantListTmp.append(
+ ParticipantModel(
+ address: pDevice.address!,
+ isJoining: pDevice.state == .Joining || pDevice.state == .Alerting,
+ onPause: pDevice.state == .OnHold,
+ isMuted: pDevice.isMuted,
+ isAdmin: isAdmin ?? false
+ )
+ )
+ }
+ }
+ })
+
+ var activeSpeakerParticipantTmp: ParticipantModel?
+ var activeSpeakerNameTmp = ""
+
+ if self.activeSpeakerParticipant == nil {
+ if conference.activeSpeakerParticipantDevice?.address != nil {
+ activeSpeakerParticipantTmp = ParticipantModel(
+ address: conference.activeSpeakerParticipantDevice!.address!,
+ isJoining: false,
+ onPause: conference.activeSpeakerParticipantDevice!.state == .OnHold,
+ isMuted: conference.activeSpeakerParticipantDevice!.isMuted
+ )
+ } else if conference.participantList.first?.address != nil && conference.participantList.first!.address!.clone()!.equal(address2: (conference.me?.address)!) {
+ activeSpeakerParticipantTmp = ParticipantModel(
+ address: conference.participantDeviceList.first!.address!,
+ isJoining: false,
+ onPause: conference.participantDeviceList.first!.state == .OnHold,
+ isMuted: conference.participantDeviceList.first!.isMuted
+ )
+ } else if conference.participantList.last?.address != nil {
+ activeSpeakerParticipantTmp = ParticipantModel(
+ address: conference.participantDeviceList.last!.address!,
+ isJoining: false,
+ onPause: conference.participantDeviceList.last!.state == .OnHold,
+ isMuted: conference.participantDeviceList.last!.isMuted
+ )
+ }
+
+ if activeSpeakerParticipantTmp != nil {
+ let friend = ContactsManager.shared.getFriendWithAddress(address: activeSpeakerParticipantTmp?.address)
+ if friend != nil && friend!.address != nil && friend!.address!.displayName != nil {
+ activeSpeakerNameTmp = friend!.address!.displayName!
+ } else {
+ if activeSpeakerParticipantTmp!.address.displayName != nil {
+ activeSpeakerNameTmp = activeSpeakerParticipantTmp!.address.displayName!
+ } else if activeSpeakerParticipantTmp!.address.username != nil {
+ activeSpeakerNameTmp = activeSpeakerParticipantTmp!.address.username!
+ }
+ }
+ DispatchQueue.main.async {
+ if self.activeSpeakerParticipant == nil {
+ self.activeSpeakerName = activeSpeakerNameTmp
+ }
+ }
+ }
+ }
+
+ DispatchQueue.main.async {
+ if self.activeSpeakerParticipant == nil {
+ self.activeSpeakerParticipant = activeSpeakerParticipantTmp
+ self.activeSpeakerName = activeSpeakerNameTmp
+ }
+ self.participantList = participantListTmp
+ }
+ }
+ }, onParticipantDeviceRemoved: { (conference: Conference, participantDevice: ParticipantDevice) in
+ if participantDevice.address != nil {
+ var participantListTmp: [ParticipantModel] = []
+ conference.participantDeviceList.forEach({ pDevice in
+ if pDevice.address != nil && !conference.isMe(uri: pDevice.address!.clone()!) {
+ if !conference.isMe(uri: pDevice.address!.clone()!) {
+ let isAdmin = conference.participantList.first(where: {$0.address!.equal(address2: pDevice.address!.clone()!)})?.isAdmin
+ participantListTmp.append(
+ ParticipantModel(
+ address: pDevice.address!,
+ isJoining: pDevice.state == .Joining || pDevice.state == .Alerting,
+ onPause: pDevice.state == .OnHold,
+ isMuted: pDevice.isMuted,
+ isAdmin: isAdmin ?? false
+ )
+ )
+ }
+ }
+ })
+
+ let participantDeviceListCount = conference.participantDeviceList.count
+
+ DispatchQueue.main.async {
+ self.participantList = participantListTmp
+
+ if participantDeviceListCount == 1 {
+ self.activeSpeakerParticipant = nil
+ }
+ }
+ }
+ }, onParticipantAdminStatusChanged: { (_: Conference, participant: Participant) in
+ let isAdmin = participant.isAdmin
+ if self.myParticipantModel != nil && self.myParticipantModel!.address.clone()!.equal(address2: participant.address!) {
+ DispatchQueue.main.async {
+ self.myParticipantModel!.isAdmin = isAdmin
+ }
+ }
+ self.participantList.forEach({ participantDevice in
+ if participantDevice.address.clone()!.equal(address2: participant.address!) {
+ DispatchQueue.main.async {
+ participantDevice.isAdmin = isAdmin
+ }
+ }
+ })
+ }, onParticipantDeviceStateChanged: { (_: Conference, device: ParticipantDevice, state: ParticipantDevice.State) in
+ Log.info(
+ "[CallViewModel] Participant device \(device.address!.asStringUriOnly()) state changed \(state)"
+ )
+ if self.activeSpeakerParticipant != nil && self.activeSpeakerParticipant!.address.equal(address2: device.address!) {
+ DispatchQueue.main.async {
+ self.activeSpeakerParticipant!.onPause = state == .OnHold
+ self.activeSpeakerParticipant!.isJoining = state == .Joining || state == .Alerting
+ }
+ }
+ self.participantList.forEach({ participantDevice in
+ if participantDevice.address.equal(address2: device.address!) {
+ DispatchQueue.main.async {
+ participantDevice.onPause = state == .OnHold
+ participantDevice.isJoining = state == .Joining || state == .Alerting
+ }
+ }
+ })
+ }, onParticipantDeviceIsSpeakingChanged: { (_: Conference, device: ParticipantDevice, isSpeaking: Bool) in
+ let isSpeaking = device.isSpeaking
+ if self.myParticipantModel != nil && self.myParticipantModel!.address.clone()!.equal(address2: device.address!) {
+ DispatchQueue.main.async {
+ self.myParticipantModel!.isSpeaking = isSpeaking
+ }
+ }
+ self.participantList.forEach({ participantDeviceList in
+ if participantDeviceList.address.clone()!.equal(address2: device.address!) {
+ DispatchQueue.main.async {
+ participantDeviceList.isSpeaking = isSpeaking
+ }
+ }
+ })
+ }, onParticipantDeviceIsMuted: { (_: Conference, device: ParticipantDevice, isMuted: Bool) in
+ if self.activeSpeakerParticipant != nil && self.activeSpeakerParticipant!.address.equal(address2: device.address!) {
+ DispatchQueue.main.async {
+ self.activeSpeakerParticipant!.isMuted = isMuted
+ }
+ }
+ self.participantList.forEach({ participantDevice in
+ if participantDevice.address.equal(address2: device.address!) {
+ DispatchQueue.main.async {
+ participantDevice.isMuted = isMuted
+ }
+ }
+ })
+ }, onActiveSpeakerParticipantDevice: { (conference: Conference, participantDevice: ParticipantDevice) in
+ if participantDevice.address != nil {
+ let activeSpeakerParticipantBis = self.activeSpeakerParticipant
+
+ let activeSpeakerParticipantTmp = ParticipantModel(
+ address: participantDevice.address!,
+ isJoining: false,
+ onPause: participantDevice.state == .OnHold,
+ isMuted: participantDevice.isMuted
+ )
+
+ var activeSpeakerNameTmp = ""
+ let friend = ContactsManager.shared.getFriendWithAddress(address: activeSpeakerParticipantTmp.address)
+ if friend != nil && friend!.address != nil && friend!.address!.displayName != nil {
+ activeSpeakerNameTmp = friend!.address!.displayName!
+ } else {
+ if activeSpeakerParticipantTmp.address.displayName != nil {
+ activeSpeakerNameTmp = activeSpeakerParticipantTmp.address.displayName!
+ } else if activeSpeakerParticipantTmp.address.username != nil {
+ activeSpeakerNameTmp = activeSpeakerParticipantTmp.address.username!
+ }
+ }
+
+ var participantListTmp: [ParticipantModel] = []
+ if (activeSpeakerParticipantBis != nil && !activeSpeakerParticipantBis!.address.equal(address2: activeSpeakerParticipantTmp.address))
+ || ( activeSpeakerParticipantBis == nil) {
+
+ conference.participantDeviceList.forEach({ pDevice in
+ if pDevice.address != nil && !conference.isMe(uri: pDevice.address!.clone()!) {
+ if !conference.isMe(uri: pDevice.address!.clone()!) {
+ let isAdmin = conference.participantList.first(where: {$0.address!.equal(address2: pDevice.address!.clone()!)})?.isAdmin
+ participantListTmp.append(
+ ParticipantModel(
+ address: pDevice.address!,
+ isJoining: pDevice.state == .Joining || pDevice.state == .Alerting,
+ onPause: pDevice.state == .OnHold,
+ isMuted: pDevice.isMuted,
+ isAdmin: isAdmin ?? false
+ )
+ )
+ }
+ }
+ })
+ }
+
+ DispatchQueue.main.async {
+ self.activeSpeakerParticipant = activeSpeakerParticipantTmp
+ self.activeSpeakerName = activeSpeakerNameTmp
+ if (activeSpeakerParticipantBis != nil && !activeSpeakerParticipantBis!.address.equal(address2: activeSpeakerParticipantTmp.address))
+ || ( activeSpeakerParticipantBis == nil) {
+ self.participantList = participantListTmp
+ }
+ }
+ }
+ })
+ conference.addDelegate(delegate: self.conferenceDelegate!)
+ }
+ }
+
+ func terminateCall() {
+ coreContext.doOnCoreQueue { core in
+ if self.currentCall != nil {
+ self.telecomManager.terminateCall(call: self.currentCall!)
+ }
+
+ if core.callsNb == 0 {
+ DispatchQueue.main.async {
+ self.timer.upstream.connect().cancel()
+ self.currentCall = nil
+ }
+ }
+ }
+ }
+
+ func acceptCall() {
+ withAnimation {
+ telecomManager.outgoingCallStarted = false
+ telecomManager.callInProgress = true
+ telecomManager.callDisplayed = true
+ telecomManager.callStarted = true
+ telecomManager.isNotVerifiedCounter = 0
+ }
+
+ coreContext.doOnCoreQueue { core in
+ if self.currentCall != nil {
+ self.telecomManager.acceptCall(core: core, call: self.currentCall!, hasVideo: false)
+ }
+ }
+
+ timer.upstream.connect().cancel()
+ }
+
+ func toggleMuteMicrophone() {
+ coreContext.doOnCoreQueue { core in
+ if self.currentCall != nil {
+ if !core.micEnabled && !self.currentCall!.microphoneMuted {
+ core.micEnabled = true
+ } else {
+ self.currentCall!.microphoneMuted = !self.currentCall!.microphoneMuted
+ }
+
+ let micMuttedTmp = self.currentCall!.microphoneMuted || !core.micEnabled
+ DispatchQueue.main.async {
+ self.micMutted = micMuttedTmp
+ }
+
+ Log.info(
+ "[CallViewModel] Microphone mute switch \(self.micMutted)"
+ )
+ }
+ }
+ }
+
+ func displayMyVideo() {
+ coreContext.doOnCoreQueue { core in
+ if self.currentCall != nil {
+ do {
+ let params = try core.createCallParams(call: self.currentCall)
+
+ params.videoEnabled = true
+
+ if params.videoEnabled {
+ if params.videoDirection == .SendRecv {
+ params.videoDirection = .RecvOnly
+ } else if params.videoDirection == .RecvOnly {
+ params.videoDirection = .SendRecv
+ } else if params.videoDirection == .SendOnly {
+ params.videoDirection = .Inactive
+ } else if params.videoDirection == .Inactive {
+ params.videoDirection = .SendRecv
+ }
+ }
+
+ try self.currentCall!.update(params: params)
+
+ let video = params.videoDirection == .SendRecv || params.videoDirection == .SendOnly
+
+ DispatchQueue.main.asyncAfter(deadline: .now() + (video ? 1 : 0)) {
+ if video {
+ self.videoDisplayed = false
+ }
+ self.videoDisplayed = video
+ }
+ } catch {
+
+ }
+ }
+ }
+ }
+
+ func toggleVideoMode(isAudioOnlyMode: Bool) {
+ coreContext.doOnCoreQueue { core in
+ if self.currentCall != nil {
+ do {
+ let params = try core.createCallParams(call: self.currentCall)
+
+ params.videoEnabled = !isAudioOnlyMode
+
+ try self.currentCall!.update(params: params)
+ } catch {
+
+ }
+ }
+ }
+ }
+
+ func switchCamera() {
+ coreContext.doOnCoreQueue { core in
+ let currentDevice = core.videoDevice
+ Log.info("[CallViewModel] Current camera device is \(currentDevice ?? "nil")")
+
+ core.videoDevicesList.forEach { camera in
+ if camera != currentDevice && camera != "StaticImage: Static picture" {
+ Log.info("[CallViewModel] New camera device will be \(camera)")
+ do {
+ try core.setVideodevice(newValue: camera)
+ } catch _ {
+
+ }
+ }
+ }
+ }
+ }
+
+ func toggleRecording() {
+ coreContext.doOnCoreQueue { _ in
+ if self.currentCall != nil && self.currentCall!.params != nil {
+ if self.currentCall!.params!.isRecording {
+ Log.info("[CallViewModel] Stopping call recording")
+ self.currentCall!.stopRecording()
+ } else {
+ Log.info("[CallViewModel] Starting call recording \(self.currentCall!.params!.isRecording)")
+ self.currentCall!.startRecording()
+ }
+
+ let isRecordingTmp = self.currentCall!.params!.isRecording
+ DispatchQueue.main.async {
+ self.isRecording = isRecordingTmp
+ }
+ }
+ }
+ }
+
+ func togglePause() {
+ coreContext.doOnCoreQueue { _ in
+ if self.currentCall != nil && self.currentCall!.remoteAddress != nil {
+ do {
+ if self.isCallPaused() {
+ Log.info("[CallViewModel] Resuming call \(self.currentCall!.remoteAddress!.asStringUriOnly())")
+ try self.currentCall!.resume()
+
+ DispatchQueue.main.async {
+ self.isPaused = false
+ }
+ } else {
+ Log.info("[CallViewModel] Pausing call \(self.currentCall!.remoteAddress!.asStringUriOnly())")
+ try self.currentCall!.pause()
+
+ DispatchQueue.main.async {
+ self.isPaused = true
+ }
+ }
+ } catch _ {
+
+ }
+ }
+ }
+ }
+
+ func isCallPaused() -> Bool {
+ var result = false
+ if self.currentCall != nil {
+ switch self.currentCall!.state {
+ case Call.State.Paused, Call.State.Pausing:
+ result = true
+ default:
+ result = false
+ }
+ }
+ return result
+ }
+
+ func counterToMinutes() -> String {
+ let currentTime = timeElapsed
+ let seconds = currentTime % 60
+ let minutes = String(format: "%02d", Int(currentTime / 60))
+ let hours = String(format: "%02d", Int(currentTime / 3600))
+
+ if Int(currentTime / 3600) > 0 {
+ return "\(hours):\(minutes):\(seconds < 10 ? "0" : "")\(seconds)"
+ } else {
+ return "\(minutes):\(seconds < 10 ? "0" : "")\(seconds)"
+ }
+ }
+
+ func isHeadPhoneAvailable() -> Bool {
+ guard let availableInputs = AVAudioSession.sharedInstance().availableInputs else {return false}
+ for inputDevice in availableInputs {
+ if inputDevice.portType == .headsetMic || inputDevice.portType == .headphones {
+ return true
+ }
+ }
+ return false
+ }
+
+ func orientationUpdate(orientation: UIDeviceOrientation) {
+ coreContext.doOnCoreQueue { core in
+ let oldLinphoneOrientation = core.deviceRotation
+ var newRotation = 0
+ switch orientation {
+ case .portrait:
+ newRotation = 0
+ case .portraitUpsideDown:
+ newRotation = 180
+ case .landscapeRight:
+ newRotation = 90
+ case .landscapeLeft:
+ newRotation = 270
+ default:
+ newRotation = oldLinphoneOrientation
+ }
+
+ if oldLinphoneOrientation != newRotation {
+ core.deviceRotation = newRotation
+ }
+ }
+ }
+
+ func skipZrtpAuthentication() {
+ Log.info(
+ "[ZRTPPopup] User skipped SAS validation in ZRTP call"
+ )
+
+ coreContext.doOnCoreQueue { core in
+ if core.currentCall != nil {
+ core.currentCall!.skipZrtpAuthentication()
+ }
+ }
+ }
+
+ func updateZrtpSas(authTokenClicked: String) {
+ coreContext.doOnCoreQueue { core in
+ if core.currentCall != nil {
+ if authTokenClicked.isEmpty {
+ Log.error(
+ "[ZRTPPopup] Doing a fake ZRTP SAS check with empty token because user clicked on 'Not Found' button!"
+ )
+ } else {
+ Log.info(
+ "[ZRTPPopup] Checking if ZRTP SAS auth token \(authTokenClicked) is the right one"
+ )
+ }
+ core.currentCall!.checkAuthenticationTokenSelected(selectedValue: authTokenClicked)
+ }
+ }
+ }
+
+ func remoteAuthenticationTokens() {
+ coreContext.doOnCoreQueue { core in
+ if core.currentCall != nil {
+ let tokens = core.currentCall!.remoteAuthenticationTokens
+ if !tokens.isEmpty {
+ DispatchQueue.main.async {
+ self.letters1 = tokens[0]
+ self.letters2 = tokens[1]
+ self.letters3 = tokens[2]
+ self.letters4 = tokens[3]
+ }
+ }
+ }
+ }
+ }
+
+ private func updateEncryption(withToast: Bool) {
+ coreContext.doOnCoreQueue { _ in
+ if self.currentCall != nil && self.currentCall!.currentParams != nil {
+ switch self.currentCall!.currentParams!.mediaEncryption {
+ case MediaEncryption.ZRTP:
+ let authToken = self.currentCall!.localAuthenticationToken
+ let isDeviceTrusted = self.currentCall!.authenticationTokenVerified && authToken != nil
+
+ Log.info(
+ "[CallViewModel] Current call media encryption is ZRTP, auth token is \(isDeviceTrusted ? "trusted" : "not trusted yet")"
+ )
+
+ let cacheMismatchFlag = self.currentCall!.zrtpCacheMismatchFlag
+ let isRemoteDeviceTrustedTmp = !cacheMismatchFlag && isDeviceTrusted
+
+ /*
+ let securityLevel = isDeviceTrusted ? SecurityLevel.Safe : SecurityLevel.Encrypted
+ let avatarModel = contact
+ if (avatarModel != nil) {
+ avatarModel.trust.postValue(securityLevel)
+ contact.postValue(avatarModel!!)
+ } else {
+ Log.error("$TAG No avatar model found!")
+ }
+ */
+
+ DispatchQueue.main.async {
+ self.isRemoteDeviceTrusted = isRemoteDeviceTrustedTmp
+ self.isMediaEncrypted = true
+ self.isZrtp = true
+ self.cacheMismatch = cacheMismatchFlag
+ self.isNotEncrypted = false
+
+ if isDeviceTrusted && withToast {
+ ToastViewModel.shared.toastMessage = "Info_call_securised"
+ ToastViewModel.shared.displayToast = true
+ }
+ }
+
+ if !isDeviceTrusted && authToken != nil && !authToken!.isEmpty {
+ Log.info("[CallViewModel] Showing ZRTP SAS confirmation dialog")
+ self.showZrtpSasDialog(authToken: authToken!)
+ }
+ case MediaEncryption.SRTP, MediaEncryption.DTLS:
+ DispatchQueue.main.async {
+ self.isMediaEncrypted = true
+ self.isZrtp = false
+ self.isNotEncrypted = false
+ }
+ case MediaEncryption.None:
+ DispatchQueue.main.async {
+ self.isMediaEncrypted = false
+ self.isZrtp = false
+ if self.currentCall!.state == .StreamsRunning {
+ self.isNotEncrypted = true
+ } else {
+ self.isNotEncrypted = false
+ }
+ }
+ }
+ }
+ }
+ }
+
+ func showZrtpSasDialogIfPossible() {
+ if currentCall != nil && currentCall!.currentParams != nil && currentCall!.currentParams!.mediaEncryption == MediaEncryption.ZRTP {
+ let authToken = currentCall!.localAuthenticationToken
+ let isDeviceTrusted = currentCall!.authenticationTokenVerified && authToken != nil
+ Log.info(
+ "[CallViewModel] Current call media encryption is ZRTP, auth token is \(isDeviceTrusted ? "trusted" : "not trusted yet")"
+ )
+ if authToken != nil && !authToken!.isEmpty {
+ showZrtpSasDialog(authToken: authToken!)
+ }
+ }
+ }
+
+ private func showZrtpSasDialog(authToken: String) {
+ if self.currentCall != nil {
+ let upperCaseAuthToken = authToken.localizedUppercase
+
+ let mySubstringPrefix = upperCaseAuthToken.prefix(2)
+
+ let mySubstringSuffix = upperCaseAuthToken.suffix(2)
+
+ DispatchQueue.main.async {
+ switch self.currentCall!.dir {
+ case Call.Dir.Incoming:
+ self.upperCaseAuthTokenToRead = String(mySubstringPrefix)
+ self.upperCaseAuthTokenToListen = String(mySubstringSuffix)
+ default:
+ self.upperCaseAuthTokenToRead = String(mySubstringSuffix)
+ self.upperCaseAuthTokenToListen = String(mySubstringPrefix)
+ }
+
+ self.zrtpPopupDisplayed = true
+ }
+ }
+ }
+
+ func transferClicked() {
+ coreContext.doOnCoreQueue { core in
+ let callToTransferTo = core.calls.last { call in
+ call.state == Call.State.Paused && call.callLog?.callId != self.currentCall?.callLog?.callId
+ }
+
+ if callToTransferTo == nil {
+ Log.error(
+ "[CallViewModel] Couldn't find a call in Paused state to transfer current call to"
+ )
+ } else {
+ if self.currentCall != nil && self.currentCall!.remoteAddress != nil && callToTransferTo!.remoteAddress != nil {
+ Log.info(
+ "[CallViewModel] Doing an attended transfer between currently displayed call \(self.currentCall!.remoteAddress!.asStringUriOnly()) "
+ + "and paused call \(callToTransferTo!.remoteAddress!.asStringUriOnly())"
+ )
+
+ do {
+ try callToTransferTo!.transferToAnother(dest: self.currentCall!)
+ Log.info("[CallViewModel] Attended transfer is successful")
+ } catch _ {
+ ToastViewModel.shared.toastMessage = "Failed_toast_call_transfer_failed"
+ ToastViewModel.shared.displayToast = true
+
+ Log.error("[CallViewModel] Failed to make attended transfer!")
+ }
+ }
+ }
+ }
+ }
+
+ func blindTransferCallTo(toAddress: Address) {
+ if self.currentCall != nil && self.currentCall!.remoteAddress != nil {
+ Log.info(
+ "[CallViewModel] Call \(self.currentCall!.remoteAddress!.asStringUriOnly()) is being blindly transferred to \(toAddress.asStringUriOnly())"
+ )
+
+ do {
+ try self.currentCall!.transferTo(referTo: toAddress)
+ Log.info("[CallViewModel] Blind call transfer is successful")
+ } catch _ {
+ ToastViewModel.shared.toastMessage = "Failed_toast_call_transfer_failed"
+ ToastViewModel.shared.displayToast = true
+
+ Log.error("[CallViewModel] Failed to make blind call transfer!")
+ }
+ }
+ }
+
+ func toggleAdminParticipant(index: Int) {
+ coreContext.doOnCoreQueue { _ in
+ self.currentCall?.conference?.participantList.forEach({ participant in
+ if participant.address != nil && self.participantList[index].address.clone() != nil && participant.address!.equal(address2: self.participantList[index].address.clone()!) {
+ self.currentCall?.conference?.setParticipantAdminStatus(participant: participant, isAdmin: !participant.isAdmin)
+ }
+ })
+ }
+ }
+
+ func removeParticipant(index: Int) {
+ coreContext.doOnCoreQueue { _ in
+ self.currentCall?.conference?.participantList.forEach({ participant in
+ if participant.address != nil && self.participantList[index].address.clone() != nil && participant.address!.equal(address2: self.participantList[index].address.clone()!) {
+ do {
+ try self.currentCall?.conference?.removeParticipant(participant: participant)
+ } catch {
+
+ }
+ }
+ })
+ }
+ }
+
+ func updateCallQualityIcon() {
+ DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
+ self.coreContext.doOnCoreQueue { core in
+ if self.currentCall != nil {
+ let quality = self.currentCall!.currentQuality
+ let icon = switch floor(quality) {
+ case 4, 5: "cell-signal-full"
+ case 3: "cell-signal-high"
+ case 2: "cell-signal-medium"
+ case 1: "cell-signal-low"
+ default: "cell-signal-none"
+ }
+
+ DispatchQueue.main.async {
+ self.qualityValue = quality
+ self.qualityIcon = icon
+ }
+
+ if core.callsNb > 0 {
+ self.updateCallQualityIcon()
+ }
+ }
+ }
+ }
+ }
+
+ func mergeCallsIntoConference() {
+ self.coreContext.doOnCoreQueue { core in
+ let callsCount = core.callsNb
+ let defaultAccount = core.defaultAccount
+ var subject = ""
+
+ if defaultAccount != nil && defaultAccount!.params != nil && defaultAccount!.params!.audioVideoConferenceFactoryAddress != nil {
+ Log.info("[CallViewModel] Merging \(callsCount) calls into a remotely hosted conference")
+ subject = "Remote group call"
+ } else {
+ Log.info("[CallViewModel] Merging \(callsCount) calls into a locally hosted conference")
+ subject = "Local group call"
+ }
+ do {
+ let params = try core.createConferenceParams(conference: nil)
+ params.subject = subject
+ // Prevent group call to start in audio only layout
+ params.videoEnabled = true
+
+ let conference = try core.createConferenceWithParams(params: params)
+ try conference.addParticipants(calls: core.calls)
+ } catch {
+
+ }
+ }
+ }
+
+ func addParticipants(participantsToAdd: [SelectedAddressModel]) {
+ var list: [SelectedAddressModel] = []
+ for selectedAddr in participantsToAdd {
+ if let found = list.first(where: { $0.address.weakEqual(address2: selectedAddr.address) }) {
+ Log.info("\(CallViewModel.TAG) Participant \(found.address.asStringUriOnly()) already in list, skipping")
+ continue
+ }
+
+ list.append(selectedAddr)
+ Log.info("\(CallViewModel.TAG) Added participant \(selectedAddr.address.asStringUriOnly())")
+ }
+
+ do {
+ try self.currentCall!.conference?.addParticipants(addresses: list.map { $0.address })
+ } catch {
+
+ }
+
+ Log.info("\(CallViewModel.TAG) \(list.count) participants added to conference")
+ }
+
+ func createOneToOneChatRoomWith(remote: Address) {
+ CoreContext.shared.doOnCoreQueue { core in
+ let account = core.defaultAccount
+ if account == nil {
+ Log.error(
+ "\(StartConversationViewModel.TAG) No default account found, can't create conversation with \(remote.asStringUriOnly())!"
+ )
+ return
+ }
+
+ DispatchQueue.main.async {
+ self.operationInProgress = true
+ }
+
+ do {
+ let params: ChatRoomParams = try core.createDefaultChatRoomParams()
+ params.groupEnabled = false
+ params.subject = "Dummy subject"
+ params.ephemeralLifetime = 0 // Make sure ephemeral is disabled by default
+
+ let sameDomain = remote.domain == account?.params?.domain ?? ""
+ if StartConversationViewModel.isEndToEndEncryptionMandatory() && sameDomain {
+ Log.info("\(StartConversationViewModel.TAG) Account is in secure mode & domain matches, creating a E2E conversation")
+ params.backend = ChatRoom.Backend.FlexisipChat
+ params.encryptionEnabled = true
+ } else if !StartConversationViewModel.isEndToEndEncryptionMandatory() {
+ if LinphoneUtils.isEndToEndEncryptedChatAvailable(core: core) {
+ Log.info(
+ "\(StartConversationViewModel.TAG) Account is in interop mode but LIME is available, creating a E2E conversation"
+ )
+ params.backend = ChatRoom.Backend.FlexisipChat
+ params.encryptionEnabled = true
+ } else {
+ Log.info(
+ "\(StartConversationViewModel.TAG) Account is in interop mode but LIME isn't available, creating a SIP simple conversation"
+ )
+ params.backend = ChatRoom.Backend.Basic
+ params.encryptionEnabled = false
+ }
+ } else {
+ Log.error(
+ "\(StartConversationViewModel.TAG) Account is in secure mode, can't chat with SIP address of different domain \(remote.asStringUriOnly())"
+ )
+ DispatchQueue.main.async {
+ self.operationInProgress = false
+ ToastViewModel.shared.toastMessage = "Failed_to_create_conversation_invalid_participant_error"
+ ToastViewModel.shared.displayToast = true
+ }
+ return
+ }
+
+ let participants = [remote]
+ let localAddress = account?.params?.identityAddress
+ let existingChatRoom = core.searchChatRoom(params: params, localAddr: localAddress, remoteAddr: nil, participants: participants)
+ if existingChatRoom == nil {
+ Log.info(
+ "\(StartConversationViewModel.TAG) No existing 1-1 conversation between local account "
+ + "\(localAddress?.asStringUriOnly() ?? "") and remote \(remote.asStringUriOnly()) was found for given parameters, let's create it"
+ )
+ let chatRoom = try core.createChatRoom(params: params, localAddr: localAddress, participants: participants)
+ if params.backend == ChatRoom.Backend.FlexisipChat {
+ if chatRoom.state == ChatRoom.State.Created {
+ let id = LinphoneUtils.getChatRoomId(room: chatRoom)
+ Log.info("\(StartConversationViewModel.TAG) 1-1 conversation \(id) has been created")
+
+ let model = ConversationModel(chatRoom: chatRoom)
+ if self.operationInProgress == false {
+ DispatchQueue.main.async {
+ self.operationInProgress = true
+ }
+
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
+ self.operationInProgress = false
+ self.displayedConversation = model
+ }
+ } else {
+ DispatchQueue.main.async {
+ self.operationInProgress = false
+ self.displayedConversation = model
+ }
+ }
+ } else {
+ Log.info("\(StartConversationViewModel.TAG) Conversation isn't in Created state yet, wait for it")
+ self.chatRoomAddDelegate(core: core, chatRoom: chatRoom)
+ }
+ } else {
+ let id = LinphoneUtils.getChatRoomId(room: chatRoom)
+ Log.info("\(StartConversationViewModel.TAG) Conversation successfully created \(id)")
+
+ let model = ConversationModel(chatRoom: chatRoom)
+ if self.operationInProgress == false {
+ DispatchQueue.main.async {
+ self.operationInProgress = true
+ }
+
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
+ self.operationInProgress = false
+ self.displayedConversation = model
+ }
+ } else {
+ DispatchQueue.main.async {
+ self.operationInProgress = false
+ self.displayedConversation = model
+ }
+ }
+ }
+ } else {
+ Log.warn(
+ "\(StartConversationViewModel.TAG) A 1-1 conversation between local account \(localAddress?.asStringUriOnly() ?? "") and remote \(remote.asStringUriOnly()) for given parameters already exists!"
+ )
+
+ let model = ConversationModel(chatRoom: existingChatRoom!)
+ if self.operationInProgress == false {
+ DispatchQueue.main.async {
+ self.operationInProgress = true
+ }
+
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
+ self.operationInProgress = false
+ self.displayedConversation = model
+ }
+ } else {
+ DispatchQueue.main.async {
+ self.operationInProgress = false
+ self.displayedConversation = model
+ }
+ }
+ }
+ } catch {
+ DispatchQueue.main.async {
+ self.operationInProgress = false
+ ToastViewModel.shared.toastMessage = "Failed_to_create_conversation_error"
+ ToastViewModel.shared.displayToast = true
+ }
+ Log.error("\(StartConversationViewModel.TAG) Failed to create 1-1 conversation with \(remote.asStringUriOnly())!")
+ }
+ }
+ }
+
+ func chatRoomAddDelegate(core: Core, chatRoom: ChatRoom) {
+ self.chatRoomDelegate = ChatRoomDelegateStub(onStateChanged: { (chatRoom: ChatRoom, state: ChatRoom.State) in
+ let state = chatRoom.state
+ let id = LinphoneUtils.getChatRoomId(room: chatRoom)
+ if state == ChatRoom.State.CreationFailed {
+ Log.error("\(StartConversationViewModel.TAG) Conversation \(id) creation has failed!")
+ chatRoom.removeDelegate(delegate: self.chatRoomDelegate!)
+ self.chatRoomDelegate = nil
+ DispatchQueue.main.async {
+ self.operationInProgress = false
+ ToastViewModel.shared.toastMessage = "Failed_to_create_conversation_error"
+ ToastViewModel.shared.displayToast = true
+ }
+ }
+ }, onConferenceJoined: { (chatRoom: ChatRoom, _: EventLog) in
+ let state = chatRoom.state
+ let id = LinphoneUtils.getChatRoomId(room: chatRoom)
+ Log.info("\(StartConversationViewModel.TAG) Conversation \(id) \(chatRoom.subject ?? "") state changed: \(state)")
+ if state == ChatRoom.State.Created {
+ Log.info("\(StartConversationViewModel.TAG) Conversation \(id) successfully created")
+ chatRoom.removeDelegate(delegate: self.chatRoomDelegate!)
+ self.chatRoomDelegate = nil
+
+ let model = ConversationModel(chatRoom: chatRoom)
+ if self.operationInProgress == false {
+ DispatchQueue.main.async {
+ self.operationInProgress = true
+ }
+
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
+ self.operationInProgress = false
+ self.displayedConversation = model
+ }
+ } else {
+ DispatchQueue.main.async {
+ self.operationInProgress = false
+ self.displayedConversation = model
+ }
+ }
+ } else if state == ChatRoom.State.CreationFailed {
+ Log.error("\(StartConversationViewModel.TAG) Conversation \(id) creation has failed!")
+ chatRoom.removeDelegate(delegate: self.chatRoomDelegate!)
+ self.chatRoomDelegate = nil
+ DispatchQueue.main.async {
+ self.operationInProgress = false
+ ToastViewModel.shared.toastMessage = "Failed_to_create_conversation_error"
+ ToastViewModel.shared.displayToast = true
+ }
+ }
+ })
+ chatRoom.addDelegate(delegate: self.chatRoomDelegate!)
+ }
+}
+// swiftlint:enable type_body_length
+// swiftlint:enable line_length
+// swiftlint:enable cyclomatic_complexity
diff --git a/Linphone/UI/Call/ViewModel/MeetingWaitingRoomViewModel.swift b/Linphone/UI/Call/ViewModel/MeetingWaitingRoomViewModel.swift
new file mode 100644
index 000000000..365f78d2d
--- /dev/null
+++ b/Linphone/UI/Call/ViewModel/MeetingWaitingRoomViewModel.swift
@@ -0,0 +1,291 @@
+/*
+ * Copyright (c) 2010-2020 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 .
+ */
+
+import Foundation
+import linphonesw
+import SwiftUI
+import AVFAudio
+
+class MeetingWaitingRoomViewModel: ObservableObject {
+ var coreContext = CoreContext.shared
+ var telecomManager = TelecomManager.shared
+
+ @Published var userName: String = ""
+ @Published var avatarModel: ContactAvatarModel?
+ @Published var micMutted: Bool = false
+ @Published var isRemoteDeviceTrusted: Bool = false
+ @Published var selectedCall: Call?
+ @Published var isConference: Bool = false
+ @Published var videoDisplayed: Bool = false
+ @Published var avatarDisplayed: Bool = true
+ @Published var imageAudioRoute: String = ""
+ @Published var meetingDate: String = ""
+
+ init() {
+ do {
+ try AVAudioSession.sharedInstance().setCategory(.playAndRecord, mode: .voiceChat, options: .allowBluetooth)
+ } catch _ {
+
+ }
+ if !telecomManager.callStarted {
+ self.resetMeetingRoomView()
+ }
+ }
+
+ 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!)
+
+ do {
+ try core.setVideodevice(newValue: "AV Capture: com.apple.avfoundation.avcapturedevice.built-in_video:1")
+ } catch _ {
+
+ }
+
+ if conf != nil && conf!.uri != nil {
+ let confNameTmp = conf?.subject ?? "Conference"
+ var userNameTmp = ""
+
+ let friend = core.defaultAccount != nil && core.defaultAccount!.contactAddress != nil
+ ? ContactsManager.shared.getFriendWithAddress(address: core.defaultAccount?.contactAddress)
+ : nil
+
+ let addressTmp = friend?.address?.asStringUriOnly() ?? ""
+
+ if friend != nil && friend!.address != nil && friend!.address!.displayName != nil {
+ userNameTmp = friend!.address!.displayName!
+ } else {
+ 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!
+ }
+ }
+ }
+
+ let avatarModelTmp = friend != nil
+ ? ContactsManager.shared.avatarListModel.first(where: {
+ $0.friend!.name == friend!.name
+ && $0.friend!.address!.asStringUriOnly() == core.defaultAccount!.contactAddress!.asStringUriOnly()
+ }) ?? ContactAvatarModel(friend: nil, name: userNameTmp, address: addressTmp, withPresence: false)
+ : ContactAvatarModel(friend: nil, name: userNameTmp, address: addressTmp, withPresence: false)
+
+ if core.videoEnabled && !core.videoPreviewEnabled {
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
+ core.videoPreviewEnabled = true
+ self.videoDisplayed = true
+ }
+ }
+
+ core.micEnabled = true
+
+ let micMuttedTmp = !core.micEnabled
+
+ let timeInterval = TimeInterval(conf!.dateTime)
+ let date = Date(timeIntervalSince1970: timeInterval)
+ let dateFormatter = DateFormatter()
+ dateFormatter.dateStyle = .full
+ dateFormatter.timeStyle = .none
+ let dateTmp = dateFormatter.string(from: date)
+
+ let timeFormatter = DateFormatter()
+ timeFormatter.dateFormat = Locale.current.identifier == "fr_FR" ? "HH:mm" : "h:mm a"
+ let timeTmp = timeFormatter.string(from: date)
+
+ let timeBisInterval = TimeInterval(conf!.dateTime + (Int(conf!.duration) * 60))
+ let timeBis = Date(timeIntervalSince1970: timeBisInterval)
+ let timeBisTmp = timeFormatter.string(from: timeBis)
+
+ let meetingDateTmp = "\(dateTmp) | \(timeTmp) - \(timeBisTmp)"
+
+ DispatchQueue.main.async {
+ if self.telecomManager.meetingWaitingRoomName.isEmpty || self.telecomManager.meetingWaitingRoomName != confNameTmp {
+ self.telecomManager.meetingWaitingRoomName = confNameTmp
+ }
+
+ self.userName = userNameTmp
+ self.avatarModel = avatarModelTmp
+ self.micMutted = micMuttedTmp
+ self.meetingDate = meetingDateTmp
+ }
+ }
+ }
+ }
+ }
+
+ func enableVideoPreview() {
+ self.coreContext.doOnCoreQueue { core in
+ if core.videoEnabled {
+ DispatchQueue.main.async {
+ self.videoDisplayed = true
+ }
+ core.videoPreviewEnabled = true
+ }
+ }
+ }
+
+ func disableVideoPreview() {
+ coreContext.doOnCoreQueue { core in
+ if core.videoEnabled {
+ DispatchQueue.main.async {
+ self.videoDisplayed = false
+ }
+ core.videoPreviewEnabled = false
+ }
+ }
+ }
+
+ func switchCamera() {
+ coreContext.doOnCoreQueue { core in
+ let currentDevice = core.videoDevice
+ Log.info("[CallViewModel] Current camera device is \(currentDevice ?? "nil")")
+
+ core.videoDevicesList.forEach { camera in
+ if camera != currentDevice && camera != "StaticImage: Static picture" {
+ Log.info("[CallViewModel] New camera device will be \(camera)")
+ do {
+ try core.setVideodevice(newValue: camera)
+ } catch _ {
+
+ }
+ }
+ }
+ }
+ }
+
+ func orientationUpdate(orientation: UIDeviceOrientation) {
+ coreContext.doOnCoreQueue { core in
+ let oldLinphoneOrientation = core.deviceRotation
+ var newRotation = 0
+ switch orientation {
+ case .portrait:
+ newRotation = 0
+ case .portraitUpsideDown:
+ newRotation = 180
+ case .landscapeRight:
+ newRotation = 90
+ case .landscapeLeft:
+ newRotation = 270
+ default:
+ newRotation = oldLinphoneOrientation
+ }
+
+ if oldLinphoneOrientation != newRotation {
+ core.deviceRotation = newRotation
+ }
+ }
+ }
+
+ func enableMicrophone() {
+ self.micMutted = false
+ }
+
+ func toggleMuteMicrophone() {
+ self.micMutted = !self.micMutted
+ }
+
+ func enableAVAudioSession() {
+ do {
+ try AVAudioSession.sharedInstance().setActive(true)
+ } catch _ {
+
+ }
+ }
+
+ func disableAVAudioSession() {
+ do {
+ try AVAudioSession.sharedInstance().setActive(false)
+ } catch _ {
+
+ }
+ }
+
+ func isHeadPhoneAvailable() -> Bool {
+ guard let availableInputs = AVAudioSession.sharedInstance().availableInputs else {return false}
+ for inputDevice in availableInputs {
+ if inputDevice.portType == .headsetMic || inputDevice.portType == .headphones {
+ return true
+ }
+ }
+ return false
+ }
+
+ func joinMeeting() {
+ if self.telecomManager.meetingWaitingRoomSelected != nil {
+ if self.micMutted {
+ coreContext.doOnCoreQueue { core in
+ core.micEnabled = false
+ }
+ }
+
+ let audioSession = imageAudioRoute
+
+ telecomManager.doCallWithCore(
+ addr: self.telecomManager.meetingWaitingRoomSelected!, isVideo: self.videoDisplayed, isConference: true
+ )
+
+ DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
+ switch audioSession {
+ case "bluetooth":
+ do {
+ try AVAudioSession.sharedInstance().overrideOutputAudioPort(.none)
+ try AVAudioSession.sharedInstance().setPreferredInput(AVAudioSession.sharedInstance().availableInputs?
+ .filter({ $0.portType.rawValue.contains("Bluetooth") }).first)
+ } catch _ {
+
+ }
+ case "speaker-high":
+ do {
+ try AVAudioSession.sharedInstance().overrideOutputAudioPort(.speaker)
+ } catch _ {
+
+ }
+ default:
+ do {
+ try AVAudioSession.sharedInstance().overrideOutputAudioPort(.none)
+ if self.isHeadPhoneAvailable() {
+ try AVAudioSession.sharedInstance().setPreferredInput(AVAudioSession.sharedInstance()
+ .availableInputs?.filter({ $0.portType.rawValue.contains("Receiver") }).first)
+ } else {
+ try AVAudioSession.sharedInstance().setPreferredInput(AVAudioSession.sharedInstance().availableInputs?.first)
+ }
+ } catch _ {
+
+ }
+ }
+ }
+ }
+ }
+
+ func cancelMeeting() {
+ coreContext.doOnCoreQueue { core in
+ if core.currentCall != nil {
+ self.telecomManager.terminateCall(call: core.currentCall!)
+ }
+ }
+ }
+}
diff --git a/Linphone/UI/Main/Contacts/ContactsView.swift b/Linphone/UI/Main/Contacts/ContactsView.swift
new file mode 100644
index 000000000..b6492c4fe
--- /dev/null
+++ b/Linphone/UI/Main/Contacts/ContactsView.swift
@@ -0,0 +1,73 @@
+/*
+ * 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 .
+ */
+
+import SwiftUI
+
+struct ContactsView: View {
+
+ @ObservedObject var contactViewModel: ContactViewModel
+ @ObservedObject var historyViewModel: HistoryViewModel
+ @ObservedObject var editContactViewModel: EditContactViewModel
+
+ @Binding var isShowEditContactFragment: Bool
+ @Binding var isShowDeletePopup: Bool
+ @Binding var text: String
+
+ var body: some View {
+ NavigationView {
+ ZStack(alignment: .bottomTrailing) {
+ ContactsFragment(contactViewModel: contactViewModel, isShowDeletePopup: $isShowDeletePopup, text: $text)
+
+ Button {
+ withAnimation {
+ editContactViewModel.selectedEditFriend = nil
+ editContactViewModel.resetValues()
+ isShowEditContactFragment.toggle()
+ }
+ } label: {
+ Image("user-plus")
+ .renderingMode(.template)
+ .foregroundStyle(.white)
+ .padding()
+ .background(Color.orangeMain500)
+ .clipShape(Circle())
+ .shadow(color: .black.opacity(0.2), radius: 4)
+
+ }
+ .padding()
+ // For testing crashlytics
+ /*Button(action: CoreContext.shared.crashForCrashlytics, label: {
+ Text("CRASH ME")
+ })*/
+ }
+ }
+ .navigationViewStyle(.stack)
+ }
+}
+
+#Preview {
+ ContactsView(
+ contactViewModel: ContactViewModel(),
+ historyViewModel: HistoryViewModel(),
+ editContactViewModel: EditContactViewModel(),
+ isShowEditContactFragment: .constant(false),
+ isShowDeletePopup: .constant(false),
+ text: .constant("")
+ )
+}
diff --git a/Linphone/UI/Main/Contacts/Fragments/ContactFragment.swift b/Linphone/UI/Main/Contacts/Fragments/ContactFragment.swift
new file mode 100644
index 000000000..bc127ce8b
--- /dev/null
+++ b/Linphone/UI/Main/Contacts/Fragments/ContactFragment.swift
@@ -0,0 +1,101 @@
+/*
+ * 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 .
+ */
+
+import SwiftUI
+import Contacts
+
+struct ContactFragment: View {
+
+ private var idiom: UIUserInterfaceIdiom { UIDevice.current.userInterfaceIdiom }
+
+ @ObservedObject var contactViewModel: ContactViewModel
+ @ObservedObject var editContactViewModel: EditContactViewModel
+ @ObservedObject var conversationViewModel: ConversationViewModel
+
+ @Binding var isShowDeletePopup: Bool
+ @Binding var isShowDismissPopup: Bool
+ @Binding var isShowSipAddressesPopup: Bool
+ @Binding var isShowSipAddressesPopupType: Int
+
+ @State private var showingSheet = false
+ @State private var showShareSheet = false
+
+ var body: some View {
+ let indexDisplayed = contactViewModel.indexDisplayedFriend != nil ? contactViewModel.indexDisplayedFriend! : 0
+ if ContactsManager.shared.avatarListModel.count > indexDisplayed {
+ if #available(iOS 16.0, *), idiom != .pad {
+ ContactInnerFragment(
+ contactAvatarModel: ContactsManager.shared.avatarListModel[indexDisplayed],
+ contactViewModel: contactViewModel,
+ editContactViewModel: editContactViewModel,
+ conversationViewModel: conversationViewModel,
+ cnContact: CNContact(),
+ isShowDeletePopup: $isShowDeletePopup,
+ showingSheet: $showingSheet,
+ showShareSheet: $showShareSheet,
+ isShowDismissPopup: $isShowDismissPopup,
+ isShowSipAddressesPopup: $isShowSipAddressesPopup,
+ isShowSipAddressesPopupType: $isShowSipAddressesPopupType
+ )
+ .sheet(isPresented: $showingSheet) {
+ ContactListBottomSheet(contactViewModel: contactViewModel, showingSheet: $showingSheet)
+ .presentationDetents([.fraction(0.2)])
+ }
+ .sheet(isPresented: $showShareSheet) {
+ ShareSheet(friendToShare: ContactsManager.shared.lastSearch[contactViewModel.indexDisplayedFriend!].friend!)
+ .presentationDetents([.medium])
+ .edgesIgnoringSafeArea(.bottom)
+ }
+ } else {
+ ContactInnerFragment(
+ contactAvatarModel: ContactsManager.shared.avatarListModel[indexDisplayed],
+ contactViewModel: contactViewModel,
+ editContactViewModel: editContactViewModel,
+ conversationViewModel: conversationViewModel,
+ cnContact: CNContact(),
+ isShowDeletePopup: $isShowDeletePopup,
+ showingSheet: $showingSheet,
+ showShareSheet: $showShareSheet,
+ isShowDismissPopup: $isShowDismissPopup,
+ isShowSipAddressesPopup: $isShowSipAddressesPopup,
+ isShowSipAddressesPopupType: $isShowSipAddressesPopupType
+ )
+ .halfSheet(showSheet: $showingSheet) {
+ ContactListBottomSheet(contactViewModel: contactViewModel, showingSheet: $showingSheet)
+ } onDismiss: {}
+ .sheet(isPresented: $showShareSheet) {
+ ShareSheet(friendToShare: ContactsManager.shared.lastSearch[contactViewModel.indexDisplayedFriend!].friend!)
+ .edgesIgnoringSafeArea(.bottom)
+ }
+ }
+ }
+ }
+}
+
+#Preview {
+ ContactFragment(
+ contactViewModel: ContactViewModel(),
+ editContactViewModel: EditContactViewModel(),
+ conversationViewModel: ConversationViewModel(),
+ isShowDeletePopup: .constant(false),
+ isShowDismissPopup: .constant(false),
+ isShowSipAddressesPopup: .constant(false),
+ isShowSipAddressesPopupType: .constant(0)
+ )
+}
diff --git a/Linphone/UI/Main/Contacts/Fragments/ContactInnerActionsFragment.swift b/Linphone/UI/Main/Contacts/Fragments/ContactInnerActionsFragment.swift
new file mode 100644
index 000000000..04fe4b782
--- /dev/null
+++ b/Linphone/UI/Main/Contacts/Fragments/ContactInnerActionsFragment.swift
@@ -0,0 +1,379 @@
+/*
+ * 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 .
+ */
+
+import SwiftUI
+import linphonesw
+
+// swiftlint:disable type_body_length
+struct ContactInnerActionsFragment: View {
+
+ @ObservedObject var contactsManager = ContactsManager.shared
+ @ObservedObject private var telecomManager = TelecomManager.shared
+
+ @ObservedObject var contactViewModel: ContactViewModel
+ @ObservedObject var editContactViewModel: EditContactViewModel
+ @ObservedObject var contactAvatarModel: ContactAvatarModel
+
+ @State private var informationIsOpen = true
+
+ @Binding var showingSheet: Bool
+ @Binding var showShareSheet: Bool
+ @Binding var isShowDeletePopup: Bool
+ @Binding var isShowDismissPopup: Bool
+
+ var actionEditButton: () -> Void
+
+ var body: some View {
+ HStack(alignment: .center) {
+ Text("Information")
+ .default_text_style_800(styleSize: 16)
+
+ Spacer()
+
+ Image(informationIsOpen ? "caret-up" : "caret-down")
+ .renderingMode(.template)
+ .resizable()
+ .foregroundStyle(Color.grayMain2c600)
+ .frame(width: 25, height: 25, alignment: .leading)
+ .padding(.all, 10)
+ }
+ .padding(.top, 30)
+ .padding(.bottom, 10)
+ .padding(.horizontal, 16)
+ .background(Color.gray100)
+ .onTapGesture {
+ withAnimation {
+ informationIsOpen.toggle()
+ }
+ }
+
+ if informationIsOpen {
+ VStack(spacing: 0) {
+ ForEach(0...
+ */
+
+import SwiftUI
+import Contacts
+import ContactsUI
+import linphonesw
+
+struct ContactInnerFragment: View {
+
+ @ObservedObject private var sharedMainViewModel = SharedMainViewModel.shared
+ @ObservedObject var contactsManager = ContactsManager.shared
+ @ObservedObject private var telecomManager = TelecomManager.shared
+
+ @ObservedObject var contactAvatarModel: ContactAvatarModel
+ @ObservedObject var contactViewModel: ContactViewModel
+ @ObservedObject var editContactViewModel: EditContactViewModel
+ @ObservedObject var conversationViewModel: ConversationViewModel
+
+ @State private var orientation = UIDevice.current.orientation
+
+ @State var cnContact: CNContact?
+ @State private var presentingEditContact = false
+
+ @Binding var isShowDeletePopup: Bool
+ @Binding var showingSheet: Bool
+ @Binding var showShareSheet: Bool
+ @Binding var isShowDismissPopup: Bool
+ @Binding var isShowSipAddressesPopup: Bool
+ @Binding var isShowSipAddressesPopupType: Int
+
+ var body: some View {
+ NavigationView {
+ ZStack {
+ VStack(spacing: 1) {
+ Rectangle()
+ .foregroundColor(Color.orangeMain500)
+ .edgesIgnoringSafeArea(.top)
+ .frame(height: 0)
+
+ HStack {
+ if !(orientation == .landscapeLeft || orientation == .landscapeRight
+ || UIScreen.main.bounds.size.width > UIScreen.main.bounds.size.height) {
+ Image("caret-left")
+ .renderingMode(.template)
+ .resizable()
+ .foregroundStyle(Color.orangeMain500)
+ .frame(width: 25, height: 25, alignment: .leading)
+ .padding(.all, 10)
+ .padding(.top, 2)
+ .padding(.leading, -10)
+ .onTapGesture {
+ withAnimation {
+ contactViewModel.indexDisplayedFriend = nil
+ }
+ }
+ }
+
+ Spacer()
+ if contactViewModel.indexDisplayedFriend != nil && contactViewModel.indexDisplayedFriend! < contactsManager.lastSearch.count
+ && !contactAvatarModel.nativeUri.isEmpty {
+ Button(action: {
+ editNativeContact()
+ }, label: {
+ Image("pencil-simple")
+ .renderingMode(.template)
+ .resizable()
+ .foregroundStyle(Color.orangeMain500)
+ .frame(width: 25, height: 25, alignment: .leading)
+ .padding(.all, 10)
+ .padding(.top, 2)
+ })
+ } else {
+ NavigationLink(destination: EditContactFragment(
+ editContactViewModel: editContactViewModel,
+ contactViewModel: contactViewModel,
+ isShowEditContactFragment: .constant(false),
+ isShowDismissPopup: $isShowDismissPopup)) {
+ Image("pencil-simple")
+ .renderingMode(.template)
+ .resizable()
+ .foregroundStyle(Color.orangeMain500)
+ .frame(width: 25, height: 25, alignment: .leading)
+ .padding(.all, 10)
+ .padding(.top, 2)
+ }
+ .simultaneousGesture(
+ TapGesture().onEnded {
+ editContactViewModel.selectedEditFriend = contactAvatarModel.friend
+ editContactViewModel.resetValues()
+ }
+ )
+ }
+ }
+ .frame(maxWidth: .infinity)
+ .frame(height: 50)
+ .padding(.horizontal)
+ .padding(.bottom, 4)
+ .background(.white)
+
+ ScrollView {
+ VStack(spacing: 0) {
+ VStack(spacing: 0) {
+ VStack(spacing: 0) {
+ if contactViewModel.indexDisplayedFriend != nil && contactViewModel.indexDisplayedFriend! < contactsManager.lastSearch.count {
+ Avatar(contactAvatarModel: contactAvatarModel, avatarSize: 100)
+ } else if contactViewModel.indexDisplayedFriend != nil
+ && contactViewModel.indexDisplayedFriend! < contactsManager.lastSearch.count {
+ Image("profil-picture-default")
+ .resizable()
+ .frame(width: 100, height: 100)
+ .clipShape(Circle())
+ }
+ if contactViewModel.indexDisplayedFriend != nil
+ && contactViewModel.indexDisplayedFriend! < contactsManager.lastSearch.count {
+ Text(contactAvatarModel.name)
+ .foregroundStyle(Color.grayMain2c700)
+ .multilineTextAlignment(.center)
+ .default_text_style(styleSize: 14)
+ .frame(maxWidth: .infinity)
+ .padding(.top, 10)
+
+ Text(contactAvatarModel.lastPresenceInfo)
+ .foregroundStyle(contactAvatarModel.lastPresenceInfo == "Online"
+ ? Color.greenSuccess500
+ : Color.orangeWarning600)
+ .multilineTextAlignment(.center)
+ .default_text_style_300(styleSize: 12)
+ .frame(maxWidth: .infinity)
+ }
+
+ }
+ .frame(minHeight: 150)
+ .frame(maxWidth: .infinity)
+ .padding(.top, 10)
+ .background(Color.gray100)
+
+ HStack {
+ Spacer()
+
+ Button(action: {
+ if contactAvatarModel.addresses.count <= 1 {
+ do {
+ let address = try Factory.Instance.createAddress(addr: contactAvatarModel.address)
+ telecomManager.doCallOrJoinConf(address: address, isVideo: false)
+ } catch {
+ Log.error("[ContactInnerFragment] unable to create address for a new outgoing call : \(contactAvatarModel.address) \(error) ")
+ }
+ } else {
+ isShowSipAddressesPopupType = 0
+ isShowSipAddressesPopup = true
+ }
+ }, label: {
+ VStack {
+ HStack(alignment: .center) {
+ Image("phone")
+ .renderingMode(.template)
+ .resizable()
+ .foregroundStyle(Color.grayMain2c600)
+ .frame(width: 25, height: 25)
+ }
+ .padding(16)
+ .background(Color.grayMain2c200)
+ .cornerRadius(40)
+
+ Text("Appel")
+ .default_text_style(styleSize: 14)
+ }
+ })
+
+ Spacer()
+
+ Button(action: {
+ if contactAvatarModel.addresses.count <= 1 {
+ do {
+ let address = try Factory.Instance.createAddress(addr: contactAvatarModel.address)
+ contactViewModel.createOneToOneChatRoomWith(remote: address)
+ } catch {
+ Log.error("[ContactInnerFragment] unable to create address for a new outgoing call : \(contactAvatarModel.address) \(error) ")
+ }
+ } else {
+ isShowSipAddressesPopupType = 1
+ isShowSipAddressesPopup = true
+ }
+ }, label: {
+ VStack {
+ HStack(alignment: .center) {
+ Image("chat-teardrop-text")
+ .renderingMode(.template)
+ .resizable()
+ .foregroundStyle(Color.grayMain2c600)
+ .frame(width: 25, height: 25)
+ }
+ .padding(16)
+ .background(Color.grayMain2c200)
+ .cornerRadius(40)
+
+ Text("Message")
+ .default_text_style(styleSize: 14)
+ }
+ })
+
+ Spacer()
+
+ Button(action: {
+ if contactAvatarModel.addresses.count <= 1 {
+ do {
+ let address = try Factory.Instance.createAddress(addr: contactAvatarModel.address)
+ telecomManager.doCallOrJoinConf(address: address, isVideo: true)
+ } catch {
+ Log.error("[ContactInnerFragment] unable to create address for a new outgoing call : \(contactAvatarModel.address) \(error) ")
+ }
+ } else {
+ isShowSipAddressesPopupType = 2
+ isShowSipAddressesPopup = true
+ }
+ }, label: {
+ VStack {
+ HStack(alignment: .center) {
+ Image("video-camera")
+ .renderingMode(.template)
+ .resizable()
+ .foregroundStyle(Color.grayMain2c600)
+ .frame(width: 25, height: 25)
+ }
+ .padding(16)
+ .background(Color.grayMain2c200)
+ .cornerRadius(40)
+
+ Text("Video Call")
+ .default_text_style(styleSize: 14)
+ }
+ })
+
+ Spacer()
+ }
+ .padding(.top, 20)
+ .frame(maxWidth: .infinity)
+ .background(Color.gray100)
+
+ ContactInnerActionsFragment(
+ contactViewModel: contactViewModel,
+ editContactViewModel: editContactViewModel,
+ contactAvatarModel: contactAvatarModel, showingSheet: $showingSheet,
+ showShareSheet: $showShareSheet,
+ isShowDeletePopup: $isShowDeletePopup,
+ isShowDismissPopup: $isShowDismissPopup,
+ actionEditButton: editNativeContact
+ )
+ }
+ .frame(maxWidth: sharedMainViewModel.maxWidth)
+ }
+ .frame(maxWidth: .infinity)
+ }
+ .background(Color.gray100)
+ }
+ .background(.white)
+ .navigationBarHidden(true)
+ .onRotate { newOrientation in
+ orientation = newOrientation
+ }
+ .fullScreenCover(isPresented: $presentingEditContact) {
+ NavigationView {
+ EditContactView(contact: $cnContact)
+ .navigationBarTitle("Edit Contact")
+ .navigationBarTitleDisplayMode(.inline)
+ .edgesIgnoringSafeArea(.vertical)
+ }
+ }
+ }
+ }
+ .navigationViewStyle(.stack)
+ }
+
+ func editNativeContact() {
+ do {
+ let store = CNContactStore()
+ let descriptor = CNContactViewController.descriptorForRequiredKeys()
+ cnContact = try store.unifiedContact(
+ withIdentifier: contactAvatarModel.nativeUri,
+ keysToFetch: [descriptor]
+ )
+
+ if cnContact != nil {
+ presentingEditContact.toggle()
+ }
+ } catch {
+ print(error)
+ }
+ }
+}
+
+#Preview {
+ ContactInnerFragment(
+ contactAvatarModel: ContactAvatarModel(friend: nil, name: "", address: "", withPresence: true),
+ contactViewModel: ContactViewModel(),
+ editContactViewModel: EditContactViewModel(),
+ conversationViewModel: ConversationViewModel(),
+ isShowDeletePopup: .constant(false),
+ showingSheet: .constant(false),
+ showShareSheet: .constant(false),
+ isShowDismissPopup: .constant(false),
+ isShowSipAddressesPopup: .constant(false),
+ isShowSipAddressesPopupType: .constant(0)
+ )
+}
diff --git a/Linphone/UI/Main/Contacts/Fragments/ContactListBottomSheet.swift b/Linphone/UI/Main/Contacts/Fragments/ContactListBottomSheet.swift
new file mode 100644
index 000000000..a103ea00d
--- /dev/null
+++ b/Linphone/UI/Main/Contacts/Fragments/ContactListBottomSheet.swift
@@ -0,0 +1,170 @@
+/*
+ * 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 .
+ */
+
+import SwiftUI
+import UniformTypeIdentifiers
+
+struct ContactListBottomSheet: View {
+
+ private var idiom: UIUserInterfaceIdiom { UIDevice.current.userInterfaceIdiom }
+
+ @ObservedObject private var sharedMainViewModel = SharedMainViewModel.shared
+
+ @ObservedObject var contactViewModel: ContactViewModel
+
+ @State private var orientation = UIDevice.current.orientation
+
+ @Environment(\.dismiss) var dismiss
+
+ @Binding var showingSheet: Bool
+
+ var body: some View {
+ VStack(alignment: .leading) {
+ if idiom != .pad && (orientation == .landscapeLeft
+ || orientation == .landscapeRight
+ || UIScreen.main.bounds.size.width > UIScreen.main.bounds.size.height) {
+ Spacer()
+ HStack {
+ Spacer()
+ Button("Close") {
+ if #available(iOS 16.0, *) {
+ showingSheet.toggle()
+ } else {
+ showingSheet.toggle()
+ dismiss()
+ }
+ }
+ }
+ .padding(.trailing)
+ }
+
+ Spacer()
+ Button {
+ UIPasteboard.general.setValue(
+ contactViewModel.stringToCopy.prefix(4) == "sip:"
+ ? contactViewModel.stringToCopy.dropFirst(4)
+ : contactViewModel.stringToCopy,
+ forPasteboardType: UTType.plainText.identifier)
+
+ if #available(iOS 16.0, *) {
+ showingSheet.toggle()
+ } else {
+ showingSheet.toggle()
+ dismiss()
+ }
+
+ ToastViewModel.shared.toastMessage = "Success_address_copied_into_clipboard"
+ ToastViewModel.shared.displayToast.toggle()
+
+ } label: {
+ HStack {
+ Image("copy")
+ .renderingMode(.template)
+ .resizable()
+ .foregroundStyle(Color.grayMain2c500)
+ .frame(width: 25, height: 25, alignment: .leading)
+ .padding(.all, 10)
+ Text(contactViewModel.stringToCopy.prefix(4) == "sip:"
+ ? "Copy address" : "Copy number")
+ .default_text_style(styleSize: 16)
+ Spacer()
+ }
+ .frame(maxHeight: .infinity)
+ }
+ .padding(.horizontal, 30)
+ .background(Color.gray100)
+
+ VStack {
+ Divider()
+ }
+ .frame(maxWidth: .infinity)
+
+ if contactViewModel.stringToCopy.prefix(4) != "sip:" {
+ Button {
+ if #available(iOS 16.0, *) {
+ if idiom != .pad {
+ showingSheet.toggle()
+ } else {
+ showingSheet.toggle()
+ dismiss()
+ }
+ } else {
+ showingSheet.toggle()
+ dismiss()
+ }
+ } label: {
+ HStack {
+ Image("envelope-simple-open")
+ .renderingMode(.template)
+ .resizable()
+ .foregroundStyle(Color.grayMain2c500)
+ .frame(width: 25, height: 25, alignment: .leading)
+ .padding(.all, 10)
+ Text("Invitation")
+ .default_text_style(styleSize: 16)
+ Spacer()
+ }
+ .frame(maxHeight: .infinity)
+ }
+ .padding(.horizontal, 30)
+ .background(Color.gray100)
+
+ VStack {
+ Divider()
+ }
+ .frame(maxWidth: .infinity)
+ }
+
+ Button {
+ if #available(iOS 16.0, *) {
+ showingSheet.toggle()
+ } else {
+ showingSheet.toggle()
+ dismiss()
+ }
+ } label: {
+ HStack {
+ Image("x-circle")
+ .renderingMode(.template)
+ .resizable()
+ .foregroundStyle(Color.grayMain2c500)
+ .frame(width: 25, height: 25, alignment: .leading)
+ .padding(.all, 10)
+ Text(contactViewModel.stringToCopy.prefix(4) == "sip:"
+ ? "Block the address" : "Block the number")
+ .default_text_style(styleSize: 16)
+ Spacer()
+ }
+ .frame(maxHeight: .infinity)
+ }
+ .padding(.horizontal, 30)
+ .background(Color.gray100)
+
+ }
+ .onRotate { newOrientation in
+ orientation = newOrientation
+ }
+ .background(Color.gray100)
+ .frame(maxWidth: .infinity)
+ }
+}
+
+#Preview {
+ ContactListBottomSheet(contactViewModel: ContactViewModel(), showingSheet: .constant(false))
+}
diff --git a/Linphone/UI/Main/Contacts/Fragments/ContactsFragment.swift b/Linphone/UI/Main/Contacts/Fragments/ContactsFragment.swift
new file mode 100644
index 000000000..7ed99e3bb
--- /dev/null
+++ b/Linphone/UI/Main/Contacts/Fragments/ContactsFragment.swift
@@ -0,0 +1,73 @@
+/*
+ * 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 .
+ */
+
+import SwiftUI
+
+struct ContactsFragment: View {
+
+ private var idiom: UIUserInterfaceIdiom { UIDevice.current.userInterfaceIdiom }
+
+ @ObservedObject var contactViewModel: ContactViewModel
+
+ @Binding var isShowDeletePopup: Bool
+
+ @State private var showingSheet = false
+ @State private var showShareSheet = false
+ @Binding var text: String
+
+ var body: some View {
+ ZStack {
+ if #available(iOS 16.0, *), idiom != .pad {
+ ContactsInnerFragment(contactViewModel: contactViewModel, showingSheet: $showingSheet, text: $text)
+ .sheet(isPresented: $showingSheet) {
+ ContactsListBottomSheet(
+ contactViewModel: contactViewModel,
+ isShowDeletePopup: $isShowDeletePopup,
+ showingSheet: $showingSheet,
+ showShareSheet: $showShareSheet
+ )
+ .presentationDetents([.fraction(0.2)])
+ }
+ .sheet(isPresented: $showShareSheet) {
+ ShareSheet(friendToShare: contactViewModel.selectedFriendToShare!)
+ .presentationDetents([.medium])
+ .edgesIgnoringSafeArea(.bottom)
+ }
+ } else {
+ ContactsInnerFragment(contactViewModel: contactViewModel, showingSheet: $showingSheet, text: $text)
+ .halfSheet(showSheet: $showingSheet) {
+ ContactsListBottomSheet(
+ contactViewModel: contactViewModel,
+ isShowDeletePopup: $isShowDeletePopup,
+ showingSheet: $showingSheet,
+ showShareSheet: $showShareSheet
+ )
+ } onDismiss: {}
+ .sheet(isPresented: $showShareSheet) {
+ ShareSheet(friendToShare: contactViewModel.selectedFriendToShare!)
+ .edgesIgnoringSafeArea(.bottom)
+ }
+ }
+ }
+ }
+}
+
+#Preview {
+ ContactsFragment(contactViewModel: ContactViewModel(), isShowDeletePopup: .constant(false), text: .constant(""))
+}
diff --git a/Linphone/UI/Main/Contacts/Fragments/ContactsInnerFragment.swift b/Linphone/UI/Main/Contacts/Fragments/ContactsInnerFragment.swift
new file mode 100644
index 000000000..580b37bbb
--- /dev/null
+++ b/Linphone/UI/Main/Contacts/Fragments/ContactsInnerFragment.swift
@@ -0,0 +1,112 @@
+/*
+ * 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 .
+ */
+
+import SwiftUI
+import linphonesw
+
+struct ContactsInnerFragment: View {
+
+ @ObservedObject var contactsManager = ContactsManager.shared
+
+ @ObservedObject var contactViewModel: ContactViewModel
+
+ @State private var isFavoriteOpen = true
+
+ @Binding var showingSheet: Bool
+ @Binding var text: String
+
+ var body: some View {
+ VStack(alignment: .leading) {
+ if !contactsManager.avatarListModel.filter({ $0.friend?.starred == true }).isEmpty {
+ HStack(alignment: .center) {
+ Text("Favourites")
+ .default_text_style_800(styleSize: 16)
+
+ Spacer()
+
+ Image(isFavoriteOpen ? "caret-up" : "caret-down")
+ .renderingMode(.template)
+ .resizable()
+ .foregroundStyle(Color.grayMain2c600)
+ .frame(width: 25, height: 25, alignment: .leading)
+ .padding(.all, 10)
+ }
+ .padding(.top, 10)
+ .padding(.horizontal, 16)
+ .background(.white)
+ .onTapGesture {
+ withAnimation {
+ isFavoriteOpen.toggle()
+ }
+ }
+
+ if isFavoriteOpen {
+ FavoriteContactsListFragment(
+ contactViewModel: contactViewModel,
+ favoriteContactsListViewModel: FavoriteContactsListViewModel(),
+ showingSheet: $showingSheet)
+ .zIndex(-1)
+ .transition(.move(edge: .top))
+ }
+
+ HStack(alignment: .center) {
+ Text("All contacts")
+ .default_text_style_800(styleSize: 16)
+
+ Spacer()
+ }
+ .padding(.top, 10)
+ .padding(.horizontal, 16)
+ }
+
+ VStack {
+ List {
+ ContactsListFragment(contactViewModel: contactViewModel, contactsListViewModel: ContactsListViewModel(),
+ showingSheet: $showingSheet, startCallFunc: {_ in })}
+ .safeAreaInset(edge: .top, content: {
+ Spacer()
+ .frame(height: 12)
+ })
+ .listStyle(.plain)
+ .overlay(
+ VStack {
+ if contactsManager.avatarListModel.isEmpty {
+ Spacer()
+ Image("illus-belledonne")
+ .resizable()
+ .scaledToFit()
+ .clipped()
+ .padding(.all)
+ Text(!text.isEmpty ? "list_filter_no_result_found" : "contacts_list_empty")
+ .default_text_style_800(styleSize: 16)
+ Spacer()
+ Spacer()
+ }
+ }
+ .padding(.all)
+ )
+ }
+ }
+ .navigationBarHidden(true)
+ }
+}
+
+#Preview {
+ ContactsInnerFragment(contactViewModel: ContactViewModel(), showingSheet: .constant(false), text: .constant(""))
+}
diff --git a/Linphone/UI/Main/Contacts/Fragments/ContactsListBottomSheet.swift b/Linphone/UI/Main/Contacts/Fragments/ContactsListBottomSheet.swift
new file mode 100644
index 000000000..f0ca349e9
--- /dev/null
+++ b/Linphone/UI/Main/Contacts/Fragments/ContactsListBottomSheet.swift
@@ -0,0 +1,192 @@
+/*
+ * 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 .
+ */
+
+import SwiftUI
+import linphonesw
+import Contacts
+
+struct ContactsListBottomSheet: View {
+
+ @Environment(\.dismiss) var dismiss
+
+ private var idiom: UIUserInterfaceIdiom { UIDevice.current.userInterfaceIdiom }
+
+ @ObservedObject var contactViewModel: ContactViewModel
+
+ @State private var orientation = UIDevice.current.orientation
+
+ @Binding var isShowDeletePopup: Bool
+ @Binding var showingSheet: Bool
+ @Binding var showShareSheet: Bool
+
+ var body: some View {
+ VStack(alignment: .leading) {
+ if idiom != .pad && (orientation == .landscapeLeft
+ || orientation == .landscapeRight
+ || UIScreen.main.bounds.size.width > UIScreen.main.bounds.size.height) {
+ Spacer()
+ HStack {
+ Spacer()
+ Button("Close") {
+ if #available(iOS 16.0, *) {
+ showingSheet.toggle()
+ } else {
+ showingSheet.toggle()
+ dismiss()
+ }
+ }
+ }
+ .padding(.trailing)
+ }
+
+ Spacer()
+ Button {
+ if contactViewModel.selectedFriend != nil {
+ contactViewModel.selectedFriend!.edit()
+ contactViewModel.selectedFriend!.starred.toggle()
+ contactViewModel.selectedFriend!.done()
+ }
+
+ MagicSearchSingleton.shared.searchForContacts(sourceFlags: MagicSearch.Source.Friends.rawValue | MagicSearch.Source.LdapServers.rawValue)
+
+ if #available(iOS 16.0, *) {
+ if idiom != .pad {
+ showingSheet.toggle()
+ } else {
+ showingSheet.toggle()
+ dismiss()
+ }
+ } else {
+ showingSheet.toggle()
+ dismiss()
+ }
+ } label: {
+ HStack {
+ Image(contactViewModel.selectedFriend != nil && contactViewModel.selectedFriend!.starred == true ? "heart-fill" : "heart")
+ .renderingMode(.template)
+ .resizable()
+ .foregroundStyle(
+ contactViewModel.selectedFriend != nil && contactViewModel.selectedFriend!.starred == true
+ ? Color.redDanger500
+ : Color.grayMain2c500
+ )
+ .frame(width: 25, height: 25, alignment: .leading)
+ .padding(.all, 10)
+ Text(contactViewModel.selectedFriend != nil && contactViewModel.selectedFriend!.starred == true
+ ? "Remove from favourites"
+ : "Add to favourites")
+ .default_text_style(styleSize: 16)
+ Spacer()
+ }
+ .frame(maxHeight: .infinity)
+ }
+ .padding(.horizontal, 30)
+ .background(Color.gray100)
+
+ VStack {
+ Divider()
+ }
+ .frame(maxWidth: .infinity)
+
+ Button {
+ if #available(iOS 16.0, *) {
+ if idiom != .pad {
+ showingSheet.toggle()
+ } else {
+ showingSheet.toggle()
+ dismiss()
+ }
+ } else {
+ showingSheet.toggle()
+ dismiss()
+ }
+
+ contactViewModel.selectedFriendToShare = contactViewModel.selectedFriend
+
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
+ showShareSheet.toggle()
+ }
+
+ } label: {
+ HStack {
+ Image("share-network")
+ .renderingMode(.template)
+ .resizable()
+ .foregroundStyle(Color.grayMain2c500)
+ .frame(width: 25, height: 25, alignment: .leading)
+ .padding(.all, 10)
+ Text("Share")
+ .default_text_style(styleSize: 16)
+ Spacer()
+ }
+ .frame(maxHeight: .infinity)
+ }
+ .padding(.horizontal, 30)
+ .background(Color.gray100)
+
+ VStack {
+ Divider()
+ }
+ .frame(maxWidth: .infinity)
+
+ Button {
+ if contactViewModel.selectedFriend != nil {
+ isShowDeletePopup.toggle()
+ }
+
+ if #available(iOS 16.0, *) {
+ if idiom != .pad {
+ showingSheet.toggle()
+ } else {
+ showingSheet.toggle()
+ dismiss()
+ }
+ } else {
+ showingSheet.toggle()
+ dismiss()
+ }
+ } label: {
+ HStack {
+ Image("trash-simple")
+ .renderingMode(.template)
+ .resizable()
+ .foregroundStyle(Color.redDanger500)
+ .frame(width: 25, height: 25, alignment: .leading)
+ .padding(.all, 10)
+ Text("Delete")
+ .foregroundStyle(Color.redDanger500)
+ .default_text_style(styleSize: 16)
+ Spacer()
+ }
+ .frame(maxHeight: .infinity)
+ }
+ .padding(.horizontal, 30)
+ .background(Color.gray100)
+
+ }
+ .background(Color.gray100)
+ .frame(maxWidth: .infinity)
+ .onRotate { newOrientation in
+ orientation = newOrientation
+ }
+ .onDisappear {
+ contactViewModel.selectedFriend = nil
+ }
+ }
+}
diff --git a/Linphone/UI/Main/Contacts/Fragments/ContactsListFragment.swift b/Linphone/UI/Main/Contacts/Fragments/ContactsListFragment.swift
new file mode 100644
index 000000000..1d6bcbf2b
--- /dev/null
+++ b/Linphone/UI/Main/Contacts/Fragments/ContactsListFragment.swift
@@ -0,0 +1,109 @@
+/*
+ * 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 .
+ */
+
+import SwiftUI
+import linphonesw
+
+struct ContactsListFragment: View {
+
+ @ObservedObject var contactsManager = ContactsManager.shared
+
+ @ObservedObject var contactViewModel: ContactViewModel
+ @ObservedObject var contactsListViewModel: ContactsListViewModel
+
+ @Binding var showingSheet: Bool
+
+ var startCallFunc: (_ addr: Address) -> Void
+
+ var body: some View {
+ ForEach(0...
+ */
+
+import SwiftUI
+import linphonesw
+
+// swiftlint:disable type_body_length
+struct EditContactFragment: View {
+
+ @ObservedObject var editContactViewModel: EditContactViewModel
+ @ObservedObject private var sharedMainViewModel = SharedMainViewModel.shared
+
+ @Environment(\.dismiss) var dismiss
+
+ private var idiom: UIUserInterfaceIdiom { UIDevice.current.userInterfaceIdiom }
+ @State private var orientation = UIDevice.current.orientation
+
+ var contactViewModel: ContactViewModel
+
+ @Binding var isShowEditContactFragment: Bool
+ @Binding var isShowDismissPopup: Bool
+
+ @State private var delayedColor = Color.white
+
+ @FocusState var isFirstNameFocused: Bool
+ @FocusState var isLastNameFocused: Bool
+ @FocusState var isSIPAddressFocused: Int?
+ @FocusState var isPhoneNumberFocused: Int?
+ @FocusState var isCompanyFocused: Bool
+ @FocusState var isJobTitleFocused: Bool
+
+ @State private var showPhotoPicker = false
+ @State private var selectedImage: UIImage?
+ @State private var removedImage = false
+
+ var body: some View {
+ ZStack {
+ VStack(spacing: 1) {
+ if editContactViewModel.selectedEditFriend == nil {
+ if #available(iOS 16.0, *) {
+ Rectangle()
+ .foregroundColor(delayedColor)
+ .edgesIgnoringSafeArea(.top)
+ .frame(height: 0)
+ .task(delayColor)
+ } else if idiom != .pad && !(orientation == .landscapeLeft || orientation == .landscapeRight
+ || UIScreen.main.bounds.size.width > UIScreen.main.bounds.size.height) {
+ Rectangle()
+ .foregroundColor(delayedColor)
+ .edgesIgnoringSafeArea(.top)
+ .frame(height: 1)
+ .task(delayColor)
+ }
+ } else {
+ if #available(iOS 16.0, *) {
+ Rectangle()
+ .foregroundColor(Color.orangeMain500)
+ .edgesIgnoringSafeArea(.top)
+ .frame(height: 0)
+ } else if idiom != .pad && !(orientation == .landscapeLeft || orientation == .landscapeRight
+ || UIScreen.main.bounds.size.width > UIScreen.main.bounds.size.height) {
+ Rectangle()
+ .foregroundColor(Color.orangeMain500)
+ .edgesIgnoringSafeArea(.top)
+ .frame(height: 1)
+ }
+ }
+
+ HStack {
+ Image("caret-left")
+ .renderingMode(.template)
+ .resizable()
+ .foregroundStyle(Color.orangeMain500)
+ .frame(width: 25, height: 25, alignment: .leading)
+ .padding(.all, 10)
+ .padding(.top, 2)
+ .padding(.leading, -10)
+ .onTapGesture {
+ if editContactViewModel.selectedEditFriend == nil
+ && editContactViewModel.firstName.isEmpty
+ && editContactViewModel.lastName.isEmpty
+ && editContactViewModel.sipAddresses.first!.isEmpty
+ && editContactViewModel.phoneNumbers.first!.isEmpty
+ && editContactViewModel.company.isEmpty
+ && editContactViewModel.jobTitle.isEmpty {
+ delayColorDismiss()
+ withAnimation {
+ isShowEditContactFragment.toggle()
+ }
+ } else if editContactViewModel.selectedEditFriend == nil {
+ isShowDismissPopup.toggle()
+ } else {
+ if editContactViewModel.firstName.isEmpty
+ && editContactViewModel.lastName.isEmpty
+ && editContactViewModel.sipAddresses.first!.isEmpty
+ && editContactViewModel.phoneNumbers.first!.isEmpty
+ && editContactViewModel.company.isEmpty
+ && editContactViewModel.jobTitle.isEmpty {
+ withAnimation {
+ dismiss()
+ }
+ } else {
+ isShowDismissPopup.toggle()
+ }
+ }
+ }
+
+ Text(editContactViewModel.selectedEditFriend == nil ? "New contact" : "Edit contact")
+ .multilineTextAlignment(.leading)
+ .default_text_style_orange_800(styleSize: 16)
+
+ Spacer()
+
+ Image("check")
+ .renderingMode(.template)
+ .resizable()
+ .foregroundStyle(editContactViewModel.firstName.isEmpty ? Color.orangeMain100 : Color.orangeMain500)
+ .frame(width: 25, height: 25, alignment: .leading)
+ .padding(.all, 10)
+ .padding(.top, 2)
+ .disabled(editContactViewModel.firstName.isEmpty)
+ .onTapGesture {
+ withAnimation {
+ addOrEditFriend()
+ }
+ }
+ }
+ .frame(maxWidth: .infinity)
+ .frame(height: 50)
+ .padding(.horizontal)
+ .padding(.bottom, 4)
+ .background(.white)
+
+ ScrollView {
+ VStack(spacing: 0) {
+ VStack(spacing: 0) {
+ VStack(spacing: 0) {
+ if editContactViewModel.selectedEditFriend != nil
+ && editContactViewModel.selectedEditFriend!.photo != nil
+ && !editContactViewModel.selectedEditFriend!.photo!.isEmpty && selectedImage == nil && !removedImage {
+
+ Avatar(contactAvatarModel:
+ ContactAvatarModel(
+ friend: editContactViewModel.selectedEditFriend!,
+ name: editContactViewModel.selectedEditFriend?.name ?? "",
+ address: editContactViewModel.selectedEditFriend?.address?.asStringUriOnly() ?? "",
+ withPresence: false
+ ), avatarSize: 100
+ )
+
+ } else if selectedImage == nil {
+ Image("profil-picture-default")
+ .resizable()
+ .frame(width: 100, height: 100)
+ .clipShape(Circle())
+ } else {
+ Image(uiImage: selectedImage!)
+ .resizable()
+ .aspectRatio(contentMode: .fill)
+ .frame(width: 100, height: 100)
+ .clipShape(Circle())
+ }
+
+ if editContactViewModel.selectedEditFriend != nil
+ && editContactViewModel.selectedEditFriend!.photo != nil
+ && !editContactViewModel.selectedEditFriend!.photo!.isEmpty
+ && (editContactViewModel.selectedEditFriend!.photo!.suffix(11) != "default.png" || selectedImage != nil) && !removedImage {
+ HStack {
+ Spacer()
+
+ Button(action: {
+ showPhotoPicker = true
+ }, label: {
+ HStack {
+ Image("pencil-simple")
+ .resizable()
+ .frame(width: 20, height: 20)
+
+ Text("Edit picture")
+ .foregroundStyle(Color.grayMain2c700)
+ .multilineTextAlignment(.center)
+ .default_text_style(styleSize: 14)
+ }
+ })
+ .padding(.top, 10)
+ .padding(.trailing, 10)
+ .sheet(isPresented: $showPhotoPicker) {
+ PhotoPicker(filter: .images, limit: 1) { results in
+ PhotoPicker.convertToUIImageArray(fromResults: results) { imagesOrNil, errorOrNil in
+ if let error = errorOrNil {
+ print(error)
+ }
+ if let images = imagesOrNil {
+ if let first = images.first {
+ selectedImage = first
+ removedImage = false
+ }
+ }
+ }
+ }
+ .edgesIgnoringSafeArea(.all)
+ }
+
+ Button(action: {
+ removedImage = true
+ selectedImage = nil
+ }, label: {
+ HStack {
+ Image("trash-simple")
+ .resizable()
+ .frame(width: 20, height: 20)
+
+ Text("Remove picture")
+ .foregroundStyle(Color.grayMain2c700)
+ .multilineTextAlignment(.center)
+ .default_text_style(styleSize: 14)
+ }
+ })
+ .padding(.top, 10)
+
+ Spacer()
+ }
+ } else {
+ Button(action: {
+ showPhotoPicker = true
+ }, label: {
+ HStack {
+ Image("camera")
+ .resizable()
+ .frame(width: 20, height: 20)
+
+ Text("Add a picture")
+ .foregroundStyle(Color.grayMain2c700)
+ .multilineTextAlignment(.center)
+ .default_text_style(styleSize: 14)
+ }
+ })
+ .padding(.top, 10)
+ .sheet(isPresented: $showPhotoPicker) {
+ PhotoPicker(filter: .images, limit: 1) { results in
+ PhotoPicker.convertToUIImageArray(fromResults: results) { imagesOrNil, errorOrNil in
+ if let error = errorOrNil {
+ print(error)
+ }
+ if let images = imagesOrNil {
+ if let first = images.first {
+ selectedImage = first
+ removedImage = false
+ }
+ }
+ }
+ }
+ .edgesIgnoringSafeArea(.all)
+ }
+ }
+ }
+ .frame(minHeight: 150)
+ .frame(maxWidth: .infinity)
+ .padding(.top, 10)
+ .background(Color.gray100)
+
+ VStack(alignment: .leading) {
+ Text("First name*")
+ .default_text_style_700(styleSize: 15)
+ .padding(.bottom, -5)
+
+ TextField("First Name", text: $editContactViewModel.firstName)
+ .default_text_style(styleSize: 15)
+ .frame(height: 25)
+ .padding(.horizontal, 20)
+ .padding(.vertical, 15)
+ .background(.white)
+ .cornerRadius(60)
+ .overlay(
+ RoundedRectangle(cornerRadius: 60)
+ .inset(by: 0.5)
+ .stroke(isFirstNameFocused ? Color.orangeMain500 : Color.gray200, lineWidth: 1)
+ )
+ .padding(.bottom)
+ .focused($isFirstNameFocused)
+ }
+
+ VStack(alignment: .leading) {
+ Text("Last name")
+ .default_text_style_700(styleSize: 15)
+ .padding(.bottom, -5)
+
+ TextField("Last name", text: $editContactViewModel.lastName)
+ .default_text_style(styleSize: 15)
+ .frame(height: 25)
+ .padding(.horizontal, 20)
+ .padding(.vertical, 15)
+ .background(.white)
+ .cornerRadius(60)
+ .overlay(
+ RoundedRectangle(cornerRadius: 60)
+ .inset(by: 0.5)
+ .stroke(isLastNameFocused ? Color.orangeMain500 : Color.gray200, lineWidth: 1)
+ )
+ .padding(.bottom)
+ .focused($isLastNameFocused)
+ }
+
+ VStack(alignment: .leading) {
+ Text("SIP address")
+ .default_text_style_700(styleSize: 15)
+ .padding(.bottom, -5)
+
+ ForEach(0...
+ */
+
+import SwiftUI
+import linphonesw
+
+struct FavoriteContactsListFragment: View {
+
+ @ObservedObject var contactsManager = ContactsManager.shared
+
+ @ObservedObject var contactViewModel: ContactViewModel
+ @ObservedObject var favoriteContactsListViewModel: FavoriteContactsListViewModel
+
+ @Binding var showingSheet: Bool
+
+ var body: some View {
+ ScrollView(.horizontal) {
+ HStack {
+ ForEach(0...
+ */
+
+import SwiftUI
+import linphonesw
+
+struct SipAddressesPopup: View {
+
+ @ObservedObject private var telecomManager = TelecomManager.shared
+ @ObservedObject private var sharedMainViewModel = SharedMainViewModel.shared
+
+ @ObservedObject var contactAvatarModel: ContactAvatarModel
+ @ObservedObject var contactViewModel: ContactViewModel
+
+ @Binding var isShowSipAddressesPopup: Bool
+ @Binding var isShowSipAddressesPopupType: Int
+
+ var body: some View {
+ GeometryReader { geometry in
+ VStack(alignment: .leading) {
+ HStack {
+ Text("contact_dialog_pick_phone_number_or_sip_address_title")
+ .default_text_style_800(styleSize: 16)
+ .padding(.bottom, 2)
+
+ Spacer()
+
+ Image("x")
+ .renderingMode(.template)
+ .resizable()
+ .foregroundStyle(Color.grayMain2c600)
+ .frame(width: 25, height: 25)
+ .padding(.all, 10)
+ }
+ .frame(maxWidth: .infinity)
+
+ ForEach(0...
+ */
+
+import Foundation
+import linphonesw
+import Combine
+
+class ContactAvatarModel: ObservableObject {
+
+ let friend: Friend?
+
+ let name: String
+
+ let address: String
+
+ @Published var addresses: [String]
+
+ let nativeUri: String
+
+ let withPresence: Bool?
+
+ @Published var lastPresenceInfo: String
+
+ @Published var presenceStatus: ConsolidatedPresence
+
+ private var friendDelegate: FriendDelegate?
+
+ init(friend: Friend?, name: String, address: String, withPresence: Bool?) {
+ self.friend = friend
+ self.name = name
+ self.address = address
+ var addressesTmp: [String] = []
+ if friend != nil {
+ friend!.addresses.forEach { address in
+ addressesTmp.append(address.asStringUriOnly())
+ }
+ }
+ self.addresses = addressesTmp
+ self.nativeUri = friend?.nativeUri ?? ""
+ self.withPresence = withPresence
+ if friend != nil &&
+ withPresence == true {
+ self.lastPresenceInfo = ""
+
+ self.presenceStatus = friend!.consolidatedPresence
+
+ if friend!.consolidatedPresence == .Online || friend!.consolidatedPresence == .Busy {
+ if friend!.consolidatedPresence == .Online || friend!.presenceModel!.latestActivityTimestamp != -1 {
+ self.lastPresenceInfo = (friend!.consolidatedPresence == .Online) ?
+ "Online" : getCallTime(startDate: friend!.presenceModel!.latestActivityTimestamp)
+ } else {
+ self.lastPresenceInfo = "Away"
+ }
+ } else {
+ self.lastPresenceInfo = ""
+ }
+
+ if let delegate = friendDelegate {
+ self.friend?.removeDelegate(delegate: delegate)
+ self.friendDelegate = nil
+ }
+
+ addFriendDelegate()
+ } else {
+ self.lastPresenceInfo = ""
+ self.presenceStatus = .Offline
+ }
+ }
+
+ func addFriendDelegate() {
+ friendDelegate = FriendDelegateStub(onPresenceReceived: { (friend: Friend) in
+ let latestActivityTimestamp = friend.presenceModel?.latestActivityTimestamp ?? -1
+ DispatchQueue.main.async {
+ self.presenceStatus = friend.consolidatedPresence
+ if friend.consolidatedPresence == .Online || friend.consolidatedPresence == .Busy {
+ if friend.consolidatedPresence == .Online || latestActivityTimestamp != -1 {
+ self.lastPresenceInfo = friend.consolidatedPresence == .Online ?
+ "Online" : self.getCallTime(startDate: latestActivityTimestamp)
+ } else {
+ self.lastPresenceInfo = "Away"
+ }
+ } else {
+ self.lastPresenceInfo = ""
+ }
+ }
+ })
+ friend?.addDelegate(delegate: friendDelegate!)
+ }
+
+ func removeFriendDelegate() {
+ if let delegate = friendDelegate {
+ presenceStatus = .Offline
+ friend?.removeDelegate(delegate: delegate)
+ friendDelegate = nil
+ }
+ }
+
+ func getCallTime(startDate: time_t) -> String {
+ let timeInterval = TimeInterval(startDate)
+
+ let myNSDate = Date(timeIntervalSince1970: timeInterval)
+
+ if Calendar.current.isDateInToday(myNSDate) {
+ let formatter = DateFormatter()
+ formatter.dateFormat = Locale.current.identifier == "fr_FR" ? "HH:mm" : "h:mm a"
+ return "Online today at " + formatter.string(from: myNSDate)
+ } else if Calendar.current.isDateInYesterday(myNSDate) {
+ let formatter = DateFormatter()
+ formatter.dateFormat = Locale.current.identifier == "fr_FR" ? "HH:mm" : "h:mm a"
+ return "Online yesterday at " + formatter.string(from: myNSDate)
+ } else if Calendar.current.isDate(myNSDate, equalTo: .now, toGranularity: .year) {
+ let formatter = DateFormatter()
+ formatter.dateFormat = Locale.current.identifier == "fr_FR" ? "dd/MM | HH:mm" : "MM/dd | h:mm a"
+ return "Online on " + formatter.string(from: myNSDate)
+ } else {
+ let formatter = DateFormatter()
+ formatter.dateFormat = Locale.current.identifier == "fr_FR" ? "dd/MM/yy | HH:mm" : "MM/dd/yy | h:mm a"
+ return "Online on " + formatter.string(from: myNSDate)
+ }
+ }
+
+ static func getAvatarModelFromAddress(address: Address, completion: @escaping (ContactAvatarModel) -> Void) {
+ ContactsManager.shared.getFriendWithAddressInCoreQueue(address: address) { resultFriend in
+ if let addressFriend = resultFriend {
+ if addressFriend.address != nil {
+ var avatarModel = ContactsManager.shared.avatarListModel.first(where: {
+ $0.friend != nil && $0.friend!.name == addressFriend.name && $0.friend!.address != nil
+ && $0.friend!.address!.asStringUriOnly() == addressFriend.address!.asStringUriOnly()
+ })
+
+ if avatarModel == nil {
+ avatarModel = ContactAvatarModel(friend: nil, name: addressFriend.name!, address: addressFriend.address!.asStringUriOnly(), withPresence: false)
+ }
+ completion(avatarModel!)
+ } else {
+ let name = address.displayName != nil ? address.displayName! : address.username!
+ completion(ContactAvatarModel(friend: nil, name: name, address: address.asStringUriOnly(), withPresence: false))
+ }
+ } else {
+ let name = address.displayName != nil ? address.displayName! : address.username!
+ completion(ContactAvatarModel(friend: nil, name: name, address: address.asStringUriOnly(), withPresence: false))
+ }
+ }
+ }
+}
diff --git a/Linphone/UI/Main/Contacts/ViewModel/ContactViewModel.swift b/Linphone/UI/Main/Contacts/ViewModel/ContactViewModel.swift
new file mode 100644
index 000000000..38390ac93
--- /dev/null
+++ b/Linphone/UI/Main/Contacts/ViewModel/ContactViewModel.swift
@@ -0,0 +1,239 @@
+/*
+ * 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 .
+ */
+
+import linphonesw
+import Combine
+
+// swiftlint:disable line_length
+class ContactViewModel: ObservableObject {
+
+ @Published var indexDisplayedFriend: Int?
+
+ var stringToCopy: String = ""
+
+ var selectedFriend: Friend?
+ var selectedFriendToShare: Friend?
+ var selectedFriendToDelete: Friend?
+
+ @Published var operationInProgress: Bool = false
+ @Published var displayedConversation: ConversationModel?
+
+ private var contactChatRoomDelegate: ChatRoomDelegate?
+
+ init() {}
+
+ func createOneToOneChatRoomWith(remote: Address) {
+ CoreContext.shared.doOnCoreQueue { core in
+ let account = core.defaultAccount
+ if account == nil {
+ Log.error(
+ "\(StartConversationViewModel.TAG) No default account found, can't create conversation with \(remote.asStringUriOnly())!"
+ )
+ return
+ }
+
+ DispatchQueue.main.async {
+ self.operationInProgress = true
+ }
+
+ do {
+ let params: ChatRoomParams = try core.createDefaultChatRoomParams()
+ params.groupEnabled = false
+ params.subject = "Dummy subject"
+ params.ephemeralLifetime = 0 // Make sure ephemeral is disabled by default
+
+ let sameDomain = remote.domain == account?.params?.domain ?? ""
+ if StartConversationViewModel.isEndToEndEncryptionMandatory() && sameDomain {
+ Log.info("\(StartConversationViewModel.TAG) Account is in secure mode & domain matches, creating a E2E conversation")
+ params.backend = ChatRoom.Backend.FlexisipChat
+ params.encryptionEnabled = true
+ } else if !StartConversationViewModel.isEndToEndEncryptionMandatory() {
+ if LinphoneUtils.isEndToEndEncryptedChatAvailable(core: core) {
+ Log.info(
+ "\(StartConversationViewModel.TAG) Account is in interop mode but LIME is available, creating a E2E conversation"
+ )
+ params.backend = ChatRoom.Backend.FlexisipChat
+ params.encryptionEnabled = true
+ } else {
+ Log.info(
+ "\(StartConversationViewModel.TAG) Account is in interop mode but LIME isn't available, creating a SIP simple conversation"
+ )
+ params.backend = ChatRoom.Backend.Basic
+ params.encryptionEnabled = false
+ }
+ } else {
+ Log.error(
+ "\(StartConversationViewModel.TAG) Account is in secure mode, can't chat with SIP address of different domain \(remote.asStringUriOnly())"
+ )
+ DispatchQueue.main.async {
+ self.operationInProgress = false
+ ToastViewModel.shared.toastMessage = "Failed_to_create_conversation_invalid_participant_error"
+ ToastViewModel.shared.displayToast = true
+ }
+ return
+ }
+
+ let participants = [remote]
+ let localAddress = account?.params?.identityAddress
+ let existingChatRoom = core.searchChatRoom(params: params, localAddr: localAddress, remoteAddr: nil, participants: participants)
+ if existingChatRoom == nil {
+ Log.info(
+ "\(StartConversationViewModel.TAG) No existing 1-1 conversation between local account "
+ + "\(localAddress?.asStringUriOnly() ?? "") and remote \(remote.asStringUriOnly()) was found for given parameters, let's create it"
+ )
+ let chatRoom = try core.createChatRoom(params: params, localAddr: localAddress, participants: participants)
+ if params.backend == ChatRoom.Backend.FlexisipChat {
+ if chatRoom.state == ChatRoom.State.Created {
+ let id = LinphoneUtils.getChatRoomId(room: chatRoom)
+ Log.info("\(StartConversationViewModel.TAG) 1-1 conversation \(id) has been created")
+
+ let model = ConversationModel(chatRoom: chatRoom)
+ if self.operationInProgress == false {
+ DispatchQueue.main.async {
+ self.operationInProgress = true
+ }
+
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
+ self.operationInProgress = false
+ self.displayedConversation = model
+ }
+ } else {
+ DispatchQueue.main.async {
+ self.operationInProgress = false
+ self.displayedConversation = model
+ }
+ }
+ } else {
+ Log.info("\(StartConversationViewModel.TAG) Conversation isn't in Created state yet, wait for it")
+ self.chatRoomAddDelegate(core: core, chatRoom: chatRoom)
+ }
+ } else {
+ let id = LinphoneUtils.getChatRoomId(room: chatRoom)
+ Log.info("\(StartConversationViewModel.TAG) Conversation successfully created \(id)")
+
+ let model = ConversationModel(chatRoom: chatRoom)
+ if self.operationInProgress == false {
+ DispatchQueue.main.async {
+ self.operationInProgress = true
+ }
+
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
+ self.operationInProgress = false
+ self.displayedConversation = model
+ }
+ } else {
+ DispatchQueue.main.async {
+ self.operationInProgress = false
+ self.displayedConversation = model
+ }
+ }
+ }
+ } else {
+ Log.warn(
+ "\(StartConversationViewModel.TAG) A 1-1 conversation between local account \(localAddress?.asStringUriOnly() ?? "") and remote \(remote.asStringUriOnly()) for given parameters already exists!"
+ )
+
+ let model = ConversationModel(chatRoom: existingChatRoom!)
+ if self.operationInProgress == false {
+ DispatchQueue.main.async {
+ self.operationInProgress = true
+ }
+
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
+ self.operationInProgress = false
+ self.displayedConversation = model
+ }
+ } else {
+ DispatchQueue.main.async {
+ self.operationInProgress = false
+ self.displayedConversation = model
+ }
+ }
+ }
+ } catch {
+ DispatchQueue.main.async {
+ self.operationInProgress = false
+ ToastViewModel.shared.toastMessage = "Failed_to_create_conversation_error"
+ ToastViewModel.shared.displayToast = true
+ }
+ Log.error("\(StartConversationViewModel.TAG) Failed to create 1-1 conversation with \(remote.asStringUriOnly())!")
+ }
+ }
+ }
+
+ func chatRoomAddDelegate(core: Core, chatRoom: ChatRoom) {
+ contactChatRoomDelegate = ChatRoomDelegateStub(onStateChanged: { (chatRoom: ChatRoom, state: ChatRoom.State) in
+ let state = chatRoom.state
+ let id = LinphoneUtils.getChatRoomId(room: chatRoom)
+ if state == ChatRoom.State.CreationFailed {
+ Log.error("\(StartConversationViewModel.TAG) Conversation \(id) creation has failed!")
+ if let delegate = self.contactChatRoomDelegate {
+ chatRoom.removeDelegate(delegate: delegate)
+ self.contactChatRoomDelegate = nil
+ }
+ DispatchQueue.main.async {
+ self.operationInProgress = false
+ ToastViewModel.shared.toastMessage = "Failed_to_create_conversation_error"
+ ToastViewModel.shared.displayToast = true
+ }
+ }
+ }, onConferenceJoined: { (chatRoom: ChatRoom, _: EventLog) in
+ let state = chatRoom.state
+ let id = LinphoneUtils.getChatRoomId(room: chatRoom)
+ Log.info("\(StartConversationViewModel.TAG) Conversation \(id) \(chatRoom.subject ?? "") state changed: \(state)")
+ if state == ChatRoom.State.Created {
+ Log.info("\(StartConversationViewModel.TAG) Conversation \(id) successfully created")
+ if let delegate = self.contactChatRoomDelegate {
+ chatRoom.removeDelegate(delegate: delegate)
+ self.contactChatRoomDelegate = nil
+ }
+ let model = ConversationModel(chatRoom: chatRoom)
+ if self.operationInProgress == false {
+ DispatchQueue.main.async {
+ self.operationInProgress = true
+ }
+
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
+ self.operationInProgress = false
+ self.displayedConversation = model
+ }
+ } else {
+ DispatchQueue.main.async {
+ self.operationInProgress = false
+ self.displayedConversation = model
+ }
+ }
+ } else if state == ChatRoom.State.CreationFailed {
+ Log.error("\(StartConversationViewModel.TAG) Conversation \(id) creation has failed!")
+
+ if let delegate = self.contactChatRoomDelegate {
+ chatRoom.removeDelegate(delegate: delegate)
+ self.contactChatRoomDelegate = nil
+ }
+ DispatchQueue.main.async {
+ self.operationInProgress = false
+ ToastViewModel.shared.toastMessage = "Failed_to_create_conversation_error"
+ ToastViewModel.shared.displayToast = true
+ }
+ }
+ })
+ chatRoom.addDelegate(delegate: contactChatRoomDelegate!)
+ }
+}
+// swiftlint:enable line_length
diff --git a/Linphone/UI/Main/Contacts/ViewModel/ContactsListViewModel.swift b/Linphone/UI/Main/Contacts/ViewModel/ContactsListViewModel.swift
new file mode 100644
index 000000000..3c59661d2
--- /dev/null
+++ b/Linphone/UI/Main/Contacts/ViewModel/ContactsListViewModel.swift
@@ -0,0 +1,25 @@
+/*
+ * 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 .
+ */
+
+import linphonesw
+
+class ContactsListViewModel: ObservableObject {
+
+ init() {}
+}
diff --git a/Linphone/UI/Main/Contacts/ViewModel/EditContactViewModel.swift b/Linphone/UI/Main/Contacts/ViewModel/EditContactViewModel.swift
new file mode 100644
index 000000000..25fa52755
--- /dev/null
+++ b/Linphone/UI/Main/Contacts/ViewModel/EditContactViewModel.swift
@@ -0,0 +1,77 @@
+/*
+ * 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 .
+ */
+
+import linphonesw
+
+class EditContactViewModel: ObservableObject {
+
+ @Published var selectedEditFriend: Friend?
+
+ @Published var identifier: String = ""
+ @Published var firstName: String = ""
+ @Published var lastName: String = ""
+ @Published var sipAddresses: [String] = []
+ @Published var phoneNumbers: [String] = []
+ @Published var company: String = ""
+ @Published var jobTitle: String = ""
+ @Published var removePopup: Bool = false
+
+ init() {
+ resetValues()
+ }
+
+ func resetValues() {
+ CoreContext.shared.doOnCoreQueue { _ in
+ let nativeUriTmp = (self.selectedEditFriend == nil ? "" : self.selectedEditFriend!.nativeUri) ?? ""
+ let givenNameTmp = (self.selectedEditFriend == nil ? "" : self.selectedEditFriend!.vcard?.givenName) ?? ""
+ let familyNameTmp = (self.selectedEditFriend == nil ? "" : self.selectedEditFriend!.vcard?.familyName) ?? ""
+ let organizationTmp = (self.selectedEditFriend == nil ? "" : self.selectedEditFriend!.organization) ?? ""
+ let jobTitleTmp = (self.selectedEditFriend == nil ? "" : self.selectedEditFriend!.jobTitle) ?? ""
+
+ var sipAddressesTmp: [String] = []
+ var phoneNumbersTmp: [String] = []
+
+ if self.selectedEditFriend != nil {
+ self.selectedEditFriend?.addresses.forEach({ address in
+ sipAddressesTmp.append(String(address.asStringUriOnly().dropFirst(4)))
+ })
+
+ self.selectedEditFriend?.phoneNumbers.forEach({ phoneNumber in
+ phoneNumbersTmp.append(phoneNumber)
+ })
+ }
+
+ DispatchQueue.main.async {
+ self.identifier = nativeUriTmp
+ self.firstName = givenNameTmp
+ self.lastName = familyNameTmp
+ self.sipAddresses = []
+ self.phoneNumbers = []
+ self.company = organizationTmp
+ self.jobTitle = jobTitleTmp
+
+ self.sipAddresses = sipAddressesTmp
+ self.phoneNumbers = phoneNumbersTmp
+
+ self.sipAddresses.append("")
+ self.phoneNumbers.append("")
+ }
+ }
+ }
+}
diff --git a/Linphone/UI/Main/Contacts/ViewModel/FavoriteContactsListViewModel.swift b/Linphone/UI/Main/Contacts/ViewModel/FavoriteContactsListViewModel.swift
new file mode 100644
index 000000000..852da7946
--- /dev/null
+++ b/Linphone/UI/Main/Contacts/ViewModel/FavoriteContactsListViewModel.swift
@@ -0,0 +1,25 @@
+/*
+ * 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 .
+ */
+
+import linphonesw
+
+class FavoriteContactsListViewModel: ObservableObject {
+
+ init() {}
+}
diff --git a/Linphone/UI/Main/ContentView.swift b/Linphone/UI/Main/ContentView.swift
new file mode 100644
index 000000000..64c4dd3b7
--- /dev/null
+++ b/Linphone/UI/Main/ContentView.swift
@@ -0,0 +1,1302 @@
+/*
+ * 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 .
+ */
+
+// swiftlint:disable type_body_length
+// swiftlint:disable line_length
+import SwiftUI
+import linphonesw
+
+struct ContentView: View {
+
+ @Environment(\.scenePhase) var scenePhase
+ private var idiom: UIUserInterfaceIdiom { UIDevice.current.userInterfaceIdiom }
+
+ @EnvironmentObject var navigationManager: NavigationManager
+
+ @ObservedObject private var coreContext = CoreContext.shared
+ @ObservedObject private var sharedMainViewModel = SharedMainViewModel.shared
+ @ObservedObject private var telecomManager = TelecomManager.shared
+
+ @ObservedObject var contactsManager = ContactsManager.shared
+ var magicSearch = MagicSearchSingleton.shared
+
+ @ObservedObject var contactViewModel: ContactViewModel
+ @ObservedObject var editContactViewModel: EditContactViewModel
+ @ObservedObject var historyViewModel: HistoryViewModel
+ @ObservedObject var historyListViewModel: HistoryListViewModel
+ @ObservedObject var startCallViewModel: StartCallViewModel
+ @ObservedObject var startConversationViewModel: StartConversationViewModel
+ @ObservedObject var callViewModel: CallViewModel
+ @ObservedObject var meetingWaitingRoomViewModel: MeetingWaitingRoomViewModel
+ @ObservedObject var conversationsListViewModel: ConversationsListViewModel
+ @ObservedObject var conversationViewModel: ConversationViewModel
+ @ObservedObject var meetingsListViewModel: MeetingsListViewModel
+ @ObservedObject var meetingViewModel: MeetingViewModel
+ @ObservedObject var conversationForwardMessageViewModel: ConversationForwardMessageViewModel
+
+ @State var index = 0
+ @State private var orientation = UIDevice.current.orientation
+ @State var sideMenuIsOpen: Bool = false
+
+ @State private var searchIsActive = false
+ @State private var text = ""
+ @FocusState private var focusedField: Bool
+ @State private var showingDialer = false
+ @State var isMenuOpen = false
+ @State var isShowDeleteContactPopup = false
+ @State var isShowDeleteAllHistoryPopup = false
+ @State var isShowEditContactFragment = false
+ @State var isShowStartCallFragment = false
+ @State var isShowStartConversationFragment = false
+ @State var isShowDismissPopup = false
+ @State var isShowSendCancelMeetingNotificationPopup = false
+ @State var isShowStartCallGroupPopup = false
+ @State var isShowSipAddressesPopup = false
+ @State var isShowSipAddressesPopupType = 0 // 0 to call, 1 to message, 2 to video call
+ @State var isShowConversationFragment = false
+
+ @State var fullscreenVideo = false
+
+ @State var isShowScheduleMeetingFragment = false
+ @State private var isShowLoginFragment: Bool = false
+
+ var body: some View {
+ let pub = NotificationCenter.default
+ .publisher(for: NSNotification.Name("ContactLoaded"))
+
+ GeometryReader { geometry in
+ VStack(spacing: 0) {
+ if (telecomManager.callInProgress && !fullscreenVideo && ((!telecomManager.callDisplayed && callViewModel.callsCounter == 1) || callViewModel.callsCounter > 1)) || isShowConversationFragment {
+ HStack {
+ Image("phone")
+ .renderingMode(.template)
+ .resizable()
+ .foregroundStyle(.white)
+ .frame(width: 26, height: 26)
+ .padding(.leading, 10)
+
+ if callViewModel.callsCounter > 1 {
+ Text("\(callViewModel.callsCounter) appels")
+ .default_text_style_white(styleSize: 16)
+ } else {
+ Text("\(callViewModel.displayName)")
+ .default_text_style_white(styleSize: 16)
+ }
+
+ Spacer()
+
+ if callViewModel.callsCounter == 1 {
+ Text("\(callViewModel.isPaused || telecomManager.isPausedByRemote ? "En pause" : "Actif")")
+ .default_text_style_white(styleSize: 16)
+ .padding(.trailing, 10)
+ }
+ }
+ .frame(maxWidth: .infinity)
+ .frame(height: 30)
+ .background(Color.greenSuccess500)
+ .onTapGesture {
+ withAnimation {
+ telecomManager.callDisplayed = true
+ }
+ }
+ }
+
+ ZStack {
+ VStack(spacing: 0) {
+ HStack(spacing: 0) {
+ if orientation == .landscapeLeft
+ || orientation == .landscapeRight
+ || UIScreen.main.bounds.size.width > UIScreen.main.bounds.size.height {
+ VStack(spacing: 0) {
+ Group {
+ Spacer()
+
+ Button(action: {
+ self.index = 0
+ historyViewModel.displayedCall = nil
+ conversationViewModel.displayedConversation = nil
+ meetingViewModel.displayedMeeting = nil
+ }, label: {
+ VStack {
+ Image("address-book")
+ .renderingMode(.template)
+ .resizable()
+ .foregroundStyle(self.index == 0 ? Color.orangeMain500 : Color.grayMain2c600)
+ .frame(width: 25, height: 25)
+ if self.index == 0 {
+ Text("Contacts")
+ .default_text_style_700(styleSize: 10)
+ } else {
+ Text("Contacts")
+ .default_text_style(styleSize: 10)
+ }
+ }
+ })
+ .padding(.top)
+ .frame(height: geometry.size.height/4)
+
+ ZStack {
+ if historyListViewModel.missedCallsCount > 0 {
+ VStack {
+ HStack {
+ Text(
+ historyListViewModel.missedCallsCount < 99
+ ? String(historyListViewModel.missedCallsCount)
+ : "99+"
+ )
+ .foregroundStyle(.white)
+ .default_text_style(styleSize: 10)
+ .lineLimit(1)
+ }
+ .frame(width: 18, height: 18)
+ .background(Color.redDanger500)
+ .cornerRadius(50)
+ }
+ .padding(.bottom, 30)
+ .padding(.leading, 30)
+ }
+
+ Button(action: {
+ self.index = 1
+ contactViewModel.indexDisplayedFriend = nil
+ conversationViewModel.displayedConversation = nil
+ meetingViewModel.displayedMeeting = nil
+ if historyListViewModel.missedCallsCount > 0 {
+ historyListViewModel.resetMissedCallsCount()
+ }
+ }, label: {
+ VStack {
+ Image("phone")
+ .renderingMode(.template)
+ .resizable()
+ .foregroundStyle(self.index == 1 ? Color.orangeMain500 : Color.grayMain2c600)
+ .frame(width: 25, height: 25)
+ if self.index == 1 {
+ Text("Calls")
+ .default_text_style_700(styleSize: 10)
+ } else {
+ Text("Calls")
+ .default_text_style(styleSize: 10)
+ }
+ }
+ })
+ .padding(.top)
+ }
+ .frame(height: geometry.size.height/4)
+
+ ZStack {
+ if conversationsListViewModel.unreadMessages > 0 {
+ VStack {
+ HStack {
+ Text(
+ conversationsListViewModel.unreadMessages < 99
+ ? String(conversationsListViewModel.unreadMessages)
+ : "99+"
+ )
+ .foregroundStyle(.white)
+ .default_text_style(styleSize: 10)
+ .lineLimit(1)
+ }
+ .frame(width: 18, height: 18)
+ .background(Color.redDanger500)
+ .cornerRadius(50)
+ }
+ .padding(.bottom, 30)
+ .padding(.leading, 30)
+ }
+
+ Button(action: {
+ self.index = 2
+ historyViewModel.displayedCall = nil
+ contactViewModel.indexDisplayedFriend = nil
+ meetingViewModel.displayedMeeting = nil
+ }, label: {
+ VStack {
+ Image("chat-teardrop-text")
+ .renderingMode(.template)
+ .resizable()
+ .foregroundStyle(self.index == 2 ? Color.orangeMain500 : Color.grayMain2c600)
+ .frame(width: 25, height: 25)
+
+ if self.index == 2 {
+ Text("Conversations")
+ .default_text_style_700(styleSize: 10)
+ } else {
+ Text("Conversations")
+ .default_text_style(styleSize: 10)
+ }
+ }
+ })
+ .padding(.top)
+ }
+ .frame(height: geometry.size.height/4)
+
+ Button(action: {
+ self.index = 3
+ contactViewModel.indexDisplayedFriend = nil
+ historyViewModel.displayedCall = nil
+ conversationViewModel.displayedConversation = nil
+ }, label: {
+ VStack {
+ Image("video-conference")
+ .renderingMode(.template)
+ .resizable()
+ .foregroundStyle(self.index == 3 ? Color.orangeMain500 : Color.grayMain2c600)
+ .frame(width: 25, height: 25)
+ if self.index == 0 {
+ Text("Meetings")
+ .default_text_style_700(styleSize: 10)
+ } else {
+ Text("Meetings")
+ .default_text_style(styleSize: 10)
+ }
+ }
+ })
+ .padding(.top)
+ .frame(height: geometry.size.height/4)
+
+ Spacer()
+ }
+ }
+ .frame(width: 75, height: geometry.size.height)
+ .padding(.leading,
+ orientation == .landscapeRight && geometry.safeAreaInsets.bottom > 0
+ ? -geometry.safeAreaInsets.leading
+ : 0)
+ }
+
+ VStack(spacing: 0) {
+ Rectangle()
+ .foregroundColor(Color.orangeMain500)
+ .edgesIgnoringSafeArea(.top)
+ .frame(height: 1)
+
+ ZStack {
+ VStack {
+ Rectangle()
+ .foregroundColor(
+ (orientation == .landscapeLeft
+ || orientation == .landscapeRight
+ || UIScreen.main.bounds.size.width > UIScreen.main.bounds.size.height)
+ ? Color.white
+ : Color.orangeMain500
+ )
+ .frame(height: 100)
+
+ Spacer()
+ }
+
+ VStack(spacing: 0) {
+ if searchIsActive == false {
+ HStack {
+ Image("profile-image-example")
+ .resizable()
+ .frame(width: 45, height: 45)
+ .clipShape(Circle())
+ .onTapGesture {
+ openMenu()
+ }
+
+ Text(index == 0 ? "Contacts" : (index == 1 ? "Calls" : (index == 2 ? "Conversations" : "Meetings")))
+ .default_text_style_white_800(styleSize: 20)
+ .padding(.leading, 10)
+
+ Spacer()
+
+ Button {
+ withAnimation {
+ searchIsActive.toggle()
+ }
+ } label: {
+ Image("magnifying-glass")
+ .renderingMode(.template)
+ .resizable()
+ .foregroundStyle(.white)
+ .frame(width: 25, height: 25, alignment: .leading)
+ .padding(.all, 10)
+ }
+ .padding(.trailing, index == 2 ? 10 : 0)
+
+ if index == 3 {
+ Button {
+ NotificationCenter.default.post(name: MeetingsListViewModel.ScrollToTodayNotification, object: nil)
+ } label: {
+ Image("calendar")
+ .renderingMode(.template)
+ .resizable()
+ .foregroundStyle(.white)
+ .frame(width: 25, height: 25, alignment: .leading)
+ .padding(.all, 10)
+ }
+ .padding(.trailing, 10)
+ } else if index != 2 {
+ Menu {
+ if index == 0 {
+ Button {
+ contactViewModel.indexDisplayedFriend = nil
+ isMenuOpen = false
+ magicSearch.allContact = true
+ MagicSearchSingleton.shared.searchForContacts(
+ sourceFlags: MagicSearch.Source.Friends.rawValue | MagicSearch.Source.LdapServers.rawValue)
+ } label: {
+ HStack {
+ Text("See all")
+ Spacer()
+ if magicSearch.allContact {
+ Image("green-check")
+ .resizable()
+ .frame(width: 25, height: 25, alignment: .leading)
+ .padding(.all, 10)
+ }
+ }
+ }
+
+ Button {
+ contactViewModel.indexDisplayedFriend = nil
+ isMenuOpen = false
+ magicSearch.allContact = false
+ MagicSearchSingleton.shared.searchForContacts(
+ sourceFlags: MagicSearch.Source.Friends.rawValue | MagicSearch.Source.LdapServers.rawValue)
+ } label: {
+ HStack {
+ Text("See Linphone contact")
+ Spacer()
+ if !magicSearch.allContact {
+ Image("green-check")
+ .resizable()
+ .frame(width: 25, height: 25, alignment: .leading)
+ .padding(.all, 10)
+ }
+ }
+ }
+ } else {
+ Button(role: .destructive) {
+ isMenuOpen = false
+ isShowDeleteAllHistoryPopup.toggle()
+ } label: {
+ HStack {
+ Text("Delete all history")
+ Spacer()
+ Image("trash-simple-red")
+ .resizable()
+ .frame(width: 25, height: 25, alignment: .leading)
+ .padding(.all, 10)
+ }
+ }
+ }
+ } label: {
+ Image(index == 0 ? "funnel" : "dots-three-vertical")
+ .renderingMode(.template)
+ .resizable()
+ .foregroundStyle(.white)
+ .frame(width: 25, height: 25, alignment: .leading)
+ .padding(.all, 10)
+ }
+ .padding(.trailing, 10)
+ .onTapGesture {
+ isMenuOpen = true
+ }
+ }
+ }
+ .frame(maxWidth: .infinity)
+ .frame(height: 50)
+ .padding(.leading)
+ .padding(.top, 2.5)
+ .padding(.bottom, 2.5)
+ .background(Color.orangeMain500)
+ .roundedCorner(10, corners: [.bottomRight, .bottomLeft])
+ } else {
+ HStack {
+ Button {
+ withAnimation {
+ self.focusedField = false
+ searchIsActive.toggle()
+ }
+
+ text = ""
+
+ if index == 0 {
+ magicSearch.currentFilter = ""
+ MagicSearchSingleton.shared.searchForContacts(
+ sourceFlags: MagicSearch.Source.Friends.rawValue | MagicSearch.Source.LdapServers.rawValue)
+ } else if index == 1 {
+ historyListViewModel.resetFilterCallLogs()
+ } else if index == 2 {
+ conversationsListViewModel.resetFilterConversations()
+ } else if index == 3 {
+ meetingsListViewModel.currentFilter = ""
+ meetingsListViewModel.computeMeetingsList()
+ }
+ } label: {
+ Image("caret-left")
+ .renderingMode(.template)
+ .resizable()
+ .foregroundStyle(.white)
+ .frame(width: 25, height: 25, alignment: .leading)
+ .padding(.all, 10)
+ .padding(.leading, -10)
+ }
+
+ if #available(iOS 16.0, *) {
+ TextEditor(text: Binding(
+ get: {
+ return text
+ },
+ set: { value in
+ var newValue = value
+ if value.contains("\n") {
+ newValue = value.replacingOccurrences(of: "\n", with: "")
+ }
+ text = newValue
+ }
+ ))
+ .default_text_style_white_700(styleSize: 15)
+ .padding(.all, 6)
+ .disableAutocorrection(true)
+ .autocapitalization(.none)
+ .accentColor(.white)
+ .scrollContentBackground(.hidden)
+ .focused($focusedField)
+ .onAppear {
+ self.focusedField = true
+ }
+ .onChange(of: text) { newValue in
+ if index == 0 {
+ magicSearch.currentFilter = newValue
+ MagicSearchSingleton.shared.searchForContacts(
+ sourceFlags: MagicSearch.Source.Friends.rawValue | MagicSearch.Source.LdapServers.rawValue)
+ } else if index == 1 {
+ if text.isEmpty {
+ historyListViewModel.resetFilterCallLogs()
+ } else {
+ historyListViewModel.filterCallLogs(filter: text)
+ }
+ } else if index == 2 {
+ if text.isEmpty {
+ conversationsListViewModel.resetFilterConversations()
+ } else {
+ conversationsListViewModel.filterConversations(filter: text)
+ }
+ } else if index == 3 {
+ meetingsListViewModel.currentFilter = text
+ meetingsListViewModel.computeMeetingsList()
+ }
+ }
+ } else {
+ TextEditor(text: Binding(
+ get: {
+ return text
+ },
+ set: { value in
+ var newValue = value
+ if value.contains("\n") {
+ newValue = value.replacingOccurrences(of: "\n", with: "")
+ }
+ text = newValue
+ }
+ ))
+ .default_text_style_700(styleSize: 15)
+ .padding(.all, 6)
+ .focused($focusedField)
+ .disableAutocorrection(true)
+ .autocapitalization(.none)
+ .onAppear {
+ self.focusedField = true
+ }
+ .onChange(of: text) { newValue in
+ if index == 0 {
+ magicSearch.currentFilter = newValue
+ MagicSearchSingleton.shared.searchForContacts(
+ sourceFlags: MagicSearch.Source.Friends.rawValue | MagicSearch.Source.LdapServers.rawValue)
+ } else if index == 1 {
+ historyListViewModel.filterCallLogs(filter: text)
+ } else if index == 2 {
+ conversationsListViewModel.filterConversations(filter: text)
+ } else if index == 3 {
+ meetingsListViewModel.currentFilter = text
+ meetingsListViewModel.computeMeetingsList()
+ }
+ }
+ }
+
+ Button {
+ text = ""
+ } label: {
+ Image("x")
+ .renderingMode(.template)
+ .resizable()
+ .foregroundStyle(.white)
+ .frame(width: 25, height: 25, alignment: .leading)
+ .padding(.all, 10)
+ }
+ .padding(.leading)
+ }
+ .frame(maxWidth: .infinity)
+ .frame(height: 50)
+ .padding(.horizontal)
+ .padding(.bottom, 5)
+ .background(Color.orangeMain500)
+ .roundedCorner(10, corners: [.bottomRight, .bottomLeft])
+ }
+
+ if self.index == 0 {
+ ContactsView(
+ contactViewModel: contactViewModel,
+ historyViewModel: historyViewModel,
+ editContactViewModel: editContactViewModel,
+ isShowEditContactFragment: $isShowEditContactFragment,
+ isShowDeletePopup: $isShowDeleteContactPopup,
+ text: $text
+ )
+ .roundedCorner(25, corners: [.topRight, .topLeft])
+ .shadow(
+ color: (orientation == .landscapeLeft
+ || orientation == .landscapeRight
+ || UIScreen.main.bounds.size.width > UIScreen.main.bounds.size.height)
+ ? .white.opacity(0.0)
+ : .black.opacity(0.2),
+ radius: 25
+ )
+ } else if self.index == 1 {
+ HistoryView(
+ historyListViewModel: historyListViewModel,
+ historyViewModel: historyViewModel,
+ contactViewModel: contactViewModel,
+ editContactViewModel: editContactViewModel,
+ index: $index,
+ isShowStartCallFragment: $isShowStartCallFragment,
+ isShowEditContactFragment: $isShowEditContactFragment,
+ text: $text
+ )
+ .roundedCorner(25, corners: [.topRight, .topLeft])
+ .shadow(
+ color: (orientation == .landscapeLeft
+ || orientation == .landscapeRight
+ || UIScreen.main.bounds.size.width > UIScreen.main.bounds.size.height)
+ ? .white.opacity(0.0)
+ : .black.opacity(0.2),
+ radius: 25
+ )
+ } else if self.index == 2 {
+ ConversationsView(
+ conversationViewModel: conversationViewModel,
+ conversationsListViewModel: conversationsListViewModel,
+ text: $text,
+ isShowStartConversationFragment: $isShowStartConversationFragment
+ )
+ .roundedCorner(25, corners: [.topRight, .topLeft])
+ .shadow(
+ color: (orientation == .landscapeLeft
+ || orientation == .landscapeRight
+ || UIScreen.main.bounds.size.width > UIScreen.main.bounds.size.height)
+ ? .white.opacity(0.0)
+ : .black.opacity(0.2),
+ radius: 25
+ )
+ } else if self.index == 3 {
+ MeetingsView(
+ meetingsListViewModel: meetingsListViewModel,
+ meetingViewModel: meetingViewModel,
+ isShowScheduleMeetingFragment: $isShowScheduleMeetingFragment,
+ isShowSendCancelMeetingNotificationPopup: $isShowSendCancelMeetingNotificationPopup,
+ text: $text
+ )
+ .roundedCorner(25, corners: [.topRight, .topLeft])
+ .shadow(
+ color: (orientation == .landscapeLeft
+ || orientation == .landscapeRight
+ || UIScreen.main.bounds.size.width > UIScreen.main.bounds.size.height)
+ ? .white.opacity(0.0)
+ : .black.opacity(0.2),
+ radius: 25
+ )
+ }
+ }
+ }
+ }
+ .frame(maxWidth:
+ (orientation == .landscapeLeft
+ || orientation == .landscapeRight
+ || UIScreen.main.bounds.size.width > UIScreen.main.bounds.size.height)
+ ? geometry.size.width/100*40
+ : .infinity
+ )
+ .background(
+ Color.white
+ .shadow(color: Color.gray200, radius: 4, x: 0, y: 0)
+ .mask(Rectangle().padding(.horizontal, -8))
+ )
+
+ if orientation == .landscapeLeft
+ || orientation == .landscapeRight
+ || UIScreen.main.bounds.size.width > UIScreen.main.bounds.size.height {
+ Spacer()
+ }
+ }
+
+ if !(orientation == .landscapeLeft
+ || orientation == .landscapeRight
+ || UIScreen.main.bounds.size.width > UIScreen.main.bounds.size.height) && !searchIsActive {
+ HStack {
+ Group {
+ Spacer()
+ Button(action: {
+ self.index = 0
+ historyViewModel.displayedCall = nil
+ conversationViewModel.displayedConversation = nil
+ meetingViewModel.displayedMeeting = nil
+ }, label: {
+ VStack {
+ Image("address-book")
+ .renderingMode(.template)
+ .resizable()
+ .foregroundStyle(self.index == 0 ? Color.orangeMain500 : Color.grayMain2c600)
+ .frame(width: 25, height: 25)
+ if self.index == 0 {
+ Text("Contacts")
+ .default_text_style_700(styleSize: 10)
+ } else {
+ Text("Contacts")
+ .default_text_style(styleSize: 10)
+ }
+ }
+ })
+ .padding(.top)
+ .frame(width: 66)
+
+ Spacer()
+
+ ZStack {
+ if historyListViewModel.missedCallsCount > 0 {
+ VStack {
+ HStack {
+ Text(
+ historyListViewModel.missedCallsCount < 99
+ ? String(historyListViewModel.missedCallsCount)
+ : "99+"
+ )
+ .foregroundStyle(.white)
+ .default_text_style(styleSize: 10)
+ .lineLimit(1)
+ }
+ .frame(width: 18, height: 18)
+ .background(Color.redDanger500)
+ .cornerRadius(50)
+ }
+ .padding(.bottom, 30)
+ .padding(.leading, 30)
+ }
+
+ Button(action: {
+ self.index = 1
+ contactViewModel.indexDisplayedFriend = nil
+ conversationViewModel.displayedConversation = nil
+ meetingViewModel.displayedMeeting = nil
+ if historyListViewModel.missedCallsCount > 0 {
+ historyListViewModel.resetMissedCallsCount()
+ }
+ }, label: {
+ VStack {
+ Image("phone")
+ .renderingMode(.template)
+ .resizable()
+ .foregroundStyle(self.index == 1 ? Color.orangeMain500 : Color.grayMain2c600)
+ .frame(width: 25, height: 25)
+ if self.index == 1 {
+ Text("Calls")
+ .default_text_style_700(styleSize: 9)
+ } else {
+ Text("Calls")
+ .default_text_style(styleSize: 9)
+ }
+ }
+ })
+ .padding(.top)
+ .frame(width: 66)
+ }
+
+ Spacer()
+
+ ZStack {
+ if conversationsListViewModel.unreadMessages > 0 {
+ VStack {
+ HStack {
+ Text(
+ conversationsListViewModel.unreadMessages < 99
+ ? String(conversationsListViewModel.unreadMessages)
+ : "99+"
+ )
+ .foregroundStyle(.white)
+ .default_text_style(styleSize: 10)
+ .lineLimit(1)
+ }
+ .frame(width: 18, height: 18)
+ .background(Color.redDanger500)
+ .cornerRadius(50)
+ }
+ .padding(.bottom, 30)
+ .padding(.leading, 30)
+ }
+
+ Button(action: {
+ self.index = 2
+ historyViewModel.displayedCall = nil
+ contactViewModel.indexDisplayedFriend = nil
+ meetingViewModel.displayedMeeting = nil
+ }, label: {
+ VStack {
+ Image("chat-teardrop-text")
+ .renderingMode(.template)
+ .resizable()
+ .foregroundStyle(self.index == 2 ? Color.orangeMain500 : Color.grayMain2c600)
+ .frame(width: 25, height: 25)
+
+ if self.index == 2 {
+ Text("Conversations")
+ .default_text_style_700(styleSize: 9)
+ } else {
+ Text("Conversations")
+ .default_text_style(styleSize: 9)
+ }
+ }
+ })
+ .padding(.top)
+ .frame(width: 66)
+ }
+
+ Spacer()
+ Button(action: {
+ self.index = 3
+ contactViewModel.indexDisplayedFriend = nil
+ historyViewModel.displayedCall = nil
+ conversationViewModel.displayedConversation = nil
+ }, label: {
+ VStack {
+ Image("video-conference")
+ .renderingMode(.template)
+ .resizable()
+ .foregroundStyle(self.index == 3 ? Color.orangeMain500 : Color.grayMain2c600)
+ .frame(width: 25, height: 25)
+ if self.index == 3 {
+ Text("Meetings")
+ .default_text_style_700(styleSize: 9)
+ } else {
+ Text("Meetings")
+ .default_text_style(styleSize: 9)
+ }
+ }
+ })
+ .padding(.top)
+ .frame(width: 66)
+
+ Spacer()
+ }
+ }
+ .padding(.bottom, geometry.safeAreaInsets.bottom > 0 ? 0 : 15)
+ .background(
+ Color.white
+ .shadow(color: Color.gray200, radius: 4, x: 0, y: 0)
+ .mask(Rectangle().padding(.top, -8))
+ )
+ }
+ }
+
+ if contactViewModel.indexDisplayedFriend != nil || historyViewModel.displayedCall != nil || conversationViewModel.displayedConversation != nil ||
+ meetingViewModel.displayedMeeting != nil {
+ HStack(spacing: 0) {
+ Spacer()
+ .frame(maxWidth:
+ (orientation == .landscapeLeft
+ || orientation == .landscapeRight
+ || UIScreen.main.bounds.size.width > UIScreen.main.bounds.size.height)
+ ? (geometry.size.width/100*40) + 75
+ : 0
+ )
+ if self.index == 0 {
+ ContactFragment(
+ contactViewModel: contactViewModel,
+ editContactViewModel: editContactViewModel,
+ conversationViewModel: conversationViewModel,
+ isShowDeletePopup: $isShowDeleteContactPopup,
+ isShowDismissPopup: $isShowDismissPopup,
+ isShowSipAddressesPopup: $isShowSipAddressesPopup,
+ isShowSipAddressesPopupType: $isShowSipAddressesPopupType
+ )
+ .frame(maxWidth: .infinity)
+ .background(Color.gray100)
+ .ignoresSafeArea(.keyboard)
+ } else if self.index == 1 {
+ if historyViewModel.displayedCall != nil && historyViewModel.displayedCall!.avatarModel != nil {
+ HistoryContactFragment(
+ contactAvatarModel: historyViewModel.displayedCall!.avatarModel!,
+ historyViewModel: historyViewModel,
+ historyListViewModel: historyListViewModel,
+ contactViewModel: contactViewModel,
+ editContactViewModel: editContactViewModel,
+ isShowDeleteAllHistoryPopup: $isShowDeleteAllHistoryPopup,
+ isShowEditContactFragment: $isShowEditContactFragment,
+ indexPage: $index
+ )
+ .frame(maxWidth: .infinity)
+ .background(Color.gray100)
+ .ignoresSafeArea(.keyboard)
+ }
+ } else if self.index == 2 {
+ ConversationFragment(
+ conversationViewModel: conversationViewModel,
+ conversationsListViewModel: conversationsListViewModel,
+ conversationForwardMessageViewModel: conversationForwardMessageViewModel,
+ isShowConversationFragment: $isShowConversationFragment,
+ isShowStartCallGroupPopup: $isShowStartCallGroupPopup
+ )
+ .frame(maxWidth: .infinity)
+ .background(Color.gray100)
+ .ignoresSafeArea(.keyboard)
+ } else if self.index == 3 {
+ MeetingFragment(meetingViewModel: meetingViewModel, meetingsListViewModel: meetingsListViewModel, isShowScheduleMeetingFragment: $isShowScheduleMeetingFragment, isShowSendCancelMeetingNotificationPopup: $isShowSendCancelMeetingNotificationPopup)
+ .frame(maxWidth: .infinity)
+ .background(Color.gray100)
+ .ignoresSafeArea(.keyboard)
+ }
+
+ }
+ .onAppear {
+ if !(orientation == .landscapeLeft
+ || orientation == .landscapeRight
+ || UIScreen.main.bounds.size.width > UIScreen.main.bounds.size.height)
+ && searchIsActive {
+ self.focusedField = false
+ }
+ }
+ .onDisappear {
+ if !(orientation == .landscapeLeft
+ || orientation == .landscapeRight
+ || UIScreen.main.bounds.size.width > UIScreen.main.bounds.size.height)
+ && searchIsActive {
+ self.focusedField = true
+ }
+ }
+ .padding(.leading,
+ orientation == .landscapeRight && geometry.safeAreaInsets.bottom > 0
+ ? -geometry.safeAreaInsets.leading
+ : 0)
+ .transition(.move(edge: .trailing))
+ .zIndex(1)
+ }
+
+ SideMenu(
+ width: geometry.size.width / 5 * 4,
+ isOpen: self.sideMenuIsOpen,
+ menuClose: self.openMenu,
+ safeAreaInsets: geometry.safeAreaInsets,
+ isShowLoginFragment: $isShowLoginFragment
+ )
+ .ignoresSafeArea(.all)
+ .zIndex(2)
+
+ if isShowLoginFragment {
+ LoginFragment(
+ accountLoginViewModel: AccountLoginViewModel(),
+ isShowBack: true,
+ onBackPressed: {
+ withAnimation {
+ isShowLoginFragment.toggle()
+ }
+ })
+ .zIndex(3)
+ .transition(.move(edge: .bottom))
+ .onAppear {
+ }
+ }
+
+ if isShowEditContactFragment {
+ EditContactFragment(
+ editContactViewModel: editContactViewModel,
+ contactViewModel: contactViewModel,
+ isShowEditContactFragment: $isShowEditContactFragment,
+ isShowDismissPopup: $isShowDismissPopup
+ )
+ .zIndex(3)
+ .transition(.opacity.combined(with: .move(edge: .bottom)))
+ .onAppear {
+ contactViewModel.indexDisplayedFriend = nil
+ }
+ }
+
+ if isShowStartCallFragment {
+ if #available(iOS 16.4, *), idiom != .pad {
+ StartCallFragment(
+ callViewModel: callViewModel,
+ startCallViewModel: startCallViewModel,
+ isShowStartCallFragment: $isShowStartCallFragment,
+ showingDialer: $showingDialer,
+ resetCallView: {callViewModel.resetCallView()}
+ )
+ .zIndex(6)
+ .transition(.opacity.combined(with: .move(edge: .bottom)))
+ .sheet(isPresented: $showingDialer) {
+ DialerBottomSheet(
+ startCallViewModel: startCallViewModel,
+ callViewModel: callViewModel,
+ isShowStartCallFragment: $isShowStartCallFragment,
+ showingDialer: $showingDialer,
+ currentCall: nil
+ )
+ .presentationDetents([.medium])
+ .presentationBackgroundInteraction(.enabled(upThrough: .medium))
+ }
+ } else {
+ StartCallFragment(
+ callViewModel: callViewModel,
+ startCallViewModel: startCallViewModel,
+ isShowStartCallFragment: $isShowStartCallFragment,
+ showingDialer: $showingDialer,
+ resetCallView: {callViewModel.resetCallView()}
+ )
+ .zIndex(6)
+ .transition(.opacity.combined(with: .move(edge: .bottom)))
+ .halfSheet(showSheet: $showingDialer) {
+ DialerBottomSheet(
+ startCallViewModel: startCallViewModel,
+ callViewModel: callViewModel,
+ isShowStartCallFragment: $isShowStartCallFragment,
+ showingDialer: $showingDialer,
+ currentCall: nil
+ )
+ } onDismiss: {}
+ }
+ }
+
+ if isShowStartConversationFragment {
+ StartConversationFragment(
+ startConversationViewModel: startConversationViewModel,
+ conversationViewModel: conversationViewModel,
+ isShowStartConversationFragment: $isShowStartConversationFragment
+ )
+ .zIndex(6)
+ .transition(.opacity.combined(with: .move(edge: .bottom)))
+ }
+
+ if isShowDeleteContactPopup {
+ PopupView(isShowPopup: $isShowDeleteContactPopup,
+ title: Text(
+ contactViewModel.selectedFriend != nil
+ ? "Delete \(contactViewModel.selectedFriend!.name!)?"
+ : (contactViewModel.indexDisplayedFriend != nil
+ ? "Delete \(contactsManager.lastSearch[contactViewModel.indexDisplayedFriend!].friend!.name!)?"
+ : "Error Name")),
+ content: Text("This contact will be deleted definitively."),
+ titleFirstButton: Text("Cancel"),
+ actionFirstButton: {
+ self.isShowDeleteContactPopup.toggle()},
+ titleSecondButton: Text("Ok"),
+ actionSecondButton: {
+ if contactViewModel.selectedFriendToDelete != nil {
+ if contactViewModel.indexDisplayedFriend != nil {
+ withAnimation {
+ contactViewModel.indexDisplayedFriend = nil
+ }
+ }
+ contactViewModel.selectedFriendToDelete!.remove()
+ } else if contactViewModel.indexDisplayedFriend != nil {
+ let tmpIndex = contactViewModel.indexDisplayedFriend
+ withAnimation {
+ contactViewModel.indexDisplayedFriend = nil
+ }
+ contactsManager.lastSearch[tmpIndex!].friend!.remove()
+ }
+ MagicSearchSingleton.shared.searchForContacts(
+ sourceFlags: MagicSearch.Source.Friends.rawValue | MagicSearch.Source.LdapServers.rawValue)
+ self.isShowDeleteContactPopup.toggle()
+ })
+ .background(.black.opacity(0.65))
+ .zIndex(3)
+ .onTapGesture {
+ self.isShowDeleteContactPopup.toggle()
+ }
+ .onAppear {
+ contactViewModel.selectedFriendToDelete = contactViewModel.selectedFriend
+ }
+ }
+
+ if isShowDeleteAllHistoryPopup {
+ PopupView(isShowPopup: $isShowDeleteContactPopup,
+ title: Text("Do you really want to delete all calls history?"),
+ content: Text("All calls will be removed from the history."),
+ titleFirstButton: Text("Cancel"),
+ actionFirstButton: {
+ self.isShowDeleteAllHistoryPopup.toggle()
+ historyListViewModel.callLogsAddressToDelete = ""
+ },
+ titleSecondButton: Text("Ok"),
+ actionSecondButton: {
+ historyListViewModel.removeCallLogs()
+ self.isShowDeleteAllHistoryPopup.toggle()
+ historyViewModel.displayedCall = nil
+
+ ToastViewModel.shared.toastMessage = "Success_remove_call_logs"
+ ToastViewModel.shared.displayToast.toggle()
+ })
+ .background(.black.opacity(0.65))
+ .zIndex(3)
+ .onTapGesture {
+ self.isShowDeleteAllHistoryPopup.toggle()
+ }
+ }
+
+ if isShowDismissPopup {
+ PopupView(isShowPopup: $isShowDismissPopup,
+ title: Text("Don’t save modifications?"),
+ content: Text("All modifications will be canceled."),
+ titleFirstButton: Text("Cancel"),
+ actionFirstButton: {self.isShowDismissPopup.toggle()},
+ titleSecondButton: Text("Ok"),
+ actionSecondButton: {
+ if editContactViewModel.selectedEditFriend == nil {
+ self.isShowDismissPopup.toggle()
+ editContactViewModel.removePopup = true
+ editContactViewModel.resetValues()
+ withAnimation {
+ isShowEditContactFragment.toggle()
+ }
+ } else {
+ self.isShowDismissPopup.toggle()
+ editContactViewModel.resetValues()
+ withAnimation {
+ editContactViewModel.removePopup = true
+ }
+ }
+ })
+ .background(.black.opacity(0.65))
+ .zIndex(3)
+ .onTapGesture {
+ self.isShowDismissPopup.toggle()
+ }
+ }
+
+ if isShowSipAddressesPopup {
+ SipAddressesPopup(
+ contactAvatarModel: ContactsManager.shared.avatarListModel[contactViewModel.indexDisplayedFriend != nil ? contactViewModel.indexDisplayedFriend! : 0],
+ contactViewModel: contactViewModel,
+ isShowSipAddressesPopup: $isShowSipAddressesPopup,
+ isShowSipAddressesPopupType: $isShowSipAddressesPopupType
+ )
+ .background(.black.opacity(0.65))
+ .zIndex(3)
+ .onTapGesture {
+ isShowSipAddressesPopup.toggle()
+ }
+ }
+
+ if contactViewModel.operationInProgress {
+ PopupLoadingView()
+ .background(.black.opacity(0.65))
+ .zIndex(3)
+ .onDisappear {
+ if contactViewModel.displayedConversation != nil {
+ contactViewModel.indexDisplayedFriend = nil
+ historyViewModel.displayedCall = nil
+ index = 2
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
+ withAnimation {
+ self.conversationViewModel.changeDisplayedChatRoom(conversationModel: contactViewModel.displayedConversation!)
+ }
+ contactViewModel.displayedConversation = nil
+ }
+ }
+ }
+ }
+
+ if isShowScheduleMeetingFragment {
+ ScheduleMeetingFragment(
+ meetingViewModel: meetingViewModel,
+ meetingsListViewModel: meetingsListViewModel,
+ isShowScheduleMeetingFragment: $isShowScheduleMeetingFragment
+ )
+ .zIndex(3)
+ .transition(.move(edge: .bottom))
+ .onAppear {
+ }
+ }
+
+ if isShowSendCancelMeetingNotificationPopup {
+ PopupView(isShowPopup: $isShowSendCancelMeetingNotificationPopup,
+ title: Text("The meeting will be cancelled"),
+ content: Text("Send notification to participants ?"),
+ titleFirstButton: Text("Cancel for me only"),
+ actionFirstButton: {
+ meetingViewModel.displayedMeeting = nil
+ meetingsListViewModel.deleteSelectedMeeting()
+ self.isShowSendCancelMeetingNotificationPopup.toggle(
+ ) },
+ titleSecondButton: Text("Send cancellation notifications"),
+ actionSecondButton: {
+ meetingViewModel.displayedMeeting = nil
+ if let meetingToDelete = self.meetingsListViewModel.selectedMeetingToDelete {
+ self.meetingViewModel.cancelMeetingWithNotifications(meeting: meetingToDelete)
+ meetingsListViewModel.deleteSelectedMeeting()
+ self.isShowSendCancelMeetingNotificationPopup.toggle()
+ }
+ })
+ .background(.black.opacity(0.65))
+ .zIndex(3)
+ .onTapGesture {
+ self.isShowSendCancelMeetingNotificationPopup.toggle()
+ }
+ }
+
+ if isShowStartCallGroupPopup {
+ PopupView(
+ isShowPopup: $isShowStartCallGroupPopup,
+ title: Text("conversation_info_confirm_start_group_call_dialog_title"),
+ content: Text("conversation_info_confirm_start_group_call_dialog_message"),
+ titleFirstButton: Text("Cancel"),
+ actionFirstButton: {
+ self.isShowStartCallGroupPopup.toggle()
+ },
+ titleSecondButton: Text("Confirm"),
+ actionSecondButton: {
+ if conversationViewModel.displayedConversation != nil {
+ conversationViewModel.displayedConversation!.createGroupCall()
+ }
+ self.isShowStartCallGroupPopup.toggle()
+ }
+ )
+ .background(.black.opacity(0.65))
+ .zIndex(3)
+ .onTapGesture {
+ self.isShowStartCallGroupPopup.toggle()
+ }
+ }
+
+ if telecomManager.meetingWaitingRoomDisplayed {
+ MeetingWaitingRoomFragment(meetingWaitingRoomViewModel: meetingWaitingRoomViewModel)
+ .zIndex(3)
+ .transition(.opacity.combined(with: .move(edge: .bottom)))
+ .onAppear {
+ meetingWaitingRoomViewModel.resetMeetingRoomView()
+ }
+ }
+
+ if telecomManager.callDisplayed && ((telecomManager.callInProgress && telecomManager.outgoingCallStarted) || telecomManager.callConnected) && !telecomManager.meetingWaitingRoomDisplayed {
+ CallView(
+ callViewModel: callViewModel,
+ conversationViewModel: conversationViewModel,
+ conversationsListViewModel: conversationsListViewModel,
+ conversationForwardMessageViewModel: conversationForwardMessageViewModel,
+ fullscreenVideo: $fullscreenVideo,
+ isShowStartCallFragment: $isShowStartCallFragment,
+ isShowConversationFragment: $isShowConversationFragment,
+ isShowStartCallGroupPopup: $isShowStartCallGroupPopup
+ )
+ .zIndex(5)
+ .transition(.scale.combined(with: .move(edge: .top)))
+ .onAppear {
+ UIApplication.shared.isIdleTimerDisabled = true
+ callViewModel.resetCallView()
+ if callViewModel.callsCounter >= 1 {
+ DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
+ callViewModel.resetCallView()
+ }
+ }
+ }
+ .onDisappear {
+ UIApplication.shared.isIdleTimerDisabled = false
+ }
+ }
+
+ ToastView()
+ .zIndex(6)
+ }
+ }
+ .onAppear {
+ MagicSearchSingleton.shared.searchForContacts(sourceFlags: MagicSearch.Source.Friends.rawValue | MagicSearch.Source.LdapServers.rawValue)
+ }
+ .onChange(of: navigationManager.selectedCallId) { newCallId in
+ if newCallId != nil {
+ self.index = 2
+ }
+ }
+ .onReceive(pub) { _ in
+ conversationsListViewModel.computeChatRoomsList(filter: "")
+ historyListViewModel.refreshHistoryAvatarModel()
+ }
+ }
+ .overlay {
+ if isMenuOpen {
+ Color.white.opacity(0.001)
+ .ignoresSafeArea()
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ .onTapGesture {
+ isMenuOpen = false
+ }
+ }
+ }
+ .onRotate { newOrientation in
+ if (contactViewModel.indexDisplayedFriend != nil || historyViewModel.displayedCall != nil || conversationViewModel.displayedConversation != nil) && searchIsActive {
+ self.focusedField = false
+ } else if searchIsActive {
+ self.focusedField = true
+ }
+ orientation = newOrientation
+ }
+ .onChange(of: scenePhase) { newPhase in
+ CoreContext.shared.enteredForeground = newPhase == .active
+ orientation = UIDevice.current.orientation
+ }
+ }
+
+ func openMenu() {
+ withAnimation {
+ self.sideMenuIsOpen.toggle()
+ }
+ }
+}
+
+class NavigationManager: ObservableObject {
+ @Published var selectedCallId: String?
+ @Published var peerAddr: String?
+ @Published var localAddr: String?
+
+ func openChatRoom(callId: String, peerAddr: String, localAddr: String) {
+ self.selectedCallId = callId
+ self.peerAddr = peerAddr
+ self.localAddr = localAddr
+ }
+}
+
+#Preview {
+ ContentView(
+ contactViewModel: ContactViewModel(),
+ editContactViewModel: EditContactViewModel(),
+ historyViewModel: HistoryViewModel(),
+ historyListViewModel: HistoryListViewModel(),
+ startCallViewModel: StartCallViewModel(),
+ startConversationViewModel: StartConversationViewModel(),
+ callViewModel: CallViewModel(),
+ meetingWaitingRoomViewModel: MeetingWaitingRoomViewModel(),
+ conversationsListViewModel: ConversationsListViewModel(),
+ conversationViewModel: ConversationViewModel(),
+ meetingsListViewModel: MeetingsListViewModel(),
+ meetingViewModel: MeetingViewModel(),
+ conversationForwardMessageViewModel: ConversationForwardMessageViewModel()
+ )
+}
+// swiftlint:enable type_body_length
+// swiftlint:enable line_length
diff --git a/Linphone/UI/Main/Conversations/ConversationsView.swift b/Linphone/UI/Main/Conversations/ConversationsView.swift
new file mode 100644
index 000000000..3918ed6c8
--- /dev/null
+++ b/Linphone/UI/Main/Conversations/ConversationsView.swift
@@ -0,0 +1,67 @@
+/*
+ * 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 .
+ */
+
+import SwiftUI
+
+struct ConversationsView: View {
+
+ @ObservedObject var conversationViewModel: ConversationViewModel
+ @ObservedObject var conversationsListViewModel: ConversationsListViewModel
+ @Binding var text: String
+
+ @Binding var isShowStartConversationFragment: Bool
+
+ var body: some View {
+ NavigationView {
+ ZStack(alignment: .bottomTrailing) {
+ ConversationsFragment(conversationViewModel: conversationViewModel, conversationsListViewModel: conversationsListViewModel, text: $text)
+
+ Button {
+ withAnimation {
+ isShowStartConversationFragment = true
+ }
+
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
+ MagicSearchSingleton.shared.searchForSuggestions()
+ }
+ } label: {
+ Image("conversation")
+ .renderingMode(.template)
+ .foregroundStyle(.white)
+ .padding()
+ .background(Color.orangeMain500)
+ .clipShape(Circle())
+ .shadow(color: .black.opacity(0.2), radius: 4)
+
+ }
+ .padding()
+ }
+ }
+ .navigationViewStyle(.stack)
+ }
+}
+
+#Preview {
+ ConversationsListFragment(
+ conversationViewModel: ConversationViewModel(),
+ conversationsListViewModel: ConversationsListViewModel(),
+ showingSheet: .constant(false),
+ text: .constant("")
+ )
+}
diff --git a/Linphone/UI/Main/Conversations/Fragments/ChatBubbleView.swift b/Linphone/UI/Main/Conversations/Fragments/ChatBubbleView.swift
new file mode 100644
index 000000000..ab3ea8e76
--- /dev/null
+++ b/Linphone/UI/Main/Conversations/Fragments/ChatBubbleView.swift
@@ -0,0 +1,997 @@
+/*
+ * 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 .
+ */
+
+import SwiftUI
+import WebKit
+
+// swiftlint:disable type_body_length
+// swiftlint:disable cyclomatic_complexity
+struct ChatBubbleView: View {
+
+ @ObservedObject private var sharedMainViewModel = SharedMainViewModel.shared
+
+ @ObservedObject var conversationViewModel: ConversationViewModel
+
+ let eventLogMessage: EventLogMessage
+
+ let geometryProxy: GeometryProxy
+
+ @State private var ticker = Ticker()
+ @State private var isPressed: Bool = false
+ @State private var timePassed: TimeInterval?
+
+ @State private var timer: Timer?
+ @State private var ephemeralLifetime: String = ""
+
+ var body: some View {
+ HStack {
+ if eventLogMessage.eventModel.eventLogType == .ConferenceChatMessage {
+ VStack {
+ if !eventLogMessage.message.text.isEmpty || !eventLogMessage.message.attachments.isEmpty || eventLogMessage.message.isIcalendar {
+ HStack(alignment: .top, content: {
+ if eventLogMessage.message.isOutgoing {
+ Spacer()
+ }
+ if conversationViewModel.displayedConversation != nil && conversationViewModel.displayedConversation!.isGroup
+ && !eventLogMessage.message.isOutgoing && eventLogMessage.message.isFirstMessage {
+ VStack {
+ Avatar(
+ contactAvatarModel: conversationViewModel.participantConversationModel.first(where: {$0.address == eventLogMessage.message.address}) ??
+ ContactAvatarModel(friend: nil, name: "??", address: "", withPresence: false),
+ avatarSize: 35
+ )
+ .padding(.top, 30)
+ }
+ } else if conversationViewModel.displayedConversation != nil
+ && conversationViewModel.displayedConversation!.isGroup && !eventLogMessage.message.isOutgoing {
+ VStack {
+ }
+ .padding(.leading, 43)
+ }
+
+ VStack(alignment: .leading, spacing: 0) {
+ if conversationViewModel.displayedConversation != nil && conversationViewModel.displayedConversation!.isGroup
+ && !eventLogMessage.message.isOutgoing && eventLogMessage.message.isFirstMessage {
+ Text(conversationViewModel.participantConversationModel.first(where: {$0.address == eventLogMessage.message.address})?.name ?? "")
+ .default_text_style(styleSize: 12)
+ .padding(.top, 5)
+ .padding(.bottom, 2)
+ }
+
+ if eventLogMessage.message.isForward {
+ HStack {
+ if eventLogMessage.message.isOutgoing {
+ Spacer()
+ }
+
+ VStack(alignment: eventLogMessage.message.isOutgoing ? .trailing : .leading, spacing: 0) {
+ HStack {
+ Image("forward")
+ .resizable()
+ .frame(width: 15, height: 15, alignment: .leading)
+
+ Text("message_forwarded_label")
+ .default_text_style(styleSize: 12)
+ }
+ .padding(.bottom, 2)
+ }
+
+ if !eventLogMessage.message.isOutgoing {
+ Spacer()
+ }
+ }
+ .frame(maxWidth: .infinity)
+ }
+
+ if eventLogMessage.message.replyMessage != nil {
+ HStack {
+ if eventLogMessage.message.isOutgoing {
+ Spacer()
+ }
+
+ VStack(alignment: eventLogMessage.message.isOutgoing ? .trailing : .leading, spacing: 0) {
+ HStack {
+ Image("reply")
+ .resizable()
+ .frame(width: 15, height: 15, alignment: .leading)
+
+ Text(conversationViewModel.participantConversationModel.first(
+ where: {$0.address == eventLogMessage.message.replyMessage!.address})?.name ?? "")
+ .default_text_style(styleSize: 12)
+ }
+ .padding(.bottom, 2)
+
+ VStack(alignment: eventLogMessage.message.isOutgoing ? .trailing : .leading) {
+ if !eventLogMessage.message.replyMessage!.text.isEmpty {
+ Text(eventLogMessage.message.replyMessage!.text)
+ .foregroundStyle(Color.grayMain2c700)
+ .default_text_style(styleSize: 14)
+ .lineLimit(/*@START_MENU_TOKEN@*/2/*@END_MENU_TOKEN@*/)
+ } else if !eventLogMessage.message.replyMessage!.attachmentsNames.isEmpty {
+ Text(eventLogMessage.message.replyMessage!.attachmentsNames)
+ .foregroundStyle(Color.grayMain2c700)
+ .default_text_style(styleSize: 14)
+ .lineLimit(/*@START_MENU_TOKEN@*/2/*@END_MENU_TOKEN@*/)
+ }
+ }
+ .padding(.all, 15)
+ .padding(.bottom, 15)
+ .background(Color.gray200)
+ .clipShape(RoundedRectangle(cornerRadius: 1))
+ .roundedCorner(
+ 16,
+ corners: eventLogMessage.message.isOutgoing ? [.topLeft, .topRight, .bottomLeft] : [.topLeft, .topRight, .bottomRight]
+ )
+ }
+ .onTapGesture {
+ conversationViewModel.scrollToMessage(message: eventLogMessage.message)
+ }
+
+ if !eventLogMessage.message.isOutgoing {
+ Spacer()
+ }
+ }
+ .frame(maxWidth: .infinity)
+ .padding(.bottom, -20)
+ }
+
+ ZStack {
+ HStack {
+ if eventLogMessage.message.isOutgoing {
+ Spacer()
+ }
+
+ VStack(alignment: eventLogMessage.message.isOutgoing ? .trailing : .leading) {
+ VStack(alignment: eventLogMessage.message.isOutgoing ? .trailing : .leading) {
+ if !eventLogMessage.message.attachments.isEmpty && !eventLogMessage.message.isIcalendar {
+ messageAttachments()
+ }
+
+ if !eventLogMessage.message.text.isEmpty {
+ DynamicLinkText(text: eventLogMessage.message.text)
+ }
+
+ if eventLogMessage.message.isIcalendar && eventLogMessage.message.messageConferenceInfo != nil {
+ VStack(spacing: 0) {
+ VStack {
+ if eventLogMessage.message.messageConferenceInfo!.meetingState != .new {
+ if eventLogMessage.message.messageConferenceInfo!.meetingState == .updated {
+ Text("conversation_message_meeting_updated_label")
+ .foregroundStyle(Color.orangeWarning600)
+ .default_text_style_600(styleSize: 12)
+ .lineLimit(1)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .padding(.bottom, 5)
+ } else {
+ Text("conversation_message_meeting_cancelled_label")
+ .foregroundStyle(Color.redDanger500)
+ .default_text_style_600(styleSize: 12)
+ .lineLimit(1)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .padding(.bottom, 5)
+ }
+ }
+
+ HStack {
+ VStack(spacing: 0) {
+ Text(eventLogMessage.message.messageConferenceInfo!.meetingDay)
+ .default_text_style(styleSize: 16)
+
+ Text(eventLogMessage.message.messageConferenceInfo!.meetingDayNumber)
+ .foregroundStyle(.white)
+ .default_text_style_800(styleSize: 18)
+ .lineLimit(1)
+ .frame(width: 30, height: 30, alignment: .center)
+ .background(Color.orangeMain500)
+ .clipShape(Circle())
+
+ }
+ .padding(.all, 10)
+ .frame(width: 70, height: 70)
+ .background(.white)
+ .cornerRadius(15)
+ .shadow(color: .black.opacity(0.1), radius: 15)
+
+ VStack {
+ HStack {
+ Image("video-conference")
+ .renderingMode(.template)
+ .resizable()
+ .foregroundStyle(Color.grayMain2c600)
+ .frame(width: 25, height: 25)
+
+ Text(eventLogMessage.message.messageConferenceInfo!.meetingSubject)
+ .default_text_style_800(styleSize: 15)
+ .lineLimit(1)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ }
+ .frame(maxWidth: .infinity, alignment: .leading)
+
+ Text(eventLogMessage.message.messageConferenceInfo!.meetingDate)
+ .default_text_style_300(styleSize: 14)
+ .lineLimit(1)
+ .frame(maxWidth: .infinity, alignment: .leading)
+
+ Text(eventLogMessage.message.messageConferenceInfo!.meetingTime)
+ .default_text_style_300(styleSize: 14)
+ .lineLimit(1)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ }
+ .padding(.leading, 5)
+ }
+ .frame(maxWidth: .infinity)
+ }
+ .padding(.all, 15)
+ .frame(maxWidth: .infinity)
+ .background(Color.gray100)
+
+ VStack(spacing: 2) {
+ if !eventLogMessage.message.messageConferenceInfo!.meetingDescription.isEmpty {
+ Text("Description")
+ .default_text_style(styleSize: 14)
+ .frame(maxWidth: .infinity, alignment: .leading)
+
+ Text(eventLogMessage.message.messageConferenceInfo!.meetingDescription)
+ .default_text_style_300(styleSize: 14)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ }
+
+ if eventLogMessage.message.messageConferenceInfo!.meetingState != .cancelled {
+ HStack {
+ Image("users")
+ .renderingMode(.template)
+ .resizable()
+ .foregroundStyle(Color.grayMain2c600)
+ .frame(width: 20, height: 20)
+
+ Text(eventLogMessage.message.messageConferenceInfo!.meetingParticipants)
+ .default_text_style(styleSize: 14)
+ .frame(maxWidth: .infinity, alignment: .leading)
+
+ Button(action: {
+ conversationViewModel.joinMeetingInvite(addressUri: eventLogMessage.message.messageConferenceInfo!.meetingConferenceUri)
+ }, label: {
+ Text("meeting_waiting_room_join")
+ .default_text_style_white_600(styleSize: 14)
+ })
+ .padding(.horizontal, 15)
+ .padding(.vertical, 10)
+ .background(Color.orangeMain500)
+ .cornerRadius(60)
+ }
+ .padding(.top, !eventLogMessage.message.messageConferenceInfo!.meetingDescription.isEmpty ? 10 : 0)
+ }
+ }
+ .padding(.all,
+ eventLogMessage.message.messageConferenceInfo!.meetingState != .cancelled
+ || !eventLogMessage.message.messageConferenceInfo!.meetingDescription.isEmpty
+ ? 15
+ : 0
+ )
+ .frame(maxWidth: .infinity)
+ .background(.white)
+ }
+ .frame(width: geometryProxy.size.width - 110)
+ .background(.white)
+ .cornerRadius(10)
+ }
+
+ HStack(alignment: .center) {
+ if eventLogMessage.message.isEphemeral && eventLogMessage.message.isOutgoing {
+ Text(ephemeralLifetime)
+ .foregroundStyle(Color.grayMain2c500)
+ .default_text_style_300(styleSize: 12)
+ .padding(.top, 1)
+ .padding(.trailing, -4)
+ .onAppear {
+ updateEphemeralTimer()
+ }
+ .onChange(of: eventLogMessage.message.ephemeralExpireTime) { ephemeralExpireTimeTmp in
+ if ephemeralExpireTimeTmp > 0 {
+ updateEphemeralTimer()
+ }
+ }
+
+ Image("clock-countdown")
+ .renderingMode(.template)
+ .resizable()
+ .foregroundStyle(Color.grayMain2c500)
+ .frame(width: 15, height: 15)
+ .padding(.top, 1)
+ }
+
+ Text(conversationViewModel.getMessageTime(startDate: eventLogMessage.message.dateReceived))
+ .foregroundStyle(Color.grayMain2c500)
+ .default_text_style_300(styleSize: 12)
+ .padding(.top, 1)
+ .padding(.trailing, -4)
+
+ if (conversationViewModel.displayedConversation != nil && conversationViewModel.displayedConversation!.isGroup)
+ || eventLogMessage.message.isOutgoing {
+ if eventLogMessage.message.status == .sending {
+ ProgressView()
+ .controlSize(.mini)
+ .progressViewStyle(CircularProgressViewStyle(tint: .orangeMain500))
+ .frame(width: 10, height: 10)
+ .padding(.top, 1)
+ } else if eventLogMessage.message.status != nil {
+ Image(conversationViewModel.getImageIMDN(status: eventLogMessage.message.status!))
+ .renderingMode(.template)
+ .resizable()
+ .foregroundStyle(Color.orangeMain500)
+ .frame(width: 15, height: 15)
+ .padding(.top, 1)
+ }
+ }
+
+ if eventLogMessage.message.isEphemeral && !eventLogMessage.message.isOutgoing {
+ Image("clock-countdown")
+ .renderingMode(.template)
+ .resizable()
+ .foregroundStyle(Color.grayMain2c500)
+ .frame(width: 15, height: 15)
+ .padding(.top, 1)
+ .padding(.trailing, -4)
+
+ Text(ephemeralLifetime)
+ .foregroundStyle(Color.grayMain2c500)
+ .default_text_style_300(styleSize: 12)
+ .padding(.top, 1)
+ .onAppear {
+ updateEphemeralTimer()
+ }
+ .onChange(of: eventLogMessage.message.ephemeralExpireTime) { ephemeralExpireTimeTmp in
+ if ephemeralExpireTimeTmp > 0 {
+ updateEphemeralTimer()
+ }
+ }
+ }
+ }
+ .onTapGesture {
+ if !CoreContext.shared.enteredForeground {
+ conversationViewModel.selectedMessageToDisplayDetails = eventLogMessage
+ conversationViewModel.prepareBottomSheetForDeliveryStatus()
+ }
+ }
+ .disabled(conversationViewModel.selectedMessage != nil)
+ .padding(.top, -4)
+ }
+ .padding(.all, 15)
+ .background(eventLogMessage.message.isOutgoing ? Color.orangeMain100 : Color.grayMain2c100)
+ .clipShape(RoundedRectangle(cornerRadius: 3))
+ .roundedCorner(
+ 16,
+ corners: eventLogMessage.message.isOutgoing && eventLogMessage.message.isFirstMessage ? [.topLeft, .topRight, .bottomLeft] :
+ (!eventLogMessage.message.isOutgoing && eventLogMessage.message.isFirstMessage ? [.topRight, .bottomRight, .bottomLeft] : [.allCorners]))
+
+ if !eventLogMessage.message.reactions.isEmpty {
+ HStack {
+ ForEach(0.. Bool {
+ let uniqueStrings = Set(strings)
+ return uniqueStrings.count != strings.count
+ }
+
+ @ViewBuilder
+ func messageAttachments() -> some View {
+ if eventLogMessage.message.attachments.count == 1 {
+ if eventLogMessage.message.attachments.first!.type == .image || eventLogMessage.message.attachments.first!.type == .gif
+ || eventLogMessage.message.attachments.first!.type == .video {
+ let result = imageDimensions(url: eventLogMessage.message.attachments.first!.thumbnail.absoluteString)
+ ZStack {
+ Rectangle()
+ .fill(Color(.white))
+ .aspectRatio(result.0/result.1, contentMode: .fit)
+ .if(result.0 < geometryProxy.size.width - 110) { view in
+ view.frame(maxWidth: result.0)
+ }
+ .if(result.1 < UIScreen.main.bounds.height/2) { view in
+ view.frame(maxHeight: result.1)
+ }
+ .if(result.0 >= result.1 && geometryProxy.size.width > 0 && result.0 >= geometryProxy.size.width - 110
+ && result.1 >= UIScreen.main.bounds.height/2.5) { view in
+ view.frame(
+ maxWidth: geometryProxy.size.width - 110,
+ maxHeight: result.1 * ((geometryProxy.size.width - 110) / result.0)
+ )
+ }
+ .if(result.0 < result.1 && geometryProxy.size.width > 0 && result.1 >= UIScreen.main.bounds.height/2.5) { view in
+ view.frame(
+ maxWidth: result.0 * ((UIScreen.main.bounds.height/2.5) / result.1),
+ maxHeight: UIScreen.main.bounds.height/2.5
+ )
+ }
+
+ if eventLogMessage.message.attachments.first!.type == .image || eventLogMessage.message.attachments.first!.type == .video {
+ if #available(iOS 16.0, *) {
+ AsyncImage(url: eventLogMessage.message.attachments.first!.thumbnail) { phase in
+ switch phase {
+ case .empty:
+ ProgressView()
+ case .success(let image):
+ ZStack {
+ image
+ .resizable()
+ .interpolation(.medium)
+ .aspectRatio(contentMode: .fill)
+
+ if eventLogMessage.message.attachments.first!.type == .video {
+ Image("play-fill")
+ .renderingMode(.template)
+ .resizable()
+ .foregroundStyle(.white)
+ .frame(width: 40, height: 40, alignment: .leading)
+ }
+ }
+ case .failure:
+ Image("image-broken")
+ @unknown default:
+ EmptyView()
+ }
+ }
+ .layoutPriority(-1)
+ .clipShape(RoundedRectangle(cornerRadius: 4))
+ } else {
+ AsyncImage(url: eventLogMessage.message.attachments.first!.thumbnail) { phase in
+ switch phase {
+ case .empty:
+ ProgressView()
+ case .success(let image):
+ ZStack {
+ image
+ .resizable()
+ .interpolation(.medium)
+ .aspectRatio(contentMode: .fill)
+
+ if eventLogMessage.message.attachments.first!.type == .video {
+ Image("play-fill")
+ .renderingMode(.template)
+ .resizable()
+ .foregroundStyle(.white)
+ .frame(width: 40, height: 40, alignment: .leading)
+ }
+ }
+ case .failure:
+ Image("image-broken")
+ @unknown default:
+ EmptyView()
+ }
+ }
+ .layoutPriority(-1)
+ .clipShape(RoundedRectangle(cornerRadius: 4))
+ .id(UUID())
+ }
+ } else if eventLogMessage.message.attachments.first!.type == .gif {
+ if #available(iOS 16.0, *) {
+ GifImageView(eventLogMessage.message.attachments.first!.thumbnail)
+ .layoutPriority(-1)
+ .clipShape(RoundedRectangle(cornerRadius: 4))
+ } else {
+ GifImageView(eventLogMessage.message.attachments.first!.thumbnail)
+ .id(UUID())
+ .layoutPriority(-1)
+ .clipShape(RoundedRectangle(cornerRadius: 4))
+ }
+ }
+ }
+ .clipShape(RoundedRectangle(cornerRadius: 4))
+ .clipped()
+ } else if eventLogMessage.message.attachments.first!.type == .voiceRecording {
+ CustomSlider(
+ conversationViewModel: conversationViewModel,
+ eventLogMessage: eventLogMessage
+ )
+ .frame(width: geometryProxy.size.width - 160, height: 50)
+ } else {
+ HStack {
+ VStack {
+ Image(getImageOfType(type: eventLogMessage.message.attachments.first!.type))
+ .renderingMode(.template)
+ .resizable()
+ .foregroundStyle(Color.grayMain2c700)
+ .frame(width: 60, height: 60, alignment: .leading)
+ }
+ .frame(width: 100, height: 100)
+ .background(Color.grayMain2c200)
+
+ VStack {
+ Text(eventLogMessage.message.attachments.first!.name)
+ .foregroundStyle(Color.grayMain2c700)
+ .default_text_style_600(styleSize: 14)
+ .truncationMode(.middle)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .lineLimit(1)
+
+ Text(eventLogMessage.message.attachments.first!.size.formatBytes())
+ .default_text_style_300(styleSize: 14)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .lineLimit(1)
+ }
+ .padding(.horizontal, 10)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ }
+ .frame(width: geometryProxy.size.width - 110, height: 100)
+ .background(.white)
+ .clipShape(RoundedRectangle(cornerRadius: 10))
+ }
+ } else if eventLogMessage.message.attachments.count > 1 {
+ let isGroup = conversationViewModel.displayedConversation != nil && conversationViewModel.displayedConversation!.isGroup
+ LazyVGrid(columns: [
+ GridItem(.adaptive(minimum: 120), spacing: 1)
+ ], spacing: 3) {
+ ForEach(eventLogMessage.message.attachments) { attachment in
+ ZStack {
+ Rectangle()
+ .fill(Color(.white))
+ .frame(width: 120, height: 120)
+
+ if #available(iOS 16.0, *) {
+ AsyncImage(url: attachment.thumbnail) { image in
+ ZStack {
+ image
+ .resizable()
+ .interpolation(.medium)
+ .aspectRatio(contentMode: .fill)
+
+ if attachment.type == .video {
+ Image("play-fill")
+ .renderingMode(.template)
+ .resizable()
+ .foregroundStyle(.white)
+ .frame(width: 40, height: 40, alignment: .leading)
+ }
+ }
+ } placeholder: {
+ ProgressView()
+ }
+ .layoutPriority(-1)
+ } else {
+ AsyncImage(url: attachment.thumbnail) { image in
+ ZStack {
+ image
+ .resizable()
+ .interpolation(.medium)
+ .aspectRatio(contentMode: .fill)
+
+ if attachment.type == .video {
+ Image("play-fill")
+ .renderingMode(.template)
+ .resizable()
+ .foregroundStyle(.white)
+ .frame(width: 40, height: 40, alignment: .leading)
+ }
+ }
+ } placeholder: {
+ ProgressView()
+ }
+ .id(UUID())
+ .layoutPriority(-1)
+ }
+ }
+ .clipShape(RoundedRectangle(cornerRadius: 4))
+ .contentShape(Rectangle())
+ }
+ }
+ .frame( width: geometryProxy.size.width > 0
+ && CGFloat(122 * eventLogMessage.message.attachments.count) > geometryProxy.size.width - 110 - (isGroup ? 40 : 0)
+ ? 122 * floor(CGFloat(geometryProxy.size.width - 110 - (isGroup ? 40 : 0)) / 122)
+ : CGFloat(122 * eventLogMessage.message.attachments.count)
+ )
+ }
+ }
+
+ func imageDimensions(url: String) -> (CGFloat, CGFloat) {
+ if let imageSource = CGImageSourceCreateWithURL(URL(string: url)! as CFURL, nil) {
+ if let imageProperties = CGImageSourceCopyPropertiesAtIndex(imageSource, 0, nil) as Dictionary? {
+ let pixelWidth = imageProperties[kCGImagePropertyPixelWidth] as? CGFloat
+ let pixelHeight = imageProperties[kCGImagePropertyPixelHeight] as? CGFloat
+ let orientation = imageProperties[kCGImagePropertyOrientation] as? Int
+ return orientation != nil && orientation == 6 ? (pixelHeight ?? 0, pixelWidth ?? 0) : (pixelWidth ?? 0, pixelHeight ?? 0)
+ }
+ }
+ return (100, 100)
+ }
+
+ func getImageOfType(type: AttachmentType) -> String {
+ if type == .audio {
+ return "file-audio"
+ } else if type == .pdf {
+ return "file-pdf"
+ } else if type == .text {
+ return "file-text"
+ } else if type == .fileTransfer {
+ return "download-simple"
+ } else {
+ return "file"
+ }
+ }
+
+ private func updateEphemeralTimer() {
+ if eventLogMessage.message.isEphemeral {
+ if eventLogMessage.message.ephemeralExpireTime == 0 {
+ // Message hasn't been read by all participants yet
+ self.ephemeralLifetime = eventLogMessage.message.ephemeralLifetime.convertDurationToString()
+ } else {
+ let remaining = eventLogMessage.message.ephemeralExpireTime - Int(Date().timeIntervalSince1970)
+ self.ephemeralLifetime = remaining.convertDurationToString()
+
+ if timer == nil {
+ timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { _ in
+ let updatedRemaining = eventLogMessage.message.ephemeralExpireTime - Int(Date().timeIntervalSince1970)
+ if updatedRemaining <= 0 {
+ timer?.invalidate()
+ timer = nil
+ } else {
+ self.ephemeralLifetime = updatedRemaining.convertDurationToString()
+ }
+ }
+ }
+ }
+ }
+ }
+}
+
+struct DynamicLinkText: View {
+ let text: String
+
+ var body: some View {
+ let components = text.components(separatedBy: " ")
+
+ Text(makeAttributedString(from: components))
+ .fixedSize(horizontal: false, vertical: true)
+ .multilineTextAlignment(.leading)
+ .lineLimit(nil)
+ .foregroundStyle(Color.grayMain2c700)
+ .default_text_style(styleSize: 14)
+ }
+
+ // Function to create an AttributedString with clickable links
+ private func makeAttributedString(from components: [String]) -> AttributedString {
+ var result = AttributedString("")
+ for (index, component) in components.enumerated() {
+ if let url = URL(string: component.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? ""),
+ url.scheme == "http" || url.scheme == "https" {
+ var attributedText = AttributedString(component)
+ attributedText.link = url
+ attributedText.foregroundColor = .blue
+ attributedText.underlineStyle = .single
+ result.append(attributedText)
+ } else {
+ result.append(AttributedString(component))
+ }
+
+ // Add space between words except for the last one
+ if index < components.count - 1 {
+ result.append(AttributedString(" "))
+ }
+ }
+ return result
+ }
+}
+
+enum URLType {
+ case name(String) // local file name of gif
+ case url(URL) // remote url
+
+ var url: URL? {
+ switch self {
+ case .name(let name):
+ return Bundle.main.url(forResource: name, withExtension: "gif")
+ case .url(let remoteURL):
+ return remoteURL
+ }
+ }
+}
+
+struct GifImageView: UIViewRepresentable {
+ private let name: URL
+ init(_ name: URL) {
+ self.name = name
+ }
+
+ func makeUIView(context: Context) -> WKWebView {
+ let webview = WKWebView()
+ let url = name
+ let data = try? Data(contentsOf: url)
+ if data != nil {
+ webview.load(data!, mimeType: "image/gif", characterEncodingName: "UTF-8", baseURL: url.deletingLastPathComponent())
+ webview.scrollView.isScrollEnabled = false
+ }
+ return webview
+ }
+
+ func updateUIView(_ uiView: WKWebView, context: Context) {
+ uiView.reload()
+ }
+}
+
+class Ticker: ObservableObject {
+
+ var startedAt: Date = Date()
+
+ var timeIntervalSinceStarted: TimeInterval {
+ return Date().timeIntervalSince(startedAt)
+ }
+
+ private var timer: Timer?
+ func start(interval: TimeInterval) {
+ stop()
+ startedAt = Date()
+ timer = Timer.scheduledTimer(withTimeInterval: interval, repeats: true) { _ in
+ self.objectWillChange.send()
+ }
+ }
+
+ func stop() {
+ timer?.invalidate()
+ }
+
+ deinit {
+ timer?.invalidate()
+ }
+
+}
+
+struct RoundedCorner: Shape {
+ var radius: CGFloat = .infinity
+ var corners: UIRectCorner = .allCorners
+
+ func path(in rect: CGRect) -> Path {
+ let path = UIBezierPath(roundedRect: rect, byRoundingCorners: corners, cornerRadii: CGSize(width: radius, height: radius))
+ return Path(path.cgPath)
+ }
+}
+
+extension View {
+ func roundedCorner(_ radius: CGFloat, corners: UIRectCorner) -> some View {
+ clipShape(RoundedCorner(radius: radius, corners: corners) )
+ }
+}
+
+struct CustomSlider: View {
+ @ObservedObject var conversationViewModel: ConversationViewModel
+
+ let eventLogMessage: EventLogMessage
+
+ @State private var value: Double = 0.0
+ @State private var isPlaying: Bool = false
+ @State private var timer: Timer?
+
+ var minTrackColor: Color = .white.opacity(0.5)
+ var maxTrackGradient: Gradient = Gradient(colors: [Color.orangeMain300, Color.orangeMain500])
+
+ var body: some View {
+ GeometryReader { geometry in
+ let radius = geometry.size.height * 0.5
+ ZStack(alignment: .leading) {
+ LinearGradient(
+ gradient: maxTrackGradient,
+ startPoint: .leading,
+ endPoint: .trailing
+ )
+ .frame(width: geometry.size.width, height: geometry.size.height)
+ HStack {
+ Rectangle()
+ .foregroundColor(minTrackColor)
+ .frame(width: self.value * geometry.size.width / 100, height: geometry.size.height)
+ .animation(self.value > 0 ? .linear(duration: 0.1) : nil, value: self.value)
+ }
+
+ HStack {
+ Button(
+ action: {
+ if isPlaying {
+ conversationViewModel.pauseVoiceRecordPlayer()
+ pauseProgress()
+ } else {
+ conversationViewModel.startVoiceRecordPlayer(voiceRecordPath: eventLogMessage.message.attachments.first!.full)
+ playProgress()
+ }
+ },
+ label: {
+ Image(isPlaying ? "pause-fill" : "play-fill")
+ .renderingMode(.template)
+ .resizable()
+ .foregroundStyle(Color.orangeMain500)
+ .frame(width: 20, height: 20)
+ }
+ )
+ .padding(8)
+ .background(.white)
+ .clipShape(RoundedRectangle(cornerRadius: 25))
+
+ Spacer()
+
+ HStack {
+ Text((eventLogMessage.message.attachments.first!.duration/1000).convertDurationToString())
+ .default_text_style(styleSize: 16)
+ .padding(.horizontal, 5)
+ }
+ .padding(8)
+ .background(.white)
+ .clipShape(RoundedRectangle(cornerRadius: 25))
+ }
+ .padding(.horizontal, 10)
+ }
+ .clipShape(RoundedRectangle(cornerRadius: radius))
+ .onDisappear {
+ resetProgress()
+ }
+ }
+ }
+
+ private func playProgress() {
+ isPlaying = true
+ self.value = conversationViewModel.getPositionVoiceRecordPlayer(voiceRecordPath: eventLogMessage.message.attachments.first!.full)
+ timer = Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true) { _ in
+ if self.value < 100.0 {
+ let valueTmp = conversationViewModel.getPositionVoiceRecordPlayer(voiceRecordPath: eventLogMessage.message.attachments.first!.full)
+ if self.value > 90 && self.value == valueTmp {
+ self.value = 100
+ } else {
+ if valueTmp == 0 && !conversationViewModel.isPlayingVoiceRecordPlayer(voiceRecordPath: eventLogMessage.message.attachments.first!.full) {
+ stopProgress()
+ value = 0.0
+ isPlaying = false
+ } else {
+ self.value = valueTmp
+ }
+ }
+ } else {
+ resetProgress()
+ }
+ }
+ }
+
+ // Pause the progress
+ private func pauseProgress() {
+ isPlaying = false
+ stopProgress()
+ }
+
+ // Reset the progress
+ private func resetProgress() {
+ conversationViewModel.stopVoiceRecordPlayer()
+ stopProgress()
+ value = 0.0
+ isPlaying = false
+ }
+
+ // Stop the progress and invalidate the timer
+ private func stopProgress() {
+ timer?.invalidate()
+ timer = nil
+ }
+}
+
+/*
+ #Preview {
+ ChatBubbleView(conversationViewModel: ConversationViewModel(), index: 0)
+ }
+ */
+
+// swiftlint:enable type_body_length
+// swiftlint:enable cyclomatic_complexity
diff --git a/Linphone/UI/Main/Conversations/Fragments/ConversationForwardMessageFragment.swift b/Linphone/UI/Main/Conversations/Fragments/ConversationForwardMessageFragment.swift
new file mode 100644
index 000000000..5f3028247
--- /dev/null
+++ b/Linphone/UI/Main/Conversations/Fragments/ConversationForwardMessageFragment.swift
@@ -0,0 +1,320 @@
+/*
+ * 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 .
+ */
+
+import SwiftUI
+import linphonesw
+
+struct ConversationForwardMessageFragment: View {
+ @ObservedObject private var sharedMainViewModel = SharedMainViewModel.shared
+
+ @ObservedObject var contactsManager = ContactsManager.shared
+ @ObservedObject var magicSearch = MagicSearchSingleton.shared
+
+ @ObservedObject var conversationViewModel: ConversationViewModel
+ @ObservedObject var conversationsListViewModel: ConversationsListViewModel
+ @ObservedObject var conversationForwardMessageViewModel: ConversationForwardMessageViewModel
+
+ @Binding var isShowConversationForwardMessageFragment: Bool
+
+ @FocusState var isSearchFieldFocused: Bool
+ @State private var delayedColor = Color.white
+
+ @FocusState var isMessageTextFocused: Bool
+
+ var body: some View {
+ NavigationView {
+ ZStack {
+ VStack(spacing: 1) {
+
+ Rectangle()
+ .foregroundStyle(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, 2)
+ .padding(.leading, -10)
+ .onTapGesture {
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
+ magicSearch.searchForContacts(
+ sourceFlags: MagicSearch.Source.Friends.rawValue | MagicSearch.Source.LdapServers.rawValue)
+ }
+
+ conversationForwardMessageViewModel.searchField = ""
+ magicSearch.currentFilterSuggestions = ""
+
+ conversationForwardMessageViewModel.selectedMessage = nil
+ withAnimation {
+ isShowConversationForwardMessageFragment = false
+ }
+ }
+
+ Text("conversation_forward_message_title")
+ .multilineTextAlignment(.leading)
+ .default_text_style_orange_800(styleSize: 16)
+
+ Spacer()
+
+ }
+ .frame(maxWidth: .infinity)
+ .frame(height: 50)
+ .padding(.horizontal)
+ .padding(.bottom, 4)
+ .background(.white)
+
+ VStack(spacing: 0) {
+ ZStack(alignment: .trailing) {
+ TextField("history_call_start_search_bar_filter_hint", text: $conversationForwardMessageViewModel.searchField)
+ .default_text_style(styleSize: 15)
+ .frame(height: 25)
+ .focused($isSearchFieldFocused)
+ .padding(.horizontal, 30)
+ .onChange(of: conversationForwardMessageViewModel.searchField) { newValue in
+ if newValue.isEmpty {
+ conversationForwardMessageViewModel.resetFilterConversations()
+ } else {
+ conversationForwardMessageViewModel.filterConversations()
+ }
+ magicSearch.currentFilterSuggestions = newValue
+ magicSearch.searchForSuggestions()
+ }
+
+ HStack {
+ Button(action: {
+ }, label: {
+ Image("magnifying-glass")
+ .renderingMode(.template)
+ .resizable()
+ .foregroundStyle(Color.grayMain2c500)
+ .frame(width: 25, height: 25)
+ })
+
+ Spacer()
+
+ if !conversationForwardMessageViewModel.searchField.isEmpty {
+ Button(action: {
+ conversationForwardMessageViewModel.searchField = ""
+ magicSearch.currentFilterSuggestions = ""
+ conversationForwardMessageViewModel.resetFilterConversations()
+ magicSearch.searchForSuggestions()
+ }, label: {
+ Image("x")
+ .renderingMode(.template)
+ .resizable()
+ .foregroundStyle(Color.grayMain2c500)
+ .frame(width: 25, height: 25)
+ })
+ }
+ }
+ }
+ .padding(.horizontal, 15)
+ .padding(.vertical, 10)
+ .cornerRadius(60)
+ .overlay(
+ RoundedRectangle(cornerRadius: 60)
+ .inset(by: 0.5)
+ .stroke(isSearchFieldFocused ? Color.orangeMain500 : Color.gray200, lineWidth: 1)
+ )
+ .padding(.vertical)
+ .padding(.horizontal)
+
+ ScrollView {
+ if !conversationForwardMessageViewModel.conversationsList.isEmpty {
+ HStack(alignment: .center) {
+ Text("Conversations")
+ .default_text_style_800(styleSize: 16)
+
+ Spacer()
+ }
+ .padding(.vertical, 10)
+ .padding(.horizontal, 16)
+
+ conversationsList
+ }
+
+ if !ContactsManager.shared.lastSearch.isEmpty {
+ HStack(alignment: .center) {
+ Text("All contacts")
+ .default_text_style_800(styleSize: 16)
+
+ Spacer()
+ }
+ .padding(.vertical, 10)
+ .padding(.horizontal, 16)
+ }
+
+ ContactsListFragment(contactViewModel: ContactViewModel(), contactsListViewModel: ContactsListViewModel(), showingSheet: .constant(false)
+ , startCallFunc: { addr in
+ withAnimation {
+ conversationForwardMessageViewModel.createOneToOneChatRoomWith(remote: addr)
+ }
+
+ })
+ .padding(.horizontal, 16)
+
+ if !contactsManager.lastSearchSuggestions.isEmpty {
+ HStack(alignment: .center) {
+ Text("Suggestions")
+ .default_text_style_800(styleSize: 16)
+
+ Spacer()
+ }
+ .padding(.vertical, 10)
+ .padding(.horizontal, 16)
+
+ suggestionsList
+ }
+ }
+ }
+ .frame(maxWidth: .infinity)
+ }
+ .background(.white)
+
+ if conversationForwardMessageViewModel.operationInProgress {
+ PopupLoadingView()
+ .background(.black.opacity(0.65))
+ .onDisappear {
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
+ magicSearch.searchForContacts(
+ sourceFlags: MagicSearch.Source.Friends.rawValue | MagicSearch.Source.LdapServers.rawValue
+ )
+ }
+
+ conversationForwardMessageViewModel.searchField = ""
+ magicSearch.currentFilterSuggestions = ""
+
+ conversationForwardMessageViewModel.forwardMessage()
+
+ isShowConversationForwardMessageFragment = false
+
+ if conversationForwardMessageViewModel.displayedConversation != nil {
+ if conversationViewModel.displayedConversation != nil {
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
+ self.conversationViewModel.changeDisplayedChatRoom(conversationModel: conversationForwardMessageViewModel.displayedConversation!)
+ }
+ } else {
+ self.conversationViewModel.changeDisplayedChatRoom(conversationModel: conversationForwardMessageViewModel.displayedConversation!)
+ }
+ }
+ }
+ }
+ }
+ .navigationTitle("")
+ .navigationBarHidden(true)
+ .onDisappear {
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
+ magicSearch.searchForContacts(
+ sourceFlags: MagicSearch.Source.Friends.rawValue | MagicSearch.Source.LdapServers.rawValue)
+ }
+
+ conversationForwardMessageViewModel.searchField = ""
+ magicSearch.currentFilterSuggestions = ""
+
+ conversationForwardMessageViewModel.selectedMessage = nil
+ withAnimation {
+ isShowConversationForwardMessageFragment = false
+ }
+ }
+ }
+ .navigationViewStyle(StackNavigationViewStyle())
+ }
+
+ var conversationsList: some View {
+ ForEach(0...
+ */
+
+import SwiftUI
+import UniformTypeIdentifiers
+
+// swiftlint:disable line_length
+// swiftlint:disable type_body_length
+struct ConversationFragment: View {
+
+ @State private var orientation = UIDevice.current.orientation
+ private var idiom: UIUserInterfaceIdiom { UIDevice.current.userInterfaceIdiom }
+
+ @ObservedObject var contactsManager = ContactsManager.shared
+ @ObservedObject private var sharedMainViewModel = SharedMainViewModel.shared
+
+ @ObservedObject var conversationViewModel: ConversationViewModel
+ @ObservedObject var conversationsListViewModel: ConversationsListViewModel
+ @ObservedObject var conversationForwardMessageViewModel: ConversationForwardMessageViewModel
+
+ @State var isMenuOpen = false
+ @State private var isMuted: Bool = false
+
+ @FocusState var isMessageTextFocused: Bool
+
+ @State var offset: CGPoint = .zero
+
+ private let ids: [String] = []
+
+ @StateObject private var viewModel = ChatViewModel()
+ @StateObject private var paginationState = PaginationState()
+
+ @State private var displayFloatingButton = false
+
+ @State private var isShowPhotoLibrary = false
+ @State private var isShowCamera = false
+
+ @State private var mediasIsLoading = false
+ @State private var voiceRecordingInProgress = false
+
+ @State private var isShowConversationForwardMessageFragment = false
+
+ @Binding var isShowConversationFragment: Bool
+ @Binding var isShowStartCallGroupPopup: Bool
+
+ @State private var selectedCategoryIndex = 0
+
+ var body: some View {
+ NavigationView {
+ GeometryReader { geometry in
+ if #available(iOS 16.0, *), idiom != .pad {
+ innerView(geometry: geometry)
+ .background(.white)
+ .navigationBarHidden(true)
+ .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: {
+ conversationViewModel.isShowSelectedMessageToDisplayDetails = false
+ }, content: {
+ ImdnOrReactionsSheet(conversationViewModel: conversationViewModel, selectedCategoryIndex: $selectedCategoryIndex)
+ .presentationDetents([.medium])
+ .presentationDragIndicator(.visible)
+ })
+ .sheet(isPresented: $isShowPhotoLibrary, onDismiss: {
+ isShowPhotoLibrary = false
+ }, content: {
+ PhotoPicker(filter: nil, limit: conversationViewModel.maxMediaCount - conversationViewModel.mediasToSend.count) { results in
+ PhotoPicker.convertToAttachmentArray(fromResults: results) { mediasOrNil, errorOrNil in
+ if let error = errorOrNil {
+ print(error)
+ }
+
+ if let medias = mediasOrNil {
+ conversationViewModel.mediasToSend.append(contentsOf: medias)
+ }
+
+ self.mediasIsLoading = false
+ }
+ }
+ .edgesIgnoringSafeArea(.all)
+ })
+ .fullScreenCover(isPresented: $isShowCamera) {
+ ImagePicker(conversationViewModel: conversationViewModel, selectedMedia: self.$conversationViewModel.mediasToSend)
+ .edgesIgnoringSafeArea(.all)
+ }
+ .background(Color.gray100.ignoresSafeArea(.keyboard))
+ } else {
+ innerView(geometry: geometry)
+ .background(.white)
+ .navigationBarHidden(true)
+ .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) {
+ ImdnOrReactionsSheet(conversationViewModel: conversationViewModel, selectedCategoryIndex: $selectedCategoryIndex)
+ } onDismiss: {
+ conversationViewModel.isShowSelectedMessageToDisplayDetails = false
+ }
+ .sheet(isPresented: $isShowPhotoLibrary, onDismiss: {
+ isShowPhotoLibrary = false
+ }, content: {
+ PhotoPicker(filter: nil, limit: conversationViewModel.maxMediaCount - conversationViewModel.mediasToSend.count) { results in
+ PhotoPicker.convertToAttachmentArray(fromResults: results) { mediasOrNil, errorOrNil in
+ if let error = errorOrNil {
+ print(error)
+ }
+
+ if let medias = mediasOrNil {
+ conversationViewModel.mediasToSend.append(contentsOf: medias)
+ }
+
+ self.mediasIsLoading = false
+ }
+ }
+ .edgesIgnoringSafeArea(.all)
+ })
+ .fullScreenCover(isPresented: $isShowCamera) {
+ ImagePicker(conversationViewModel: conversationViewModel, selectedMedia: self.$conversationViewModel.mediasToSend)
+ }
+ .background(Color.gray100.ignoresSafeArea(.keyboard))
+ }
+ }
+ }
+ .navigationViewStyle(.stack)
+ }
+
+ // swiftlint:disable cyclomatic_complexity
+ // swiftlint:disable function_body_length
+ @ViewBuilder
+ func innerView(geometry: GeometryProxy) -> some View {
+ ZStack {
+ VStack(spacing: 1) {
+ if conversationViewModel.displayedConversation != nil {
+ Rectangle()
+ .foregroundColor(Color.orangeMain500)
+ .edgesIgnoringSafeArea(.top)
+ .frame(height: 0)
+
+ HStack {
+ if (!(orientation == .landscapeLeft || orientation == .landscapeRight
+ || UIScreen.main.bounds.size.width > UIScreen.main.bounds.size.height)) || isShowConversationFragment {
+ 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 {
+ if isShowConversationFragment {
+ isShowConversationFragment = false
+ }
+ conversationViewModel.displayedConversation = nil
+ }
+ }
+ }
+
+ Avatar(contactAvatarModel: conversationViewModel.displayedConversation!.avatarModel, avatarSize: 50)
+ .padding(.top, 4)
+
+ Text(conversationViewModel.displayedConversation!.subject)
+ .default_text_style(styleSize: 16)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .padding(.top, 4)
+ .lineLimit(1)
+
+ Spacer()
+
+ Button {
+ if conversationViewModel.displayedConversation!.isGroup {
+ isShowStartCallGroupPopup.toggle()
+ } else {
+ conversationViewModel.displayedConversation!.call()
+ }
+ } label: {
+ Image("phone")
+ .renderingMode(.template)
+ .resizable()
+ .foregroundStyle(Color.grayMain2c500)
+ .frame(width: 25, height: 25, alignment: .leading)
+ .padding(.all, 10)
+ .padding(.top, 4)
+ }
+
+ Menu {
+ Button {
+ isMenuOpen = false
+ } label: {
+ HStack {
+ Text("conversation_menu_go_to_info")
+ Spacer()
+ Image("info")
+ .renderingMode(.template)
+ .resizable()
+ .foregroundStyle(Color.grayMain2c500)
+ .frame(width: 25, height: 25, alignment: .leading)
+ .padding(.all, 10)
+ }
+ }
+
+ Button {
+ isMenuOpen = false
+ conversationViewModel.displayedConversation!.toggleMute()
+ isMuted = !isMuted
+ } label: {
+ HStack {
+ Text(isMuted ? "conversation_action_unmute" : "conversation_action_mute")
+ Spacer()
+ Image(isMuted ? "bell-simple" : "bell-simple-slash")
+ .renderingMode(.template)
+ .resizable()
+ .foregroundStyle(Color.grayMain2c500)
+ .frame(width: 25, height: 25, alignment: .leading)
+ .padding(.all, 10)
+ }
+ }
+
+ Button {
+ isMenuOpen = false
+ } label: {
+ HStack {
+ Text("conversation_menu_configure_ephemeral_messages")
+ Spacer()
+ Image("clock-countdown")
+ .renderingMode(.template)
+ .resizable()
+ .foregroundStyle(Color.grayMain2c500)
+ .frame(width: 25, height: 25, alignment: .leading)
+ .padding(.all, 10)
+ }
+ }
+ } label: {
+ Image("dots-three-vertical")
+ .renderingMode(.template)
+ .resizable()
+ .foregroundStyle(Color.grayMain2c500)
+ .frame(width: 25, height: 25, alignment: .leading)
+ .padding(.all, 10)
+ .padding(.top, 4)
+ .onChange(of: isMuted) { _ in }
+ .onAppear {
+ isMuted = conversationViewModel.displayedConversation!.isMuted
+ }
+ }
+ .onTapGesture {
+ isMenuOpen = true
+ }
+ }
+ .frame(maxWidth: .infinity)
+ .frame(height: 50)
+ .padding(.horizontal)
+ .padding(.bottom, 4)
+ .background(.white)
+
+ if #available(iOS 16.0, *) {
+ ZStack(alignment: .bottomTrailing) {
+ UIList(
+ viewModel: viewModel,
+ paginationState: paginationState,
+ conversationViewModel: conversationViewModel,
+ conversationsListViewModel: conversationsListViewModel,
+ geometryProxy: geometry,
+ sections: conversationViewModel.conversationMessagesSection
+ )
+ }
+ .onAppear {
+ conversationViewModel.getMessages()
+ }
+ .onDisappear {
+ conversationViewModel.resetMessage()
+ }
+ } else {
+ ScrollViewReader { proxy in
+ ZStack(alignment: .bottomTrailing) {
+ List {
+ if conversationViewModel.conversationMessagesSection.first != nil {
+ let counter = conversationViewModel.conversationMessagesSection.first!.rows.count
+ ForEach(0.. conversationViewModel.conversationMessagesSection.first!.rows.count {
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
+ conversationViewModel.getOldMessages()
+ }
+ }
+
+ if index == 0 {
+ displayFloatingButton = false
+ conversationViewModel.markAsRead()
+ conversationsListViewModel.computeChatRoomsList(filter: "")
+ }
+ }
+ .onDisappear {
+ if index == 0 {
+ displayFloatingButton = true
+ }
+ }
+ }
+ }
+ }
+ .scaleEffect(x: 1, y: -1, anchor: .center)
+ .listStyle(.plain)
+ .onAppear {
+ conversationViewModel.markAsRead()
+ conversationsListViewModel.computeChatRoomsList(filter: "")
+ }
+
+ if displayFloatingButton {
+ Button {
+ if conversationViewModel.conversationMessagesSection.first != nil && conversationViewModel.conversationMessagesSection.first!.rows.first != nil {
+ withAnimation {
+ proxy.scrollTo(conversationViewModel.conversationMessagesSection.first!.rows.first!.message.id)
+ }
+ }
+ } label: {
+ ZStack {
+
+ Image("caret-down")
+ .renderingMode(.template)
+ .foregroundStyle(.white)
+ .padding()
+ .background(Color.orangeMain500)
+ .clipShape(Circle())
+ .shadow(color: .black.opacity(0.2), radius: 4)
+
+ if conversationViewModel.displayedConversationUnreadMessagesCount > 0 {
+ VStack {
+ HStack {
+ Spacer()
+
+ HStack {
+ Text(
+ conversationViewModel.displayedConversationUnreadMessagesCount < 99
+ ? String(conversationViewModel.displayedConversationUnreadMessagesCount)
+ : "99+"
+ )
+ .foregroundStyle(.white)
+ .default_text_style(styleSize: 10)
+ .lineLimit(1)
+
+ }
+ .frame(width: 18, height: 18)
+ .background(Color.redDanger500)
+ .cornerRadius(50)
+ }
+
+ Spacer()
+ }
+ }
+ }
+
+ }
+ .frame(width: 50, height: 50)
+ .padding()
+ }
+ }
+ .onAppear {
+ conversationViewModel.getMessages()
+ }
+ .onDisappear {
+ conversationViewModel.resetMessage()
+ }
+ }
+ }
+
+ if !conversationViewModel.composingLabel.isEmpty {
+ HStack {
+ Text(conversationViewModel.composingLabel)
+ .lineLimit(1)
+ .default_text_style_300(styleSize: 15)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .padding(.horizontal, 10)
+ }
+ .onDisappear {
+ conversationViewModel.composingLabel = ""
+ }
+ .transition(.move(edge: .bottom))
+ }
+
+ if conversationViewModel.messageToReply != nil {
+ ZStack(alignment: .top) {
+ HStack {
+ VStack {
+ (
+ Text("conversation_reply_to_message_title")
+ + Text("**\(conversationViewModel.participantConversationModel.first(where: {$0.address == conversationViewModel.messageToReply!.message.address})?.name ?? "")**"))
+ .default_text_style_300(styleSize: 15)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .padding(.bottom, 1)
+ .lineLimit(1)
+
+ if conversationViewModel.messageToReply!.message.text.isEmpty {
+ Text(conversationViewModel.messageToReply!.message.attachmentsNames)
+ .default_text_style_300(styleSize: 15)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .lineLimit(1)
+ } else {
+ Text("\(conversationViewModel.messageToReply!.message.text)")
+ .default_text_style_300(styleSize: 15)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .lineLimit(1)
+ }
+ }
+ }
+ .frame(maxWidth: .infinity)
+ .padding(.all, 20)
+ .background(Color.gray100)
+
+ HStack {
+ Spacer()
+
+ Button(action: {
+ withAnimation {
+ conversationViewModel.messageToReply = nil
+ }
+ }, label: {
+ Image("x")
+ .resizable()
+ .frame(width: 30, height: 30, alignment: .leading)
+ .padding(.all, 10)
+ })
+ }
+ }
+ .transition(.move(edge: .bottom))
+ }
+
+ if !conversationViewModel.mediasToSend.isEmpty || mediasIsLoading {
+ ZStack(alignment: .top) {
+ HStack {
+ if mediasIsLoading {
+ HStack {
+ Spacer()
+
+ ProgressView()
+
+ Spacer()
+ }
+ .frame(height: 120)
+ }
+
+ if !mediasIsLoading {
+ LazyVGrid(columns: [
+ GridItem(.adaptive(minimum: 100), spacing: 1)
+ ], spacing: 3) {
+ ForEach(conversationViewModel.mediasToSend, id: \.id) { attachment in
+ ZStack {
+ Rectangle()
+ .fill(Color(.white))
+ .frame(width: 100, height: 100)
+
+ AsyncImage(url: attachment.thumbnail) { image in
+ ZStack {
+ image
+ .resizable()
+ .interpolation(.medium)
+ .aspectRatio(contentMode: .fill)
+
+ if attachment.type == .video {
+ Image("play-fill")
+ .renderingMode(.template)
+ .resizable()
+ .foregroundStyle(.white)
+ .frame(width: 40, height: 40, alignment: .leading)
+ }
+ }
+ } placeholder: {
+ ProgressView()
+ }
+ .layoutPriority(-1)
+ .onTapGesture {
+ if conversationViewModel.mediasToSend.count == 1 {
+ withAnimation {
+ conversationViewModel.mediasToSend.removeAll()
+ }
+ } else {
+ guard let index = self.conversationViewModel.mediasToSend.firstIndex(of: attachment) else { return }
+ self.conversationViewModel.mediasToSend.remove(at: index)
+ }
+ }
+ }
+ .clipShape(RoundedRectangle(cornerRadius: 4))
+ .contentShape(Rectangle())
+ }
+ }
+ .frame(
+ width: geometry.size.width > 0 && CGFloat(102 * conversationViewModel.mediasToSend.count) > geometry.size.width - 20
+ ? 102 * floor(CGFloat(geometry.size.width - 20) / 102)
+ : CGFloat(102 * conversationViewModel.mediasToSend.count)
+ )
+ }
+ }
+ .frame(maxWidth: .infinity)
+ .padding(.all, conversationViewModel.mediasToSend.isEmpty ? 0 : 10)
+ .background(Color.gray100)
+
+ if !mediasIsLoading {
+ HStack {
+ Spacer()
+
+ Button(action: {
+ withAnimation {
+ conversationViewModel.mediasToSend.removeAll()
+ }
+ }, label: {
+ Image("x")
+ .resizable()
+ .frame(width: 30, height: 30, alignment: .leading)
+ .padding(.all, 10)
+ })
+ }
+ }
+ }
+ .transition(.move(edge: .bottom))
+ }
+
+ HStack(spacing: 0) {
+ if !voiceRecordingInProgress {
+ Button {
+ } label: {
+ Image("smiley")
+ .renderingMode(.template)
+ .resizable()
+ .foregroundStyle(Color.grayMain2c500)
+ .frame(width: 28, height: 28, alignment: .leading)
+ .padding(.all, 6)
+ .padding(.top, 4)
+ }
+ .padding(.horizontal, isMessageTextFocused ? 0 : 2)
+
+ Button {
+ self.isShowPhotoLibrary = true
+ self.mediasIsLoading = true
+ } label: {
+ Image("paperclip")
+ .renderingMode(.template)
+ .resizable()
+ .foregroundStyle(conversationViewModel.maxMediaCount <= conversationViewModel.mediasToSend.count || mediasIsLoading ? Color.grayMain2c300 : Color.grayMain2c500)
+ .frame(width: isMessageTextFocused ? 0 : 28, height: isMessageTextFocused ? 0 : 28, alignment: .leading)
+ .padding(.all, isMessageTextFocused ? 0 : 6)
+ .padding(.top, 4)
+ .disabled(conversationViewModel.maxMediaCount <= conversationViewModel.mediasToSend.count || mediasIsLoading)
+ }
+ .padding(.horizontal, isMessageTextFocused ? 0 : 2)
+
+ Button {
+ self.isShowCamera = true
+ } label: {
+ Image("camera")
+ .renderingMode(.template)
+ .resizable()
+ .foregroundStyle(conversationViewModel.maxMediaCount <= conversationViewModel.mediasToSend.count || mediasIsLoading ? Color.grayMain2c300 : Color.grayMain2c500)
+ .frame(width: isMessageTextFocused ? 0 : 28, height: isMessageTextFocused ? 0 : 28, alignment: .leading)
+ .padding(.all, isMessageTextFocused ? 0 : 6)
+ .padding(.top, 4)
+ .disabled(conversationViewModel.maxMediaCount <= conversationViewModel.mediasToSend.count || mediasIsLoading)
+ }
+ .padding(.horizontal, isMessageTextFocused ? 0 : 2)
+
+ HStack {
+ if #available(iOS 16.0, *) {
+ TextField("Say something...", text: $conversationViewModel.messageText, axis: .vertical)
+ .default_text_style(styleSize: 15)
+ .focused($isMessageTextFocused)
+ .padding(.vertical, 5)
+ .onChange(of: conversationViewModel.messageText) { text in
+ if !text.isEmpty && !CoreContext.shared.enteredForeground {
+ conversationViewModel.compose()
+ }
+ }
+ } else {
+ ZStack(alignment: .leading) {
+ TextEditor(text: $conversationViewModel.messageText)
+ .multilineTextAlignment(.leading)
+ .frame(maxHeight: 160)
+ .fixedSize(horizontal: false, vertical: true)
+ .default_text_style(styleSize: 15)
+ .focused($isMessageTextFocused)
+ .onChange(of: conversationViewModel.messageText) { text in
+ if !text.isEmpty && !CoreContext.shared.enteredForeground {
+ conversationViewModel.compose()
+ }
+ }
+
+ if conversationViewModel.messageText.isEmpty {
+ Text("Say something...")
+ .padding(.leading, 4)
+ .lineLimit(1)
+ .opacity(conversationViewModel.messageText.isEmpty ? 1 : 0)
+ .foregroundStyle(Color.gray300)
+ .default_text_style(styleSize: 15)
+ }
+ }
+ .onTapGesture {
+ isMessageTextFocused = true
+ }
+ }
+
+ if conversationViewModel.messageText.isEmpty && conversationViewModel.mediasToSend.isEmpty {
+ Button {
+ voiceRecordingInProgress = true
+ } label: {
+ Image("microphone")
+ .renderingMode(.template)
+ .resizable()
+ .foregroundStyle(Color.grayMain2c500)
+ .frame(width: 28, height: 28, alignment: .leading)
+ .padding(.all, 6)
+ .padding(.top, 4)
+ }
+ } else {
+ Button {
+ if conversationViewModel.displayedConversationHistorySize > 0 {
+ NotificationCenter.default.post(name: .onScrollToBottom, object: nil)
+ }
+ conversationViewModel.sendMessage()
+ } label: {
+ Image("paper-plane-tilt")
+ .renderingMode(.template)
+ .resizable()
+ .foregroundStyle(Color.orangeMain500)
+ .frame(width: 28, height: 28, alignment: .leading)
+ .padding(.all, 6)
+ .padding(.top, 4)
+ .rotationEffect(.degrees(45))
+ }
+ .padding(.trailing, 4)
+ }
+ }
+ .padding(.leading, 15)
+ .padding(.trailing, 5)
+ .padding(.vertical, 6)
+ .frame(maxWidth: .infinity, minHeight: 55)
+ .background(.white)
+ .cornerRadius(30)
+ .overlay(
+ RoundedRectangle(cornerRadius: 30)
+ .inset(by: 0.5)
+ .stroke(Color.gray200, lineWidth: 1.5)
+ )
+ .padding(.horizontal, 4)
+ } else {
+ VoiceRecorderPlayer(conversationViewModel: conversationViewModel, voiceRecordingInProgress: $voiceRecordingInProgress)
+ .frame(maxHeight: 60)
+ }
+ }
+ .frame(maxWidth: .infinity, minHeight: 60)
+ .padding(.top, 12)
+ .padding(.bottom, geometry.safeAreaInsets.bottom > 0 ? (isMessageTextFocused ? 12 : 0) : 12)
+ .padding(.horizontal, 10)
+ .background(Color.gray100)
+ }
+ }
+ .blur(radius: conversationViewModel.selectedMessage != nil ? 8 : 0)
+
+ if conversationViewModel.selectedMessage != nil && conversationViewModel.displayedConversation != nil {
+ let iconSize = ((geometry.size.width - (conversationViewModel.displayedConversation!.isGroup ? 43 : 10) - 10) / 6) - 30
+ VStack {
+ Spacer()
+
+ VStack {
+ HStack {
+ if conversationViewModel.selectedMessage!.message.isOutgoing {
+ Spacer()
+ }
+
+ HStack {
+ Button {
+ conversationViewModel.sendReaction(emoji: "👍")
+ } label: {
+ Text("👍")
+ .default_text_style(styleSize: iconSize > 50 ? 50 : iconSize)
+ }
+ .padding(.horizontal, 8)
+ .background(conversationViewModel.selectedMessage?.message.ownReaction == "👍" ? Color.gray200 : .white)
+ .cornerRadius(10)
+
+ Button {
+ conversationViewModel.sendReaction(emoji: "❤️")
+ } label: {
+ Text("❤️")
+ .default_text_style(styleSize: iconSize > 50 ? 50 : iconSize)
+ }
+ .padding(.horizontal, 8)
+ .background(conversationViewModel.selectedMessage?.message.ownReaction == "❤️" ? Color.gray200 : .white)
+ .cornerRadius(10)
+
+ Button {
+ conversationViewModel.sendReaction(emoji: "😂")
+ } label: {
+ Text("😂")
+ .default_text_style(styleSize: iconSize > 50 ? 50 : iconSize)
+ }
+ .padding(.horizontal, 8)
+ .background(conversationViewModel.selectedMessage?.message.ownReaction == "😂" ? Color.gray200 : .white)
+ .cornerRadius(10)
+
+ Button {
+ conversationViewModel.sendReaction(emoji: "😮")
+ } label: {
+ Text("😮")
+ .default_text_style(styleSize: iconSize > 50 ? 50 : iconSize)
+ }
+ .padding(.horizontal, 8)
+ .background(conversationViewModel.selectedMessage?.message.ownReaction == "😮" ? Color.gray200 : .white)
+ .cornerRadius(10)
+
+ Button {
+ conversationViewModel.sendReaction(emoji: "😢")
+ } label: {
+ Text("😢")
+ .default_text_style(styleSize: iconSize > 50 ? 50 : iconSize)
+ }
+ .padding(.horizontal, 8)
+ .background(conversationViewModel.selectedMessage?.message.ownReaction == "😢" ? Color.gray200 : .white)
+ .cornerRadius(10)
+
+ /*
+ Button {
+ } label: {
+ Image("plus-circle")
+ .renderingMode(.template)
+ .resizable()
+ .foregroundStyle(Color.grayMain2c500)
+ .frame(width: iconSize > 50 ? 50 : iconSize, height: iconSize > 50 ? 50 : iconSize, alignment: .leading)
+ }
+ .padding(.trailing, 5)
+ */
+ }
+ .padding(.vertical, 5)
+ .padding(.horizontal, 10)
+ .background(.white)
+ .cornerRadius(20)
+
+ if !conversationViewModel.selectedMessage!.message.isOutgoing {
+ Spacer()
+ }
+ }
+ .frame(maxWidth: .infinity)
+ .padding(.horizontal, 10)
+ .padding(.leading, conversationViewModel.displayedConversation!.isGroup ? 43 : 0)
+ .shadow(color: .black.opacity(0.1), radius: 10)
+
+ ChatBubbleView(conversationViewModel: conversationViewModel, eventLogMessage: conversationViewModel.selectedMessage!, geometryProxy: geometry)
+ .padding(.horizontal, 10)
+ .padding(.vertical, 1)
+ .shadow(color: .black.opacity(0.1), radius: 10)
+
+ HStack {
+ if conversationViewModel.selectedMessage!.message.isOutgoing {
+ Spacer()
+ }
+
+ VStack {
+ Button {
+ let indexMessage = conversationViewModel.conversationMessagesSection[0].rows.firstIndex(where: {$0.message.id == conversationViewModel.selectedMessage!.message.id})
+ conversationViewModel.selectedMessage = nil
+ conversationViewModel.replyToMessage(index: indexMessage ?? 0)
+ } label: {
+ HStack {
+ Text("menu_reply_to_chat_message")
+ .default_text_style(styleSize: 15)
+ Spacer()
+ Image("reply")
+ .resizable()
+ .frame(width: 20, height: 20, alignment: .leading)
+ }
+ .padding(.vertical, 5)
+ .padding(.horizontal, 20)
+ }
+
+ Divider()
+
+ if !conversationViewModel.selectedMessage!.message.text.isEmpty {
+ Button {
+ UIPasteboard.general.setValue(
+ conversationViewModel.selectedMessage?.message.text ?? "Error_message_not_available",
+ forPasteboardType: UTType.plainText.identifier
+ )
+
+ ToastViewModel.shared.toastMessage = "Success_message_copied_into_clipboard"
+ ToastViewModel.shared.displayToast = true
+
+ conversationViewModel.selectedMessage = nil
+ } label: {
+ HStack {
+ Text("menu_copy_chat_message")
+ .default_text_style(styleSize: 15)
+ Spacer()
+ Image("copy")
+ .resizable()
+ .frame(width: 20, height: 20, alignment: .leading)
+ }
+ .padding(.vertical, 5)
+ .padding(.horizontal, 20)
+ }
+
+ Divider()
+ }
+
+ Button {
+ conversationForwardMessageViewModel.initConversationsLists(convsList: conversationsListViewModel.conversationsListTmp)
+ conversationForwardMessageViewModel.selectedMessage = conversationViewModel.selectedMessage
+ conversationViewModel.selectedMessage = nil
+ withAnimation {
+ isShowConversationForwardMessageFragment = true
+ }
+ } label: {
+ HStack {
+ Text("menu_forward_chat_message")
+ .default_text_style(styleSize: 15)
+ Spacer()
+ Image("forward")
+ .resizable()
+ .frame(width: 20, height: 20, alignment: .leading)
+ }
+ .padding(.vertical, 5)
+ .padding(.horizontal, 20)
+ }
+
+ Divider()
+
+ Button {
+ } label: {
+ HStack {
+ Text("menu_delete_selected_item")
+ .foregroundStyle(.red)
+ .default_text_style(styleSize: 15)
+ Spacer()
+ Image("trash-simple-red")
+ .renderingMode(.template)
+ .resizable()
+ .foregroundStyle(.red)
+ .frame(width: 20, height: 20, alignment: .leading)
+ }
+ .padding(.vertical, 5)
+ .padding(.horizontal, 20)
+ }
+ }
+ .frame(maxWidth: geometry.size.width / 1.5)
+ .padding(.vertical, 8)
+ .background(.white)
+ .cornerRadius(20)
+
+ if !conversationViewModel.selectedMessage!.message.isOutgoing {
+ Spacer()
+ }
+ }
+ .frame(maxWidth: .infinity)
+ .padding(.horizontal, 10)
+ .padding(.bottom, 20)
+ .padding(.leading, conversationViewModel.displayedConversation!.isGroup ? 43 : 0)
+ .shadow(color: .black.opacity(0.1), radius: 10)
+ }
+ }
+ .frame(maxWidth: .infinity)
+ .background(.gray.opacity(0.1))
+ .onTapGesture {
+ withAnimation {
+ conversationViewModel.selectedMessage = nil
+ }
+ }
+ .onAppear {
+ touchFeedback()
+ }
+ .onDisappear {
+ if conversationViewModel.selectedMessage != nil {
+ conversationViewModel.selectedMessage = nil
+ }
+ }
+ }
+
+ if isShowConversationForwardMessageFragment {
+ ConversationForwardMessageFragment(
+ conversationViewModel: conversationViewModel,
+ conversationsListViewModel: conversationsListViewModel,
+ conversationForwardMessageViewModel: conversationForwardMessageViewModel,
+ isShowConversationForwardMessageFragment: $isShowConversationForwardMessageFragment
+ )
+ .zIndex(5)
+ .transition(.move(edge: .trailing))
+ }
+ }
+ }
+ // swiftlint:enable cyclomatic_complexity
+ // swiftlint:enable function_body_length
+}
+
+struct ImdnOrReactionsSheet: View {
+ @ObservedObject var conversationViewModel: ConversationViewModel
+
+ @Binding var selectedCategoryIndex: Int
+
+ var body: some View {
+ VStack {
+ Picker("Categories", selection: $selectedCategoryIndex) {
+ ForEach(0..) -> UIImagePickerController {
+ let imagePicker = UIImagePickerController()
+ imagePicker.sourceType = .camera
+ imagePicker.mediaTypes = ["public.image", "public.movie"]
+ imagePicker.delegate = context.coordinator
+
+ return imagePicker
+ }
+
+ func updateUIViewController(_ uiViewController: UIImagePickerController, context: UIViewControllerRepresentableContext) {
+
+ }
+
+ func makeCoordinator() -> Coordinator {
+ Coordinator(self)
+ }
+}
+
+struct VoiceRecorderPlayer: View {
+ @ObservedObject var conversationViewModel: ConversationViewModel
+
+ @Binding var voiceRecordingInProgress: Bool
+
+ @StateObject var audioRecorder = AudioRecorder()
+
+ @State private var value: Double = 0.0
+ @State private var isPlaying: Bool = false
+ @State private var isRecording: Bool = true
+ @State private var timer: Timer?
+
+ var minTrackColor: Color = .white.opacity(0.5)
+ var maxTrackGradient: Gradient = Gradient(colors: [Color.orangeMain300, Color.orangeMain500])
+
+ var body: some View {
+ GeometryReader { geometry in
+ let radius = geometry.size.height * 0.5
+ HStack {
+ Button(
+ action: {
+ self.audioRecorder.stopVoiceRecorder()
+ voiceRecordingInProgress = false
+ },
+ label: {
+ Image("x")
+ .renderingMode(.template)
+ .resizable()
+ .foregroundStyle(Color.orangeMain500)
+ .frame(width: 25, height: 25)
+ }
+ )
+ .padding(10)
+ .background(.white)
+ .clipShape(RoundedRectangle(cornerRadius: 25))
+
+ ZStack(alignment: .leading) {
+ LinearGradient(
+ gradient: maxTrackGradient,
+ startPoint: .leading,
+ endPoint: .trailing
+ )
+ .frame(width: geometry.size.width - 110, height: 50)
+ HStack {
+ if !isRecording {
+ Rectangle()
+ .foregroundColor(minTrackColor)
+ .frame(width: self.value * (geometry.size.width - 110) / 100, height: 50)
+ } else {
+ Rectangle()
+ .foregroundColor(minTrackColor)
+ .frame(width: CGFloat(audioRecorder.soundPower) * (geometry.size.width - 110) / 100, height: 50)
+ }
+ }
+
+ HStack {
+ Button(
+ action: {
+ if isRecording {
+ self.audioRecorder.stopVoiceRecorder()
+ isRecording = false
+ } else if isPlaying {
+ conversationViewModel.pauseVoiceRecordPlayer()
+ pauseProgress()
+ } else {
+ if audioRecorder.audioFilename != nil {
+ conversationViewModel.startVoiceRecordPlayer(voiceRecordPath: audioRecorder.audioFilename!)
+ playProgress()
+ }
+ }
+ },
+ label: {
+ Image(isRecording ? "stop-fill" : (isPlaying ? "pause-fill" : "play-fill"))
+ .renderingMode(.template)
+ .resizable()
+ .foregroundStyle(Color.orangeMain500)
+ .frame(width: 20, height: 20)
+ }
+ )
+ .padding(8)
+ .background(.white)
+ .clipShape(RoundedRectangle(cornerRadius: 25))
+
+ Spacer()
+
+ HStack {
+ if isRecording {
+ Image("record-fill")
+ .renderingMode(.template)
+ .resizable()
+ .foregroundStyle(isRecording ? Color.redDanger500 : Color.orangeMain500)
+ .frame(width: 18, height: 18)
+ }
+
+ Text(Int(audioRecorder.recordingTime).convertDurationToString())
+ .default_text_style(styleSize: 16)
+ .padding(.horizontal, 5)
+ }
+ .padding(8)
+ .background(.white)
+ .clipShape(RoundedRectangle(cornerRadius: 25))
+ }
+ .padding(.horizontal, 10)
+ }
+ .clipShape(RoundedRectangle(cornerRadius: radius))
+
+ Button {
+ if conversationViewModel.displayedConversationHistorySize > 0 {
+ NotificationCenter.default.post(name: .onScrollToBottom, object: nil)
+ }
+ conversationViewModel.sendMessage(audioRecorder: self.audioRecorder)
+ voiceRecordingInProgress = false
+ } label: {
+ Image("paper-plane-tilt")
+ .renderingMode(.template)
+ .resizable()
+ .foregroundStyle(Color.orangeMain500)
+ .frame(width: 28, height: 28, alignment: .leading)
+ .padding(.all, 6)
+ .padding(.top, 4)
+ .rotationEffect(.degrees(45))
+ }
+ .padding(.trailing, 4)
+ }
+ .padding(.horizontal, 4)
+ .padding(.vertical, 5)
+ .onAppear {
+ self.audioRecorder.startRecording()
+ }
+ .onDisappear {
+ self.audioRecorder.stopVoiceRecorder()
+ resetProgress()
+ }
+ }
+ }
+
+ private func playProgress() {
+ isPlaying = true
+ if audioRecorder.audioFilename != nil {
+ self.value = conversationViewModel.getPositionVoiceRecordPlayer(voiceRecordPath: audioRecorder.audioFilename!)
+ timer = Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true) { _ in
+ if self.value < 100.0 {
+ let valueTmp = conversationViewModel.getPositionVoiceRecordPlayer(voiceRecordPath: audioRecorder.audioFilename!)
+ if self.value > 90 && self.value == valueTmp {
+ self.value = 100
+ } else {
+ if valueTmp == 0 && !conversationViewModel.isPlayingVoiceRecordPlayer(voiceRecordPath: audioRecorder.audioFilename!) {
+ stopProgress()
+ value = 0.0
+ isPlaying = false
+ } else {
+ self.value = valueTmp
+ }
+ }
+ } else {
+ resetProgress()
+ }
+ }
+ }
+ }
+
+ // Pause the progress
+ private func pauseProgress() {
+ isPlaying = false
+ stopProgress()
+ }
+
+ // Reset the progress
+ private func resetProgress() {
+ conversationViewModel.stopVoiceRecordPlayer()
+ stopProgress()
+ value = 0.0
+ isPlaying = false
+ }
+
+ // Stop the progress and invalidate the timer
+ private func stopProgress() {
+ timer?.invalidate()
+ timer = nil
+ }
+}
+/*
+#Preview {
+ ConversationFragment(conversationViewModel: ConversationViewModel(), conversationsListViewModel: ConversationsListViewModel(), sections: [MessagesSection], ids: [""])
+}
+*/
+
+// swiftlint:enable type_body_length
+// swiftlint:enable line_length
diff --git a/Linphone/UI/Main/Conversations/Fragments/ConversationsFragment.swift b/Linphone/UI/Main/Conversations/Fragments/ConversationsFragment.swift
new file mode 100644
index 000000000..b91bf86e0
--- /dev/null
+++ b/Linphone/UI/Main/Conversations/Fragments/ConversationsFragment.swift
@@ -0,0 +1,60 @@
+/*
+ * 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 .
+ */
+
+import SwiftUI
+
+struct ConversationsFragment: View {
+
+ @ObservedObject var conversationViewModel: ConversationViewModel
+ @ObservedObject var conversationsListViewModel: ConversationsListViewModel
+
+ private var idiom: UIUserInterfaceIdiom { UIDevice.current.userInterfaceIdiom }
+
+ @State var showingSheet: Bool = false
+ @Binding var text: String
+
+ var body: some View {
+ ZStack {
+ if #available(iOS 16.0, *), idiom != .pad {
+ ConversationsListFragment(conversationViewModel: conversationViewModel,
+ conversationsListViewModel: conversationsListViewModel, showingSheet: $showingSheet, text: $text)
+ .sheet(isPresented: $showingSheet) {
+ ConversationsListBottomSheet(
+ conversationsListViewModel: conversationsListViewModel,
+ showingSheet: $showingSheet
+ )
+ .presentationDetents([.fraction(0.4)])
+ }
+ } else {
+ ConversationsListFragment(conversationViewModel: conversationViewModel,
+ conversationsListViewModel: conversationsListViewModel, showingSheet: $showingSheet, text: $text)
+ .halfSheet(showSheet: $showingSheet) {
+ ConversationsListBottomSheet(
+ conversationsListViewModel: conversationsListViewModel,
+ showingSheet: $showingSheet
+ )
+ } onDismiss: {}
+ }
+ }
+ }
+}
+
+#Preview {
+ ConversationsFragment(conversationViewModel: ConversationViewModel(), conversationsListViewModel: ConversationsListViewModel(), text: .constant(""))
+}
diff --git a/Linphone/UI/Main/Conversations/Fragments/ConversationsListBottomSheet.swift b/Linphone/UI/Main/Conversations/Fragments/ConversationsListBottomSheet.swift
new file mode 100644
index 000000000..a7a773704
--- /dev/null
+++ b/Linphone/UI/Main/Conversations/Fragments/ConversationsListBottomSheet.swift
@@ -0,0 +1,257 @@
+/*
+ * 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 .
+ */
+
+import SwiftUI
+import linphonesw
+
+struct ConversationsListBottomSheet: View {
+
+ @Environment(\.dismiss) var dismiss
+
+ private var idiom: UIUserInterfaceIdiom { UIDevice.current.userInterfaceIdiom }
+
+ @State private var orientation = UIDevice.current.orientation
+
+ @ObservedObject var conversationsListViewModel: ConversationsListViewModel
+
+ @Binding var showingSheet: Bool
+
+ var body: some View {
+ VStack(alignment: .leading) {
+ if idiom != .pad && (orientation == .landscapeLeft
+ || orientation == .landscapeRight
+ || UIScreen.main.bounds.size.width > UIScreen.main.bounds.size.height) {
+ Spacer()
+ HStack {
+ Spacer()
+ Button("Close") {
+ if #available(iOS 16.0, *) {
+ showingSheet.toggle()
+ } else {
+ showingSheet.toggle()
+ dismiss()
+ }
+ }
+ }
+ .padding(.trailing)
+ }
+
+ Spacer()
+
+ Button {
+ if conversationsListViewModel.selectedConversation != nil {
+ conversationsListViewModel.markAsReadSelectedConversation()
+ conversationsListViewModel.updateUnreadMessagesCount()
+ }
+
+ if #available(iOS 16.0, *) {
+ if idiom != .pad {
+ showingSheet.toggle()
+ } else {
+ showingSheet.toggle()
+ dismiss()
+ }
+ } else {
+ showingSheet.toggle()
+ dismiss()
+ }
+ } label: {
+ HStack {
+ Image("envelope-simple")
+ .renderingMode(.template)
+ .resizable()
+ .foregroundStyle(Color.grayMain2c500)
+ .frame(width: 25, height: 25, alignment: .leading)
+ .padding(.all, 10)
+ Text("Marquer comme non lu")
+ .default_text_style(styleSize: 16)
+ Spacer()
+ }
+ .frame(maxHeight: .infinity)
+ }
+ .padding(.horizontal, 30)
+ .background(Color.gray100)
+
+ VStack {
+ Divider()
+ }
+ .frame(maxWidth: .infinity)
+
+ Button {
+ if conversationsListViewModel.selectedConversation != nil {
+ conversationsListViewModel.selectedConversation!.toggleMute()
+ }
+
+ if #available(iOS 16.0, *) {
+ if idiom != .pad {
+ showingSheet.toggle()
+ } else {
+ showingSheet.toggle()
+ dismiss()
+ }
+ } else {
+ showingSheet.toggle()
+ dismiss()
+ }
+ } label: {
+ HStack {
+ Image(conversationsListViewModel.selectedConversation!.isMuted ? "bell" : "bell-slash")
+ .renderingMode(.template)
+ .resizable()
+ .foregroundStyle(Color.grayMain2c500)
+ .frame(width: 25, height: 25, alignment: .leading)
+ .padding(.all, 10)
+ Text(conversationsListViewModel.selectedConversation!.isMuted ? "Réactiver les notifications" : "Mettre en sourdine")
+ .default_text_style(styleSize: 16)
+ Spacer()
+ }
+ .frame(maxHeight: .infinity)
+ }
+ .padding(.horizontal, 30)
+ .background(Color.gray100)
+
+ VStack {
+ Divider()
+ }
+ .frame(maxWidth: .infinity)
+
+ if conversationsListViewModel.selectedConversation != nil
+ && !conversationsListViewModel.selectedConversation!.isGroup {
+ Button {
+ if !conversationsListViewModel.selectedConversation!.isGroup {
+ conversationsListViewModel.selectedConversation!.call()
+ }
+
+ if #available(iOS 16.0, *) {
+ if idiom != .pad {
+ showingSheet.toggle()
+ } else {
+ showingSheet.toggle()
+ dismiss()
+ }
+ } else {
+ showingSheet.toggle()
+ dismiss()
+ }
+
+ } label: {
+ HStack {
+ Image("phone")
+ .renderingMode(.template)
+ .resizable()
+ .foregroundStyle(Color.grayMain2c500)
+ .frame(width: 25, height: 25, alignment: .leading)
+ .padding(.all, 10)
+ Text("Appel")
+ .default_text_style(styleSize: 16)
+ Spacer()
+ }
+ .frame(maxHeight: .infinity)
+ }
+ .padding(.horizontal, 30)
+ .background(Color.gray100)
+
+ VStack {
+ Divider()
+ }
+ .frame(maxWidth: .infinity)
+ }
+
+ Button {
+ conversationsListViewModel.selectedConversation!.deleteChatRoom()
+ conversationsListViewModel.computeChatRoomsList(filter: "")
+
+ if #available(iOS 16.0, *) {
+ if idiom != .pad {
+ showingSheet.toggle()
+ } else {
+ showingSheet.toggle()
+ dismiss()
+ }
+ } else {
+ showingSheet.toggle()
+ dismiss()
+ }
+ } label: {
+ HStack {
+ Image("trash-simple")
+ .renderingMode(.template)
+ .resizable()
+ .foregroundStyle(Color.redDanger500)
+ .frame(width: 25, height: 25, alignment: .leading)
+ .padding(.all, 10)
+ Text("Supprimer la conversation")
+ .foregroundStyle(Color.redDanger500)
+ .default_text_style(styleSize: 16)
+ Spacer()
+ }
+ .frame(maxHeight: .infinity)
+ }
+ .padding(.horizontal, 30)
+ .background(Color.gray100)
+
+ VStack {
+ Divider()
+ }
+ .frame(maxWidth: .infinity)
+
+ Button {
+ if conversationsListViewModel.selectedConversation != nil {
+ conversationsListViewModel.selectedConversation!.leave()
+ }
+
+ if #available(iOS 16.0, *) {
+ if idiom != .pad {
+ showingSheet.toggle()
+ } else {
+ showingSheet.toggle()
+ dismiss()
+ }
+ } else {
+ showingSheet.toggle()
+ dismiss()
+ }
+ } label: {
+ HStack {
+ Image("sign-out")
+ .renderingMode(.template)
+ .resizable()
+ .foregroundStyle(Color.grayMain2c500)
+ .frame(width: 25, height: 25, alignment: .leading)
+ .padding(.all, 10)
+ Text("Quitter la conversation")
+ .default_text_style(styleSize: 16)
+ Spacer()
+ }
+ .frame(maxHeight: .infinity)
+ }
+ .padding(.horizontal, 30)
+ .background(Color.gray100)
+ }
+ .background(Color.gray100)
+ .frame(maxWidth: .infinity)
+ .onRotate { newOrientation in
+ orientation = newOrientation
+ }
+ }
+}
+
+#Preview {
+ ConversationsListBottomSheet(conversationsListViewModel: ConversationsListViewModel(), showingSheet: .constant(true))
+}
diff --git a/Linphone/UI/Main/Conversations/Fragments/ConversationsListFragment.swift b/Linphone/UI/Main/Conversations/Fragments/ConversationsListFragment.swift
new file mode 100644
index 000000000..38212c570
--- /dev/null
+++ b/Linphone/UI/Main/Conversations/Fragments/ConversationsListFragment.swift
@@ -0,0 +1,223 @@
+/*
+ * 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 .
+ */
+
+import SwiftUI
+import linphonesw
+
+struct ConversationsListFragment: View {
+
+ @EnvironmentObject var navigationManager: NavigationManager
+
+ @ObservedObject var conversationViewModel: ConversationViewModel
+ @ObservedObject var conversationsListViewModel: ConversationsListViewModel
+
+ @Binding var showingSheet: Bool
+ @Binding var text: String
+
+ var body: some View {
+ VStack {
+ List {
+ ForEach(conversationsListViewModel.conversationsList) { conversation in
+ ConversationRow(
+ navigationManager: _navigationManager,
+ conversation: conversation,
+ conversationViewModel: conversationViewModel,
+ conversationsListViewModel: conversationsListViewModel,
+ showingSheet: $showingSheet,
+ text: $text
+ )
+ }
+ }
+ .safeAreaInset(edge: .top, content: {
+ Spacer()
+ .frame(height: 12)
+ })
+ .listStyle(.plain)
+ .overlay(
+ VStack {
+ if conversationsListViewModel.conversationsList.isEmpty {
+ Spacer()
+ Image("illus-belledonne")
+ .resizable()
+ .scaledToFit()
+ .clipped()
+ .padding(.all)
+ Text(!text.isEmpty ? "list_filter_no_result_found" : "conversations_list_empty")
+ .default_text_style_800(styleSize: 16)
+ Spacer()
+ Spacer()
+ }
+ }
+ .padding(.all)
+ )
+ }
+ .navigationTitle("")
+ .navigationBarHidden(true)
+ }
+}
+
+struct ConversationRow: View {
+ @EnvironmentObject var navigationManager: NavigationManager
+
+ @ObservedObject var conversation: ConversationModel
+ @ObservedObject var conversationViewModel: ConversationViewModel
+ @ObservedObject var conversationsListViewModel: ConversationsListViewModel
+
+ @Binding var showingSheet: Bool
+ @Binding var text: String
+
+ var body: some View {
+ let pub = NotificationCenter.default
+ .publisher(for: NSNotification.Name("ChatRoomsComputed"))
+ HStack {
+ Avatar(contactAvatarModel: conversation.avatarModel, avatarSize: 50)
+
+ VStack(spacing: 0) {
+ Spacer()
+
+ Text(conversation.subject)
+ .foregroundStyle(Color.grayMain2c800)
+ .if(conversation.unreadMessagesCount > 0) { view in
+ view.default_text_style_700(styleSize: 14)
+ }
+ .default_text_style(styleSize: 14)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .lineLimit(1)
+
+ Text(conversation.lastMessageText)
+ .foregroundStyle(Color.grayMain2c400)
+ .if(conversation.unreadMessagesCount > 0) { view in
+ view.default_text_style_700(styleSize: 14)
+ }
+ .default_text_style(styleSize: 14)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .lineLimit(1)
+
+ Spacer()
+ }
+
+ Spacer()
+
+ VStack(alignment: .trailing, spacing: 0) {
+ Spacer()
+
+ HStack {
+ if !conversation.encryptionEnabled {
+ Image("warning-circle")
+ .renderingMode(.template)
+ .resizable()
+ .foregroundStyle(Color.redDanger500)
+ .frame(width: 18, height: 18, alignment: .trailing)
+ }
+
+ Text(conversationsListViewModel.getCallTime(startDate: conversation.lastUpdateTime))
+ .foregroundStyle(Color.grayMain2c400)
+ .default_text_style(styleSize: 14)
+ .lineLimit(1)
+ }
+
+ Spacer()
+
+ HStack {
+ if conversation.isMuted == false
+ && !(!conversation.lastMessageText.isEmpty
+ && conversation.lastMessageIsOutgoing == true)
+ && conversation.unreadMessagesCount == 0 {
+ Text("")
+ .frame(width: 18, height: 18, alignment: .trailing)
+ }
+
+ if conversation.isMuted {
+ Image("bell-slash")
+ .renderingMode(.template)
+ .resizable()
+ .foregroundStyle(Color.orangeMain500)
+ .frame(width: 18, height: 18, alignment: .trailing)
+ }
+
+ if !conversation.lastMessageText.isEmpty
+ && conversation.lastMessageIsOutgoing == true {
+ let imageName = LinphoneUtils.getChatIconState(chatState: conversation.lastMessageState)
+ Image(imageName)
+ .renderingMode(.template)
+ .resizable()
+ .foregroundStyle(Color.orangeMain500)
+ .frame(width: 18, height: 18, alignment: .trailing)
+ }
+
+ if conversation.unreadMessagesCount > 0 {
+ HStack {
+ Text(
+ conversation.unreadMessagesCount < 99
+ ? String(conversation.unreadMessagesCount)
+ : "99+"
+ )
+ .foregroundStyle(.white)
+ .default_text_style(styleSize: 10)
+ .lineLimit(1)
+ }
+ .frame(width: 18, height: 18)
+ .background(Color.redDanger500)
+ .cornerRadius(50)
+ }
+ }
+
+ Spacer()
+ }
+ .padding(.trailing, 10)
+ }
+ .frame(height: 50)
+ .buttonStyle(.borderless)
+ .listRowInsets(EdgeInsets(top: 6, leading: 20, bottom: 6, trailing: 20))
+ .listRowSeparator(.hidden)
+ .background(.white)
+ .onReceive(pub) { _ in
+ if CoreContext.shared.enteredForeground && conversationViewModel.displayedConversation != nil
+ && (navigationManager.peerAddr == nil || navigationManager.peerAddr == conversationViewModel.displayedConversation!.remoteSipUri) {
+ if conversationViewModel.displayedConversation != nil {
+ conversationViewModel.resetDisplayedChatRoom(conversationsList: conversationsListViewModel.conversationsList)
+ }
+ }
+
+ CoreContext.shared.enteredForeground = false
+
+ if navigationManager.peerAddr != nil
+ && conversation.remoteSipUri.contains(navigationManager.peerAddr!) {
+ conversationViewModel.getChatRoomWithStringAddress(conversationsList: conversationsListViewModel.conversationsList, stringAddr: navigationManager.peerAddr!)
+ navigationManager.peerAddr = nil
+ }
+ }
+ .onTapGesture {
+ conversationViewModel.changeDisplayedChatRoom(conversationModel: conversation)
+ }
+ .onLongPressGesture(minimumDuration: 0.2) {
+ conversationsListViewModel.selectedConversation = conversation
+ showingSheet.toggle()
+ }
+ }
+}
+
+#Preview {
+ ConversationsListFragment(
+ conversationViewModel: ConversationViewModel(),
+ conversationsListViewModel: ConversationsListViewModel(),
+ showingSheet: .constant(false),
+ text: .constant("")
+ )
+}
diff --git a/Linphone/UI/Main/Conversations/Fragments/MessageMenu.swift b/Linphone/UI/Main/Conversations/Fragments/MessageMenu.swift
new file mode 100644
index 000000000..559dbf76c
--- /dev/null
+++ b/Linphone/UI/Main/Conversations/Fragments/MessageMenu.swift
@@ -0,0 +1,30 @@
+/*
+ * 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 .
+ */
+
+import SwiftUI
+
+struct MessageMenu: View {
+ var body: some View {
+ Text(/*@START_MENU_TOKEN@*/"Hello, World!"/*@END_MENU_TOKEN@*/)
+ }
+}
+
+#Preview {
+ MessageMenu()
+}
diff --git a/Linphone/UI/Main/Conversations/Fragments/StartConversationFragment.swift b/Linphone/UI/Main/Conversations/Fragments/StartConversationFragment.swift
new file mode 100644
index 000000000..c7d37a931
--- /dev/null
+++ b/Linphone/UI/Main/Conversations/Fragments/StartConversationFragment.swift
@@ -0,0 +1,385 @@
+/*
+ * 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 .
+ */
+
+import SwiftUI
+import linphonesw
+
+// swiftlint:disable type_body_length
+struct StartConversationFragment: View {
+ @ObservedObject private var sharedMainViewModel = SharedMainViewModel.shared
+
+ @ObservedObject var contactsManager = ContactsManager.shared
+ @ObservedObject var magicSearch = MagicSearchSingleton.shared
+
+ @ObservedObject var startConversationViewModel: StartConversationViewModel
+ @ObservedObject var conversationViewModel: ConversationViewModel
+
+ @Binding var isShowStartConversationFragment: Bool
+
+ @FocusState var isSearchFieldFocused: Bool
+ @State private var delayedColor = Color.white
+
+ @FocusState var isMessageTextFocused: Bool
+
+ @State var operationInProgress: Bool = false
+
+ var body: some View {
+ NavigationView {
+ ZStack {
+ VStack(spacing: 1) {
+
+ Rectangle()
+ .foregroundColor(delayedColor)
+ .edgesIgnoringSafeArea(.top)
+ .frame(height: 0)
+ .task(delayColor)
+
+ HStack {
+ Image("caret-left")
+ .renderingMode(.template)
+ .resizable()
+ .foregroundStyle(Color.orangeMain500)
+ .frame(width: 25, height: 25, alignment: .leading)
+ .padding(.all, 10)
+ .padding(.top, 2)
+ .padding(.leading, -10)
+ .onTapGesture {
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
+ magicSearch.searchForContacts(
+ sourceFlags: MagicSearch.Source.Friends.rawValue | MagicSearch.Source.LdapServers.rawValue)
+ }
+
+ startConversationViewModel.searchField = ""
+ magicSearch.currentFilterSuggestions = ""
+ delayColorDismiss()
+ withAnimation {
+ isShowStartConversationFragment = false
+ }
+ }
+
+ Text("new_conversation_title")
+ .multilineTextAlignment(.leading)
+ .default_text_style_orange_800(styleSize: 16)
+
+ Spacer()
+
+ }
+ .frame(maxWidth: .infinity)
+ .frame(height: 50)
+ .padding(.horizontal)
+ .padding(.bottom, 4)
+ .background(.white)
+
+ VStack(spacing: 0) {
+ ZStack(alignment: .trailing) {
+ TextField("history_call_start_search_bar_filter_hint", text: $startConversationViewModel.searchField)
+ .default_text_style(styleSize: 15)
+ .frame(height: 25)
+ .focused($isSearchFieldFocused)
+ .padding(.horizontal, 30)
+ .onChange(of: startConversationViewModel.searchField) { newValue in
+ magicSearch.currentFilterSuggestions = newValue
+ magicSearch.searchForSuggestions()
+ }
+
+ HStack {
+ Button(action: {
+ }, label: {
+ Image("magnifying-glass")
+ .renderingMode(.template)
+ .resizable()
+ .foregroundStyle(Color.grayMain2c500)
+ .frame(width: 25, height: 25)
+ })
+
+ Spacer()
+
+ if !startConversationViewModel.searchField.isEmpty {
+ Button(action: {
+ startConversationViewModel.searchField = ""
+ magicSearch.currentFilterSuggestions = ""
+ magicSearch.searchForSuggestions()
+ }, label: {
+ Image("x")
+ .renderingMode(.template)
+ .resizable()
+ .foregroundStyle(Color.grayMain2c500)
+ .frame(width: 25, height: 25)
+ })
+ }
+ }
+ }
+ .padding(.horizontal, 15)
+ .padding(.vertical, 10)
+ .cornerRadius(60)
+ .overlay(
+ RoundedRectangle(cornerRadius: 60)
+ .inset(by: 0.5)
+ .stroke(isSearchFieldFocused ? Color.orangeMain500 : Color.gray200, lineWidth: 1)
+ )
+ .padding(.vertical)
+ .padding(.horizontal)
+
+ NavigationLink(destination: {
+ StartGroupConversationFragment(startConversationViewModel: startConversationViewModel)
+ }, label: {
+ HStack {
+ HStack(alignment: .center) {
+ Image("meetings")
+ .renderingMode(.template)
+ .resizable()
+ .foregroundStyle(.white)
+ .frame(width: 20, height: 20, alignment: .leading)
+ }
+ .padding(16)
+ .background(Color.orangeMain500)
+ .cornerRadius(40)
+
+ Text("new_conversation_create_group")
+ .foregroundStyle(.black)
+ .default_text_style_800(styleSize: 16)
+ .lineLimit(1)
+
+ Spacer()
+
+ Image("caret-right")
+ .renderingMode(.template)
+ .resizable()
+ .foregroundStyle(Color.grayMain2c500)
+ .frame(width: 25, height: 25, alignment: .leading)
+ }
+ })
+ .padding(.vertical, 10)
+ .padding(.horizontal, 20)
+ .background(
+ LinearGradient(gradient: Gradient(colors: [.grayMain2c100, .white]), startPoint: .leading, endPoint: .trailing)
+ .padding(.vertical, 10)
+ .padding(.horizontal, 40)
+ )
+
+ ScrollView {
+ if !ContactsManager.shared.lastSearch.isEmpty {
+ HStack(alignment: .center) {
+ Text("All contacts")
+ .default_text_style_800(styleSize: 16)
+
+ Spacer()
+ }
+ .padding(.vertical, 10)
+ .padding(.horizontal, 16)
+ }
+
+ ContactsListFragment(contactViewModel: ContactViewModel(), contactsListViewModel: ContactsListViewModel(), showingSheet: .constant(false)
+ , startCallFunc: { addr in
+ withAnimation {
+ startConversationViewModel.createOneToOneChatRoomWith(remote: addr)
+ }
+ })
+ .padding(.horizontal, 16)
+
+ if !contactsManager.lastSearchSuggestions.isEmpty {
+ HStack(alignment: .center) {
+ Text("Suggestions")
+ .default_text_style_800(styleSize: 16)
+
+ Spacer()
+ }
+ .padding(.vertical, 10)
+ .padding(.horizontal, 16)
+
+ suggestionsList
+ }
+ }
+ }
+ .frame(maxWidth: .infinity)
+ }
+ .background(.white)
+
+ if !startConversationViewModel.participants.isEmpty {
+ startConversationPopup
+ .background(.black.opacity(0.65))
+ .onAppear {
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.6) {
+ isMessageTextFocused = true
+ }
+ }
+ }
+
+ if startConversationViewModel.operationInProgress {
+ PopupLoadingView()
+ .background(.black.opacity(0.65))
+ .onDisappear {
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
+ magicSearch.searchForContacts(
+ sourceFlags: MagicSearch.Source.Friends.rawValue | MagicSearch.Source.LdapServers.rawValue
+ )
+ }
+
+ startConversationViewModel.searchField = ""
+ magicSearch.currentFilterSuggestions = ""
+ delayColorDismiss()
+
+ isShowStartConversationFragment = false
+
+ if startConversationViewModel.displayedConversation != nil {
+ self.conversationViewModel.changeDisplayedChatRoom(conversationModel: startConversationViewModel.displayedConversation!)
+
+ startConversationViewModel.displayedConversation = nil
+ }
+ }
+ }
+ }
+ .navigationTitle("")
+ .navigationBarHidden(true)
+ }
+ .navigationViewStyle(StackNavigationViewStyle())
+ }
+
+ @Sendable private func delayColor() async {
+ try? await Task.sleep(nanoseconds: 250_000_000)
+ delayedColor = Color.orangeMain500
+ }
+
+ func delayColorDismiss() {
+ Task {
+ try? await Task.sleep(nanoseconds: 80_000_000)
+ delayedColor = .white
+ }
+ }
+
+ var suggestionsList: some View {
+ ForEach(0...
+ */
+
+import SwiftUI
+
+struct StartGroupConversationFragment: View {
+ @ObservedObject var startConversationViewModel: StartConversationViewModel
+ @State var addParticipantsViewModel = AddParticipantsViewModel()
+
+ var body: some View {
+ AddParticipantsFragment(addParticipantsViewModel: addParticipantsViewModel, confirmAddParticipantsFunc: startConversationViewModel.addParticipants)
+ .onAppear {
+ addParticipantsViewModel.participantsToAdd = startConversationViewModel.participants
+ }
+ }
+}
+
+#Preview {
+ StartGroupConversationFragment(startConversationViewModel: StartConversationViewModel())
+}
diff --git a/Linphone/UI/Main/Conversations/Fragments/UIList.swift b/Linphone/UI/Main/Conversations/Fragments/UIList.swift
new file mode 100644
index 000000000..f2ac9d30c
--- /dev/null
+++ b/Linphone/UI/Main/Conversations/Fragments/UIList.swift
@@ -0,0 +1,591 @@
+/*
+ * 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 .
+ */
+
+// swiftlint:disable large_tuple
+// swiftlint:disable line_length
+// swiftlint:disable cyclomatic_complexity
+// swiftlint:disable type_body_length
+import SwiftUI
+import linphonesw
+
+public extension Notification.Name {
+ static let onScrollToBottom = Notification.Name("onScrollToBottom")
+ static let onScrollToIndex = Notification.Name("onScrollToIndex")
+}
+
+class FloatingButton: UIButton {
+
+ var unreadMessageCount: Int = 0 {
+ didSet {
+ updateUnreadBadge()
+ }
+ }
+
+ override init(frame: CGRect) {
+ super.init(frame: frame)
+ setupButton()
+ }
+
+ required init?(coder: NSCoder) {
+ super.init(coder: coder)
+ setupButton()
+ }
+
+ private func setupButton() {
+ // Set the button's appearance
+ self.setImage(UIImage(named: "caret-down")?.withRenderingMode(.alwaysTemplate), for: .normal)
+ self.tintColor = .white
+ self.backgroundColor = UIColor(Color.orangeMain500)
+ self.layer.cornerRadius = 30
+ self.layer.shadowColor = UIColor.black.withAlphaComponent(0.2).cgColor
+ self.layer.shadowOffset = CGSize(width: 0, height: 2)
+ self.layer.shadowOpacity = 1
+ self.layer.shadowRadius = 4
+
+ // Add target action
+ self.addTarget(self, action: #selector(buttonTapped), for: .touchUpInside)
+ }
+
+ private func updateUnreadBadge() {
+ // Remove old badge if exists
+ self.viewWithTag(100)?.removeFromSuperview()
+
+ if unreadMessageCount > 0 {
+ // Create the badge view
+ let badgeLabel = UILabel()
+ badgeLabel.tag = 100
+ badgeLabel.text = unreadMessageCount < 99 ? "\(unreadMessageCount)" : "99+"
+ badgeLabel.textColor = .white
+ badgeLabel.font = UIFont.systemFont(ofSize: 10)
+ badgeLabel.textAlignment = .center
+ badgeLabel.backgroundColor = UIColor(Color.redDanger500)
+ badgeLabel.layer.cornerRadius = 9
+ badgeLabel.layer.masksToBounds = true
+ badgeLabel.frame = CGRect(x: self.frame.size.width - 18, y: 0, width: 18, height: 18)
+ self.addSubview(badgeLabel)
+ }
+ }
+
+ @objc private func buttonTapped() {
+ NotificationCenter.default.post(name: .onScrollToBottom, object: nil)
+ }
+}
+
+struct UIList: UIViewRepresentable {
+
+ private static var sharedCoordinator: Coordinator?
+
+ @ObservedObject var viewModel: ChatViewModel
+ @ObservedObject var paginationState: PaginationState
+ @ObservedObject var conversationViewModel: ConversationViewModel
+ @ObservedObject var conversationsListViewModel: ConversationsListViewModel
+
+ let geometryProxy: GeometryProxy
+ let sections: [MessagesSection]
+
+ @State private var isScrolledToTop = false
+ @State private var isScrolledToBottom = true
+
+ func makeUIView(context: Context) -> UIView {
+ // Create a UIView to contain the UITableView and UIButton
+ let containerView = UIView()
+
+ // Create the UITableView
+ let tableView = UITableView(frame: .zero, style: .grouped)
+ tableView.contentInset = UIEdgeInsets(top: -10, left: 0, bottom: 0, right: 0)
+ tableView.translatesAutoresizingMaskIntoConstraints = false
+ tableView.separatorStyle = .none
+ tableView.dataSource = context.coordinator
+ tableView.delegate = context.coordinator
+ tableView.register(UITableViewCell.self, forCellReuseIdentifier: "Cell")
+ tableView.transform = CGAffineTransformMakeScale(1, -1)
+
+ tableView.showsVerticalScrollIndicator = true
+ tableView.estimatedSectionHeaderHeight = 1
+ tableView.estimatedSectionFooterHeight = UITableView.automaticDimension
+ tableView.keyboardDismissMode = .interactive
+ tableView.backgroundColor = UIColor(.white)
+ tableView.scrollsToTop = true
+
+ // Create the floating UIButton
+ let button = FloatingButton(frame: CGRect(x: 0, y: 0, width: 60, height: 60))
+ button.translatesAutoresizingMaskIntoConstraints = false
+ button.isHidden = isScrolledToBottom
+
+ // Add the tableView and floating button to the containerView
+ containerView.addSubview(tableView)
+ containerView.addSubview(button)
+
+ // Set up constraints
+ NSLayoutConstraint.activate([
+ // TableView constraints
+ tableView.topAnchor.constraint(equalTo: containerView.topAnchor),
+ tableView.leadingAnchor.constraint(equalTo: containerView.leadingAnchor),
+ tableView.trailingAnchor.constraint(equalTo: containerView.trailingAnchor),
+ tableView.bottomAnchor.constraint(equalTo: containerView.bottomAnchor),
+
+ // Floating Button constraints
+ button.widthAnchor.constraint(equalToConstant: 60),
+ button.heightAnchor.constraint(equalToConstant: 60),
+ button.trailingAnchor.constraint(equalTo: containerView.trailingAnchor, constant: -20),
+ button.bottomAnchor.constraint(equalTo: containerView.bottomAnchor, constant: -20)
+ ])
+
+ // Set the tableView as a tag for easy access in updateUIView
+ tableView.tag = 101
+ // Set the button as a tag for easy access in updateUIView
+ button.tag = 102
+
+ context.coordinator.parent = self
+ context.coordinator.tableView = tableView
+ context.coordinator.floatingButton = button
+ context.coordinator.geometryProxy = geometryProxy
+
+ return containerView
+ }
+
+ // func updateUIView(_ tableView: UITableView, context: Context) {
+ func updateUIView(_ uiView: UIView, context: Context) {
+ if let button = uiView.viewWithTag(102) as? FloatingButton {
+ button.unreadMessageCount = conversationViewModel.displayedConversationUnreadMessagesCount
+ }
+
+ if let tableView = uiView.viewWithTag(101) as? UITableView {
+ if context.coordinator.sections == sections {
+ return
+ }
+ if context.coordinator.sections == sections {
+ return
+ }
+
+ let prevSections = context.coordinator.sections
+ let (appliedDeletes, appliedDeletesSwapsAndEdits, deleteOperations, swapOperations, editOperations, insertOperations) = operationsSplit(oldSections: prevSections, newSections: sections)
+
+ tableView.performBatchUpdates {
+ context.coordinator.sections = appliedDeletes
+ for operation in deleteOperations {
+ applyOperation(operation, tableView: tableView)
+ }
+ }
+
+ tableView.performBatchUpdates {
+ context.coordinator.sections = appliedDeletesSwapsAndEdits // NOTE: this array already contains necessary edits, but won't be a problem for appplying swaps
+ for operation in swapOperations {
+ applyOperation(operation, tableView: tableView)
+ }
+ }
+
+ tableView.performBatchUpdates {
+ context.coordinator.sections = appliedDeletesSwapsAndEdits
+ for operation in editOperations {
+ applyOperation(operation, tableView: tableView)
+ }
+ }
+
+ context.coordinator.sections = sections
+
+ tableView.beginUpdates()
+ for operation in insertOperations {
+ applyOperation(operation, tableView: tableView)
+ }
+ tableView.endUpdates()
+
+ if isScrolledToBottom && conversationViewModel.displayedConversationUnreadMessagesCount > 0 {
+ conversationViewModel.markAsRead()
+ conversationsListViewModel.computeChatRoomsList(filter: "")
+ }
+ }
+ }
+
+ // MARK: - Operations
+
+ enum Operation {
+ case deleteSection(Int)
+ case insertSection(Int)
+
+ case delete(Int, Int) // delete with animation
+ case insert(Int, Int) // insert with animation
+ case swap(Int, Int, Int) // delete first with animation, then insert it into new position with animation. do not do anything with the second for now
+ case edit(Int, Int) // reload the element without animation
+ }
+
+ func applyOperation(_ operation: Operation, tableView: UITableView) {
+ switch operation {
+ case .deleteSection(let section):
+ tableView.deleteSections([section], with: .top)
+ case .insertSection(let section):
+ tableView.insertSections([section], with: .top)
+
+ case .delete(let section, let row):
+ tableView.deleteRows(at: [IndexPath(row: row, section: section)], with: .left)
+ case .insert(let section, let row):
+ tableView.insertRows(at: [IndexPath(row: row, section: section)], with: .top)
+ case .edit(let section, let row):
+ tableView.reloadRows(at: [IndexPath(row: row, section: section)], with: .none)
+ case .swap(let section, let rowFrom, let rowTo):
+ tableView.deleteRows(at: [IndexPath(row: rowFrom, section: section)], with: .top)
+ tableView.insertRows(at: [IndexPath(row: rowTo, section: section)], with: .top)
+ }
+ }
+
+ func operationsSplit(oldSections: [MessagesSection], newSections: [MessagesSection]) -> ([MessagesSection], [MessagesSection], [Operation], [Operation], [Operation], [Operation]) {
+ var appliedDeletes = oldSections
+ var appliedDeletesSwapsAndEdits = newSections
+
+ var deleteOperations = [Operation]()
+ var swapOperations = [Operation]()
+ var editOperations = [Operation]()
+ var insertOperations = [Operation]()
+
+ let oldDates = oldSections.map { $0.date }
+ let newDates = newSections.map { $0.date }
+ let commonDates = Array(Set(oldDates + newDates)).sorted(by: >)
+ for date in commonDates {
+ let oldIndex = appliedDeletes.firstIndex(where: { $0.date == date })
+ let newIndex = appliedDeletesSwapsAndEdits.firstIndex(where: { $0.date == date })
+ if oldIndex == nil, let newIndex {
+ if let operationIndex = newSections.firstIndex(where: { $0.date == date }) {
+ appliedDeletesSwapsAndEdits.remove(at: newIndex)
+ insertOperations.append(.insertSection(operationIndex))
+ }
+ continue
+ }
+ if newIndex == nil, let oldIndex {
+ if let operationIndex = oldSections.firstIndex(where: { $0.date == date }) {
+ appliedDeletes.remove(at: oldIndex)
+ deleteOperations.append(.deleteSection(operationIndex))
+ }
+ continue
+ }
+ guard let newIndex, let oldIndex else { continue }
+
+ var oldRows = appliedDeletes[oldIndex].rows
+ var newRows = appliedDeletesSwapsAndEdits[newIndex].rows
+ let oldRowIDs = Set(oldRows.map { $0.message.id })
+ let newRowIDs = Set(newRows.map { $0.message.id })
+ let rowIDsToDelete = oldRowIDs.subtracting(newRowIDs)
+ let rowIDsToInsert = newRowIDs.subtracting(oldRowIDs)
+
+ for rowId in rowIDsToDelete {
+ if let index = oldRows.firstIndex(where: { $0.message.id == rowId }) {
+ oldRows.remove(at: index)
+ deleteOperations.append(.delete(oldIndex, index))
+ }
+ }
+
+ for rowId in rowIDsToInsert {
+ if let index = newRows.firstIndex(where: { $0.message.id == rowId }) {
+ insertOperations.append(.insert(newIndex, index))
+ }
+ }
+
+ for rowId in rowIDsToInsert {
+ if let index = newRows.firstIndex(where: { $0.message.id == rowId }) {
+ newRows.remove(at: index)
+ }
+ }
+
+ for row in 0.. Bool {
+ !swaps.filter {
+ if case let .swap(section, rowFrom, rowTo) = $0 {
+ return section == section && (rowFrom == index || rowTo == index)
+ }
+ return false
+ }.isEmpty
+ }
+
+ // MARK: - Coordinator
+
+ func makeCoordinator() -> Coordinator {
+ if UIList.sharedCoordinator == nil {
+ UIList.sharedCoordinator = Coordinator(
+ parent: self,
+ geometryProxy: geometryProxy,
+ sections: sections
+ )
+ }
+ return UIList.sharedCoordinator!
+ }
+
+ class Coordinator: NSObject, UITableViewDataSource, UITableViewDelegate {
+
+ var parent: UIList
+ var tableView: UITableView?
+ var floatingButton: FloatingButton?
+
+ var geometryProxy: GeometryProxy
+ var sections: [MessagesSection]
+
+ init(parent: UIList, geometryProxy: GeometryProxy, sections: [MessagesSection]) {
+ self.parent = parent
+ self.geometryProxy = geometryProxy
+ self.sections = sections
+
+ super.init()
+
+ NotificationCenter.default.addObserver(forName: .onScrollToBottom, object: nil, queue: nil) { _ in
+ DispatchQueue.main.async {
+ if !self.sections.isEmpty {
+ if self.sections.first != nil
+ && parent.conversationViewModel.conversationMessagesSection.first != nil
+ && parent.conversationViewModel.displayedConversation != nil
+ && self.sections.first!.chatRoomID == parent.conversationViewModel.displayedConversation!.id
+ && self.sections.first!.rows.count == parent.conversationViewModel.conversationMessagesSection.first!.rows.count {
+ self.tableView!.scrollToRow(at: IndexPath(row: 0, section: 0), at: .top, animated: true)
+ }
+ }
+ }
+ }
+
+ NotificationCenter.default.addObserver(forName: .onScrollToIndex, object: nil, queue: nil) { notification in
+ DispatchQueue.main.async {
+ if !self.sections.isEmpty {
+ if self.sections.first != nil
+ && parent.conversationViewModel.conversationMessagesSection.first != nil
+ && parent.conversationViewModel.displayedConversation != nil
+ && self.sections.first!.chatRoomID == parent.conversationViewModel.displayedConversation!.id
+ && self.sections.first!.rows.count == parent.conversationViewModel.conversationMessagesSection.first!.rows.count {
+ if let dict = notification.userInfo as NSDictionary? {
+ if let index = dict["index"] as? Int {
+ if let animated = dict["animated"] as? Bool {
+ self.tableView!.scrollToRow(at: IndexPath(row: index, section: 0), at: .bottom, animated: animated)
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+
+ deinit {
+ NotificationCenter.default.removeObserver(self)
+ }
+
+ func numberOfSections(in tableView: UITableView) -> Int {
+ sections.count
+ }
+
+ func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
+ sections[section].rows.count
+ }
+
+ func tableView(_ tableView: UITableView, viewForFooterInSection section: Int) -> UIView? {
+ return progressView(section)
+ }
+
+ func progressView(_ section: Int) -> UIView? {
+ if section > parent.conversationViewModel.conversationMessagesSection.count
+ && parent.conversationViewModel.conversationMessagesSection[section].rows.count < parent.conversationViewModel.displayedConversationHistorySize {
+ let header = UIHostingController(rootView:
+ ProgressView()
+ .frame(idealWidth: .infinity, maxWidth: .infinity, alignment: .center)
+ ).view
+ header?.backgroundColor = UIColor(.white)
+ return header
+ }
+ return nil
+ }
+
+ func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
+
+ let tableViewCell = tableView.dequeueReusableCell(withIdentifier: "Cell", for: indexPath)
+ tableViewCell.selectionStyle = .none
+ tableViewCell.backgroundColor = UIColor(.white)
+
+ let row = sections[indexPath.section].rows[indexPath.row]
+ if #available(iOS 16.0, *) {
+ tableViewCell.contentConfiguration = UIHostingConfiguration {
+ ChatBubbleView(conversationViewModel: parent.conversationViewModel, eventLogMessage: row, geometryProxy: geometryProxy)
+ .padding(.vertical, 2)
+ .padding(.horizontal, 10)
+ .onTapGesture { }
+ }
+ .minSize(width: 0, height: 50)
+ .margins(.all, 0)
+ }
+
+ tableViewCell.transform = CGAffineTransformMakeScale(1, -1)
+
+ return tableViewCell
+ }
+
+ func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) {
+ let row = sections[indexPath.section].rows[indexPath.row]
+ parent.paginationState.handle(row.message)
+ }
+
+ func scrollViewDidScroll(_ scrollView: UIScrollView) {
+ let isScrolledToBottomTmp = scrollView.contentOffset.y <= 10
+ DispatchQueue.main.async {
+ if self.parent.isScrolledToBottom != isScrolledToBottomTmp {
+ self.parent.isScrolledToBottom = isScrolledToBottomTmp
+
+ if self.parent.isScrolledToBottom {
+ self.floatingButton!.isHidden = true
+ } else {
+ self.floatingButton!.isHidden = false
+ }
+
+ if self.parent.isScrolledToBottom && self.parent.conversationViewModel.displayedConversationUnreadMessagesCount > 0 {
+ self.parent.conversationViewModel.markAsRead()
+ self.parent.conversationsListViewModel.computeChatRoomsList(filter: "")
+ }
+ }
+ }
+
+ if !parent.isScrolledToTop && scrollView.contentOffset.y >= scrollView.contentSize.height - scrollView.frame.height - 500 {
+ parent.conversationViewModel.getOldMessages()
+ }
+
+ parent.isScrolledToTop = scrollView.contentOffset.y >= scrollView.contentSize.height - scrollView.frame.height - 500
+ }
+
+ func tableView(_ tableView: UITableView, leadingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? {
+
+ let archiveAction = UIContextualAction(style: .normal, title: "") { _, _, completionHandler in
+ self.parent.conversationViewModel.replyToMessage(index: indexPath.row)
+ completionHandler(true)
+ }
+
+ archiveAction.image = UIImage(named: "reply-reversed")!
+
+ let configuration = UISwipeActionsConfiguration(actions: [archiveAction])
+
+ return configuration
+ }
+ }
+}
+
+struct MessagesSection: Equatable {
+
+ let date: Date
+ let chatRoomID: String
+ var rows: [EventLogMessage]
+
+ static var formatter = {
+ let formatter = DateFormatter()
+ formatter.dateFormat = "EEEE, MMMM d"
+ return formatter
+ }()
+
+ init(date: Date, chatRoomID: String, rows: [EventLogMessage]) {
+ self.date = date
+ self.chatRoomID = chatRoomID
+ self.rows = rows
+ }
+
+ var formattedDate: String {
+ MessagesSection.formatter.string(from: date)
+ }
+
+ static func == (lhs: MessagesSection, rhs: MessagesSection) -> Bool {
+ lhs.date == rhs.date && lhs.rows == rhs.rows
+ }
+
+}
+
+struct EventLogMessage: Equatable {
+
+ let eventModel: EventModel
+ var message: Message
+
+ static var formatter = {
+ let formatter = DateFormatter()
+ formatter.dateFormat = "EEEE, MMMM d"
+ return formatter
+ }()
+
+ static func == (lhs: EventLogMessage, rhs: EventLogMessage) -> Bool {
+ lhs.message == rhs.message
+ }
+
+}
+
+final class PaginationState: ObservableObject {
+ var onEvent: ChatPaginationClosure?
+ var offset: Int
+
+ var shouldHandlePagination: Bool {
+ onEvent != nil
+ }
+
+ init(onEvent: ChatPaginationClosure? = nil, offset: Int = 0) {
+ self.onEvent = onEvent
+ self.offset = offset
+ }
+
+ func handle(_ message: Message) {
+ guard shouldHandlePagination else {
+ return
+ }
+ }
+}
+
+public typealias ChatPaginationClosure = (Message) -> Void
+
+final class ChatViewModel: ObservableObject {
+
+ @Published private(set) var fullscreenAttachmentItem: Attachment?
+ @Published var fullscreenAttachmentPresented = false
+
+ @Published var messageMenuRow: Message?
+
+ public var didSendMessage: (DraftMessage) -> Void = {_ in}
+
+ func presentAttachmentFullScreen(_ attachment: Attachment) {
+ fullscreenAttachmentItem = attachment
+ fullscreenAttachmentPresented = true
+ }
+
+ func dismissAttachmentFullScreen() {
+ fullscreenAttachmentPresented = false
+ fullscreenAttachmentItem = nil
+ }
+
+ func sendMessage(_ message: DraftMessage) {
+ didSendMessage(message)
+ }
+}
+// swiftlint:enable large_tuple
+// swiftlint:enable line_length
+// swiftlint:enable cyclomatic_complexity
+// swiftlint:enable type_body_length
diff --git a/Linphone/UI/Main/Conversations/Model/Attachment.swift b/Linphone/UI/Main/Conversations/Model/Attachment.swift
new file mode 100644
index 000000000..58b0536bb
--- /dev/null
+++ b/Linphone/UI/Main/Conversations/Model/Attachment.swift
@@ -0,0 +1,102 @@
+/*
+ * 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 .
+ */
+
+import Foundation
+
+public enum AttachmentType: String, Codable {
+ case image
+ case gif
+ case video
+ case audio
+ case voiceRecording
+ case pdf
+ case text
+ case fileTransfer
+ case other
+
+ public var title: String {
+ switch self {
+ case .image:
+ return "Image"
+ case .gif:
+ return "GIF"
+ case .video:
+ return "Video"
+ case .audio:
+ return "Audio"
+ case .voiceRecording:
+ return "Voice Recording"
+ case .pdf:
+ return "PDF"
+ case .text:
+ return "Text"
+ case .fileTransfer:
+ return "File Transfer"
+ default:
+ return "Other"
+ }
+ }
+
+ public init(mediaType: MediaType) {
+ switch mediaType {
+ case .image:
+ self = .image
+ case .gif:
+ self = .gif
+ case .video:
+ self = .video
+ case .audio:
+ self = .audio
+ case .voiceRecording:
+ self = .voiceRecording
+ case .pdf:
+ self = .pdf
+ case .text:
+ self = .text
+ case .fileTransfer:
+ self = .fileTransfer
+ default:
+ self = .other
+ }
+ }
+}
+
+public struct Attachment: Codable, Identifiable, Hashable {
+ public let id: String
+ public let name: String
+ public let thumbnail: URL
+ public let full: URL
+ public let type: AttachmentType
+ public let duration: Int
+ public let size: Int
+
+ public init(id: String, name: String, thumbnail: URL, full: URL, type: AttachmentType, duration: Int = 0, size: Int = 0) {
+ self.id = id
+ self.name = name
+ self.thumbnail = thumbnail
+ self.full = full
+ self.type = type
+ self.duration = duration
+ self.size = size
+ }
+
+ public init(id: String, name: String, url: URL, type: AttachmentType, duration: Int = 0, size: Int = 0) {
+ self.init(id: id, name: name, thumbnail: url, full: url, type: type, duration: duration, size: size)
+ }
+}
diff --git a/Linphone/UI/Main/Conversations/Model/ConversationModel.swift b/Linphone/UI/Main/Conversations/Model/ConversationModel.swift
new file mode 100644
index 000000000..587c433b8
--- /dev/null
+++ b/Linphone/UI/Main/Conversations/Model/ConversationModel.swift
@@ -0,0 +1,345 @@
+/*
+ * Copyright (c) 2010-2023 Belledonne Communications SARL.
+ *
+ * This file is part of Linphone
+ *
+ * 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 .
+ */
+
+import Foundation
+import linphonesw
+import Combine
+
+// swiftlint:disable line_length
+class ConversationModel: ObservableObject, Identifiable {
+
+ private var coreContext = CoreContext.shared
+ private var contactsManager = ContactsManager.shared
+
+ let chatRoom: ChatRoom
+ let isDisabledBecauseNotSecured: Bool = false
+
+ static let TAG = "[Conversation Model]"
+
+ let id: String
+ let localSipUri: String
+ let remoteSipUri: String
+ let isGroup: Bool
+ let isReadOnly: Bool
+ @Published var subject: String
+ @Published var participantsAddress: [String] = []
+ @Published var isComposing: Bool
+ @Published var lastUpdateTime: time_t
+ @Published var isMuted: Bool
+ @Published var isEphemeral: Bool
+ @Published var encryptionEnabled: Bool
+ @Published var lastMessageText: String
+ @Published var lastMessageIsOutgoing: Bool
+ @Published var lastMessageState: Int
+ @Published var unreadMessagesCount: Int
+ @Published var avatarModel: ContactAvatarModel
+
+ private var conferenceScheduler: ConferenceScheduler?
+ private var conferenceSchedulerDelegate: ConferenceSchedulerDelegate?
+
+ init(chatRoom: ChatRoom) {
+ self.chatRoom = chatRoom
+
+ self.id = LinphoneUtils.getChatRoomId(room: chatRoom)
+
+ self.localSipUri = chatRoom.localAddress?.asStringUriOnly() ?? ""
+
+ self.remoteSipUri = chatRoom.peerAddress?.asStringUriOnly() ?? ""
+
+ self.isGroup = !chatRoom.hasCapability(mask: ChatRoom.Capabilities.OneToOne.rawValue) && chatRoom.hasCapability(mask: ChatRoom.Capabilities.Conference.rawValue)
+
+ self.isReadOnly = chatRoom.isReadOnly
+
+ self.subject = chatRoom.subject ?? ""
+
+ self.lastUpdateTime = chatRoom.lastUpdateTime
+
+ self.isComposing = chatRoom.isRemoteComposing
+
+ self.isMuted = chatRoom.muted
+
+ self.isEphemeral = chatRoom.ephemeralEnabled
+
+ self.encryptionEnabled = chatRoom.currentParams != nil && chatRoom.currentParams!.encryptionEnabled
+
+ self.lastMessageText = ""
+
+ self.lastMessageIsOutgoing = false
+
+ self.lastMessageState = 0
+
+ self.unreadMessagesCount = 0
+
+ self.avatarModel = ContactAvatarModel(friend: nil, name: chatRoom.subject ?? "", address: chatRoom.peerAddress?.asStringUriOnly() ?? "", withPresence: false)
+
+ getContentTextMessage()
+ getChatRoomSubject()
+ getUnreadMessagesCount()
+ }
+
+ func leave() {
+ coreContext.doOnCoreQueue { _ in
+ self.chatRoom.leave()
+ }
+ }
+
+ func toggleMute() {
+ coreContext.doOnCoreQueue { _ in
+ let chatRoomMuted = self.chatRoom.muted
+ self.chatRoom.muted.toggle()
+ DispatchQueue.main.async {
+ self.isMuted = !chatRoomMuted
+ }
+ }
+ }
+
+ func call() {
+ 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()
+ }
+ }
+ }
+
+ func createGroupCall() {
+ coreContext.doOnCoreQueue { core in
+ 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)"
+ )
+
+ self.conferenceScheduler = try core.createConferenceScheduler(account: account)
+ if self.conferenceScheduler != nil {
+ self.conferenceAddDelegate(core: core, conferenceScheduler: self.conferenceScheduler!)
+ // Will trigger the conference creation/update automatically
+ self.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())"
+ )
+
+ TelecomManager.shared.doCallWithCore(addr: conferenceAddress!, isVideo: true, isConference: true)
+ } 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
+ if lastMessage != nil {
+ var fromAddressFriend = lastMessage!.fromAddress != nil
+ ? self.contactsManager.getFriendWithAddress(address: lastMessage!.fromAddress)?.name ?? nil
+ : nil
+
+ if !lastMessage!.isOutgoing && lastMessage!.chatRoom != nil && !lastMessage!.chatRoom!.hasCapability(mask: ChatRoom.Capabilities.OneToOne.rawValue) {
+ if fromAddressFriend == nil {
+ if lastMessage!.fromAddress!.displayName != nil {
+ fromAddressFriend = lastMessage!.fromAddress!.displayName! + ": "
+ } else if lastMessage!.fromAddress!.username != nil {
+ fromAddressFriend = lastMessage!.fromAddress!.username! + ": "
+ } else {
+ fromAddressFriend = ""
+ }
+ } else {
+ fromAddressFriend! += ": "
+ }
+
+ } else {
+ fromAddressFriend = nil
+ }
+
+ let lastMessageTextTmp = (fromAddressFriend ?? "")
+ + (lastMessage!.contents.first(where: {$0.isText == true})?.utf8Text ?? (lastMessage!.contents.first(where: {$0.isFile == true || $0.isFileTransfer == true})?.name ?? ""))
+
+ let lastMessageIsOutgoingTmp = lastMessage?.isOutgoing ?? false
+
+ let lastMessageStateTmp = lastMessage?.state.rawValue ?? 0
+
+ DispatchQueue.main.async {
+ self.lastMessageText = lastMessageTextTmp
+
+ self.lastMessageIsOutgoing = lastMessageIsOutgoingTmp
+
+ self.lastMessageState = lastMessageStateTmp
+ }
+ }
+ }
+ }
+
+ func getChatRoomSubject() {
+ coreContext.doOnCoreQueue { _ in
+ let addressFriend = (self.chatRoom.participants.first != nil && self.chatRoom.participants.first!.address != nil)
+ ? self.contactsManager.getFriendWithAddress(address: self.chatRoom.participants.first?.address)
+ : nil
+
+ var subjectTmp = ""
+
+ if self.isGroup {
+ subjectTmp = self.chatRoom.subject!
+ } else if addressFriend != nil {
+ subjectTmp = addressFriend!.name!
+ } else {
+ if self.chatRoom.participants.first != nil
+ && self.chatRoom.participants.first!.address != nil {
+
+ subjectTmp = self.chatRoom.participants.first!.address!.displayName != nil
+ ? self.chatRoom.participants.first!.address!.displayName!
+ : self.chatRoom.participants.first!.address!.username!
+
+ }
+ }
+
+ let addressTmp = addressFriend?.address?.asStringUriOnly() ?? ""
+
+ let avatarModelTmp = addressFriend != nil && !self.isGroup
+ ? ContactsManager.shared.avatarListModel.first(where: {
+ $0.friend!.name == addressFriend!.name
+ && $0.friend!.address!.asStringUriOnly() == addressFriend!.address!.asStringUriOnly()
+ })
+ ?? ContactAvatarModel(
+ friend: nil,
+ name: subjectTmp,
+ address: addressTmp,
+ withPresence: false
+ )
+ : ContactAvatarModel(
+ friend: nil,
+ name: subjectTmp,
+ address: self.chatRoom.peerAddress?.asStringUriOnly() ?? addressTmp,
+ withPresence: false
+ )
+
+ var participantsAddressTmp: [String] = []
+
+ self.chatRoom.participants.forEach { participant in
+ participantsAddressTmp.append(participant.address?.asStringUriOnly() ?? "")
+ }
+
+ DispatchQueue.main.async {
+ self.subject = subjectTmp
+ self.avatarModel = avatarModelTmp
+ self.participantsAddress = participantsAddressTmp
+ }
+ }
+ }
+
+ func getUnreadMessagesCount() {
+ coreContext.doOnCoreQueue { _ in
+ self.unreadMessagesCount = self.chatRoom.unreadMessagesCount
+ }
+ }
+
+ func refreshAvatarModel() {
+ coreContext.doOnCoreQueue { _ in
+ if !self.isGroup {
+ if self.chatRoom.participants.first != nil && self.chatRoom.participants.first!.address != nil {
+ ContactAvatarModel.getAvatarModelFromAddress(address: self.chatRoom.participants.first!.address!) { avatarResult in
+ let avatarModelTmp = avatarResult
+ let subjectTmp = avatarModelTmp.name
+
+ DispatchQueue.main.async {
+ self.avatarModel = avatarModelTmp
+ self.subject = subjectTmp
+ }
+ }
+ }
+ }
+ }
+ }
+
+ func downloadContent(chatMessage: ChatMessage, content: Content) {
+ coreContext.doOnCoreQueue { _ in
+ if !chatMessage.downloadContent(content: content) {
+ Log.error("\(ConversationModel.TAG) An error occured when downloading content of chat message. MessageID=\(chatMessage.messageId)")
+ }
+ }
+ }
+
+ func deleteChatRoom() {
+ CoreContext.shared.doOnCoreQueue { core in
+ core.deleteChatRoom(chatRoom: self.chatRoom)
+ }
+ }
+}
+// swiftlint:enable line_length
diff --git a/Linphone/UI/Main/Conversations/Model/EventModel.swift b/Linphone/UI/Main/Conversations/Model/EventModel.swift
new file mode 100644
index 000000000..18abeedfb
--- /dev/null
+++ b/Linphone/UI/Main/Conversations/Model/EventModel.swift
@@ -0,0 +1,124 @@
+/*
+ * 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 .
+ */
+
+import SwiftUI
+import linphonesw
+
+class EventModel: ObservableObject {
+ @Published var text: String
+ @Published var icon: Image?
+
+ var eventLog: EventLog
+ var eventLogId: String
+ var eventLogType: EventLog.Kind
+
+ init(eventLog: EventLog) {
+ self.eventLog = eventLog
+ self.eventLogId = eventLog.chatMessage != nil ? eventLog.chatMessage!.messageId : String(eventLog.notifyId)
+ self.eventLogType = eventLog.type
+ self.text = ""
+ self.icon = nil
+ setupEventData(eventLog: eventLog)
+ }
+
+ private func setupEventData(eventLog: EventLog) {
+ let address = eventLog.participantAddress ?? eventLog.peerAddress
+ if address != nil {
+ ContactsManager.shared.getFriendWithAddressInCoreQueue(address: address) { friendResult in
+ var name = ""
+ if let addressFriend = friendResult {
+ name = addressFriend.name!
+ } else {
+ name = address!.displayName != nil ? address!.displayName! : address!.username!
+ }
+
+ let textValue: String
+ let iconValue: Image?
+
+ switch eventLog.type {
+ case .ConferenceCreated:
+ textValue = NSLocalizedString("conversation_event_conference_created", comment: "")
+ case .ConferenceTerminated:
+ textValue = NSLocalizedString("conversation_event_conference_destroyed", comment: "")
+ case .ConferenceParticipantAdded:
+ textValue = String(format: NSLocalizedString("conversation_event_participant_added", comment: ""), address != nil ? name : ">")
+ case .ConferenceParticipantRemoved:
+ textValue = String(format: NSLocalizedString("conversation_event_participant_removed", comment: ""), address != nil ? name : ">")
+ case .ConferenceSubjectChanged:
+ textValue = String(format: NSLocalizedString("conversation_event_subject_changed", comment: ""), eventLog.subject ?? "")
+ case .ConferenceParticipantSetAdmin:
+ textValue = String(format: NSLocalizedString("conversation_event_admin_set", comment: ""), address != nil ? name : ">")
+ case .ConferenceParticipantUnsetAdmin:
+ textValue = String(format: NSLocalizedString("conversation_event_admin_unset", comment: ""), address != nil ? name : ">")
+ case .ConferenceParticipantDeviceAdded:
+ textValue = String(format: NSLocalizedString("conversation_event_device_added", comment: ""), address != nil ? name : ">")
+ case .ConferenceParticipantDeviceRemoved:
+ textValue = String(format: NSLocalizedString("conversation_event_device_removed", comment: ""), address != nil ? name : ">")
+ case .ConferenceEphemeralMessageEnabled:
+ textValue = NSLocalizedString("conversation_event_ephemeral_messages_enabled", comment: "")
+ case .ConferenceEphemeralMessageDisabled:
+ textValue = NSLocalizedString("conversation_event_ephemeral_messages_disabled", comment: "")
+ case .ConferenceEphemeralMessageLifetimeChanged:
+ textValue = String(format: NSLocalizedString("conversation_event_ephemeral_messages_lifetime_changed", comment: ""),
+ self.formatEphemeralExpiration(duration: Int64(eventLog.ephemeralMessageLifetime)).lowercased())
+ default:
+ textValue = String(eventLog.type.rawValue)
+ }
+
+ // Icon assignment
+ switch eventLog.type {
+ case .ConferenceEphemeralMessageEnabled, .ConferenceEphemeralMessageDisabled, .ConferenceEphemeralMessageLifetimeChanged:
+ iconValue = Image("clock-countdown")
+ case .ConferenceTerminated:
+ iconValue = Image("warning-circle")
+ case .ConferenceSubjectChanged:
+ iconValue = Image("pencil-simple")
+ case .ConferenceParticipantAdded, .ConferenceParticipantRemoved, .ConferenceParticipantDeviceAdded, .ConferenceParticipantDeviceRemoved:
+ iconValue = Image("door")
+ default:
+ iconValue = Image("user-circle")
+ }
+
+ DispatchQueue.main.async {
+ self.text = textValue
+ self.icon = iconValue
+ }
+ }
+ }
+ }
+
+ private func formatEphemeralExpiration(duration: Int64) -> String {
+ switch duration {
+ case 0:
+ return NSLocalizedString("conversation_ephemeral_messages_duration_disabled", comment: "")
+ case 60:
+ return NSLocalizedString("conversation_ephemeral_messages_duration_one_minute", comment: "")
+ case 3600:
+ return NSLocalizedString("conversation_ephemeral_messages_duration_one_hour", comment: "")
+ case 86400:
+ return NSLocalizedString("conversation_ephemeral_messages_duration_one_day", comment: "")
+ case 259200:
+ return NSLocalizedString("conversation_ephemeral_messages_duration_three_days", comment: "")
+ case 604800:
+ return NSLocalizedString("conversation_ephemeral_messages_duration_one_week", comment: "")
+ default:
+ return "\(duration) s"
+ }
+ }
+}
diff --git a/Linphone/UI/Main/Conversations/Model/Message.swift b/Linphone/UI/Main/Conversations/Model/Message.swift
new file mode 100644
index 000000000..ec035c713
--- /dev/null
+++ b/Linphone/UI/Main/Conversations/Model/Message.swift
@@ -0,0 +1,391 @@
+/*
+ * 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 .
+ */
+
+import SwiftUI
+
+// swiftlint:disable line_length
+// swiftlint:disable vertical_parameter_alignment
+public struct Message: Identifiable, Hashable {
+
+ public enum Status: Equatable, Hashable {
+ case sending
+ case sent
+ case received
+ case read
+ case error
+
+ public func hash(into hasher: inout Hasher) {
+ switch self {
+ case .sending:
+ return hasher.combine("sending")
+ case .sent:
+ return hasher.combine("sent")
+ case .received:
+ return hasher.combine("received")
+ case .read:
+ return hasher.combine("read")
+ case .error:
+ return hasher.combine("error")
+ }
+ }
+
+ public static func == (lhs: Message.Status, rhs: Message.Status) -> Bool {
+ switch (lhs, rhs) {
+ case (.sending, .sending):
+ return true
+ case (.sent, .sent):
+ return true
+ case (.received, .received):
+ return true
+ case (.read, .read):
+ return true
+ case ( .error, .error):
+ return true
+ default:
+ return false
+ }
+ }
+ }
+
+ public var id: String
+ public var appData: String
+ public var status: Status?
+ public var createdAt: Date
+ public var isOutgoing: Bool
+ public var dateReceived: time_t
+
+ public var address: String
+ public var isFirstMessage: Bool
+ public var text: String
+ public var attachmentsNames: String
+ public var attachments: [Attachment]
+ public var recording: Recording?
+ public var replyMessage: ReplyMessage?
+ public var isForward: Bool
+ public var ownReaction: String
+ public var reactions: [String]
+
+ public var isEphemeral: Bool
+ public var ephemeralExpireTime: Int
+ public var ephemeralLifetime: Int
+
+ public var isIcalendar: Bool
+ public var messageConferenceInfo: MessageConferenceInfo?
+
+ public init(
+ id: String,
+ appData: String = "",
+ status: Status? = nil,
+ createdAt: Date = Date(),
+ isOutgoing: Bool,
+ dateReceived: time_t,
+ address: String,
+ isFirstMessage: Bool = false,
+ text: String = "",
+ attachmentsNames: String = "",
+ attachments: [Attachment] = [],
+ recording: Recording? = nil,
+ replyMessage: ReplyMessage? = nil,
+ isForward: Bool = false,
+ ownReaction: String = "",
+ reactions: [String] = [],
+ isEphemeral: Bool = false,
+ ephemeralExpireTime: Int = 0,
+ ephemeralLifetime: Int = 0,
+ isIcalendar: Bool = false,
+ messageConferenceInfo: MessageConferenceInfo? = nil
+ ) {
+ self.id = id
+ self.appData = appData
+ self.status = status
+ self.createdAt = createdAt
+ self.isOutgoing = isOutgoing
+ self.dateReceived = dateReceived
+ self.isFirstMessage = isFirstMessage
+ self.address = address
+ self.text = text
+ self.attachmentsNames = attachmentsNames
+ self.attachments = attachments
+ self.recording = recording
+ self.replyMessage = replyMessage
+ self.isForward = isForward
+ self.ownReaction = ownReaction
+ self.reactions = reactions
+ self.isEphemeral = isEphemeral
+ self.ephemeralExpireTime = ephemeralExpireTime
+ self.ephemeralLifetime = ephemeralLifetime
+ self.isIcalendar = isIcalendar
+ self.messageConferenceInfo = messageConferenceInfo
+ }
+
+ public static func makeMessage(
+ id: String,
+ status: Status? = nil,
+ draft: DraftMessage) async -> Message {
+ let attachments = await draft.medias.asyncCompactMap { media -> Attachment? in
+ guard let thumbnailURL = await media.getThumbnailURL() else {
+ return nil
+ }
+
+ switch media.type {
+ case .image:
+ return Attachment(id: UUID().uuidString, name: "", url: thumbnailURL, type: .image)
+ case .video:
+ guard let fullURL = await media.getURL() else {
+ return nil
+ }
+ return Attachment(id: UUID().uuidString, name: "", thumbnail: thumbnailURL, full: fullURL, type: .video)
+ case .audio:
+ return Attachment(id: UUID().uuidString, name: "", url: thumbnailURL, type: .audio)
+ default:
+ return Attachment(id: UUID().uuidString, name: "", url: thumbnailURL, type: .other)
+ }
+ }
+
+ return Message(
+ id: id,
+ status: status,
+ createdAt: draft.createdAt,
+ isOutgoing: draft.isOutgoing,
+ dateReceived: draft.dateReceived,
+ address: draft.address,
+ isFirstMessage: draft.isFirstMessage,
+ text: draft.text,
+ attachments: attachments,
+ recording: draft.recording,
+ replyMessage: draft.replyMessage,
+ ownReaction: draft.ownReaction,
+ reactions: draft.reactions
+ )
+ }
+}
+
+extension Message {
+ var time: String {
+ DateFormatter.timeFormatter.string(from: createdAt)
+ }
+}
+
+extension Message: Equatable {
+ public static func == (lhs: Message, rhs: Message) -> Bool {
+ lhs.id == rhs.id && lhs.status == rhs.status && lhs.isFirstMessage == rhs.isFirstMessage && lhs.ownReaction == rhs.ownReaction && lhs.reactions == rhs.reactions && lhs.ephemeralExpireTime == rhs.ephemeralExpireTime
+ }
+}
+
+public struct Recording: Codable, Hashable {
+ public var duration: Double
+ public var waveformSamples: [CGFloat]
+ public var url: URL?
+
+ public init(duration: Double = 0.0, waveformSamples: [CGFloat] = [], url: URL? = nil) {
+ self.duration = duration
+ self.waveformSamples = waveformSamples
+ self.url = url
+ }
+}
+
+public struct ReplyMessage: Codable, Identifiable, Hashable {
+ public static func == (lhs: ReplyMessage, rhs: ReplyMessage) -> Bool {
+ lhs.id == rhs.id
+ }
+
+ public var id: String
+
+ public var address: String
+ public var isFirstMessage: Bool
+ public var text: String
+ public var isOutgoing: Bool
+ public var dateReceived: time_t
+ public var attachmentsNames: String
+ public var attachments: [Attachment]
+ public var recording: Recording?
+
+ public init(id: String,
+ address: String,
+ isFirstMessage: Bool = false,
+ text: String = "",
+ isOutgoing: Bool,
+ dateReceived: time_t,
+ attachmentsNames: String = "",
+ attachments: [Attachment] = [],
+ recording: Recording? = nil) {
+
+ self.id = id
+ self.address = address
+ self.isFirstMessage = isFirstMessage
+ self.text = text
+ self.isOutgoing = isOutgoing
+ self.dateReceived = dateReceived
+ self.attachmentsNames = attachmentsNames
+ self.attachments = attachments
+ self.recording = recording
+ }
+
+ func toMessage() -> Message {
+ Message(id: id, isOutgoing: isOutgoing, dateReceived: dateReceived, address: address, isFirstMessage: isFirstMessage, text: text, attachments: attachments, recording: recording)
+ }
+}
+
+public extension Message {
+
+ func toReplyMessage() -> ReplyMessage {
+ ReplyMessage(id: id, address: address, isFirstMessage: isFirstMessage, text: text, isOutgoing: isOutgoing, dateReceived: dateReceived, attachments: attachments, recording: recording)
+ }
+}
+
+public struct DraftMessage {
+ public var id: String?
+ public let isOutgoing: Bool
+ public var dateReceived: time_t
+ public let address: String
+ public let isFirstMessage: Bool
+ public let text: String
+ public let medias: [Media]
+ public let recording: Recording?
+ public let replyMessage: ReplyMessage?
+ public let createdAt: Date
+ public let ownReaction: String
+ public let reactions: [String]
+
+ public init(id: String? = nil,
+ isOutgoing: Bool,
+ dateReceived: time_t,
+ address: String,
+ isFirstMessage: Bool,
+ text: String,
+ medias: [Media],
+ recording: Recording?,
+ replyMessage: ReplyMessage?,
+ createdAt: Date,
+ ownReaction: String,
+ reactions: [String]
+ ) {
+ self.id = id
+ self.isOutgoing = isOutgoing
+ self.dateReceived = dateReceived
+ self.address = address
+ self.isFirstMessage = isFirstMessage
+ self.text = text
+ self.medias = medias
+ self.recording = recording
+ self.replyMessage = replyMessage
+ self.createdAt = createdAt
+ self.ownReaction = ownReaction
+ self.reactions = reactions
+ }
+}
+
+public enum MediaType {
+ case image
+ case gif
+ case video
+ case audio
+ case voiceRecording
+ case pdf
+ case text
+ case fileTransfer
+ case other
+}
+
+public struct Media: Identifiable, Equatable {
+ public var id = UUID()
+ internal let source: MediaModelProtocol
+
+ public static func == (lhs: Media, rhs: Media) -> Bool {
+ lhs.id == rhs.id
+ }
+}
+
+public extension Media {
+
+ var type: MediaType {
+ source.mediaType ?? .image
+ }
+
+ var duration: CGFloat? {
+ source.duration
+ }
+
+ func getURL() async -> URL? {
+ await source.getURL()
+ }
+
+ func getThumbnailURL() async -> URL? {
+ await source.getThumbnailURL()
+ }
+
+ func getData() async -> Data? {
+ try? await source.getData()
+ }
+
+ func getThumbnailData() async -> Data? {
+ await source.getThumbnailData()
+ }
+}
+
+protocol MediaModelProtocol {
+ var mediaType: MediaType? { get }
+ var duration: CGFloat? { get }
+
+ func getURL() async -> URL?
+ func getThumbnailURL() async -> URL?
+
+ func getData() async throws -> Data?
+ func getThumbnailData() async -> Data?
+}
+
+extension Sequence {
+ func asyncCompactMap(
+ _ transform: (Element) async throws -> T?
+ ) async rethrows -> [T] {
+ var values = [T]()
+
+ for element in self {
+ if let elmt = try await transform(element) {
+ values.append(elmt)
+ }
+ }
+
+ return values
+ }
+}
+
+extension DateFormatter {
+ static let timeFormatter = {
+ let formatter = DateFormatter()
+
+ formatter.dateStyle = .none
+ formatter.timeStyle = .short
+
+ return formatter
+ }()
+
+ static func timeString(_ seconds: Int) -> String {
+ let hour = Int(seconds) / 3600
+ let minute = Int(seconds) / 60 % 60
+ let second = Int(seconds) % 60
+
+ if hour > 0 {
+ return String(format: "%02i:%02i:%02i", hour, minute, second)
+ }
+ return String(format: "%02i:%02i", minute, second)
+ }
+}
+// swiftlint:enable line_length
+// swiftlint:enable vertical_parameter_alignment
diff --git a/Linphone/UI/Main/Conversations/Model/MessageConferenceInfo.swift b/Linphone/UI/Main/Conversations/Model/MessageConferenceInfo.swift
new file mode 100644
index 000000000..489fcf210
--- /dev/null
+++ b/Linphone/UI/Main/Conversations/Model/MessageConferenceInfo.swift
@@ -0,0 +1,52 @@
+/*
+ * 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 .
+ */
+
+import Foundation
+
+public enum MessageConferenceState: Codable {
+ case new
+ case updated
+ case cancelled
+}
+
+public struct MessageConferenceInfo: Codable, Identifiable, Hashable {
+ public let id: UUID
+ public let meetingConferenceUri: String
+ public let meetingSubject: String
+ public let meetingDescription: String
+ public let meetingState: MessageConferenceState
+ public let meetingDate: String
+ public let meetingTime: String
+ public let meetingDay: String
+ public let meetingDayNumber: String
+ public let meetingParticipants: String
+
+ public init(id: UUID, meetingConferenceUri: String, meetingSubject: String, meetingDescription: String, meetingState: MessageConferenceState, meetingDate: String, meetingTime: String, meetingDay: String, meetingDayNumber: String, meetingParticipants: String) {
+ self.id = id
+ self.meetingConferenceUri = meetingConferenceUri
+ self.meetingSubject = meetingSubject
+ self.meetingDescription = meetingDescription
+ self.meetingState = meetingState
+ self.meetingDate = meetingDate
+ self.meetingTime = meetingTime
+ self.meetingDay = meetingDay
+ self.meetingDayNumber = meetingDayNumber
+ self.meetingParticipants = meetingParticipants
+ }
+}
diff --git a/Linphone/UI/Main/Conversations/ViewModel/ConversationForwardMessageViewModel.swift b/Linphone/UI/Main/Conversations/ViewModel/ConversationForwardMessageViewModel.swift
new file mode 100644
index 000000000..3f1a936ee
--- /dev/null
+++ b/Linphone/UI/Main/Conversations/ViewModel/ConversationForwardMessageViewModel.swift
@@ -0,0 +1,309 @@
+/*
+ * 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 .
+ */
+
+import linphonesw
+import Combine
+
+// swiftlint:disable line_length
+class ConversationForwardMessageViewModel: ObservableObject {
+
+ static let TAG = "[ConversationForwardMessageViewModel]"
+
+ @Published var searchField: String = ""
+
+ @Published var operationInProgress: Bool = false
+
+ @Published var selectedMessage: EventLogMessage?
+
+ @Published var conversationsList: [ConversationModel] = []
+ var conversationsListTmp: [ConversationModel] = []
+
+ @Published var displayedConversation: ConversationModel?
+
+ private var chatRoomDelegate: ChatRoomDelegate?
+
+ init() {}
+
+ func initConversationsLists(convsList: [ConversationModel]) {
+ conversationsListTmp = convsList
+ conversationsList = convsList
+ searchField = ""
+ operationInProgress = false
+ selectedMessage = nil
+ }
+
+ func filterConversations() {
+ conversationsList.removeAll()
+ conversationsListTmp.forEach { conversation in
+ if conversation.subject.lowercased().contains(searchField.lowercased()) || !conversation.participantsAddress.filter({ $0.lowercased().contains(searchField.lowercased()) }).isEmpty {
+ conversationsList.append(conversation)
+ }
+ }
+ }
+
+ func resetFilterConversations() {
+ conversationsList = conversationsListTmp
+ }
+
+ func changeChatRoom(model: ConversationModel) {
+ if self.operationInProgress == false {
+ DispatchQueue.main.async {
+ self.operationInProgress = true
+ }
+
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
+ self.operationInProgress = false
+ self.displayedConversation = model
+ }
+ } else {
+ DispatchQueue.main.async {
+ self.operationInProgress = false
+ self.displayedConversation = model
+ }
+ }
+ }
+
+ func createOneToOneChatRoomWith(remote: Address) {
+ CoreContext.shared.doOnCoreQueue { core in
+ let account = core.defaultAccount
+ if account == nil {
+ Log.error(
+ "\(StartConversationViewModel.TAG) No default account found, can't create conversation with \(remote.asStringUriOnly())!"
+ )
+ return
+ }
+
+ DispatchQueue.main.async {
+ self.operationInProgress = true
+ }
+
+ do {
+ let params: ChatRoomParams = try core.createDefaultChatRoomParams()
+ params.groupEnabled = false
+ params.subject = "Dummy subject"
+ params.ephemeralLifetime = 0 // Make sure ephemeral is disabled by default
+
+ let sameDomain = remote.domain == account?.params?.domain ?? ""
+ if StartConversationViewModel.isEndToEndEncryptionMandatory() && sameDomain {
+ Log.info("\(StartConversationViewModel.TAG) Account is in secure mode & domain matches, creating a E2E conversation")
+ params.backend = ChatRoom.Backend.FlexisipChat
+ params.encryptionEnabled = true
+ } else if !StartConversationViewModel.isEndToEndEncryptionMandatory() {
+ if LinphoneUtils.isEndToEndEncryptedChatAvailable(core: core) {
+ Log.info(
+ "\(StartConversationViewModel.TAG) Account is in interop mode but LIME is available, creating a E2E conversation"
+ )
+ params.backend = ChatRoom.Backend.FlexisipChat
+ params.encryptionEnabled = true
+ } else {
+ Log.info(
+ "\(StartConversationViewModel.TAG) Account is in interop mode but LIME isn't available, creating a SIP simple conversation"
+ )
+ params.backend = ChatRoom.Backend.Basic
+ params.encryptionEnabled = false
+ }
+ } else {
+ Log.error(
+ "\(StartConversationViewModel.TAG) Account is in secure mode, can't chat with SIP address of different domain \(remote.asStringUriOnly())"
+ )
+ DispatchQueue.main.async {
+ self.operationInProgress = false
+ ToastViewModel.shared.toastMessage = "Failed_to_create_conversation_invalid_participant_error"
+ ToastViewModel.shared.displayToast = true
+ }
+ return
+ }
+
+ let participants = [remote]
+ let localAddress = account?.params?.identityAddress
+ let existingChatRoom = core.searchChatRoom(params: params, localAddr: localAddress, remoteAddr: nil, participants: participants)
+ if existingChatRoom == nil {
+ Log.info(
+ "\(StartConversationViewModel.TAG) No existing 1-1 conversation between local account "
+ + "\(localAddress?.asStringUriOnly() ?? "") and remote \(remote.asStringUriOnly()) was found for given parameters, let's create it"
+ )
+ let chatRoom = try core.createChatRoom(params: params, localAddr: localAddress, participants: participants)
+ if params.backend == ChatRoom.Backend.FlexisipChat {
+ if chatRoom.state == ChatRoom.State.Created {
+ let id = LinphoneUtils.getChatRoomId(room: chatRoom)
+ Log.info("\(StartConversationViewModel.TAG) 1-1 conversation \(id) has been created")
+
+ let model = ConversationModel(chatRoom: chatRoom)
+ if self.operationInProgress == false {
+ DispatchQueue.main.async {
+ self.operationInProgress = true
+ }
+
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
+ self.operationInProgress = false
+ self.displayedConversation = model
+ }
+ } else {
+ DispatchQueue.main.async {
+ self.operationInProgress = false
+ self.displayedConversation = model
+ }
+ }
+ } else {
+ Log.info("\(StartConversationViewModel.TAG) Conversation isn't in Created state yet, wait for it")
+ self.chatRoomAddDelegate(core: core, chatRoom: chatRoom)
+ }
+ } else {
+ let id = LinphoneUtils.getChatRoomId(room: chatRoom)
+ Log.info("\(StartConversationViewModel.TAG) Conversation successfully created \(id)")
+
+ let model = ConversationModel(chatRoom: chatRoom)
+ if self.operationInProgress == false {
+ DispatchQueue.main.async {
+ self.operationInProgress = true
+ }
+
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
+ self.operationInProgress = false
+ self.displayedConversation = model
+ }
+ } else {
+ DispatchQueue.main.async {
+ self.operationInProgress = false
+ self.displayedConversation = model
+ }
+ }
+ }
+ } else {
+ Log.warn(
+ "\(StartConversationViewModel.TAG) A 1-1 conversation between local account \(localAddress?.asStringUriOnly() ?? "") and remote \(remote.asStringUriOnly()) for given parameters already exists!"
+ )
+
+ let model = ConversationModel(chatRoom: existingChatRoom!)
+ if self.operationInProgress == false {
+ DispatchQueue.main.async {
+ self.operationInProgress = true
+ }
+
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
+ self.operationInProgress = false
+ self.displayedConversation = model
+ }
+ } else {
+ DispatchQueue.main.async {
+ self.operationInProgress = false
+ self.displayedConversation = model
+ }
+ }
+ }
+ } catch {
+ DispatchQueue.main.async {
+ self.operationInProgress = false
+ ToastViewModel.shared.toastMessage = "Failed_to_create_conversation_error"
+ ToastViewModel.shared.displayToast = true
+ }
+ Log.error("\(StartConversationViewModel.TAG) Failed to create 1-1 conversation with \(remote.asStringUriOnly())!")
+ }
+ }
+ }
+
+ func chatRoomAddDelegate(core: Core, chatRoom: ChatRoom) {
+ self.chatRoomDelegate = ChatRoomDelegateStub(onStateChanged: { (chatRoom: ChatRoom, state: ChatRoom.State) in
+ let state = chatRoom.state
+ let id = LinphoneUtils.getChatRoomId(room: chatRoom)
+ if state == ChatRoom.State.CreationFailed {
+ Log.error("\(StartConversationViewModel.TAG) Conversation \(id) creation has failed!")
+ if let chatRoomDelegate = self.chatRoomDelegate {
+ chatRoom.removeDelegate(delegate: chatRoomDelegate)
+ }
+ self.chatRoomDelegate = nil
+ DispatchQueue.main.async {
+ self.operationInProgress = false
+ ToastViewModel.shared.toastMessage = "Failed_to_create_conversation_error"
+ ToastViewModel.shared.displayToast = true
+ }
+ }
+ }, onConferenceJoined: { (chatRoom: ChatRoom, _: EventLog) in
+ let state = chatRoom.state
+ let id = LinphoneUtils.getChatRoomId(room: chatRoom)
+ Log.info("\(StartConversationViewModel.TAG) Conversation \(id) \(chatRoom.subject ?? "") state changed: \(state)")
+ if state == ChatRoom.State.Created {
+ Log.info("\(StartConversationViewModel.TAG) Conversation \(id) successfully created")
+ if let chatRoomDelegate = self.chatRoomDelegate {
+ chatRoom.removeDelegate(delegate: chatRoomDelegate)
+ }
+ self.chatRoomDelegate = nil
+
+ let model = ConversationModel(chatRoom: chatRoom)
+ if self.operationInProgress == false {
+ DispatchQueue.main.async {
+ self.operationInProgress = true
+ }
+
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
+ self.operationInProgress = false
+ self.displayedConversation = model
+ }
+ } else {
+ DispatchQueue.main.async {
+ self.operationInProgress = false
+ self.displayedConversation = model
+ }
+ }
+ } else if state == ChatRoom.State.CreationFailed {
+ Log.error("\(StartConversationViewModel.TAG) Conversation \(id) creation has failed!")
+ if let chatRoomDelegate = self.chatRoomDelegate {
+ chatRoom.removeDelegate(delegate: chatRoomDelegate)
+ }
+ self.chatRoomDelegate = nil
+ DispatchQueue.main.async {
+ self.operationInProgress = false
+ ToastViewModel.shared.toastMessage = "Failed_to_create_conversation_error"
+ ToastViewModel.shared.displayToast = true
+ }
+ }
+ })
+ chatRoom.addDelegate(delegate: self.chatRoomDelegate!)
+ }
+
+ func forwardMessage() {
+ CoreContext.shared.doOnCoreQueue { _ in
+ if self.displayedConversation != nil && self.selectedMessage != nil {
+ let chatMessageToDisplay = self.displayedConversation!.chatRoom.findEventLog(messageId: self.selectedMessage!.eventModel.eventLogId)?.chatMessage
+ if let messageToForward = chatMessageToDisplay {
+ DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
+ do {
+ let forwardedMessage = try self.displayedConversation!.chatRoom.createForwardMessage(message: messageToForward)
+ Log.info("\(ConversationForwardMessageViewModel.TAG) Sending forwarded message")
+ forwardedMessage.send()
+
+ } catch let error {
+ print("\(#function) - Failed to create forward message: \(error)")
+ }
+
+ self.selectedMessage = nil
+ self.displayedConversation = nil
+ }
+ /*
+ showGreenToastEvent.postValue(
+ Event(Pair(R.string.conversation_message_forwarded_toast, R.drawable.forward))
+ )
+ */
+ }
+ }
+ }
+ }
+}
+
+// swiftlint:enable line_length
diff --git a/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift b/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift
new file mode 100644
index 000000000..213f415f9
--- /dev/null
+++ b/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift
@@ -0,0 +1,2280 @@
+/*
+ * 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 .
+ */
+
+import Foundation
+import linphonesw
+import SwiftUI
+import AVFoundation
+
+// swiftlint:disable line_length
+// swiftlint:disable type_body_length
+// swiftlint:disable cyclomatic_complexity
+
+class ConversationViewModel: ObservableObject {
+
+ private var coreContext = CoreContext.shared
+
+ @Published var displayedConversation: ConversationModel?
+ @Published var displayedConversationHistorySize: Int = 0
+ @Published var displayedConversationUnreadMessagesCount: Int = 0
+
+ @Published var messageText: String = ""
+ @Published var composingLabel: String = ""
+
+ // Used to keep track of a ChatRoom callback without having to worry about life cycle
+ // Init will add the delegate, deinit will remove it
+ class ChatRoomDelegateHolder {
+ var chatRoom: ChatRoom
+ var chatRoomDelegate: ChatRoomDelegate
+ init (chatroom: ChatRoom, delegate: ChatRoomDelegate) {
+ chatroom.addDelegate(delegate: delegate)
+ self.chatRoom = chatroom
+ self.chatRoomDelegate = delegate
+ }
+ deinit {
+ self.chatRoom.removeDelegate(delegate: chatRoomDelegate)
+ }
+ }
+ private var chatRoomDelegateHolder: ChatRoomDelegateHolder?
+
+ // Used to keep track of a ChatMessage callback without having to worry about life cycle
+ // Init will add the delegate, deinit will remove it
+ class ChatMessageDelegateHolder {
+ var chatMessage: ChatMessage
+ var chatMessageDelegate: ChatMessageDelegate
+ init (message: ChatMessage, delegate: ChatMessageDelegate) {
+ message.addDelegate(delegate: delegate)
+ self.chatMessage = message
+ self.chatMessageDelegate = delegate
+ }
+ deinit {
+ self.chatMessage.removeDelegate(delegate: chatMessageDelegate)
+ }
+ }
+
+ private var chatMessageDelegateHolders: [ChatMessageDelegateHolder] = []
+
+ @Published var conversationMessagesSection: [MessagesSection] = []
+ @Published var participantConversationModel: [ContactAvatarModel] = []
+
+ @Published var mediasToSend: [Attachment] = []
+ var maxMediaCount = 12
+
+ var oldMessageReceived = false
+
+ @Published var isShowSelectedMessageToDisplayDetails: Bool = false
+ @Published var selectedMessageToDisplayDetails: EventLogMessage?
+ @Published var selectedMessageToPlayVoiceRecording: EventLogMessage?
+ @Published var selectedMessage: EventLogMessage?
+ @Published var messageToReply: EventLogMessage?
+
+ @Published var sheetCategories: [SheetCategory] = []
+
+ var vrpManager: VoiceRecordPlayerManager?
+ @Published var isPlaying = false
+ @Published var progress: Double = 0.0
+
+ struct SheetCategory: Identifiable {
+ let id = UUID()
+ let name: String
+ let innerCategory: [InnerSheetCategory]
+ }
+
+ struct InnerSheetCategory: Identifiable {
+ let id = UUID()
+ let contact: ContactAvatarModel
+ let detail: String
+ var isMe: Bool = false
+ }
+
+ init() {}
+
+ func addConversationDelegate() {
+ coreContext.doOnCoreQueue { _ in
+ if let chatroom = self.displayedConversation?.chatRoom {
+ let chatRoomDelegate = ChatRoomDelegateStub( onIsComposingReceived: { (_: ChatRoom, _: Address, _: Bool) in
+ self.computeComposingLabel()
+ }, onChatMessagesReceived: { (_: ChatRoom, eventLogs: [EventLog]) in
+ self.getNewMessages(eventLogs: eventLogs)
+ }, onChatMessageSending: { (_: ChatRoom, eventLog: EventLog) in
+ self.getNewMessages(eventLogs: [eventLog])
+ }, onParticipantAdded: { (_: ChatRoom, eventLogs: EventLog) in
+ self.getNewMessages(eventLogs: [eventLogs])
+ }, onParticipantRemoved: { (_: ChatRoom, eventLogs: EventLog) in
+ self.getNewMessages(eventLogs: [eventLogs])
+ }, onParticipantAdminStatusChanged: { (_: ChatRoom, eventLogs: EventLog) in
+ self.getNewMessages(eventLogs: [eventLogs])
+ }, onSubjectChanged: { (_: ChatRoom, eventLogs: EventLog) in
+ self.getNewMessages(eventLogs: [eventLogs])
+ }, onEphemeralMessageDeleted: {(_: ChatRoom, eventLog: EventLog) in
+ self.removeMessage(eventLog)
+ })
+ self.chatRoomDelegateHolder = ChatRoomDelegateHolder(chatroom: chatroom, delegate: chatRoomDelegate)
+ }
+ }
+ }
+
+ func addChatMessageDelegate(message: ChatMessage) {
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
+ if self.displayedConversation != nil {
+ var statusTmp: Message.Status? = .sending
+ switch message.state {
+ case .InProgress:
+ statusTmp = .sending
+ case .Delivered:
+ statusTmp = .sent
+ case .DeliveredToUser:
+ statusTmp = .received
+ case .Displayed:
+ statusTmp = .read
+ case .NotDelivered:
+ statusTmp = .error
+ default:
+ statusTmp = .sending
+ }
+
+ let ephemeralExpireTimeTmp = message.ephemeralExpireTime
+
+ if !self.conversationMessagesSection.isEmpty && !self.conversationMessagesSection[0].rows.isEmpty {
+ if let indexMessageEventLogId = self.conversationMessagesSection[0].rows.firstIndex(where: {$0.eventModel.eventLogId.isEmpty && $0.eventModel.eventLog.chatMessage != nil ? $0.eventModel.eventLog.chatMessage!.messageId == message.messageId : false}) {
+ self.conversationMessagesSection[0].rows[indexMessageEventLogId].eventModel.eventLogId = message.messageId
+ }
+
+ if let indexMessage = self.conversationMessagesSection[0].rows.firstIndex(where: {$0.eventModel.eventLogId == message.messageId}) {
+ if indexMessage < self.conversationMessagesSection[0].rows.count {
+ if self.conversationMessagesSection[0].rows[indexMessage].message.status != statusTmp {
+ DispatchQueue.main.async {
+ self.conversationMessagesSection[0].rows[indexMessage].message.status = statusTmp ?? .error
+ self.conversationMessagesSection[0].rows[indexMessage].message.ephemeralExpireTime = ephemeralExpireTimeTmp
+ }
+ } else {
+ DispatchQueue.main.async {
+ self.conversationMessagesSection[0].rows[indexMessage].message.ephemeralExpireTime = ephemeralExpireTimeTmp
+ }
+ }
+ }
+ }
+ }
+
+ self.coreContext.doOnCoreQueue { _ in
+ let chatMessageDelegate = ChatMessageDelegateStub(onMsgStateChanged: { (message: ChatMessage, msgState: ChatMessage.State) in
+ var statusTmp: Message.Status?
+ switch message.state {
+ case .InProgress:
+ statusTmp = .sending
+ case .Delivered:
+ statusTmp = .sent
+ case .DeliveredToUser:
+ statusTmp = .received
+ case .Displayed:
+ statusTmp = .read
+ case .NotDelivered:
+ statusTmp = .error
+ default:
+ statusTmp = .sending
+ }
+
+ if let indexMessageEventLogId = self.conversationMessagesSection[0].rows.firstIndex(where: {$0.eventModel.eventLogId.isEmpty && $0.eventModel.eventLog.chatMessage != nil ? $0.eventModel.eventLog.chatMessage!.messageId == message.messageId : false}) {
+ self.conversationMessagesSection[0].rows[indexMessageEventLogId].eventModel.eventLogId = message.messageId
+ }
+
+ let indexMessage = self.conversationMessagesSection[0].rows.firstIndex(where: {$0.eventModel.eventLogId == message.messageId})
+
+ DispatchQueue.main.async {
+ if indexMessage != nil {
+ self.conversationMessagesSection[0].rows[indexMessage!].message.status = statusTmp ?? .error
+ }
+ }
+ }, onNewMessageReaction: { (message: ChatMessage, _: ChatMessageReaction) in
+ let indexMessage = self.conversationMessagesSection[0].rows.firstIndex(where: {$0.eventModel.eventLogId == message.messageId})
+ var reactionsTmp: [String] = []
+ message.reactions.forEach({ chatMessageReaction in
+ reactionsTmp.append(chatMessageReaction.body)
+ })
+
+ DispatchQueue.main.async {
+ if indexMessage != nil {
+ self.conversationMessagesSection[0].rows[indexMessage!].message.reactions = reactionsTmp
+ }
+ }
+ }, onReactionRemoved: { (message: ChatMessage, _: Address) in
+ let indexMessage = self.conversationMessagesSection[0].rows.firstIndex(where: {$0.eventModel.eventLogId == message.messageId})
+ var reactionsTmp: [String] = []
+ message.reactions.forEach({ chatMessageReaction in
+ reactionsTmp.append(chatMessageReaction.body)
+ })
+
+ DispatchQueue.main.async {
+ if indexMessage != nil {
+ self.conversationMessagesSection[0].rows[indexMessage!].message.reactions = reactionsTmp
+ }
+ }
+ }, onEphemeralMessageTimerStarted: { (message: ChatMessage) in
+ let indexMessage = self.conversationMessagesSection[0].rows.firstIndex(where: {$0.eventModel.eventLogId == message.messageId})
+ let ephemeralExpireTimeTmp = message.ephemeralExpireTime
+
+ DispatchQueue.main.async {
+ if indexMessage != nil {
+ self.conversationMessagesSection[0].rows[indexMessage!].message.ephemeralExpireTime = ephemeralExpireTimeTmp
+ }
+ }
+ })
+
+ self.chatMessageDelegateHolders.append(ChatMessageDelegateHolder(message: message, delegate: chatMessageDelegate))
+ }
+ }
+ }
+ }
+
+ func removeConversationDelegate() {
+ coreContext.doOnCoreQueue { _ in
+ self.chatRoomDelegateHolder = nil
+ self.chatMessageDelegateHolders.removeAll()
+ }
+ }
+
+ func getHistorySize() {
+ coreContext.doOnCoreQueue { _ in
+ if self.displayedConversation != nil {
+ let historySize = self.displayedConversation!.chatRoom.historyEventsSize
+ DispatchQueue.main.async {
+ self.displayedConversationHistorySize = historySize
+ }
+ }
+ }
+ }
+
+ func getUnreadMessagesCount() {
+ coreContext.doOnCoreQueue { _ in
+ if self.displayedConversation != nil {
+ let unreadMessagesCount = self.displayedConversation!.chatRoom.unreadMessagesCount
+ DispatchQueue.main.async {
+ self.displayedConversationUnreadMessagesCount = unreadMessagesCount
+ }
+ }
+ }
+ }
+
+ func markAsRead() {
+ coreContext.doOnCoreQueue { _ in
+ if self.displayedConversation != nil {
+ let unreadMessagesCount = self.displayedConversation!.chatRoom.unreadMessagesCount
+
+ if unreadMessagesCount > 0 {
+ self.displayedConversation!.chatRoom.markAsRead()
+
+ DispatchQueue.main.async {
+ self.displayedConversationUnreadMessagesCount = 0
+ }
+ }
+ }
+ }
+ }
+
+ func getParticipantConversationModel() {
+ coreContext.doOnCoreQueue { _ in
+ if self.displayedConversation != nil {
+ self.displayedConversation!.chatRoom.participants.forEach { participant in
+ if participant.address != nil {
+ ContactAvatarModel.getAvatarModelFromAddress(address: participant.address!) { avatarResult in
+ let avatarModelTmp = avatarResult
+ DispatchQueue.main.async {
+ self.participantConversationModel.append(avatarModelTmp)
+ }
+ }
+ }
+ }
+
+ if self.displayedConversation!.chatRoom.me != nil {
+ ContactAvatarModel.getAvatarModelFromAddress(address: self.displayedConversation!.chatRoom.me!.address!) { avatarResult in
+ let avatarModelTmp = avatarResult
+ DispatchQueue.main.async {
+ self.participantConversationModel.append(avatarModelTmp)
+ }
+ }
+ }
+ }
+ }
+ }
+
+ func addParticipantConversationModel(address: Address) {
+ coreContext.doOnCoreQueue { _ in
+ ContactAvatarModel.getAvatarModelFromAddress(address: address) { avatarResult in
+ let avatarModelTmp = avatarResult
+ DispatchQueue.main.async {
+ self.participantConversationModel.append(avatarModelTmp)
+ }
+ }
+ }
+ }
+
+ func getMessages() {
+ self.getHistorySize()
+ self.getUnreadMessagesCount()
+ self.getParticipantConversationModel()
+ self.computeComposingLabel()
+
+ self.mediasToSend.removeAll()
+ self.messageToReply = nil
+
+ coreContext.doOnCoreQueue { _ in
+ if self.displayedConversation != nil {
+ let historyEvents = self.displayedConversation!.chatRoom.getHistoryRangeEvents(begin: 0, end: 30)
+
+ var conversationMessage: [EventLogMessage] = []
+ historyEvents.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.name != nil && !content.name!.isEmpty {
+ 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.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 ? historyEvents[index - 1].chatMessage?.fromAddress?.clone() : eventLog.chatMessage?.fromAddress?.clone()
+ addressPrecCleaned?.clean()
+
+ let addressNextCleaned = index <= historyEvents.count - 2 ? historyEvents[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?.asStringUriOnly() != addressCleaned?.asStringUriOnly() : true
+ let isFirstMessageOutgoingTmp = index <= historyEvents.count - 2 ? addressNextCleaned?.asStringUriOnly() != addressCleaned?.asStringUriOnly() : true
+
+ let isFirstMessageTmp = (eventLog.chatMessage?.isOutgoing ?? false) ? isFirstMessageOutgoingTmp : isFirstMessageIncomingTmp
+
+ 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 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?.asStringUriOnly() ?? "",
+ isFirstMessage: false,
+ text: contentReplyText,
+ isOutgoing: false,
+ dateReceived: 0,
+ attachmentsNames: attachmentNameReplyList,
+ attachments: []
+ )
+ }
+
+ if eventLog.chatMessage != nil {
+ conversationMessage.append(
+ EventLogMessage(
+ eventModel: EventModel(eventLog: eventLog),
+ message: Message(
+ id: !eventLog.chatMessage!.messageId.isEmpty ? eventLog.chatMessage!.messageId : UUID().uuidString,
+ status: statusTmp,
+ isOutgoing: eventLog.chatMessage?.isOutgoing ?? false,
+ dateReceived: eventLog.chatMessage?.time ?? 0,
+ address: 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
+ )
+ )
+ )
+
+ self.addChatMessageDelegate(message: eventLog.chatMessage!)
+ } else {
+ conversationMessage.append(
+ 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 {
+ if self.conversationMessagesSection.isEmpty && self.displayedConversation != nil {
+ Log.info("[ConversationViewModel] Get Messages \(self.conversationMessagesSection.count)")
+ self.conversationMessagesSection.append(MessagesSection(date: Date(), chatRoomID: self.displayedConversation!.id, rows: conversationMessage.reversed()))
+ }
+ }
+ }
+ }
+ }
+
+ func getOldMessages() {
+ coreContext.doOnCoreQueue { _ in
+ if self.displayedConversation != nil && !self.conversationMessagesSection.isEmpty
+ && self.displayedConversationHistorySize > self.conversationMessagesSection[0].rows.count && !self.oldMessageReceived {
+ self.oldMessageReceived = true
+ let historyEvents = self.displayedConversation!.chatRoom.getHistoryRangeEvents(begin: self.conversationMessagesSection[0].rows.count, end: self.conversationMessagesSection[0].rows.count + 30)
+ var conversationMessagesTmp: [EventLogMessage] = []
+
+ historyEvents.enumerated().reversed().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.name != nil && !content.name!.isEmpty {
+ 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.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 ? historyEvents[index - 1].chatMessage?.fromAddress?.clone() : eventLog.chatMessage?.fromAddress?.clone()
+ addressPrecCleaned?.clean()
+
+ let addressNextCleaned = index <= historyEvents.count - 2 ? historyEvents[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?.asStringUriOnly() != addressCleaned?.asStringUriOnly() : true
+ let isFirstMessageOutgoingTmp = index <= historyEvents.count - 2 ? addressNextCleaned?.asStringUriOnly() != addressCleaned?.asStringUriOnly() : true
+
+ let isFirstMessageTmp = (eventLog.chatMessage?.isOutgoing ?? false) ? isFirstMessageOutgoingTmp : isFirstMessageIncomingTmp
+
+ 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 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?.asStringUriOnly() ?? "",
+ isFirstMessage: false,
+ text: contentReplyText,
+ isOutgoing: false,
+ dateReceived: 0,
+ attachmentsNames: attachmentNameReplyList,
+ attachments: []
+ )
+ }
+
+ if eventLog.chatMessage != nil {
+ conversationMessagesTmp.insert(
+ EventLogMessage(
+ eventModel: EventModel(eventLog: eventLog),
+ message: Message(
+ id: !eventLog.chatMessage!.messageId.isEmpty ? eventLog.chatMessage!.messageId : UUID().uuidString,
+ status: statusTmp,
+ isOutgoing: eventLog.chatMessage?.isOutgoing ?? false,
+ dateReceived: eventLog.chatMessage?.time ?? 0,
+ address: 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
+ )
+ ), at: 0
+ )
+
+ self.addChatMessageDelegate(message: eventLog.chatMessage!)
+ } else {
+ conversationMessagesTmp.insert(
+ EventLogMessage(
+ eventModel: EventModel(eventLog: eventLog),
+ message: Message(
+ id: UUID().uuidString,
+ status: nil,
+ isOutgoing: false,
+ dateReceived: 0,
+ address: "",
+ isFirstMessage: false,
+ text: "",
+ attachments: [],
+ ownReaction: "",
+ reactions: []
+ )
+ ), at: 0
+ )
+ }
+ }
+
+ if !conversationMessagesTmp.isEmpty {
+ DispatchQueue.main.async {
+ Log.info("[ConversationViewModel] Get old Messages \(self.conversationMessagesSection.count) \(conversationMessagesTmp.count)")
+ if self.conversationMessagesSection[0].rows.last?.message.address == conversationMessagesTmp.last?.message.address {
+ self.conversationMessagesSection[0].rows[self.conversationMessagesSection[0].rows.count - 1].message.isFirstMessage = false
+ }
+ self.conversationMessagesSection[0].rows.append(contentsOf: conversationMessagesTmp.reversed())
+ self.oldMessageReceived = false
+ }
+ }
+ }
+ }
+ }
+
+ func getNewMessages(eventLogs: [EventLog]) {
+ 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 ?? ""))
+
+ 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" {
+ 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 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 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 (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()
+ }
+ }
+
+ func resetMessage() {
+ conversationMessagesSection = []
+ }
+
+ func replyToMessage(index: Int) {
+ coreContext.doOnCoreQueue { _ in
+ let messageToReplyTmp = self.conversationMessagesSection[0].rows[index]
+ DispatchQueue.main.async {
+ withAnimation(.linear(duration: 0.15)) {
+ self.messageToReply = messageToReplyTmp
+ }
+ }
+ }
+ }
+
+ func scrollToMessage(message: Message) {
+ coreContext.doOnCoreQueue { _ in
+ if message.replyMessage != nil {
+ if let indexMessage = self.conversationMessagesSection[0].rows.firstIndex(where: {$0.eventModel.eventLogId == message.replyMessage!.id}) {
+ NotificationCenter.default.post(name: NSNotification.Name(rawValue: "onScrollToIndex"), object: nil, userInfo: ["index": indexMessage, "animated": true])
+ } else {
+ if self.conversationMessagesSection[0].rows.last != nil {
+ let firstEventLog = self.displayedConversation?.chatRoom.getHistoryRangeEvents(
+ begin: self.conversationMessagesSection[0].rows.count - 1,
+ end: self.conversationMessagesSection[0].rows.count
+ )
+ let lastEventLog = self.displayedConversation!.chatRoom.findEventLog(messageId: message.replyMessage!.id)
+
+ var historyEvents = self.displayedConversation!.chatRoom.getHistoryRangeBetween(
+ firstEvent: firstEventLog!.first,
+ lastEvent: lastEventLog,
+ filters: UInt(ChatRoom.HistoryFilter([.ChatMessage, .InfoNoDevice]).rawValue)
+ )
+
+ let historyEventsAfter = self.displayedConversation!.chatRoom.getHistoryRangeEvents(
+ begin: self.conversationMessagesSection[0].rows.count + historyEvents.count + 1,
+ end: self.conversationMessagesSection[0].rows.count + historyEvents.count + 30
+ )
+
+ if lastEventLog != nil {
+ historyEvents.insert(lastEventLog!, at: 0)
+ }
+
+ historyEvents.insert(contentsOf: historyEventsAfter, at: 0)
+
+ var conversationMessagesTmp: [EventLogMessage] = []
+
+ historyEvents.enumerated().reversed().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.name != nil && !content.name!.isEmpty {
+ 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.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 ? historyEvents[index - 1].chatMessage?.fromAddress?.clone() : eventLog.chatMessage?.fromAddress?.clone()
+ addressPrecCleaned?.clean()
+
+ let addressNextCleaned = index <= historyEvents.count - 2 ? historyEvents[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?.asStringUriOnly() != addressCleaned?.asStringUriOnly() : true
+ let isFirstMessageOutgoingTmp = index <= historyEvents.count - 2 ? addressNextCleaned?.asStringUriOnly() != addressCleaned?.asStringUriOnly() : true
+
+ let isFirstMessageTmp = (eventLog.chatMessage?.isOutgoing ?? false) ? isFirstMessageOutgoingTmp : isFirstMessageIncomingTmp
+
+ 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 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?.asStringUriOnly() ?? "",
+ isFirstMessage: false,
+ text: contentReplyText,
+ isOutgoing: false,
+ dateReceived: 0,
+ attachmentsNames: attachmentNameReplyList,
+ attachments: []
+ )
+ }
+
+ if eventLog.chatMessage != nil {
+ conversationMessagesTmp.insert(
+ EventLogMessage(
+ eventModel: EventModel(eventLog: eventLog),
+ message: Message(
+ id: !eventLog.chatMessage!.messageId.isEmpty ? eventLog.chatMessage!.messageId : UUID().uuidString,
+ status: statusTmp,
+ isOutgoing: eventLog.chatMessage?.isOutgoing ?? false,
+ dateReceived: eventLog.chatMessage?.time ?? 0,
+ address: 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
+ )
+ ), at: 0
+ )
+
+ self.addChatMessageDelegate(message: eventLog.chatMessage!)
+ } else {
+ conversationMessagesTmp.insert(
+ EventLogMessage(
+ eventModel: EventModel(eventLog: eventLog),
+ message: Message(
+ id: UUID().uuidString,
+ status: nil,
+ isOutgoing: false,
+ dateReceived: 0,
+ address: "",
+ isFirstMessage: false,
+ text: "",
+ attachments: [],
+ ownReaction: "",
+ reactions: []
+ )
+ ), at: 0
+ )
+ }
+ }
+
+ if !conversationMessagesTmp.isEmpty {
+ DispatchQueue.main.async {
+ if self.conversationMessagesSection[0].rows.last?.message.address == conversationMessagesTmp.last?.message.address {
+ self.conversationMessagesSection[0].rows[self.conversationMessagesSection[0].rows.count - 1].message.isFirstMessage = false
+ }
+ self.conversationMessagesSection[0].rows.append(contentsOf: conversationMessagesTmp.reversed())
+
+ NotificationCenter.default.post(
+ name: NSNotification.Name(rawValue: "onScrollToIndex"),
+ object: nil,
+ userInfo: ["index": self.conversationMessagesSection[0].rows.count - historyEventsAfter.count - 1, "animated": true]
+ )
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+
+ func removeMessage(_ eventLog: EventLog) {
+ /*
+ if let found = self.conversationMessagesSection[0].rows.first(where: { $0.message.id == eventLog.chatMessage?.messageId }) {
+ var updatedList = self.conversationMessagesSection[0].rows
+
+ print("Removing message from conversation events list")
+ if let index = updatedList.firstIndex(where: { $0.message.id == found.message.id }) {
+ updatedList.remove(at: index)
+ }
+
+ DispatchQueue.main.async {
+ self.conversationMessagesSection[0].rows = updatedList
+ }
+ } else {
+ print("Failed to find matching message in conversation events list")
+ }
+ */
+
+ if let index = self.conversationMessagesSection[0].rows.firstIndex(where: { $0.message.id == eventLog.chatMessage?.messageId }) {
+ DispatchQueue.main.async {
+ if index > 0 && self.conversationMessagesSection[0].rows[index - 1].message.address == self.conversationMessagesSection[0].rows[index].message.address {
+ self.conversationMessagesSection[0].rows[index - 1].message.isFirstMessage = self.conversationMessagesSection[0].rows[index].message.isFirstMessage
+ }
+ self.conversationMessagesSection[0].rows.remove(at: index)
+ }
+ }
+ }
+
+ func sendMessage(audioRecorder: AudioRecorder? = nil) {
+ if self.displayedConversation != nil {
+ coreContext.doOnCoreQueue { _ in
+ do {
+ var message: ChatMessage?
+ if self.messageToReply != nil {
+ let chatMessageToReply = self.displayedConversation!.chatRoom.findEventLog(messageId: self.messageToReply!.eventModel.eventLogId)?.chatMessage
+ if chatMessageToReply != nil {
+ message = try self.displayedConversation!.chatRoom.createReplyMessage(message: chatMessageToReply!)
+ }
+ } else {
+ message = try self.displayedConversation!.chatRoom.createEmptyMessage()
+ }
+
+ let toSend = self.messageText.trimmingCharacters(in: .whitespacesAndNewlines)
+ if !toSend.isEmpty {
+ if message != nil {
+ message!.addUtf8TextContent(text: toSend)
+ }
+ }
+
+ if audioRecorder != nil {
+ do {
+ audioRecorder!.stopVoiceRecorder()
+ let content = try audioRecorder!.linphoneAudioRecorder.createContent()
+ Log.info(
+ "[ConversationViewModel] Voice recording content created, file name is \(content.name ?? "") and duration is \(content.fileDuration)"
+ )
+
+ if message != nil {
+ message!.addContent(content: content)
+ }
+ }
+ } else {
+ self.mediasToSend.forEach { attachment in
+ do {
+ let content = try Factory.Instance.createContent()
+
+ switch attachment.type {
+ case .image:
+ content.type = "image"
+ /*
+ case .audio:
+ content.type = "audio"
+ */
+ case .video:
+ content.type = "video"
+ /*
+ case .pdf:
+ content.type = "application"
+ case .plainText:
+ content.type = "text"
+ */
+ default:
+ content.type = "file"
+ }
+
+ // content.subtype = attachment.type == .plainText ? "plain" : FileUtils.getExtensionFromFileName(attachment.fileName)
+ content.subtype = attachment.full.pathExtension
+
+ content.name = attachment.full.lastPathComponent
+
+ if message != nil {
+
+ let path = FileManager.default.temporaryDirectory.appendingPathComponent((attachment.full.lastPathComponent.addingPercentEncoding(withAllowedCharacters: .urlHostAllowed) ?? ""))
+ let newPath = URL(string: FileUtil.sharedContainerUrl().appendingPathComponent("Library/Images").absoluteString
+ + (attachment.full.lastPathComponent.addingPercentEncoding(withAllowedCharacters: .urlHostAllowed) ?? ""))
+ /*
+ let data = try Data(contentsOf: path)
+ let decodedData: () = try data.write(to: path)
+ */
+
+ do {
+ if FileManager.default.fileExists(atPath: newPath!.path) {
+ try FileManager.default.removeItem(atPath: newPath!.path)
+ }
+ try FileManager.default.moveItem(atPath: path.path, toPath: newPath!.path)
+
+ let filePathTmp = newPath?.absoluteString
+ content.filePath = String(filePathTmp!.dropFirst(7))
+ message!.addFileContent(content: content)
+ } catch {
+ Log.error(error.localizedDescription)
+ }
+ }
+ } catch {
+ }
+ }
+ }
+
+ if message != nil && !message!.contents.isEmpty {
+ Log.info("[ConversationViewModel] Sending message")
+ message!.send()
+ }
+
+ Log.info("[ConversationViewModel] Message sent, re-setting defaults")
+
+ DispatchQueue.main.async {
+ self.messageToReply = nil
+ withAnimation {
+ self.mediasToSend.removeAll()
+ }
+ self.messageText = ""
+ }
+
+ /*
+ isReplying.postValue(false)
+ isFileAttachmentsListOpen.postValue(false)
+ isParticipantsListOpen.postValue(false)
+ isEmojiPickerOpen.postValue(false)
+
+ if (::voiceMessageRecorder.isInitialized) {
+ stopVoiceRecorder()
+ }
+ isVoiceRecording.postValue(false)
+
+ // Warning: do not delete files
+ val attachmentsList = arrayListOf()
+ attachments.postValue(attachmentsList)
+
+ chatMessageToReplyTo = null
+ */
+ } catch {
+
+ }
+ }
+ }
+ }
+
+ func changeDisplayedChatRoom(conversationModel: ConversationModel) {
+ self.selectedMessage = nil
+ self.resetMessage()
+ self.removeConversationDelegate()
+ withAnimation {
+ self.displayedConversation = conversationModel
+ }
+ self.addConversationDelegate()
+ self.getMessages()
+ }
+
+ func resetDisplayedChatRoom(conversationsList: [ConversationModel]) {
+ if !self.conversationMessagesSection.isEmpty && !self.conversationMessagesSection[0].rows.isEmpty {
+ if self.displayedConversation != nil {
+ conversationsList.forEach { conversation in
+ if conversation.id == self.displayedConversation!.id {
+ self.displayedConversation = conversation
+ self.computeComposingLabel()
+
+ if self.displayedConversation != nil {
+ CoreContext.shared.doOnCoreQueue { _ in
+ let historyEventsSizeTmp = self.displayedConversation!.chatRoom.historyEventsSize
+ if self.displayedConversationHistorySize < historyEventsSizeTmp {
+ let eventLogList = self.displayedConversation!.chatRoom.getHistoryRangeEvents(begin: 0, end: historyEventsSizeTmp - self.displayedConversationHistorySize)
+
+ if !eventLogList.isEmpty {
+ self.getNewMessages(eventLogs: eventLogList)
+ }
+ }
+
+ self.addConversationDelegate()
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+
+ func downloadContent(chatMessage: ChatMessage, content: Content) {
+ // Log.debug("[ConversationViewModel] Starting downloading content for file \(model.fileName)")
+ if !chatMessage.isFileTransferInProgress && (content.filePath == nil || content.filePath!.isEmpty) {
+ if let contentName = content.name {
+ // let isImage = FileUtil.isExtensionImage(path: contentName)
+ let file = FileUtil.sharedContainerUrl().appendingPathComponent("Library/Images").absoluteString + (contentName.addingPercentEncoding(withAllowedCharacters: .urlHostAllowed) ?? "")
+ // let file = FileUtil.getFileStoragePath(fileName: contentName ?? "", isImage: isImage)
+ content.filePath = String(file.dropFirst(7))
+ Log.info(
+ "[ConversationViewModel] File \(contentName) will be downloaded at \(content.filePath ?? "NIL")"
+ )
+ self.displayedConversation?.downloadContent(chatMessage: chatMessage, content: content)
+ } else {
+ Log.error("[ConversationViewModel] Content name is null, can't download it!")
+ }
+ }
+ }
+
+ func getNewFilePath(name: String) -> String {
+ return FileUtil.sharedContainerUrl().appendingPathComponent("Library/Images").absoluteString + (name.addingPercentEncoding(withAllowedCharacters: .urlHostAllowed) ?? "")
+ }
+
+ func generateThumbnail(name: String, pathThumbnail: URL? = nil) -> String {
+ do {
+ let path = pathThumbnail == nil
+ ? URL(string: "file://" + FileUtil.sharedContainerUrl().appendingPathComponent("Library/Images").absoluteString + (name.addingPercentEncoding(withAllowedCharacters: .urlHostAllowed) ?? ""))
+ : pathThumbnail!.appendingPathComponent((name.addingPercentEncoding(withAllowedCharacters: .urlHostAllowed) ?? ""))
+ let asset = AVURLAsset(url: path!, options: nil)
+ let imgGenerator = AVAssetImageGenerator(asset: asset)
+ imgGenerator.appliesPreferredTrackTransform = true
+ let cgImage = try imgGenerator.copyCGImage(at: CMTimeMake(value: 0, timescale: 1), actualTime: nil)
+ let thumbnail = UIImage(cgImage: cgImage)
+
+ guard let data = thumbnail.jpegData(compressionQuality: 1) ?? thumbnail.pngData() else {
+ return ""
+ }
+
+ let urlName = pathThumbnail == nil
+ ? URL(string: "file://"
+ + FileUtil.sharedContainerUrl().appendingPathComponent("Library/Images").absoluteString
+ + "preview_"
+ + (name.addingPercentEncoding(withAllowedCharacters: .urlHostAllowed) ?? "")
+ + ".png"
+ )
+ : pathThumbnail!.appendingPathComponent("preview_" + (name.addingPercentEncoding(withAllowedCharacters: .urlHostAllowed) ?? "") + ".png")
+
+ if urlName != nil {
+ _ = try data.write(to: urlName!)
+ }
+
+ return urlName!.absoluteString
+ } catch let error {
+ print("*** Error generating thumbnail: \(error.localizedDescription)")
+ return ""
+ }
+ }
+
+ func getMessageTime(startDate: time_t) -> String {
+ let timeInterval = TimeInterval(startDate)
+
+ let myNSDate = Date(timeIntervalSince1970: timeInterval)
+
+ if Calendar.current.isDateInToday(myNSDate) {
+ let formatter = DateFormatter()
+ formatter.dateFormat = Locale.current.identifier == "fr_FR" ? "HH:mm" : "h:mm a"
+ return formatter.string(from: myNSDate)
+ } else if Calendar.current.isDate(myNSDate, equalTo: .now, toGranularity: .year) {
+ let formatter = DateFormatter()
+ formatter.dateFormat = Locale.current.identifier == "fr_FR" ? "dd/MM HH:mm" : "MM/dd h:mm a"
+ return formatter.string(from: myNSDate)
+ } else {
+ let formatter = DateFormatter()
+ formatter.dateFormat = Locale.current.identifier == "fr_FR" ? "dd/MM/yy HH:mm" : "MM/dd/yy h:mm a"
+ return formatter.string(from: myNSDate)
+ }
+ }
+
+ func getImageIMDN(status: Message.Status) -> String {
+ switch status {
+ case .sending:
+ return ""
+ case .sent:
+ return "envelope-simple"
+ case .received:
+ return "check"
+ case .read:
+ return "checks"
+ case .error:
+ return "warning-circle"
+ }
+ }
+
+ func removeReaction() {
+ if self.displayedConversation != nil {
+ coreContext.doOnCoreQueue { _ in
+ if self.selectedMessageToDisplayDetails != nil {
+ Log.info("[ConversationViewModel] Remove reaction to message with ID \(self.selectedMessageToDisplayDetails!.message.id)")
+ let messageToSendReaction = self.displayedConversation!.chatRoom.findEventLog(messageId: self.selectedMessageToDisplayDetails!.eventModel.eventLogId)?.chatMessage
+ if messageToSendReaction != nil {
+ do {
+ let reaction = try messageToSendReaction!.createReaction(utf8Reaction: "")
+ reaction.send()
+
+ let indexMessageSelected = self.conversationMessagesSection[0].rows.firstIndex(of: self.selectedMessageToDisplayDetails!)
+
+ DispatchQueue.main.async {
+ if indexMessageSelected != nil {
+ self.conversationMessagesSection[0].rows[indexMessageSelected!].message.ownReaction = ""
+ }
+ self.selectedMessageToDisplayDetails = nil
+ self.isShowSelectedMessageToDisplayDetails = false
+ }
+ } catch {
+ Log.info("[ConversationViewModel] Error: Can't remove reaction to message with ID \(self.selectedMessageToDisplayDetails!.message.id)")
+ }
+ }
+ }
+ }
+ }
+ }
+
+ func sendReaction(emoji: String) {
+ coreContext.doOnCoreQueue { _ in
+ if self.selectedMessage != nil {
+ Log.info("[ConversationViewModel] Sending reaction \(emoji) to message with ID \(self.selectedMessage!.message.id)")
+ let messageToSendReaction = self.displayedConversation!.chatRoom.findEventLog(messageId: self.selectedMessage!.eventModel.eventLogId)?.chatMessage
+ if messageToSendReaction != nil {
+ do {
+ let reaction = try messageToSendReaction!.createReaction(utf8Reaction: messageToSendReaction?.ownReaction?.body == emoji ? "" : emoji)
+ reaction.send()
+
+ let indexMessageSelected = self.conversationMessagesSection[0].rows.firstIndex(of: self.selectedMessage!)
+
+ DispatchQueue.main.async {
+ if indexMessageSelected != nil {
+ self.conversationMessagesSection[0].rows[indexMessageSelected!].message.ownReaction = messageToSendReaction?.ownReaction?.body == emoji ? "" : emoji
+ }
+ self.selectedMessage = nil
+ }
+ } catch {
+ Log.info("[ConversationViewModel] Error: Can't send reaction \(emoji) to message with ID \(self.selectedMessage!.message.id)")
+ }
+ }
+ }
+ }
+ }
+
+ func resend() {
+ coreContext.doOnCoreQueue { _ in
+ let chatMessageToResend = self.displayedConversation!.chatRoom.findEventLog(messageId: self.selectedMessage!.eventModel.eventLogId)?.chatMessage
+ if self.selectedMessage != nil && chatMessageToResend != nil {
+ Log.info("[ConversationViewModel] Re-sending message with ID \(chatMessageToResend!)")
+ chatMessageToResend!.send()
+ }
+ }
+ }
+
+ func prepareBottomSheetForDeliveryStatus() {
+ self.sheetCategories.removeAll()
+ coreContext.doOnCoreQueue { _ in
+ let chatMessageToDisplay = self.displayedConversation!.chatRoom.findEventLog(messageId: self.selectedMessageToDisplayDetails!.eventModel.eventLogId)?.chatMessage
+ if self.selectedMessageToDisplayDetails != nil && chatMessageToDisplay != nil {
+
+ let participantsImdnDisplayed = chatMessageToDisplay!.getParticipantsByImdnState(state: .Displayed)
+ var participantListDisplayed: [InnerSheetCategory] = []
+ participantsImdnDisplayed.forEach({ participantImdn in
+ if participantImdn.participant != nil && participantImdn.participant!.address != nil {
+ ContactAvatarModel.getAvatarModelFromAddress(address: participantImdn.participant!.address!) { avatarResult in
+ let innerSheetCat = InnerSheetCategory(contact: avatarResult, detail: self.getMessageTime(startDate: participantImdn.stateChangeTime))
+ participantListDisplayed.append(innerSheetCat)
+ }
+ }
+ })
+
+ let participantsImdnDeliveredToUser = chatMessageToDisplay!.getParticipantsByImdnState(state: .DeliveredToUser)
+ var participantListDeliveredToUser: [InnerSheetCategory] = []
+ participantsImdnDeliveredToUser.forEach({ participantImdn in
+ if participantImdn.participant != nil && participantImdn.participant!.address != nil {
+ ContactAvatarModel.getAvatarModelFromAddress(address: participantImdn.participant!.address!) { avatarResult in
+ let innerSheetCat = InnerSheetCategory(contact: avatarResult, detail: self.getMessageTime(startDate: participantImdn.stateChangeTime))
+ participantListDeliveredToUser.append(innerSheetCat)
+ }
+ }
+ })
+
+ let participantsImdnDelivered = chatMessageToDisplay!.getParticipantsByImdnState(state: .Delivered)
+ var participantListDelivered: [InnerSheetCategory] = []
+ participantsImdnDelivered.forEach({ participantImdn in
+ if participantImdn.participant != nil && participantImdn.participant!.address != nil {
+ ContactAvatarModel.getAvatarModelFromAddress(address: participantImdn.participant!.address!) { avatarResult in
+ let innerSheetCat = InnerSheetCategory(contact: avatarResult, detail: self.getMessageTime(startDate: participantImdn.stateChangeTime))
+ participantListDelivered.append(innerSheetCat)
+ }
+ }
+ })
+
+ let participantsImdnNotDelivered = chatMessageToDisplay!.getParticipantsByImdnState(state: .NotDelivered)
+ var participantListNotDelivered: [InnerSheetCategory] = []
+ participantsImdnNotDelivered.forEach({ participantImdn in
+ if participantImdn.participant != nil && participantImdn.participant!.address != nil {
+ ContactAvatarModel.getAvatarModelFromAddress(address: participantImdn.participant!.address!) { avatarResult in
+ let innerSheetCat = InnerSheetCategory(contact: avatarResult, detail: self.getMessageTime(startDate: participantImdn.stateChangeTime))
+ participantListNotDelivered.append(innerSheetCat)
+ }
+ }
+ })
+
+ DispatchQueue.main.async {
+ self.sheetCategories.append(SheetCategory(name: NSLocalizedString("message_delivery_info_read_title", comment: "") + " \(participantListDisplayed.count)", innerCategory: participantListDisplayed))
+ self.sheetCategories.append(SheetCategory(name: NSLocalizedString("message_delivery_info_received_title", comment: "") + " \(participantListDeliveredToUser.count)", innerCategory: participantListDeliveredToUser))
+ self.sheetCategories.append(SheetCategory(name: NSLocalizedString("message_delivery_info_sent_title", comment: "") + " \(participantListDelivered.count)", innerCategory: participantListDelivered))
+ self.sheetCategories.append(SheetCategory(name: NSLocalizedString("message_delivery_info_error_title", comment: "") + " \(participantListNotDelivered.count)", innerCategory: participantListNotDelivered))
+
+ self.isShowSelectedMessageToDisplayDetails = true
+ }
+ }
+ }
+ }
+
+ func prepareBottomSheetForReactions() {
+ self.sheetCategories.removeAll()
+ coreContext.doOnCoreQueue { core in
+ let chatMessageToDisplay = self.displayedConversation!.chatRoom.findEventLog(messageId: self.selectedMessageToDisplayDetails!.eventModel.eventLogId)?.chatMessage
+ if self.selectedMessageToDisplayDetails != nil && chatMessageToDisplay != nil {
+ let dispatchGroup = DispatchGroup()
+
+ var sheetCategoriesTmp: [SheetCategory] = []
+
+ var participantList: [[InnerSheetCategory]] = [[]]
+ var reactionList: [String] = []
+
+ chatMessageToDisplay!.reactions.forEach { chatMessageReaction in
+ if chatMessageReaction.fromAddress != nil {
+ dispatchGroup.enter()
+ ContactAvatarModel.getAvatarModelFromAddress(address: chatMessageReaction.fromAddress!) { avatarResult in
+ if core.defaultAccount != nil && core.defaultAccount!.contactAddress != nil && core.defaultAccount!.contactAddress!.asStringUriOnly().contains(avatarResult.address) {
+ let innerSheetCat = InnerSheetCategory(contact: avatarResult, detail: chatMessageReaction.body, isMe: true)
+ participantList[0].append(innerSheetCat)
+ } else {
+ let innerSheetCat = InnerSheetCategory(contact: avatarResult, detail: chatMessageReaction.body)
+ participantList[0].append(innerSheetCat)
+ }
+
+ if !reactionList.contains(where: {$0 == chatMessageReaction.body}) {
+ reactionList.append(chatMessageReaction.body)
+ }
+
+ dispatchGroup.leave()
+ }
+ }
+ }
+
+ dispatchGroup.notify(queue: .main) {
+ reactionList.forEach { reaction in
+ participantList.append([])
+ participantList[0].forEach { innerSheetCategory in
+ if innerSheetCategory.detail == reaction {
+ participantList[participantList.count - 1].append(innerSheetCategory)
+ }
+ }
+ }
+
+ sheetCategoriesTmp.append(SheetCategory(name: NSLocalizedString("message_reactions_info_all_title", comment: "") + " \(participantList.first!.count)", innerCategory: participantList.first!))
+
+ reactionList.enumerated().forEach { index, reaction in
+ sheetCategoriesTmp.append(SheetCategory(name: reaction + " \(participantList[index + 1].count)", innerCategory: participantList[index + 1]))
+ }
+
+ DispatchQueue.main.async {
+ self.sheetCategories = sheetCategoriesTmp
+ self.isShowSelectedMessageToDisplayDetails = true
+ }
+ }
+ }
+ }
+ }
+
+ func startVoiceRecordPlayer(voiceRecordPath: URL) {
+ coreContext.doOnCoreQueue { core in
+ if self.vrpManager == nil || self.vrpManager!.voiceRecordPath != voiceRecordPath {
+ self.vrpManager = VoiceRecordPlayerManager(core: core, voiceRecordPath: voiceRecordPath)
+ }
+
+ if self.vrpManager != nil {
+ self.vrpManager!.startVoiceRecordPlayer()
+ }
+ }
+ }
+
+ func getPositionVoiceRecordPlayer(voiceRecordPath: URL) -> Double {
+ if self.vrpManager != nil && self.vrpManager!.voiceRecordPath == voiceRecordPath {
+ return self.vrpManager!.positionVoiceRecordPlayer()
+ } else {
+ return 0
+ }
+ }
+
+ func isPlayingVoiceRecordPlayer(voiceRecordPath: URL) -> Bool {
+ if self.vrpManager != nil && self.vrpManager!.voiceRecordPath == voiceRecordPath {
+ return true
+ } else {
+ return false
+ }
+ }
+
+ func pauseVoiceRecordPlayer() {
+ coreContext.doOnCoreQueue { _ in
+ if self.vrpManager != nil {
+ self.vrpManager!.pauseVoiceRecordPlayer()
+ }
+ }
+ }
+
+ func stopVoiceRecordPlayer() {
+ coreContext.doOnCoreQueue { _ in
+ if self.vrpManager != nil {
+ self.vrpManager!.stopVoiceRecordPlayer()
+ }
+ }
+ }
+
+ func compose() {
+ coreContext.doOnCoreQueue { _ in
+ if self.displayedConversation != nil {
+ self.displayedConversation!.chatRoom.compose()
+ }
+ }
+ }
+
+ func computeComposingLabel() {
+ let composing = self.displayedConversation!.chatRoom.isRemoteComposing
+
+ if !composing {
+ DispatchQueue.main.async {
+ withAnimation {
+ self.composingLabel = ""
+ }
+ }
+ return
+ }
+
+ var composingFriends: [String] = []
+ var label = ""
+
+ for address in self.displayedConversation!.chatRoom.composingAddresses {
+ if let addressCleaned = address.clone() {
+ addressCleaned.clean()
+
+ if let avatar = self.participantConversationModel.first(where: {$0.address == addressCleaned.asStringUriOnly()}) {
+ let name = avatar.name
+ composingFriends.append(name)
+ label += "\(name), "
+ }
+ }
+ }
+
+ if !composingFriends.isEmpty {
+ label = String(label.dropLast(2))
+
+ let format = composingFriends.count > 1
+ ? String(format: NSLocalizedString("conversation_composing_label_multiple", comment: ""), label)
+ : String(format: NSLocalizedString("conversation_composing_label_single", comment: ""), label)
+
+ DispatchQueue.main.async {
+ withAnimation {
+ self.composingLabel = format
+ }
+ }
+ } else {
+ DispatchQueue.main.async {
+ withAnimation {
+ self.composingLabel = ""
+ }
+ }
+ }
+ }
+
+ func getChatRoomWithStringAddress(conversationsList: [ConversationModel], stringAddr: String) {
+ CoreContext.shared.doOnCoreQueue { _ in
+ do {
+ let stringAddrCleaned = stringAddr.components(separatedBy: ";gr=")
+ let address = try Factory.Instance.createAddress(addr: stringAddrCleaned[0])
+ if let dispChatRoom = conversationsList.first(where: {$0.chatRoom.peerAddress != nil && $0.chatRoom.peerAddress!.equal(address2: address)}) {
+ if self.displayedConversation != nil {
+ if dispChatRoom.id != self.displayedConversation!.id {
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
+ self.changeDisplayedChatRoom(conversationModel: dispChatRoom)
+ }
+ }
+ } else {
+ DispatchQueue.main.async {
+ self.changeDisplayedChatRoom(conversationModel: dispChatRoom)
+ }
+ }
+ }
+ } catch {
+ }
+ }
+ }
+
+ func parseConferenceInvite(content: Content) -> MessageConferenceInfo? {
+ var meetingConferenceUriTmp: String = ""
+ var meetingSubjectTmp: String = ""
+ var meetingDescriptionTmp: String = ""
+ var meetingStateTmp: MessageConferenceState = .new
+ var meetingDateTmp: String = ""
+ var meetingTimeTmp: String = ""
+ var meetingDayTmp: String = ""
+ var meetingDayNumberTmp: String = ""
+ var meetingParticipantsTmp: String = ""
+
+ if let conferenceInfo = try? Factory.Instance.createConferenceInfoFromIcalendarContent(content: content) {
+
+ if let conferenceAddress = conferenceInfo.uri {
+ let conferenceUri = conferenceAddress.asStringUriOnly()
+ Log.info("Found conference info with URI [\(conferenceUri)] and subject [\(conferenceInfo.subject ?? "")]")
+ meetingConferenceUriTmp = conferenceAddress.asStringUriOnly()
+ meetingSubjectTmp = conferenceInfo.subject ?? ""
+ meetingDescriptionTmp = conferenceInfo.description ?? ""
+
+ if conferenceInfo.state == .Updated {
+ meetingStateTmp = .updated
+ } else if conferenceInfo.state == .Cancelled {
+ meetingStateTmp = .cancelled
+ }
+
+ let timestamp = conferenceInfo.dateTime
+ let duration = conferenceInfo.duration
+
+ let timeInterval = TimeInterval(timestamp)
+ let dateTmp = Date(timeIntervalSince1970: timeInterval)
+ let dateFormatter = DateFormatter()
+ dateFormatter.dateStyle = .full
+ dateFormatter.timeStyle = .none
+
+ meetingDateTmp = dateFormatter.string(from: dateTmp).capitalized
+
+ let timeFormatter = DateFormatter()
+ timeFormatter.dateFormat = Locale.current.identifier == "fr_FR" ? "HH:mm" : "h:mm a"
+ let timeTmp = timeFormatter.string(from: dateTmp)
+
+ let timeBisInterval = TimeInterval(timestamp + (Int(duration) * 60))
+ let timeBis = Date(timeIntervalSince1970: timeBisInterval)
+ let endTime = timeFormatter.string(from: timeBis)
+
+ meetingTimeTmp = "\(timeTmp) - \(endTime)"
+
+ meetingDayTmp = dateTmp.formatted(Date.FormatStyle().weekday(.abbreviated)).capitalized
+ meetingDayNumberTmp = dateTmp.formatted(Date.FormatStyle().day(.defaultDigits))
+
+ meetingParticipantsTmp = String(conferenceInfo.participantInfos.count) + " participant" + (conferenceInfo.participantInfos.count > 1 ? "s" : "")
+
+ if !meetingConferenceUriTmp.isEmpty {
+ return MessageConferenceInfo(
+ id: UUID(),
+ meetingConferenceUri: meetingConferenceUriTmp,
+ meetingSubject: meetingSubjectTmp,
+ meetingDescription: meetingDescriptionTmp,
+ meetingState: meetingStateTmp,
+ meetingDate: meetingDateTmp,
+ meetingTime: meetingTimeTmp,
+ meetingDay: meetingDayTmp,
+ meetingDayNumber: meetingDayNumberTmp,
+ meetingParticipants: meetingParticipantsTmp
+ )
+ }
+ }
+ }
+
+ return nil
+ }
+
+ func joinMeetingInvite(addressUri: String) {
+ coreContext.doOnCoreQueue { _ in
+ if let address = try? Factory.Instance.createAddress(addr: addressUri) {
+ TelecomManager.shared.doCallOrJoinConf(address: address)
+ }
+ }
+ }
+}
+// swiftlint:enable line_length
+// swiftlint:enable type_body_length
+// swiftlint:enable cyclomatic_complexity
+
+class VoiceRecordPlayerManager {
+ private var core: Core
+ var voiceRecordPath: URL
+ private var voiceRecordPlayer: Player?
+ //private var isPlayingVoiceRecord = false
+ private var voiceRecordAudioFocusRequest: AVAudioSession?
+ //private var voiceRecordPlayerPosition: Double = 0
+ //private var voiceRecordingDuration: TimeInterval = 0
+
+ init(core: Core, voiceRecordPath: URL) {
+ self.core = core
+ self.voiceRecordPath = voiceRecordPath
+ }
+
+ private func initVoiceRecordPlayer() {
+ print("Creating player for voice record")
+ do {
+ voiceRecordPlayer = try core.createLocalPlayer(soundCardName: getSpeakerSoundCard(core: core), videoDisplayName: nil, windowId: nil)
+ } catch {
+ print("Couldn't create local player!")
+ }
+
+ print("Voice record player created")
+ print("Opening voice record file [\(voiceRecordPath.absoluteString)]")
+
+ do {
+ try voiceRecordPlayer!.open(filename: String(voiceRecordPath.absoluteString.dropFirst(7)))
+ print("Player opened file at [\(voiceRecordPath.absoluteString)]")
+ } catch {
+ print("Player failed to open file at [\(voiceRecordPath.absoluteString)]")
+ }
+ }
+
+ func startVoiceRecordPlayer() {
+ if voiceRecordAudioFocusRequest == nil {
+ voiceRecordAudioFocusRequest = AVAudioSession.sharedInstance()
+ if let request = voiceRecordAudioFocusRequest {
+ try? request.setActive(true)
+ }
+ }
+
+ if isPlayerClosed() {
+ print("Player closed, let's open it first")
+ initVoiceRecordPlayer()
+
+ if voiceRecordPlayer!.state == .Closed {
+ print("It seems the player fails to open the file, abort playback")
+ // Handle the failure (e.g. show a toast)
+ return
+ }
+ }
+
+ do {
+ try voiceRecordPlayer!.start()
+ print("Playing voice record")
+ } catch {
+ print("Player failed to start voice recording")
+ }
+ }
+
+ func positionVoiceRecordPlayer() -> Double {
+ if !isPlayerClosed() {
+ return Double(voiceRecordPlayer!.currentPosition) / Double(voiceRecordPlayer!.duration) * 100
+ } else {
+ return 0.0
+ }
+ }
+
+ func pauseVoiceRecordPlayer() {
+ if !isPlayerClosed() {
+ print("Pausing voice record")
+ try? voiceRecordPlayer?.pause()
+ }
+ }
+
+ private func isPlayerClosed() -> Bool {
+ return voiceRecordPlayer == nil || voiceRecordPlayer?.state == .Closed
+ }
+
+ func stopVoiceRecordPlayer() {
+ if !isPlayerClosed() {
+ print("Stopping voice record")
+ try? voiceRecordPlayer?.pause()
+ try? voiceRecordPlayer?.seek(timeMs: 0)
+ voiceRecordPlayer?.close()
+ }
+
+ if let request = voiceRecordAudioFocusRequest {
+ try? request.setActive(false)
+ voiceRecordAudioFocusRequest = nil
+ }
+ }
+
+ func getSpeakerSoundCard(core: Core) -> String? {
+ var speakerCard: String? = nil
+ var earpieceCard: String? = nil
+ core.audioDevices.forEach { device in
+ if (device.hasCapability(capability: .CapabilityPlay)) {
+ if (device.type == .Speaker) {
+ speakerCard = device.id
+ } else if (device.type == .Earpiece) {
+ earpieceCard = device.id
+ }
+ }
+ }
+ return speakerCard != nil ? speakerCard : earpieceCard
+ }
+
+ func changeRouteToSpeaker() {
+ core.outputAudioDevice = core.audioDevices.first { $0.type == AudioDevice.Kind.Speaker }
+ UIDevice.current.isProximityMonitoringEnabled = false
+ }
+}
+
+class AudioRecorder: NSObject, ObservableObject {
+ var linphoneAudioRecorder: Recorder!
+ var recordingSession: AVAudioSession?
+ @Published var isRecording = false
+ @Published var audioFilename: URL?
+ @Published var audioFilenameAAC: URL?
+ @Published var recordingTime: TimeInterval = 0
+ @Published var soundPower: Float = 0
+
+ var timer: Timer?
+
+ func startRecording() {
+ recordingSession = AVAudioSession.sharedInstance()
+ CoreContext.shared.doOnCoreQueue { core in
+ core.activateAudioSession(activated: true)
+ }
+
+ if recordingSession != nil {
+ do {
+ try recordingSession!.setCategory(.playAndRecord, mode: .default)
+ try recordingSession!.setActive(true)
+ recordingSession!.requestRecordPermission { allowed in
+ if allowed {
+ self.initVoiceRecorder()
+ } else {
+ print("Permission to record not granted.")
+ }
+ }
+ } catch {
+ print("Failed to setup recording session.")
+ }
+ }
+ }
+
+ private func initVoiceRecorder() {
+ CoreContext.shared.doOnCoreQueue { core in
+ Log.info("[ConversationViewModel] [AudioRecorder] Creating voice message recorder")
+ let recorderParams = try? core.createRecorderParams()
+ if recorderParams != nil {
+ recorderParams!.fileFormat = MediaFileFormat.Mkv
+
+ let recordingAudioDevice = self.getAudioRecordingDeviceIdForVoiceMessage()
+ recorderParams!.audioDevice = recordingAudioDevice
+ Log.info(
+ "[ConversationViewModel] [AudioRecorder] Using device \(recorderParams!.audioDevice?.id ?? "Error id") to make the voice message recording"
+ )
+
+ self.linphoneAudioRecorder = try? core.createRecorder(params: recorderParams!)
+ Log.info("[ConversationViewModel] [AudioRecorder] Voice message recorder created")
+
+ self.startVoiceRecorder()
+ }
+ }
+ }
+
+ func startVoiceRecorder() {
+ switch linphoneAudioRecorder.state {
+ case .Running:
+ Log.warn("[ConversationViewModel] [AudioRecorder] Recorder is already recording")
+ case .Paused:
+ Log.warn("[ConversationViewModel] [AudioRecorder] Recorder is paused, resuming recording")
+ try? linphoneAudioRecorder.start()
+ case .Closed:
+ var extensionFileFormat: String = ""
+ switch linphoneAudioRecorder.params?.fileFormat {
+ case .Smff:
+ extensionFileFormat = "smff"
+ case .Mkv:
+ extensionFileFormat = "mka"
+ default:
+ extensionFileFormat = "wav"
+ }
+
+ let tempFileName = "voice-recording-\(Int(Date().timeIntervalSince1970)).\(extensionFileFormat)"
+ audioFilename = FileUtil.sharedContainerUrl().appendingPathComponent("Library/Images").appendingPathComponent(tempFileName)
+
+ if audioFilename != nil {
+ Log.warn("[ConversationViewModel] [AudioRecorder] Recorder is closed, starting recording in \(audioFilename!.absoluteString)")
+ try? linphoneAudioRecorder.open(file: String(audioFilename!.absoluteString.dropFirst(7)))
+ try? linphoneAudioRecorder.start()
+ }
+
+ startTimer()
+
+ DispatchQueue.main.async {
+ self.isRecording = true
+ }
+ }
+ }
+
+ func stopVoiceRecorder() {
+ if linphoneAudioRecorder.state == .Running {
+ Log.info("[ConversationViewModel] [AudioRecorder] Closing voice recorder")
+ try? linphoneAudioRecorder.pause()
+ linphoneAudioRecorder.close()
+ }
+
+ stopTimer()
+
+ DispatchQueue.main.async {
+ self.isRecording = false
+ }
+
+ if let request = recordingSession {
+ Log.info("[ConversationViewModel] [AudioRecorder] Releasing voice recording audio focus request")
+ try? request.setActive(false)
+ recordingSession = nil
+ CoreContext.shared.doOnCoreQueue { core in
+ core.activateAudioSession(activated: false)
+ }
+ }
+ }
+
+ func startTimer() {
+ DispatchQueue.main.async {
+ self.recordingTime = 0
+ let maxVoiceRecordDuration = Config.voiceRecordingMaxDuration
+ self.timer = Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true) { _ in // More frequent updates
+ self.recordingTime += 0.1
+ self.updateSoundPower()
+ let duration = self.linphoneAudioRecorder.duration
+ if duration >= maxVoiceRecordDuration {
+ print("[ConversationViewModel] [AudioRecorder] Max duration for voice recording exceeded (\(maxVoiceRecordDuration)ms), stopping.")
+ self.stopVoiceRecorder()
+ }
+ }
+ }
+ }
+
+ func stopTimer() {
+ self.timer?.invalidate()
+ self.timer = nil
+ }
+
+ func updateSoundPower() {
+ let soundPowerTmp = linphoneAudioRecorder.captureVolume * 1000 // Capture sound power
+ soundPower = soundPowerTmp < 10 ? 0 : (soundPowerTmp > 100 ? 100 : (soundPowerTmp - 10))
+ }
+
+ func getAudioRecordingDeviceIdForVoiceMessage() -> AudioDevice? {
+ // In case no headset/hearing aid/bluetooth is connected, use microphone sound card
+ // If none are available, default one will be used
+ var headsetCard: AudioDevice?
+ var bluetoothCard: AudioDevice?
+ var microphoneCard: AudioDevice?
+
+ CoreContext.shared.doOnCoreQueue { core in
+ for device in core.audioDevices {
+ if device.hasCapability(capability: .CapabilityRecord) {
+ switch device.type {
+ case .Headphones, .Headset:
+ headsetCard = device
+ case .Bluetooth, .HearingAid:
+ bluetoothCard = device
+ case .Microphone:
+ microphoneCard = device
+ default:
+ break
+ }
+ }
+ }
+ }
+
+ Log.info("Found headset/headphones/hearingAid sound card [\(String(describing: headsetCard))], "
+ + "bluetooth sound card [\(String(describing: bluetoothCard))] and microphone card [\(String(describing: microphoneCard))]")
+
+ return headsetCard ?? bluetoothCard ?? microphoneCard
+ }
+}
diff --git a/Linphone/UI/Main/Conversations/ViewModel/ConversationsListViewModel.swift b/Linphone/UI/Main/Conversations/ViewModel/ConversationsListViewModel.swift
new file mode 100644
index 000000000..bab467a99
--- /dev/null
+++ b/Linphone/UI/Main/Conversations/ViewModel/ConversationsListViewModel.swift
@@ -0,0 +1,219 @@
+/*
+ * 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 .
+ */
+
+import Foundation
+import linphonesw
+import Combine
+
+// swiftlint:disable line_length
+class ConversationsListViewModel: ObservableObject {
+
+ private var coreContext = CoreContext.shared
+ private var contactsManager = ContactsManager.shared
+
+ private var coreConversationDelegate: CoreDelegate?
+
+ @Published var conversationsList: [ConversationModel] = []
+ var conversationsListTmp: [ConversationModel] = []
+
+ @Published var unreadMessages: Int = 0
+
+ var selectedConversation: ConversationModel?
+
+ init() {
+ computeChatRoomsList(filter: "")
+ addConversationDelegate()
+ }
+
+ func computeChatRoomsList(filter: String) {
+ coreContext.doOnCoreQueue { core in
+ let account = core.defaultAccount
+ let chatRooms = account != nil ? account!.chatRooms : core.chatRooms
+
+ self.conversationsListTmp = []
+
+ chatRooms.forEach { chatRoom in
+ if filter.isEmpty {
+ let model = ConversationModel(chatRoom: chatRoom)
+ self.conversationsListTmp.append(model)
+ }
+ }
+
+ DispatchQueue.main.async {
+ self.conversationsList = self.conversationsListTmp
+ NotificationCenter.default.post(name: NSNotification.Name("ChatRoomsComputed"), object: nil)
+ }
+
+ self.updateUnreadMessagesCount()
+ }
+ }
+
+ func addConversationDelegate() {
+ coreContext.doOnCoreQueue { core in
+ let account = core.defaultAccount
+ let chatRoomsCounter = account?.chatRooms != nil ? account!.chatRooms.count : core.chatRooms.count
+ var counter = 0
+
+ self.coreConversationDelegate = CoreDelegateStub(onMessagesReceived: { (_: Core, _: ChatRoom, _: [ChatMessage]) in
+ self.computeChatRoomsList(filter: "")
+ }, onMessageSent: { (_: Core, _: ChatRoom, _: ChatMessage) in
+ self.computeChatRoomsList(filter: "")
+ }, onChatRoomRead: { (_: Core, _: ChatRoom) in
+ self.computeChatRoomsList(filter: "")
+ }, onChatRoomStateChanged: { (_: Core, chatRoom: ChatRoom, state: ChatRoom.State) in
+ // Log.info("[ConversationsListViewModel] Conversation [${LinphoneUtils.getChatRoomId(chatRoom)}] state changed [$state]")
+ switch state {
+ case ChatRoom.State.Created:
+ if !(chatRoom.isEmpty && chatRoom.hasCapability(mask: ChatRoom.Capabilities.OneToOne.rawValue)) {
+ counter += 1
+ }
+
+ if counter >= chatRoomsCounter {
+ self.computeChatRoomsList(filter: "")
+ counter = 0
+ }
+ case ChatRoom.State.Deleted:
+ self.computeChatRoomsList(filter: "")
+ // ToastViewModel.shared.toastMessage = "toast_conversation_deleted"
+ // ToastViewModel.shared.displayToast = true
+ default:
+ break
+ }
+ })
+ core.addDelegate(delegate: self.coreConversationDelegate!)
+ }
+ }
+
+ func reorderChatRooms() {
+ Log.info("[ConversationsListViewModel] Re-ordering conversations")
+ var sortedList: [ConversationModel] = []
+ sortedList.append(contentsOf: conversationsList)
+
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
+ self.conversationsList = sortedList.sorted { $0.lastUpdateTime > $1.lastUpdateTime }
+ }
+
+ updateUnreadMessagesCount()
+ }
+
+ func updateUnreadMessagesCount() {
+ coreContext.doOnCoreQueue { core in
+ let account = core.defaultAccount
+ if account != nil {
+ let count = account?.unreadChatMessageCount != nil ? account!.unreadChatMessageCount : core.unreadChatMessageCount
+
+ DispatchQueue.main.async {
+ self.unreadMessages = count
+ UIApplication.shared.applicationIconBadgeNumber = count
+ }
+ } else {
+ DispatchQueue.main.async {
+ self.unreadMessages = 0
+ UIApplication.shared.applicationIconBadgeNumber = 0
+ }
+ }
+ }
+ }
+
+ func getContentTextMessage(message: ChatMessage, completion: @escaping (String) -> Void) {
+ contactsManager.getFriendWithAddressInCoreQueue(address: message.fromAddress) { friendResult in
+ var fromAddressFriend = message.fromAddress != nil
+ ? friendResult?.name ?? nil
+ : nil
+
+ if !message.isOutgoing && message.chatRoom != nil && !message.chatRoom!.hasCapability(mask: ChatRoom.Capabilities.OneToOne.rawValue) {
+ if fromAddressFriend == nil {
+ if message.fromAddress!.displayName != nil {
+ fromAddressFriend = message.fromAddress!.displayName! + ": "
+ } else if message.fromAddress!.username != nil {
+ fromAddressFriend = message.fromAddress!.username! + ": "
+ } else {
+ fromAddressFriend = ""
+ }
+ } else {
+ fromAddressFriend! += ": "
+ }
+
+ } else {
+ fromAddressFriend = nil
+ }
+
+ completion(
+ (fromAddressFriend ?? "") + (message.contents.first(where: {$0.isText == true})?.utf8Text ?? (message.contents.first(where: {$0.isFile == true || $0.isFileTransfer == true})?.name ?? ""))
+ )
+ }
+ }
+
+ func getCallTime(startDate: time_t) -> String {
+ let timeInterval = TimeInterval(startDate)
+
+ let myNSDate = Date(timeIntervalSince1970: timeInterval)
+
+ if Calendar.current.isDateInToday(myNSDate) {
+ let formatter = DateFormatter()
+ formatter.dateFormat = Locale.current.identifier == "fr_FR" ? "HH:mm" : "h:mm a"
+ return formatter.string(from: myNSDate)
+ } else if Calendar.current.isDateInYesterday(myNSDate) {
+ let formatter = DateFormatter()
+ formatter.dateFormat = Locale.current.identifier == "fr_FR" ? "HH:mm" : "h:mm a"
+ return "Yesterday"
+ } else if Calendar.current.isDate(myNSDate, equalTo: .now, toGranularity: .year) {
+ let formatter = DateFormatter()
+ formatter.dateFormat = Locale.current.identifier == "fr_FR" ? "dd/MM" : "MM/dd"
+ return formatter.string(from: myNSDate)
+ } else {
+ let formatter = DateFormatter()
+ formatter.dateFormat = Locale.current.identifier == "fr_FR" ? "dd/MM/yy" : "MM/dd/yy"
+ return formatter.string(from: myNSDate)
+ }
+ }
+
+ func refreshContactAvatarModel() {
+ conversationsList.forEach { conversationModel in
+ conversationModel.refreshAvatarModel()
+ }
+
+ reorderChatRooms()
+ }
+
+ func markAsReadSelectedConversation() {
+ coreContext.doOnCoreQueue { _ in
+ let unreadMessagesCount = self.selectedConversation!.chatRoom.unreadMessagesCount
+
+ if unreadMessagesCount > 0 {
+ self.selectedConversation!.chatRoom.markAsRead()
+ self.selectedConversation!.unreadMessagesCount = 0
+ }
+ }
+ }
+
+ func filterConversations(filter: String) {
+ conversationsList.removeAll()
+ conversationsListTmp.forEach { conversation in
+ if conversation.subject.lowercased().contains(filter.lowercased()) || !conversation.participantsAddress.filter({ $0.lowercased().contains(filter.lowercased()) }).isEmpty {
+ conversationsList.append(conversation)
+ }
+ }
+ }
+
+ func resetFilterConversations() {
+ conversationsList = conversationsListTmp
+ }
+}
+// swiftlint:enable line_length
diff --git a/Linphone/UI/Main/Conversations/ViewModel/StartConversationViewModel.swift b/Linphone/UI/Main/Conversations/ViewModel/StartConversationViewModel.swift
new file mode 100644
index 000000000..6a97f2282
--- /dev/null
+++ b/Linphone/UI/Main/Conversations/ViewModel/StartConversationViewModel.swift
@@ -0,0 +1,368 @@
+/*
+ * 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 .
+ */
+
+import linphonesw
+import Combine
+
+// swiftlint:disable line_length
+class StartConversationViewModel: ObservableObject {
+
+ static let TAG = "[StartConversationViewModel]"
+
+ private var coreContext = CoreContext.shared
+
+ @Published var searchField: String = ""
+
+ var domain: String = ""
+
+ @Published var messageText: String = ""
+
+ @Published var participants: [SelectedAddressModel] = []
+
+ @Published var operationInProgress: Bool = false
+ @Published var displayedConversation: ConversationModel?
+
+ private var chatRoomDelegate: ChatRoomDelegate?
+
+ init() {
+ coreContext.doOnCoreQueue { core in
+ self.domain = core.defaultAccount?.params?.domain ?? ""
+ }
+ }
+
+ func addParticipants(participantsToAdd: [SelectedAddressModel]) {
+ var list = participants
+ for selectedAddr in participantsToAdd {
+ if let found = list.first(where: { $0.address.weakEqual(address2: selectedAddr.address) }) {
+ Log.info("\(StartConversationViewModel.TAG) Participant \(found.address.asStringUriOnly()) already in list, skipping")
+ continue
+ }
+
+ list.append(selectedAddr)
+ Log.info("\(StartConversationViewModel.TAG) Added participant \(selectedAddr.address.asStringUriOnly())")
+ }
+ Log.info("\(StartConversationViewModel.TAG) [\(list.count - participants.count) participants added, now there are \(list.count) participants in list")
+
+ participants = list
+ }
+
+ func createGroupChatRoom() {
+ coreContext.doOnCoreQueue { core in
+ let account = core.defaultAccount
+ if account == nil {
+ Log.error(
+ "\(StartConversationViewModel.TAG) No default account found, can't create group conversation!"
+ )
+ return
+ }
+
+ DispatchQueue.main.async {
+ self.operationInProgress = true
+ }
+
+ let groupChatRoomSubject = self.messageText
+ do {
+ let params: ChatRoomParams = try core.createDefaultChatRoomParams()
+ params.groupEnabled = true
+ params.subject = groupChatRoomSubject
+ params.backend = ChatRoom.Backend.FlexisipChat
+ params.encryptionEnabled = true
+
+ var participantsTmp: [Address] = []
+ self.participants.forEach { participant in
+ participantsTmp.append(participant.address)
+ }
+
+ if account!.params != nil {
+ let localAddress = account!.params!.identityAddress
+
+ let chatRoom = try core.createChatRoom(
+ params: params,
+ localAddr: localAddress,
+ participants: participantsTmp
+ )
+
+ if params.backend == ChatRoom.Backend.FlexisipChat {
+ if chatRoom.state == ChatRoom.State.Created {
+ let id = LinphoneUtils.getChatRoomId(room: chatRoom)
+ Log.info(
+ "\(StartConversationViewModel.TAG) Group conversation \(id) \(groupChatRoomSubject) has been created"
+ )
+
+ let model = ConversationModel(chatRoom: chatRoom)
+ if self.operationInProgress == false {
+ DispatchQueue.main.async {
+ self.operationInProgress = true
+ }
+
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
+ self.operationInProgress = false
+ self.displayedConversation = model
+ }
+ } else {
+ DispatchQueue.main.async {
+ self.operationInProgress = false
+ self.displayedConversation = model
+ }
+ }
+ } else {
+ Log.info(
+ "\(StartConversationViewModel.TAG) Conversation \(groupChatRoomSubject) isn't in Created state yet, wait for it"
+ )
+ self.chatRoomAddDelegate(core: core, chatRoom: chatRoom)
+ }
+ } else {
+ let id = LinphoneUtils.getChatRoomId(room: chatRoom)
+ Log.info("\(StartConversationViewModel.TAG) Conversation successfully created \(id) \(groupChatRoomSubject)")
+
+ let model = ConversationModel(chatRoom: chatRoom)
+ if self.operationInProgress == false {
+ DispatchQueue.main.async {
+ self.operationInProgress = true
+ }
+
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
+ self.operationInProgress = false
+ self.displayedConversation = model
+ }
+ } else {
+ DispatchQueue.main.async {
+ self.operationInProgress = false
+ self.displayedConversation = model
+ }
+ }
+ }
+ }
+ } catch let error {
+ Log.error("\(StartConversationViewModel.TAG) Failed to create group conversation \(groupChatRoomSubject)!")
+ Log.error("\(StartConversationViewModel.TAG) \(error)")
+
+ DispatchQueue.main.async {
+ self.operationInProgress = false
+ ToastViewModel.shared.toastMessage = "Failed_to_create_conversation_error"
+ ToastViewModel.shared.displayToast = true
+ }
+ }
+ }
+ }
+
+ func createOneToOneChatRoomWith(remote: Address) {
+ coreContext.doOnCoreQueue { core in
+ let account = core.defaultAccount
+ if account == nil {
+ Log.error(
+ "\(StartConversationViewModel.TAG) No default account found, can't create conversation with \(remote.asStringUriOnly())!"
+ )
+ return
+ }
+
+ DispatchQueue.main.async {
+ self.operationInProgress = true
+ }
+
+ do {
+ let params: ChatRoomParams = try core.createDefaultChatRoomParams()
+ params.groupEnabled = false
+ params.subject = "Dummy subject"
+ params.ephemeralLifetime = 0 // Make sure ephemeral is disabled by default
+
+ let sameDomain = remote.domain == account?.params?.domain ?? ""
+ if StartConversationViewModel.isEndToEndEncryptionMandatory() && sameDomain {
+ Log.info("\(StartConversationViewModel.TAG) Account is in secure mode & domain matches, creating a E2E conversation")
+ params.backend = ChatRoom.Backend.FlexisipChat
+ params.encryptionEnabled = true
+ } else if !StartConversationViewModel.isEndToEndEncryptionMandatory() {
+ if LinphoneUtils.isEndToEndEncryptedChatAvailable(core: core) {
+ Log.info(
+ "\(StartConversationViewModel.TAG) Account is in interop mode but LIME is available, creating a E2E conversation"
+ )
+ params.backend = ChatRoom.Backend.FlexisipChat
+ params.encryptionEnabled = true
+ } else {
+ Log.info(
+ "\(StartConversationViewModel.TAG) Account is in interop mode but LIME isn't available, creating a SIP simple conversation"
+ )
+ params.backend = ChatRoom.Backend.Basic
+ params.encryptionEnabled = false
+ }
+ } else {
+ Log.error(
+ "\(StartConversationViewModel.TAG) Account is in secure mode, can't chat with SIP address of different domain \(remote.asStringUriOnly())"
+ )
+ DispatchQueue.main.async {
+ self.operationInProgress = false
+ ToastViewModel.shared.toastMessage = "Failed_to_create_conversation_invalid_participant_error"
+ ToastViewModel.shared.displayToast = true
+ }
+ return
+ }
+
+ let participants = [remote]
+ let localAddress = account?.params?.identityAddress
+ let existingChatRoom = core.searchChatRoom(params: params, localAddr: localAddress, remoteAddr: nil, participants: participants)
+ if existingChatRoom == nil {
+ Log.info(
+ "\(StartConversationViewModel.TAG) No existing 1-1 conversation between local account "
+ + "\(localAddress?.asStringUriOnly() ?? "") and remote \(remote.asStringUriOnly()) was found for given parameters, let's create it"
+ )
+ let chatRoom = try core.createChatRoom(params: params, localAddr: localAddress, participants: participants)
+ if params.backend == ChatRoom.Backend.FlexisipChat {
+ if chatRoom.state == ChatRoom.State.Created {
+ let id = LinphoneUtils.getChatRoomId(room: chatRoom)
+ Log.info("\(StartConversationViewModel.TAG) 1-1 conversation \(id) has been created")
+
+ let model = ConversationModel(chatRoom: chatRoom)
+ if self.operationInProgress == false {
+ DispatchQueue.main.async {
+ self.operationInProgress = true
+ }
+
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
+ self.operationInProgress = false
+ self.displayedConversation = model
+ }
+ } else {
+ DispatchQueue.main.async {
+ self.operationInProgress = false
+ self.displayedConversation = model
+ }
+ }
+ } else {
+ Log.info("\(StartConversationViewModel.TAG) Conversation isn't in Created state yet, wait for it")
+ self.chatRoomAddDelegate(core: core, chatRoom: chatRoom)
+ }
+ } else {
+ let id = LinphoneUtils.getChatRoomId(room: chatRoom)
+ Log.info("\(StartConversationViewModel.TAG) Conversation successfully created \(id)")
+
+ let model = ConversationModel(chatRoom: chatRoom)
+ if self.operationInProgress == false {
+ DispatchQueue.main.async {
+ self.operationInProgress = true
+ }
+
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
+ self.operationInProgress = false
+ self.displayedConversation = model
+ }
+ } else {
+ DispatchQueue.main.async {
+ self.operationInProgress = false
+ self.displayedConversation = model
+ }
+ }
+ }
+ } else {
+ Log.warn(
+ "\(StartConversationViewModel.TAG) A 1-1 conversation between local account \(localAddress?.asStringUriOnly() ?? "") and remote \(remote.asStringUriOnly()) for given parameters already exists!"
+ )
+
+ let model = ConversationModel(chatRoom: existingChatRoom!)
+ if self.operationInProgress == false {
+ DispatchQueue.main.async {
+ self.operationInProgress = true
+ }
+
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
+ self.operationInProgress = false
+ self.displayedConversation = model
+ }
+ } else {
+ DispatchQueue.main.async {
+ self.operationInProgress = false
+ self.displayedConversation = model
+ }
+ }
+ }
+ } catch {
+ DispatchQueue.main.async {
+ self.operationInProgress = false
+ ToastViewModel.shared.toastMessage = "Failed_to_create_conversation_error"
+ ToastViewModel.shared.displayToast = true
+ }
+ Log.error("\(StartConversationViewModel.TAG) Failed to create 1-1 conversation with \(remote.asStringUriOnly())!")
+ }
+ }
+ }
+
+ func chatRoomAddDelegate(core: Core, chatRoom: ChatRoom) {
+ self.chatRoomDelegate = ChatRoomDelegateStub(onStateChanged: { (chatRoom: ChatRoom, state: ChatRoom.State) in
+ let state = chatRoom.state
+ let id = LinphoneUtils.getChatRoomId(room: chatRoom)
+ if state == ChatRoom.State.CreationFailed {
+ Log.error("\(StartConversationViewModel.TAG) Conversation \(id) creation has failed!")
+ if let chatRoomDelegate = self.chatRoomDelegate {
+ chatRoom.removeDelegate(delegate: chatRoomDelegate)
+ }
+ self.chatRoomDelegate = nil
+ DispatchQueue.main.async {
+ self.operationInProgress = false
+ ToastViewModel.shared.toastMessage = "Failed_to_create_conversation_error"
+ ToastViewModel.shared.displayToast = true
+ }
+ }
+ }, onConferenceJoined: { (chatRoom: ChatRoom, _: EventLog) in
+ let state = chatRoom.state
+ let id = LinphoneUtils.getChatRoomId(room: chatRoom)
+ Log.info("\(StartConversationViewModel.TAG) Conversation \(id) \(chatRoom.subject ?? "") state changed: \(state)")
+ if state == ChatRoom.State.Created {
+ Log.info("\(StartConversationViewModel.TAG) Conversation \(id) successfully created")
+ if let chatRoomDelegate = self.chatRoomDelegate {
+ chatRoom.removeDelegate(delegate: chatRoomDelegate)
+ }
+ self.chatRoomDelegate = nil
+
+ let model = ConversationModel(chatRoom: chatRoom)
+ if self.operationInProgress == false {
+ DispatchQueue.main.async {
+ self.operationInProgress = true
+ }
+
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
+ self.operationInProgress = false
+ self.displayedConversation = model
+ }
+ } else {
+ DispatchQueue.main.async {
+ self.operationInProgress = false
+ self.displayedConversation = model
+ }
+ }
+ } else if state == ChatRoom.State.CreationFailed {
+ Log.error("\(StartConversationViewModel.TAG) Conversation \(id) creation has failed!")
+ if let chatRoomDelegate = self.chatRoomDelegate {
+ chatRoom.removeDelegate(delegate: chatRoomDelegate)
+ }
+ self.chatRoomDelegate = nil
+ DispatchQueue.main.async {
+ self.operationInProgress = false
+ ToastViewModel.shared.toastMessage = "Failed_to_create_conversation_error"
+ ToastViewModel.shared.displayToast = true
+ }
+ }
+ })
+ chatRoom.addDelegate(delegate: self.chatRoomDelegate!)
+ }
+
+ public static func isEndToEndEncryptionMandatory() -> Bool {
+ return false // TODO: Will be done later in SDK
+ }
+}
+
+// swiftlint:enable line_length
diff --git a/Linphone/UI/Main/Fragments/CustomBottomSheet.swift b/Linphone/UI/Main/Fragments/CustomBottomSheet.swift
new file mode 100644
index 000000000..18683b01f
--- /dev/null
+++ b/Linphone/UI/Main/Fragments/CustomBottomSheet.swift
@@ -0,0 +1,98 @@
+/*
+ * 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 .
+ */
+
+import SwiftUI
+
+extension View {
+ func halfSheet(
+ showSheet: Binding,
+ @ViewBuilder content: @escaping () -> Content,
+ onDismiss: @escaping () -> Void
+ ) -> some View {
+ return self
+ .background(
+ HalfSheetHelper(sheetView: content(), showSheet: showSheet, onDismiss: onDismiss)
+ )
+ }
+}
+
+struct HalfSheetHelper: UIViewControllerRepresentable {
+
+ var sheetView: Content
+ let controller: UIViewController = UIViewController()
+ @Binding var showSheet: Bool
+ var onDismiss: () -> Void
+
+ func makeCoordinator() -> Coordinator {
+ Coordinator(parent: self)
+ }
+
+ func makeUIViewController(context: Context) -> UIViewController {
+ controller.view.backgroundColor = .clear
+ return controller
+ }
+
+ func updateUIViewController(_ uiViewController: UIViewController, context: Context) {
+ if showSheet {
+ let sheetController = CustomHostingController(rootView: sheetView)
+ sheetController.presentationController?.delegate = context.coordinator
+ uiViewController.present(sheetController, animated: true)
+ }
+ }
+
+ final class Coordinator: NSObject, UISheetPresentationControllerDelegate {
+
+ var parent: HalfSheetHelper
+
+ init(parent: HalfSheetHelper) {
+ self.parent = parent
+ }
+
+ func presentationControllerWillDismiss(_ presentationController: UIPresentationController) {
+ parent.showSheet = false
+ }
+ }
+}
+
+final class CustomHostingController: UIHostingController {
+ override func viewDidLoad() {
+ view.backgroundColor = .clear
+ if let presentationController = presentationController as? UISheetPresentationController {
+ presentationController.detents = [
+ .medium()
+ ]
+
+ presentationController.prefersGrabberVisible = false
+
+ presentationController.prefersScrollingExpandsWhenScrolledToEdge = false
+
+ presentationController.preferredCornerRadius = 30
+ }
+ }
+}
+
+public struct LazyView: View {
+ private let build: () -> Content
+ public init(_ build: @autoclosure @escaping () -> Content) {
+ self.build = build
+ }
+ public var body: Content {
+ build()
+ }
+}
diff --git a/Linphone/UI/Main/Fragments/DeviceRotationViewModifier.swift b/Linphone/UI/Main/Fragments/DeviceRotationViewModifier.swift
new file mode 100644
index 000000000..3f3fd0f22
--- /dev/null
+++ b/Linphone/UI/Main/Fragments/DeviceRotationViewModifier.swift
@@ -0,0 +1,46 @@
+/*
+ * 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 .
+ */
+
+import SwiftUI
+
+struct DeviceRotationViewModifier: ViewModifier {
+
+ private var idiom: UIUserInterfaceIdiom { UIDevice.current.userInterfaceIdiom }
+
+ let action: (UIDeviceOrientation) -> Void
+
+ func body(content: Content) -> some View {
+ content
+ .onAppear()
+ .onReceive(NotificationCenter.default.publisher(for: UIDevice.orientationDidChangeNotification)) { _ in
+ if UIDevice.current.orientation == .landscapeLeft
+ || UIDevice.current.orientation == .landscapeRight
+ || UIDevice.current.orientation == .portrait
+ || (UIDevice.current.orientation == .portraitUpsideDown && idiom == .pad) {
+ action(UIDevice.current.orientation)
+ }
+ }
+ }
+}
+
+extension View {
+ func onRotate(perform action: @escaping (UIDeviceOrientation) -> Void) -> some View {
+ self.modifier(DeviceRotationViewModifier(action: action))
+ }
+}
diff --git a/Linphone/UI/Main/Fragments/HelpView.swift b/Linphone/UI/Main/Fragments/HelpView.swift
new file mode 100644
index 000000000..17cf680bf
--- /dev/null
+++ b/Linphone/UI/Main/Fragments/HelpView.swift
@@ -0,0 +1,49 @@
+/*
+ * Copyright (c) 2010-2024 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 .
+ */
+
+import Foundation
+import linphonesw
+
+class HelpView { // TODO (basic debug moved here until halp view is implemented)
+
+ static func sendLogs() {
+ CoreContext.shared.doOnCoreQueue { core in
+ core.uploadLogCollection()
+ }
+ }
+
+ static func clearLogs() {
+ CoreContext.shared.doOnCoreQueue { _ in
+ Core.resetLogCollection()
+ DispatchQueue.main.async {
+ ToastViewModel.shared.toastMessage = "Success_clear_logs"
+ ToastViewModel.shared.displayToast = true
+ }
+ }
+ }
+
+ static func logout() {
+ CoreContext.shared.doOnCoreQueue { core in
+ if let account = core.defaultAccount {
+ Log.info("Account \(account.displayName()) has been removed")
+ core.removeAccount(account: account) // UI update and auth info removal moved into onRegistrationChanged core callback, in CoreContext
+ }
+ }
+ }
+}
diff --git a/Linphone/UI/Main/Fragments/PopupLoadingView.swift b/Linphone/UI/Main/Fragments/PopupLoadingView.swift
new file mode 100644
index 000000000..0e1eb2aa6
--- /dev/null
+++ b/Linphone/UI/Main/Fragments/PopupLoadingView.swift
@@ -0,0 +1,59 @@
+/*
+ * Copyright (c) 2010-2023 Belledonne Communications SARL.
+ *
+ * This file is part of Linphone
+ *
+ * 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 .
+ */
+
+import SwiftUI
+
+struct PopupLoadingView: View {
+
+ @ObservedObject private var sharedMainViewModel = SharedMainViewModel.shared
+
+ var body: some View {
+ GeometryReader { geometry in
+ VStack(alignment: .leading) {
+
+ ProgressView()
+ .controlSize(.large)
+ .progressViewStyle(CircularProgressViewStyle(tint: Color.orangeMain500))
+ .frame(maxWidth: .infinity)
+ .padding(.top)
+ .padding(.bottom)
+
+ Text("Opération en cours...")
+ .tint(Color.grayMain2c600)
+ .default_text_style(styleSize: 15)
+ .frame(maxWidth: .infinity)
+ }
+ .padding(.horizontal, 20)
+ .padding(.vertical, 20)
+ .background(.white)
+ .cornerRadius(20)
+ .padding(.horizontal)
+ .frame(maxHeight: .infinity)
+ .frame(maxWidth: .infinity)
+ .shadow(color: Color.orangeMain500, radius: 0, x: 0, y: 2)
+ .frame(maxWidth: sharedMainViewModel.maxWidth)
+ .position(x: geometry.size.width / 2, y: geometry.size.height / 2)
+ }
+ }
+}
+
+#Preview {
+ PopupLoadingView()
+ .background(.black.opacity(0.65))
+}
diff --git a/Linphone/UI/Main/Fragments/PopupView.swift b/Linphone/UI/Main/Fragments/PopupView.swift
new file mode 100644
index 000000000..ad4bb3dfd
--- /dev/null
+++ b/Linphone/UI/Main/Fragments/PopupView.swift
@@ -0,0 +1,111 @@
+/*
+ * Copyright (c) 2010-2023 Belledonne Communications SARL.
+ *
+ * This file is part of Linphone
+ *
+ * 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 .
+ */
+
+import SwiftUI
+import Photos
+
+struct PopupView: View {
+
+ @ObservedObject private var sharedMainViewModel = SharedMainViewModel.shared
+
+ var permissionManager = PermissionManager.shared
+
+ @Binding var isShowPopup: Bool
+ var title: Text
+ var content: Text?
+
+ var titleFirstButton: Text?
+ var actionFirstButton: () -> Void
+
+ var titleSecondButton: Text?
+ var actionSecondButton: () -> Void
+
+ var body: some View {
+ GeometryReader { geometry in
+ VStack(alignment: .leading) {
+ title
+ .default_text_style_800(styleSize: 16)
+ .frame(alignment: .leading)
+ .padding(.bottom, 2)
+
+ if content != nil {
+ content
+ .tint(Color.grayMain2c600)
+ .default_text_style(styleSize: 15)
+ .padding(.bottom, 20)
+ }
+
+ if titleFirstButton != nil {
+ Button(action: {
+ actionFirstButton()
+ }, label: {
+ titleFirstButton
+ .default_text_style_orange_600(styleSize: 20)
+ .frame(height: 35)
+ .frame(maxWidth: .infinity)
+ })
+ .padding(.horizontal, 20)
+ .padding(.vertical, 10)
+ .cornerRadius(60)
+ .overlay(
+ RoundedRectangle(cornerRadius: 60)
+ .inset(by: 0.5)
+ .stroke(Color.orangeMain500, lineWidth: 1)
+ )
+ .padding(.bottom, 10)
+ }
+
+ if titleSecondButton != nil {
+ Button(action: {
+ actionSecondButton()
+ }, label: {
+ titleSecondButton
+ .default_text_style_white_600(styleSize: 20)
+ .frame(height: 35)
+ .frame(maxWidth: .infinity)
+ })
+ .padding(.horizontal, 20)
+ .padding(.vertical, 10)
+ .background(Color.orangeMain500)
+ .cornerRadius(60)
+ }
+ }
+ .padding(.horizontal, 20)
+ .padding(.vertical, 20)
+ .background(.white)
+ .cornerRadius(20)
+ .padding(.horizontal)
+ .frame(maxHeight: .infinity)
+ .shadow(color: Color.orangeMain500, radius: 0, x: 0, y: 2)
+ .frame(maxWidth: sharedMainViewModel.maxWidth)
+ .position(x: geometry.size.width / 2, y: geometry.size.height / 2)
+ }
+ }
+}
+
+#Preview {
+ PopupView(isShowPopup: .constant(true),
+ title: Text("Title"),
+ content: Text("Content"),
+ titleFirstButton: Text("Deny all"),
+ actionFirstButton: {},
+ titleSecondButton: Text("Accept all"),
+ actionSecondButton: {})
+ .background(.black.opacity(0.65))
+}
diff --git a/Linphone/UI/Main/Fragments/SideMenu.swift b/Linphone/UI/Main/Fragments/SideMenu.swift
new file mode 100644
index 000000000..2b388ca57
--- /dev/null
+++ b/Linphone/UI/Main/Fragments/SideMenu.swift
@@ -0,0 +1,179 @@
+/*
+ * Copyright (c) 2010-2024 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 .
+ */
+
+import SwiftUI
+import linphonesw
+import UniformTypeIdentifiers
+
+struct SideMenu: View {
+
+ let width: CGFloat
+ let isOpen: Bool
+ let menuClose: () -> Void
+ let safeAreaInsets: EdgeInsets
+ @Binding var isShowLoginFragment: Bool
+ @State private var showHelp = false
+
+ var body: some View {
+ ZStack {
+ GeometryReader { _ in
+ EmptyView()
+ }
+ .background(.gray.opacity(0.3))
+ .opacity(self.isOpen ? 1.0 : 0.0)
+ .onTapGesture {
+ self.menuClose()
+ }
+ VStack {
+ VStack {
+ HStack {
+ Image("linphone")
+ .renderingMode(.template)
+ .resizable()
+ .foregroundStyle(Color.orangeMain500)
+ .frame(width: 32, height: 32)
+ .padding(10)
+ Text(Bundle.main.displayName)
+ .default_text_style_800(styleSize: 16)
+ Spacer()
+ Image("x")
+ .renderingMode(.template)
+ .resizable()
+ .foregroundStyle(Color.grayMain2c600)
+ .frame(width: 24, height: 24)
+ .padding(10)
+ }
+ .padding(.leading, 10)
+ .onTapGesture {
+ self.menuClose()
+ }
+
+ List {
+ ForEach(0...
+ */
+
+import SwiftUI
+import linphonesw
+import UniformTypeIdentifiers
+
+struct SideMenuAccountRow: View {
+ @ObservedObject var model: AccountModel
+ var body: some View {
+ HStack {
+
+ Avatar(contactAvatarModel:
+ ContactAvatarModel(friend: nil,
+ name: model.displayName,
+ address: model.address,
+ withPresence: true),
+ avatarSize: 45)
+ .padding(.leading, 6)
+
+ VStack {
+ Text(model.displayName)
+ .default_text_style_grey_400(styleSize: 14)
+ .lineLimit(1)
+ .frame(maxWidth: .infinity, alignment: .leading)
+
+ VStack {
+ Text(model.humanReadableRegistrationState)
+ .default_text_style_uncolored(styleSize: 12)
+ .foregroundStyle(model.registrationStateAssociatedUIColor)
+ }
+ .padding(EdgeInsets(top: 4, leading: 8, bottom: 4, trailing: 8))
+ .background(Color.grayMain2c200)
+ .cornerRadius(12)
+ .frame(height: 20)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .onTapGesture {
+ model.refreshRegiter()
+ }
+ }
+ .padding(.leading, 4)
+
+ Spacer()
+
+ HStack {
+ if model.notificationsCount > 0 {
+ Text(String(model.notificationsCount))
+ .foregroundStyle(.white)
+ .default_text_style(styleSize: 12)
+ .lineLimit(1)
+ .frame(width: 20, height: 20)
+ .background(Color.redDanger500)
+ .cornerRadius(50)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ }
+ Image("dots-three-vertical")
+ .renderingMode(.template)
+ .resizable()
+ .foregroundStyle(Color.grayMain2c600)
+ .scaledToFit()
+ .frame(height: 30)
+ }
+ .frame(width: 64, alignment: .trailing)
+ .padding(.top, 12)
+ .padding(.bottom, 12)
+ }
+ .frame(height: 61)
+ .background(model.isDefaultAccount ? Color.grayMain2c100 : .clear)
+ }
+}
diff --git a/Linphone/UI/Main/Fragments/SideMenuEntry.swift b/Linphone/UI/Main/Fragments/SideMenuEntry.swift
new file mode 100644
index 000000000..cc1240c56
--- /dev/null
+++ b/Linphone/UI/Main/Fragments/SideMenuEntry.swift
@@ -0,0 +1,53 @@
+/*
+ * Copyright (c) 2010-2024 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 .
+ */
+
+import SwiftUI
+import linphonesw
+import UniformTypeIdentifiers
+
+struct SideMenuEntry: View {
+ var iconName: String
+ var title: String
+ var body: some View {
+ HStack {
+ Image(iconName)
+ .renderingMode(.template)
+ .resizable()
+ .foregroundStyle(Color.grayMain2c500)
+ .frame(width: 20, height: 20)
+ Text(NSLocalizedString(title, comment: title))
+ .default_text_style_600(styleSize: 13)
+ .padding(.leading, 4)
+ Spacer()
+ Image("caret-right")
+ .renderingMode(.template)
+ .resizable()
+ .foregroundStyle(Color.grayMain2c600)
+ .frame(width: 20, height: 20)
+ }
+ .background()
+ }
+}
+
+#Preview {
+ SideMenuEntry(
+ iconName: "linphone",
+ title: "some text"
+ )
+}
diff --git a/Linphone/UI/Main/Fragments/ToastView.swift b/Linphone/UI/Main/Fragments/ToastView.swift
new file mode 100644
index 000000000..279be4ce1
--- /dev/null
+++ b/Linphone/UI/Main/Fragments/ToastView.swift
@@ -0,0 +1,323 @@
+/*
+ * Copyright (c) 2010-2023 Belledonne Communications SARL.
+ *
+ * This file is part of Linphone
+ *
+ * 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 .
+ */
+
+import SwiftUI
+
+struct ToastView: View {
+
+ @ObservedObject private var toastViewModel = ToastViewModel.shared
+
+ var body: some View {
+ VStack {
+ if toastViewModel.displayToast {
+ HStack {
+ if toastViewModel.toastMessage.contains("toast_call_transfer") {
+ Image("phone-transfer")
+ .resizable()
+ .renderingMode(.template)
+ .frame(width: 25, height: 25, alignment: .leading)
+ .foregroundStyle(toastViewModel.toastMessage.contains("Success") ? Color.greenSuccess500 : Color.redDanger500)
+ } else if toastViewModel.toastMessage.contains("is recording") {
+ Image("record-fill")
+ .resizable()
+ .renderingMode(.template)
+ .frame(width: 25, height: 25, alignment: .leading)
+ .foregroundStyle(Color.redDanger500)
+ } else if toastViewModel.toastMessage.contains("Info_") {
+ Image("trusted")
+ .resizable()
+ .frame(width: 25, height: 25, alignment: .leading)
+ } else {
+ Image(toastViewModel.toastMessage.contains("Success") ? "check" : "warning-circle")
+ .resizable()
+ .renderingMode(.template)
+ .frame(width: 25, height: 25, alignment: .leading)
+ .foregroundStyle(toastViewModel.toastMessage.contains("Success") ? Color.greenSuccess500 : Color.redDanger500)
+ }
+
+ switch toastViewModel.toastMessage {
+ case "Successful":
+ Text("QR code validated!")
+ .multilineTextAlignment(.center)
+ .foregroundStyle(Color.greenSuccess500)
+ .default_text_style(styleSize: 15)
+ .padding(8)
+
+ case "Success_remove_call_logs":
+ Text("History has been deleted")
+ .multilineTextAlignment(.center)
+ .foregroundStyle(Color.greenSuccess500)
+ .default_text_style(styleSize: 15)
+ .padding(8)
+
+ case "Success_clear_logs":
+ Text("Logs cleared")
+ .multilineTextAlignment(.center)
+ .foregroundStyle(Color.greenSuccess500)
+ .default_text_style(styleSize: 15)
+ .padding(8)
+
+ case "Success_send_logs":
+ Text("Logs URL copied into clipboard")
+ .multilineTextAlignment(.center)
+ .foregroundStyle(Color.greenSuccess500)
+ .default_text_style(styleSize: 15)
+ .padding(8)
+
+ case "Success_address_copied_into_clipboard":
+ Text("SIP address copied into clipboard")
+ .multilineTextAlignment(.center)
+ .foregroundStyle(Color.greenSuccess500)
+ .default_text_style(styleSize: 15)
+ .padding(8)
+
+ case "Success_message_copied_into_clipboard":
+ Text("Message copied into clipboard")
+ .multilineTextAlignment(.center)
+ .foregroundStyle(Color.greenSuccess500)
+ .default_text_style(styleSize: 15)
+ .padding(8)
+
+ case "Info_call_securised":
+ Text("call_can_be_trusted_toast")
+ .multilineTextAlignment(.center)
+ .foregroundStyle(Color.blueInfo500)
+ .default_text_style(styleSize: 15)
+ .padding(8)
+
+ case let str where str.contains("is recording"):
+ Text(toastViewModel.toastMessage)
+ .multilineTextAlignment(.center)
+ .foregroundStyle(Color.redDanger500)
+ .default_text_style(styleSize: 15)
+ .padding(8)
+
+ case "Failed":
+ Text("Invalid QR code!")
+ .multilineTextAlignment(.center)
+ .foregroundStyle(Color.redDanger500)
+ .default_text_style(styleSize: 15)
+ .padding(8)
+
+ case "Invalide URI":
+ Text("Invalide URI")
+ .multilineTextAlignment(.center)
+ .foregroundStyle(Color.redDanger500)
+ .default_text_style(styleSize: 15)
+ .padding(8)
+
+ case "Registration_failed":
+ Text("The user name or password is incorrects")
+ .multilineTextAlignment(.center)
+ .foregroundStyle(Color.redDanger500)
+ .default_text_style(styleSize: 15)
+ .padding(8)
+
+ case "Unavailable_network":
+ Text("Network is not reachable")
+ .multilineTextAlignment(.center)
+ .foregroundStyle(Color.redDanger500)
+ .default_text_style(styleSize: 15)
+ .padding(8)
+
+ case "Success_toast_network_connected":
+ Text("Network is now reachable again")
+ .multilineTextAlignment(.center)
+ .foregroundStyle(Color.greenSuccess500)
+ .default_text_style(styleSize: 15)
+ .padding(8)
+
+ case "Success_account_logged_out":
+ Text("Account successfully logged out")
+ .multilineTextAlignment(.center)
+ .foregroundStyle(Color.greenSuccess500)
+ .default_text_style(styleSize: 15)
+ .padding(8)
+
+ case "Success_toast_call_transfer_successful":
+ Text("Call has been successfully transferred")
+ .multilineTextAlignment(.center)
+ .foregroundStyle(Color.greenSuccess500)
+ .default_text_style(styleSize: 15)
+ .padding(8)
+
+ case "Success_toast_call_transfer_in_progress":
+ Text("Call is being transferred")
+ .multilineTextAlignment(.center)
+ .foregroundStyle(Color.greenSuccess500)
+ .default_text_style(styleSize: 15)
+ .padding(8)
+
+ case "Success_toast_meeting_deleted":
+ Text("Successfully removed meeting")
+ .multilineTextAlignment(.center)
+ .foregroundStyle(Color.greenSuccess500)
+ .default_text_style(styleSize: 15)
+ .padding(8)
+
+ case "Failed_toast_call_transfer_failed":
+ Text("Call transfer failed!")
+ .multilineTextAlignment(.center)
+ .foregroundStyle(Color.redDanger500)
+ .default_text_style(styleSize: 15)
+ .padding(8)
+
+ case "Failed_uri_handler_call_failed":
+ Text("Call failed")
+ .multilineTextAlignment(.center)
+ .foregroundStyle(Color.redDanger500)
+ .default_text_style(styleSize: 15)
+ .padding(8)
+
+ case "Failed_uri_handler_config_failed":
+ Text("Configuration failed")
+ .multilineTextAlignment(.center)
+ .foregroundStyle(Color.redDanger500)
+ .default_text_style(styleSize: 15)
+ .padding(8)
+
+ case "uri_handler_config_success":
+ Text("Configuration successfully applied")
+ .multilineTextAlignment(.center)
+ .foregroundStyle(Color.redDanger500)
+ .default_text_style(styleSize: 15)
+ .padding(8)
+
+ case "Failed_uri_handler_bad_call_address":
+ Text("Unable to call, invalid address")
+ .multilineTextAlignment(.center)
+ .foregroundStyle(Color.redDanger500)
+ .default_text_style(styleSize: 15)
+ .padding(8)
+
+ case "Failed_uri_handler_bad_config_address":
+ Text("Unable to retrieve configuration, invalid address")
+ .multilineTextAlignment(.center)
+ .foregroundStyle(Color.redDanger500)
+ .default_text_style(styleSize: 15)
+ .padding(8)
+
+ case "Failed_push_notification_not_received_error":
+ Text("assistant_account_register_push_notification_not_received_error")
+ .multilineTextAlignment(.center)
+ .foregroundStyle(Color.redDanger500)
+ .default_text_style(styleSize: 15)
+ .padding(8)
+
+ case "Failed_account_register_unexpected_error":
+ Text("assistant_account_register_unexpected_error")
+ .multilineTextAlignment(.center)
+ .foregroundStyle(Color.redDanger500)
+ .default_text_style(styleSize: 15)
+ .padding(8)
+
+ case let str where str.contains("Error: "):
+ Text(toastViewModel.toastMessage)
+ .multilineTextAlignment(.center)
+ .foregroundStyle(Color.redDanger500)
+ .default_text_style(styleSize: 15)
+ .padding(8)
+
+ case "Failed_to_create_group_call_error":
+ Text("conference_failed_to_create_group_call_toast")
+ .multilineTextAlignment(.center)
+ .foregroundStyle(Color.redDanger500)
+ .default_text_style(styleSize: 15)
+ .padding(8)
+
+ case "Failed_to_create_conversation_error":
+ Text("conversation_failed_to_create_toast")
+ .multilineTextAlignment(.center)
+ .foregroundStyle(Color.redDanger500)
+ .default_text_style(styleSize: 15)
+ .padding(8)
+
+ case "Failed_to_create_conversation_invalid_participant_error":
+ Text("conversation_invalid_participant_due_to_security_mode_toast")
+ .multilineTextAlignment(.center)
+ .foregroundStyle(Color.redDanger500)
+
+ case "Meeting_added_to_calendar":
+ Text("Meeting added to iPhone calendar")
+ .multilineTextAlignment(.center)
+ .foregroundStyle(Color.greenSuccess500)
+ .default_text_style(styleSize: 15)
+ .padding(8)
+
+ case "Failed_meeting_invitations_not_sent":
+ Text("Could not send ICS invitations to meeting to any participant")
+ .multilineTextAlignment(.center)
+ .foregroundStyle(Color.redDanger500)
+ .default_text_style(styleSize: 15)
+ .padding(8)
+
+ case "Failed_no_subject_or_participant":
+ Text("A subject and at least one participant is required to create a meeting")
+ .multilineTextAlignment(.center)
+ .foregroundStyle(Color.redDanger500)
+ .default_text_style(styleSize: 15)
+ .padding(8)
+
+ default:
+ Text("Error")
+ .multilineTextAlignment(.center)
+ .foregroundStyle(Color.redDanger500)
+ .default_text_style(styleSize: 15)
+ .padding(8)
+ }
+ }
+ .frame(maxWidth: .infinity)
+ .background(.white)
+ .cornerRadius(50)
+ .overlay(
+ RoundedRectangle(cornerRadius: 50)
+ .inset(by: 0.5)
+ .stroke(toastViewModel.toastMessage.contains("Success")
+ ? Color.greenSuccess500 : (toastViewModel.toastMessage.contains("Info_")
+ ? Color.blueInfo500 : Color.redDanger500), lineWidth: 1)
+ )
+ .onTapGesture {
+ if !toastViewModel.toastMessage.contains("is recording") {
+ withAnimation {
+ toastViewModel.toastMessage = ""
+ toastViewModel.displayToast = false
+ }
+ }
+ }
+ .onAppear {
+ if !toastViewModel.toastMessage.contains("is recording") {
+ DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
+ withAnimation {
+ toastViewModel.toastMessage = ""
+ toastViewModel.displayToast = false
+ }
+ }
+ }
+ }
+
+ Spacer()
+ }
+ }
+ .frame(maxWidth: SharedMainViewModel.shared.maxWidth)
+ .padding(.horizontal, 16)
+ .padding(.bottom, 18)
+ .transition(.move(edge: .top))
+ .padding(.top, 60)
+ }
+}
diff --git a/Linphone/UI/Main/History/Fragments/DialerBottomSheet.swift b/Linphone/UI/Main/History/Fragments/DialerBottomSheet.swift
new file mode 100644
index 000000000..08d04377d
--- /dev/null
+++ b/Linphone/UI/Main/History/Fragments/DialerBottomSheet.swift
@@ -0,0 +1,555 @@
+/*
+ * 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 .
+ */
+
+import SwiftUI
+import UniformTypeIdentifiers
+import linphonesw
+
+// swiftlint:disable type_body_length
+struct DialerBottomSheet: View {
+
+ @Environment(\.dismiss) var dismiss
+
+ private var idiom: UIUserInterfaceIdiom { UIDevice.current.userInterfaceIdiom }
+
+ @ObservedObject private var magicSearch = MagicSearchSingleton.shared
+ @ObservedObject private var sharedMainViewModel = SharedMainViewModel.shared
+ @ObservedObject var contactsManager = ContactsManager.shared
+ @ObservedObject private var telecomManager = TelecomManager.shared
+
+ @ObservedObject var startCallViewModel: StartCallViewModel
+ @ObservedObject var callViewModel: CallViewModel
+
+ @State private var orientation = UIDevice.current.orientation
+
+ @State var dialerField = ""
+
+ @Binding var isShowStartCallFragment: Bool
+ @Binding var showingDialer: Bool
+
+ let currentCall: Call?
+
+ var body: some View {
+ VStack(alignment: .center, spacing: 0) {
+ VStack(alignment: .center, spacing: 0) {
+ if idiom != .pad && (orientation == .landscapeLeft
+ || orientation == .landscapeRight
+ || UIScreen.main.bounds.size.width > UIScreen.main.bounds.size.height) {
+ Spacer()
+ HStack {
+ Spacer()
+ Button("Close") {
+ showingDialer.toggle()
+ dismiss()
+ }
+ }
+ .padding(.trailing)
+ } else {
+ Capsule()
+ .fill(currentCall != nil ? .white : Color.grayMain2c300)
+ .frame(width: 75, height: 5)
+ .padding(15)
+ }
+
+ if currentCall != nil {
+ HStack {
+ Text(dialerField)
+ .foregroundStyle(currentCall != nil ? .white : Color.grayMain2c600)
+ .default_text_style(styleSize: 25)
+ .frame(maxWidth: .infinity)
+ .padding(.horizontal, 10)
+ .lineLimit(1)
+ .truncationMode(.head)
+
+ Button {
+ dialerField = String(dialerField.dropLast())
+ } label: {
+ Image("backspace-fill")
+ .renderingMode(.template)
+ .resizable()
+ .foregroundStyle(currentCall != nil ? .white : Color.grayMain2c500)
+ .frame(width: 32, height: 32)
+
+ }
+ .frame(width: 60, height: 60)
+ }
+ .padding(.horizontal, 20)
+ .padding(.top, 10)
+ .frame(maxWidth: sharedMainViewModel.maxWidth)
+
+ Spacer()
+ } else {
+ Spacer()
+ }
+
+ HStack {
+ Button {
+ if currentCall != nil {
+ do {
+ let digit = ("1".cString(using: String.Encoding.utf8)?[0])!
+ try currentCall!.sendDtmf(dtmf: digit)
+ dialerField += "1"
+ } catch {
+
+ }
+ } else {
+ startCallViewModel.searchField += "1"
+ }
+ } label: {
+ Text("1")
+ .foregroundStyle(currentCall != nil ? .white : Color.grayMain2c600)
+ .default_text_style(styleSize: 32)
+ .multilineTextAlignment(.center)
+ .frame(width: 60, height: 60)
+ .background(currentCall != nil ? Color.gray500 : .white)
+ .clipShape(Circle())
+ .shadow(color: .black.opacity(0.2), radius: 4)
+ }
+
+ Spacer()
+
+ Button {
+ if currentCall != nil {
+ do {
+ let digit = ("2".cString(using: String.Encoding.utf8)?[0])!
+ try currentCall!.sendDtmf(dtmf: digit)
+ dialerField += "2"
+ } catch {
+
+ }
+ } else {
+ startCallViewModel.searchField += "2"
+ }
+ } label: {
+ Text("2")
+ .foregroundStyle(currentCall != nil ? .white : Color.grayMain2c600)
+ .default_text_style(styleSize: 32)
+ .multilineTextAlignment(.center)
+ .frame(width: 60, height: 60)
+ .background(currentCall != nil ? Color.gray500 : .white)
+ .clipShape(Circle())
+ .shadow(color: .black.opacity(0.2), radius: 4)
+ }
+
+ Spacer()
+
+ Button {
+ if currentCall != nil {
+ do {
+ let digit = ("3".cString(using: String.Encoding.utf8)?[0])!
+ try currentCall!.sendDtmf(dtmf: digit)
+ dialerField += "3"
+ } catch {
+
+ }
+ } else {
+ startCallViewModel.searchField += "3"
+ }
+ } label: {
+ Text("3")
+ .foregroundStyle(currentCall != nil ? .white : Color.grayMain2c600)
+ .default_text_style(styleSize: 32)
+ .multilineTextAlignment(.center)
+ .frame(width: 60, height: 60)
+ .background(currentCall != nil ? Color.gray500 : .white)
+ .clipShape(Circle())
+ .shadow(color: .black.opacity(0.2), radius: 4)
+ }
+ }
+ .padding(.horizontal, 60)
+ .frame(maxWidth: sharedMainViewModel.maxWidth)
+
+ HStack {
+ Button {
+ if currentCall != nil {
+ do {
+ let digit = ("4".cString(using: String.Encoding.utf8)?[0])!
+ try currentCall!.sendDtmf(dtmf: digit)
+ dialerField += "4"
+ } catch {
+
+ }
+ } else {
+ startCallViewModel.searchField += "4"
+ }
+ } label: {
+ Text("4")
+ .foregroundStyle(currentCall != nil ? .white : Color.grayMain2c600)
+ .default_text_style(styleSize: 32)
+ .multilineTextAlignment(.center)
+ .frame(width: 60, height: 60)
+ .background(currentCall != nil ? Color.gray500 : .white)
+ .clipShape(Circle())
+ .shadow(color: .black.opacity(0.2), radius: 4)
+ }
+
+ Spacer()
+
+ Button {
+ if currentCall != nil {
+ do {
+ let digit = ("5".cString(using: String.Encoding.utf8)?[0])!
+ try currentCall!.sendDtmf(dtmf: digit)
+ dialerField += "5"
+ } catch {
+
+ }
+ } else {
+ startCallViewModel.searchField += "5"
+ }
+ } label: {
+ Text("5")
+ .foregroundStyle(currentCall != nil ? .white : Color.grayMain2c600)
+ .default_text_style(styleSize: 32)
+ .multilineTextAlignment(.center)
+ .frame(width: 60, height: 60)
+ .background(currentCall != nil ? Color.gray500 : .white)
+ .clipShape(Circle())
+ .shadow(color: .black.opacity(0.2), radius: 4)
+ }
+
+ Spacer()
+
+ Button {
+ if currentCall != nil {
+ do {
+ let digit = ("6".cString(using: String.Encoding.utf8)?[0])!
+ try currentCall!.sendDtmf(dtmf: digit)
+ dialerField += "6"
+ } catch {
+
+ }
+ } else {
+ startCallViewModel.searchField += "6"
+ }
+ } label: {
+ Text("6")
+ .foregroundStyle(currentCall != nil ? .white : Color.grayMain2c600)
+ .default_text_style(styleSize: 32)
+ .multilineTextAlignment(.center)
+ .frame(width: 60, height: 60)
+ .background(currentCall != nil ? Color.gray500 : .white)
+ .clipShape(Circle())
+ .shadow(color: .black.opacity(0.2), radius: 4)
+ }
+ }
+ .padding(.horizontal, 60)
+ .padding(.top, 10)
+ .frame(maxWidth: sharedMainViewModel.maxWidth)
+
+ HStack {
+ Button {
+ if currentCall != nil {
+ do {
+ let digit = ("7".cString(using: String.Encoding.utf8)?[0])!
+ try currentCall!.sendDtmf(dtmf: digit)
+ dialerField += "7"
+ } catch {
+
+ }
+ } else {
+ startCallViewModel.searchField += "7"
+ }
+ } label: {
+ Text("7")
+ .foregroundStyle(currentCall != nil ? .white : Color.grayMain2c600)
+ .default_text_style(styleSize: 32)
+ .multilineTextAlignment(.center)
+ .frame(width: 60, height: 60)
+ .background(currentCall != nil ? Color.gray500 : .white)
+ .clipShape(Circle())
+ .shadow(color: .black.opacity(0.2), radius: 4)
+ }
+
+ Spacer()
+
+ Button {
+ if currentCall != nil {
+ do {
+ let digit = ("8".cString(using: String.Encoding.utf8)?[0])!
+ try currentCall!.sendDtmf(dtmf: digit)
+ dialerField += "8"
+ } catch {
+
+ }
+ } else {
+ startCallViewModel.searchField += "8"
+ }
+ } label: {
+ Text("8")
+ .foregroundStyle(currentCall != nil ? .white : Color.grayMain2c600)
+ .default_text_style(styleSize: 32)
+ .multilineTextAlignment(.center)
+ .frame(width: 60, height: 60)
+ .background(currentCall != nil ? Color.gray500 : .white)
+ .clipShape(Circle())
+ .shadow(color: .black.opacity(0.2), radius: 4)
+ }
+
+ Spacer()
+
+ Button {
+ if currentCall != nil {
+ do {
+ let digit = ("9".cString(using: String.Encoding.utf8)?[0])!
+ try currentCall!.sendDtmf(dtmf: digit)
+ dialerField += "9"
+ } catch {
+
+ }
+ } else {
+ startCallViewModel.searchField += "9"
+ }
+ } label: {
+ Text("9")
+ .foregroundStyle(currentCall != nil ? .white : Color.grayMain2c600)
+ .default_text_style(styleSize: 32)
+ .multilineTextAlignment(.center)
+ .frame(width: 60, height: 60)
+ .background(currentCall != nil ? Color.gray500 : .white)
+ .clipShape(Circle())
+ .shadow(color: .black.opacity(0.2), radius: 4)
+ }
+ }
+ .padding(.horizontal, 60)
+ .padding(.top, 10)
+ .frame(maxWidth: sharedMainViewModel.maxWidth)
+
+ HStack {
+ Button {
+ if currentCall != nil {
+ do {
+ let digit = ("*".cString(using: String.Encoding.utf8)?[0])!
+ try currentCall!.sendDtmf(dtmf: digit)
+ dialerField += "*"
+ } catch {
+
+ }
+ } else {
+ startCallViewModel.searchField += "*"
+ }
+ } label: {
+ Text("*")
+ .foregroundStyle(currentCall != nil ? .white : Color.grayMain2c600)
+ .default_text_style(styleSize: 32)
+ .multilineTextAlignment(.center)
+ .frame(width: 60, height: 60)
+ .background(currentCall != nil ? Color.gray500 : .white)
+ .clipShape(Circle())
+ .shadow(color: .black.opacity(0.2), radius: 4)
+ }
+
+ Spacer()
+
+ if currentCall == nil {
+ Button {
+ } label: {
+ ZStack {
+ Text("0")
+ .foregroundStyle(currentCall != nil ? .white : Color.grayMain2c600)
+ .default_text_style(styleSize: 32)
+ .multilineTextAlignment(.center)
+ .frame(width: 60, height: 75)
+ .padding(.top, -15)
+ .background(currentCall != nil ? Color.gray500 : .white)
+ .clipShape(Circle())
+ .shadow(color: .black.opacity(0.2), radius: 4)
+ Text("+")
+ .foregroundStyle(currentCall != nil ? .white : Color.grayMain2c600)
+ .default_text_style(styleSize: 20)
+ .multilineTextAlignment(.center)
+ .frame(width: 60, height: 85)
+ .padding(.bottom, -25)
+ .background(.clear)
+ .clipShape(Circle())
+ }
+ }
+ .simultaneousGesture(
+ LongPressGesture()
+ .onEnded { _ in
+ startCallViewModel.searchField += "+"
+ }
+ )
+ .highPriorityGesture(
+ TapGesture()
+ .onEnded { _ in
+ startCallViewModel.searchField += "0"
+ }
+ )
+ } else {
+ Button {
+ do {
+ let digit = ("0".cString(using: String.Encoding.utf8)?[0])!
+ try currentCall!.sendDtmf(dtmf: digit)
+ dialerField += "0"
+ } catch {
+
+ }
+ } label: {
+ Text("0")
+ .foregroundStyle(currentCall != nil ? .white : Color.grayMain2c600)
+ .default_text_style(styleSize: 32)
+ .multilineTextAlignment(.center)
+ .frame(width: 60, height: 60)
+ .background(currentCall != nil ? Color.gray500 : .white)
+ .clipShape(Circle())
+ .shadow(color: .black.opacity(0.2), radius: 4)
+ }
+ }
+
+ Spacer()
+
+ Button {
+ if currentCall != nil {
+ do {
+ let digit = ("#".cString(using: String.Encoding.utf8)?[0])!
+ try currentCall!.sendDtmf(dtmf: digit)
+ dialerField += "#"
+ } catch {
+
+ }
+ } else {
+ startCallViewModel.searchField += "#"
+ }
+ } label: {
+ Text("#")
+ .foregroundStyle(currentCall != nil ? .white : Color.grayMain2c600)
+ .default_text_style(styleSize: 32)
+ .multilineTextAlignment(.center)
+ .frame(width: 60, height: 60)
+ .background(currentCall != nil ? Color.gray500 : .white)
+ .clipShape(Circle())
+ .shadow(color: .black.opacity(0.2), radius: 4)
+ }
+ }
+ .padding(.horizontal, 60)
+ .padding(.top, 10)
+ .frame(maxWidth: sharedMainViewModel.maxWidth)
+
+ if currentCall == nil {
+ HStack {
+ HStack {
+
+ }
+ .frame(width: 60, height: 60)
+
+ Spacer()
+
+ Button {
+ if !startCallViewModel.searchField.isEmpty {
+ if callViewModel.isTransferInsteadCall {
+ showingDialer = false
+
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
+ magicSearch.searchForContacts(
+ sourceFlags: MagicSearch.Source.Friends.rawValue | MagicSearch.Source.LdapServers.rawValue)
+
+ if callViewModel.isTransferInsteadCall == true {
+ callViewModel.isTransferInsteadCall = false
+ }
+
+ callViewModel.resetCallView()
+ }
+
+ magicSearch.currentFilterSuggestions = ""
+
+ withAnimation {
+ isShowStartCallFragment.toggle()
+ startCallViewModel.interpretAndStartCall()
+ }
+
+ startCallViewModel.searchField = ""
+ } else {
+ showingDialer = false
+
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
+ magicSearch.searchForContacts(
+ sourceFlags: MagicSearch.Source.Friends.rawValue | MagicSearch.Source.LdapServers.rawValue)
+
+ if callViewModel.isTransferInsteadCall == true {
+ callViewModel.isTransferInsteadCall = false
+ }
+
+ callViewModel.resetCallView()
+ }
+
+ magicSearch.currentFilterSuggestions = ""
+
+ withAnimation {
+ isShowStartCallFragment.toggle()
+ startCallViewModel.interpretAndStartCall()
+ }
+
+ startCallViewModel.searchField = ""
+ }
+ }
+ } label: {
+ Image("phone")
+ .renderingMode(.template)
+ .resizable()
+ .foregroundStyle(.white)
+ .frame(width: 32, height: 32)
+
+ }
+ .frame(width: 90, height: 60)
+ .background(Color.greenSuccess500)
+ .cornerRadius(40)
+ .shadow(color: .black.opacity(0.2), radius: 4)
+
+ Spacer()
+
+ Button {
+ startCallViewModel.searchField = String(startCallViewModel.searchField.dropLast())
+ } label: {
+ Image("backspace-fill")
+ .resizable()
+ .frame(width: 32, height: 32)
+
+ }
+ .frame(width: 60, height: 60)
+ }
+ .padding(.horizontal, 60)
+ .padding(.top, 20)
+ .frame(maxWidth: sharedMainViewModel.maxWidth)
+ }
+
+ Spacer()
+ }
+ .frame(maxWidth: .infinity)
+ .frame(maxHeight: .infinity)
+ }
+ .background(currentCall != nil ? Color.gray600 : Color.gray100)
+ .frame(maxWidth: .infinity)
+ .frame(maxHeight: .infinity)
+ .onRotate { newOrientation in
+ orientation = newOrientation
+ }
+ }
+}
+
+#Preview {
+ DialerBottomSheet(
+ startCallViewModel: StartCallViewModel()
+ , callViewModel: CallViewModel()
+ , isShowStartCallFragment: .constant(false)
+ , showingDialer: .constant(false)
+ , currentCall: nil)
+}
+
+// swiftlint:enable type_body_length
diff --git a/Linphone/UI/Main/History/Fragments/HistoryContactFragment.swift b/Linphone/UI/Main/History/Fragments/HistoryContactFragment.swift
new file mode 100644
index 000000000..ab49963ef
--- /dev/null
+++ b/Linphone/UI/Main/History/Fragments/HistoryContactFragment.swift
@@ -0,0 +1,464 @@
+/*
+ * Copyright (c) 2010-2023 Belledonne Communications SARL.
+ *
+ * This file is part of Linphone
+ *
+ * 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 .
+ */
+
+import SwiftUI
+import UniformTypeIdentifiers
+import linphonesw
+
+// swiftlint:disable type_body_length
+struct HistoryContactFragment: View {
+
+ @State private var orientation = UIDevice.current.orientation
+
+ @ObservedObject var contactsManager = ContactsManager.shared
+ @ObservedObject private var telecomManager = TelecomManager.shared
+ @ObservedObject private var sharedMainViewModel = SharedMainViewModel.shared
+
+ @ObservedObject var contactAvatarModel: ContactAvatarModel
+ @ObservedObject var historyViewModel: HistoryViewModel
+ @ObservedObject var historyListViewModel: HistoryListViewModel
+ @ObservedObject var contactViewModel: ContactViewModel
+ @ObservedObject var editContactViewModel: EditContactViewModel
+
+ @State var isMenuOpen = false
+
+ @Binding var isShowDeleteAllHistoryPopup: Bool
+ @Binding var isShowEditContactFragment: Bool
+ @Binding var indexPage: Int
+
+ var body: some View {
+ NavigationView {
+ if historyViewModel.displayedCall != nil {
+ VStack(spacing: 1) {
+ Rectangle()
+ .foregroundColor(Color.orangeMain500)
+ .edgesIgnoringSafeArea(.top)
+ .frame(height: 0)
+
+ HStack {
+ if !(orientation == .landscapeLeft || orientation == .landscapeRight
+ || UIScreen.main.bounds.size.width > UIScreen.main.bounds.size.height) {
+ Image("caret-left")
+ .renderingMode(.template)
+ .resizable()
+ .foregroundStyle(Color.orangeMain500)
+ .frame(width: 25, height: 25, alignment: .leading)
+ .padding(.all, 10)
+ .padding(.top, 2)
+ .padding(.leading, -10)
+ .onTapGesture {
+ withAnimation {
+ historyViewModel.displayedCall = nil
+ }
+ }
+ }
+
+ Text("Call history")
+ .default_text_style_orange_800(styleSize: 20)
+
+ Spacer()
+
+ Menu {
+ if historyViewModel.displayedCall != nil && !historyViewModel.displayedCall!.isConf {
+ Button {
+ isMenuOpen = false
+
+ if historyViewModel.displayedCall != nil && historyViewModel.displayedCall!.addressFriend != nil {
+ let addressCall = historyViewModel.displayedCall!.addressFriend!.address
+
+ if addressCall != nil {
+ let friendIndex = contactsManager.lastSearch.firstIndex(
+ where: {$0.friend!.addresses.contains(where: {$0.asStringUriOnly() == addressCall!.asStringUriOnly()})})
+ if friendIndex != nil {
+
+ withAnimation {
+ historyViewModel.displayedCall = nil
+ indexPage = 0
+
+ contactViewModel.indexDisplayedFriend = friendIndex
+ }
+ }
+ }
+ } else {
+ withAnimation {
+ historyViewModel.displayedCall = nil
+ indexPage = 0
+
+ isShowEditContactFragment.toggle()
+ editContactViewModel.sipAddresses.removeAll()
+ editContactViewModel.sipAddresses.append(String(historyViewModel.displayedCall?.address.dropFirst(4) ?? ""))
+ editContactViewModel.sipAddresses.append("")
+ }
+ }
+
+ } label: {
+ HStack {
+ Text(historyViewModel.displayedCall!.addressFriend != nil ? "See contact" : "Add to contacts")
+ Spacer()
+ Image(historyViewModel.displayedCall!.addressFriend != nil ? "user-circle" : "plus-circle")
+ .resizable()
+ .frame(width: 25, height: 25, alignment: .leading)
+ .padding(.all, 10)
+ }
+ }
+ }
+
+ Button {
+ isMenuOpen = false
+
+ if historyViewModel.displayedCall != nil && historyViewModel.displayedCall!.isOutgoing {
+ UIPasteboard.general.setValue(
+ historyViewModel.displayedCall!.address.dropFirst(4),
+ forPasteboardType: UTType.plainText.identifier
+ )
+ } else {
+ UIPasteboard.general.setValue(
+ historyViewModel.displayedCall!.address.dropFirst(4),
+ forPasteboardType: UTType.plainText.identifier
+ )
+ }
+
+ ToastViewModel.shared.toastMessage = "Success_address_copied_into_clipboard"
+ ToastViewModel.shared.displayToast.toggle()
+
+ } label: {
+ HStack {
+ Text("Copy SIP address")
+ Spacer()
+ Image("copy")
+ .resizable()
+ .frame(width: 25, height: 25, alignment: .leading)
+ .padding(.all, 10)
+ }
+ }
+
+ Button(role: .destructive) {
+ isMenuOpen = false
+
+ if historyViewModel.displayedCall != nil && historyViewModel.displayedCall!.isOutgoing {
+ historyListViewModel.callLogsAddressToDelete = historyViewModel.displayedCall!.address
+ } else {
+ historyListViewModel.callLogsAddressToDelete = historyViewModel.displayedCall!.address
+ }
+
+ isShowDeleteAllHistoryPopup.toggle()
+
+ } label: {
+ HStack {
+ Text("Delete history")
+ Spacer()
+ Image("trash-simple-red")
+ .resizable()
+ .frame(width: 25, height: 25, alignment: .leading)
+ .padding(.all, 10)
+ }
+ }
+ } label: {
+ Image("dots-three-vertical")
+ .renderingMode(.template)
+ .resizable()
+ .foregroundStyle(Color.orangeMain500)
+ .frame(width: 25, height: 25, alignment: .leading)
+ .padding(.all, 10)
+ }
+ .padding(.leading)
+ .onTapGesture {
+ isMenuOpen = true
+ }
+ }
+ .frame(maxWidth: .infinity)
+ .frame(height: 50)
+ .padding(.horizontal)
+ .padding(.bottom, 4)
+ .background(.white)
+
+ ScrollView {
+ VStack(spacing: 0) {
+ VStack(spacing: 0) {
+ if #unavailable(iOS 16.0) {
+ Rectangle()
+ .foregroundColor(Color.gray100)
+ .frame(height: 7)
+ }
+
+ VStack(spacing: 0) {
+ if historyViewModel.displayedCall != nil && !historyViewModel.displayedCall!.isConf {
+ if historyViewModel.displayedCall!.avatarModel != nil {
+ Avatar(contactAvatarModel: historyViewModel.displayedCall!.avatarModel!, avatarSize: 100)
+ }
+
+ Text(historyViewModel.displayedCall!.addressName)
+ .foregroundStyle(Color.grayMain2c700)
+ .multilineTextAlignment(.center)
+ .default_text_style(styleSize: 14)
+ .frame(maxWidth: .infinity)
+ .padding(.top, 10)
+
+ Text(historyViewModel.displayedCall!.address)
+ .foregroundStyle(Color.grayMain2c700)
+ .multilineTextAlignment(.center)
+ .default_text_style(styleSize: 14)
+ .frame(maxWidth: .infinity)
+ .padding(.top, 5)
+
+ if historyViewModel.displayedCall!.avatarModel != nil {
+ Text(contactAvatarModel.lastPresenceInfo)
+ .foregroundStyle(contactAvatarModel.lastPresenceInfo == "Online"
+ ? Color.greenSuccess500
+ : Color.orangeWarning600)
+ .multilineTextAlignment(.center)
+ .default_text_style_300(styleSize: 12)
+ .frame(maxWidth: .infinity)
+ .frame(height: 20)
+ .padding(.top, 5)
+ } else {
+ Text("")
+ .multilineTextAlignment(.center)
+ .default_text_style_300(styleSize: 12)
+ .frame(maxWidth: .infinity)
+ .frame(height: 20)
+ }
+ } else {
+ VStack {
+ Image("users-three-square")
+ .renderingMode(.template)
+ .resizable()
+ .frame(width: 60, height: 60)
+ .foregroundStyle(Color.grayMain2c600)
+ }
+ .frame(width: 100, height: 100)
+ .background(Color.grayMain2c200)
+ .clipShape(Circle())
+
+ Text(historyViewModel.displayedCall!.subject)
+ .foregroundStyle(Color.grayMain2c700)
+ .multilineTextAlignment(.center)
+ .default_text_style(styleSize: 14)
+ .frame(maxWidth: .infinity)
+ .padding(.top, 10)
+ }
+ }
+ .frame(minHeight: 150)
+ .frame(maxWidth: .infinity)
+ .padding(.top, 10)
+ .padding(.bottom, 2)
+ .background(Color.gray100)
+
+ HStack {
+ Spacer()
+
+ if historyViewModel.displayedCall != nil && !historyViewModel.displayedCall!.isConf {
+ Button(action: {
+ telecomManager.doCallOrJoinConf(address: historyViewModel.displayedCall!.addressLinphone)
+ }, label: {
+ VStack {
+ HStack(alignment: .center) {
+ Image("phone")
+ .renderingMode(.template)
+ .resizable()
+ .foregroundStyle(Color.grayMain2c600)
+ .frame(width: 25, height: 25)
+ }
+ .padding(16)
+ .background(Color.grayMain2c200)
+ .cornerRadius(40)
+
+ Text("Appel")
+ .default_text_style(styleSize: 14)
+ .frame(minWidth: 80)
+ }
+ })
+
+ Spacer()
+
+ Button(action: {
+ contactViewModel.createOneToOneChatRoomWith(remote: historyViewModel.displayedCall!.addressLinphone)
+ }, label: {
+ VStack {
+ HStack(alignment: .center) {
+ Image("chat-teardrop-text")
+ .renderingMode(.template)
+ .resizable()
+ .foregroundStyle(Color.grayMain2c600)
+ .frame(width: 25, height: 25)
+ }
+ .padding(16)
+ .background(Color.grayMain2c200)
+ .cornerRadius(40)
+
+ Text("Message")
+ .default_text_style(styleSize: 14)
+ .frame(minWidth: 80)
+ }
+ })
+
+ Spacer()
+
+ Button(action: {
+ telecomManager.doCallOrJoinConf(address: historyViewModel.displayedCall!.addressLinphone, isVideo: true)
+ }, label: {
+ VStack {
+ HStack(alignment: .center) {
+ Image("video-camera")
+ .renderingMode(.template)
+ .resizable()
+ .foregroundStyle(Color.grayMain2c600)
+ .frame(width: 25, height: 25)
+ }
+ .padding(16)
+ .background(Color.grayMain2c200)
+ .cornerRadius(40)
+
+ Text("Video Call")
+ .default_text_style(styleSize: 14)
+ .frame(minWidth: 80)
+ }
+ })
+ } else {
+ Button(action: {
+ withAnimation {
+ if historyViewModel.displayedCall != nil && historyViewModel.displayedCall!.address.hasPrefix("sip:conference-focus@sip.linphone.org") {
+ do {
+ let meetingAddress = try Factory.Instance.createAddress(addr: historyViewModel.displayedCall!.address)
+
+ telecomManager.meetingWaitingRoomDisplayed = true
+ telecomManager.meetingWaitingRoomSelected = meetingAddress
+ } catch {}
+ } else {
+ telecomManager.doCallOrJoinConf(address: historyViewModel.displayedCall!.addressLinphone)
+ }
+ }
+ }, label: {
+ VStack {
+ HStack(alignment: .center) {
+ Image("users-three-square")
+ .renderingMode(.template)
+ .resizable()
+ .foregroundStyle(Color.grayMain2c600)
+ .frame(width: 25, height: 25)
+ }
+ .padding(16)
+ .background(Color.grayMain2c200)
+ .cornerRadius(40)
+
+ Text("meeting_waiting_room_join")
+ .default_text_style(styleSize: 14)
+ .frame(minWidth: 80)
+ }
+ })
+ }
+
+ Spacer()
+ }
+ .padding(.top, 20)
+ .padding(.bottom, 10)
+ .frame(maxWidth: .infinity)
+ .background(Color.gray100)
+
+ VStack(spacing: 0) {
+
+ let addressFriend = historyViewModel.displayedCall != nil
+ ? historyViewModel.displayedCall!.address : nil
+
+ let callLogsFilter = historyListViewModel.callLogs.filter({ $0.address == addressFriend})
+
+ ForEach(0...
+ */
+
+import SwiftUI
+
+struct HistoryFragment: View {
+ private var idiom: UIUserInterfaceIdiom { UIDevice.current.userInterfaceIdiom }
+
+ @ObservedObject var historyListViewModel: HistoryListViewModel
+ @ObservedObject var historyViewModel: HistoryViewModel
+ @ObservedObject var contactViewModel: ContactViewModel
+ @ObservedObject var editContactViewModel: EditContactViewModel
+
+ @State private var showingSheet = false
+ @Binding var index: Int
+ @Binding var isShowEditContactFragment: Bool
+ @Binding var text: String
+
+ var body: some View {
+ ZStack {
+ if #available(iOS 16.0, *), idiom != .pad {
+ HistoryListFragment(historyListViewModel: historyListViewModel, historyViewModel: historyViewModel, showingSheet: $showingSheet, text: $text)
+ .sheet(isPresented: $showingSheet) {
+ HistoryListBottomSheet(
+ historyViewModel: historyViewModel,
+ contactViewModel: contactViewModel,
+ editContactViewModel: editContactViewModel,
+ historyListViewModel: historyListViewModel,
+ showingSheet: $showingSheet,
+ index: $index,
+ isShowEditContactFragment: $isShowEditContactFragment
+ )
+ .presentationDetents([.fraction(0.2)])
+ }
+ } else {
+ HistoryListFragment(historyListViewModel: historyListViewModel, historyViewModel: historyViewModel, showingSheet: $showingSheet, text: $text)
+ .halfSheet(showSheet: $showingSheet) {
+ HistoryListBottomSheet(
+ historyViewModel: historyViewModel,
+ contactViewModel: contactViewModel,
+ editContactViewModel: editContactViewModel,
+ historyListViewModel: historyListViewModel,
+ showingSheet: $showingSheet,
+ index: $index,
+ isShowEditContactFragment: $isShowEditContactFragment
+ )
+ } onDismiss: {}
+ }
+ }
+ }
+}
+
+#Preview {
+ HistoryFragment(
+ historyListViewModel: HistoryListViewModel(),
+ historyViewModel: HistoryViewModel(),
+ contactViewModel: ContactViewModel(),
+ editContactViewModel: EditContactViewModel(),
+ index: .constant(1),
+ isShowEditContactFragment: .constant(false),
+ text: .constant("")
+ )
+}
diff --git a/Linphone/UI/Main/History/Fragments/HistoryListBottomSheet.swift b/Linphone/UI/Main/History/Fragments/HistoryListBottomSheet.swift
new file mode 100644
index 000000000..19705fcd4
--- /dev/null
+++ b/Linphone/UI/Main/History/Fragments/HistoryListBottomSheet.swift
@@ -0,0 +1,236 @@
+/*
+ * 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 .
+ */
+
+import SwiftUI
+import UniformTypeIdentifiers
+
+struct HistoryListBottomSheet: View {
+
+ @Environment(\.dismiss) var dismiss
+
+ private var idiom: UIUserInterfaceIdiom { UIDevice.current.userInterfaceIdiom }
+
+ @ObservedObject private var sharedMainViewModel = SharedMainViewModel.shared
+ @ObservedObject var contactsManager = ContactsManager.shared
+
+ @ObservedObject var historyViewModel: HistoryViewModel
+ @ObservedObject var contactViewModel: ContactViewModel
+ @ObservedObject var editContactViewModel: EditContactViewModel
+ @ObservedObject var historyListViewModel: HistoryListViewModel
+
+ @State private var orientation = UIDevice.current.orientation
+
+ @Binding var showingSheet: Bool
+ @Binding var index: Int
+ @Binding var isShowEditContactFragment: Bool
+
+ var body: some View {
+ VStack(alignment: .leading) {
+ if idiom != .pad && (orientation == .landscapeLeft
+ || orientation == .landscapeRight
+ || UIScreen.main.bounds.size.width > UIScreen.main.bounds.size.height) {
+ Spacer()
+ HStack {
+ Spacer()
+ Button("Close") {
+ if #available(iOS 16.0, *) {
+ showingSheet.toggle()
+ } else {
+ showingSheet.toggle()
+ dismiss()
+ }
+ }
+ }
+ .padding(.trailing)
+ }
+
+ Spacer()
+ Button {
+
+ if #available(iOS 16.0, *) {
+ if idiom != .pad {
+ showingSheet.toggle()
+ } else {
+ showingSheet.toggle()
+ dismiss()
+ }
+ } else {
+ showingSheet.toggle()
+ dismiss()
+ }
+
+ index = 0
+
+ if historyViewModel.selectedCall != nil && historyViewModel.selectedCall!.addressFriend != nil {
+ let addressCall = historyViewModel.selectedCall!.address
+
+ let friendIndex = contactsManager.lastSearch.firstIndex(where: {$0.friend!.addresses.contains(where: {$0.asStringUriOnly() == addressCall})})
+ if friendIndex != nil {
+ withAnimation {
+ contactViewModel.indexDisplayedFriend = friendIndex
+ }
+ }
+ } else if historyViewModel.selectedCall != nil {
+ let addressCall = historyViewModel.selectedCall!.address
+
+ withAnimation {
+ isShowEditContactFragment.toggle()
+ editContactViewModel.sipAddresses.removeAll()
+ editContactViewModel.sipAddresses.append(String(addressCall.dropFirst(4)))
+ editContactViewModel.sipAddresses.append("")
+ }
+ }
+ } label: {
+ HStack {
+ if historyViewModel.selectedCall != nil && historyViewModel.selectedCall!.addressFriend != nil {
+ Image("user-circle")
+ .renderingMode(.template)
+ .resizable()
+ .foregroundStyle(Color.grayMain2c500)
+ .frame(width: 25, height: 25, alignment: .leading)
+ .padding(.all, 10)
+ Text("See contact")
+ .default_text_style(styleSize: 16)
+ Spacer()
+ } else {
+ Image("plus-circle")
+ .renderingMode(.template)
+ .resizable()
+ .foregroundStyle(Color.grayMain2c500)
+ .frame(width: 25, height: 25, alignment: .leading)
+ .padding(.all, 10)
+ Text("Add the contact")
+ .default_text_style(styleSize: 16)
+ Spacer()
+ }
+ }
+ .frame(maxHeight: .infinity)
+ }
+ .padding(.horizontal, 30)
+ .background(Color.gray100)
+
+ VStack {
+ Divider()
+ }
+ .frame(maxWidth: .infinity)
+
+ Button {
+ if historyViewModel.selectedCall != nil && historyViewModel.selectedCall!.isOutgoing {
+ UIPasteboard.general.setValue(
+ historyViewModel.selectedCall!.address.dropFirst(4),
+ forPasteboardType: UTType.plainText.identifier
+ )
+ } else {
+ UIPasteboard.general.setValue(
+ historyViewModel.selectedCall!.address.dropFirst(4),
+ forPasteboardType: UTType.plainText.identifier
+ )
+ }
+
+ if #available(iOS 16.0, *) {
+ if idiom != .pad {
+ showingSheet.toggle()
+ } else {
+ showingSheet.toggle()
+ dismiss()
+ }
+ } else {
+ showingSheet.toggle()
+ dismiss()
+ }
+
+ ToastViewModel.shared.toastMessage = "Success_address_copied_into_clipboard"
+ ToastViewModel.shared.displayToast.toggle()
+
+ } label: {
+ HStack {
+ Image("copy")
+ .renderingMode(.template)
+ .resizable()
+ .foregroundStyle(Color.grayMain2c500)
+ .frame(width: 25, height: 25, alignment: .leading)
+ .padding(.all, 10)
+ Text("Copy SIP address")
+ .default_text_style(styleSize: 16)
+ Spacer()
+ }
+ .frame(maxHeight: .infinity)
+ }
+ .padding(.horizontal, 30)
+ .background(Color.gray100)
+
+ VStack {
+ Divider()
+ }
+ .frame(maxWidth: .infinity)
+
+ Button {
+ if historyViewModel.selectedCall != nil {
+ historyListViewModel.removeCallLog(historyModel: historyViewModel.selectedCall!)
+ }
+
+ if #available(iOS 16.0, *) {
+ if idiom != .pad {
+ showingSheet.toggle()
+ } else {
+ showingSheet.toggle()
+ dismiss()
+ }
+ } else {
+ showingSheet.toggle()
+ dismiss()
+ }
+ } label: {
+ HStack {
+ Image("trash-simple")
+ .renderingMode(.template)
+ .resizable()
+ .foregroundStyle(Color.redDanger500)
+ .frame(width: 25, height: 25, alignment: .leading)
+ .padding(.all, 10)
+ Text("Delete")
+ .foregroundStyle(Color.redDanger500)
+ .default_text_style(styleSize: 16)
+ Spacer()
+ }
+ .frame(maxHeight: .infinity)
+ }
+ .padding(.horizontal, 30)
+ .background(Color.gray100)
+
+ }
+ .background(Color.gray100)
+ .frame(maxWidth: .infinity)
+ .onRotate { newOrientation in
+ orientation = newOrientation
+ }
+ }
+}
+
+#Preview {
+ HistoryListBottomSheet(
+ historyViewModel: HistoryViewModel(),
+ contactViewModel: ContactViewModel(),
+ editContactViewModel: EditContactViewModel(),
+ historyListViewModel: HistoryListViewModel(),
+ showingSheet: .constant(false),
+ index: .constant(1),
+ isShowEditContactFragment: .constant(false)
+ )
+}
diff --git a/Linphone/UI/Main/History/Fragments/HistoryListFragment.swift b/Linphone/UI/Main/History/Fragments/HistoryListFragment.swift
new file mode 100644
index 000000000..753489b06
--- /dev/null
+++ b/Linphone/UI/Main/History/Fragments/HistoryListFragment.swift
@@ -0,0 +1,182 @@
+/*
+ * 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 .
+ */
+
+// swiftlint:disable line_length
+
+import SwiftUI
+import linphonesw
+
+struct HistoryListFragment: View {
+
+ @ObservedObject var contactsManager = ContactsManager.shared
+ @ObservedObject private var telecomManager = TelecomManager.shared
+
+ @ObservedObject var historyListViewModel: HistoryListViewModel
+ @ObservedObject var historyViewModel: HistoryViewModel
+
+ @Binding var showingSheet: Bool
+ @Binding var text: String
+
+ var body: some View {
+ VStack {
+ List {
+ ForEach(0.. 1
+ ? historyListViewModel.callLogs[index].addressName.components(separatedBy: " ")[1]
+ : ""))
+ .resizable()
+ .frame(width: 50, height: 50)
+ .clipShape(Circle())
+ } else {
+ VStack {
+ Image("profil-picture-default")
+ .renderingMode(.template)
+ .resizable()
+ .frame(width: 28, height: 28)
+ .foregroundStyle(Color.grayMain2c600)
+ }
+ .frame(width: 50, height: 50)
+ .background(Color.grayMain2c200)
+ .clipShape(Circle())
+ }
+ }
+ } else {
+ VStack {
+ Image("users-three-square")
+ .renderingMode(.template)
+ .resizable()
+ .frame(width: 28, height: 28)
+ .foregroundStyle(Color.grayMain2c600)
+ }
+ .frame(width: 50, height: 50)
+ .background(Color.grayMain2c200)
+ .clipShape(Circle())
+ }
+
+ VStack(spacing: 0) {
+ Spacer()
+ if !historyListViewModel.callLogs[index].isConf {
+ Text(historyListViewModel.callLogs[index].addressName)
+ .default_text_style(styleSize: 14)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .lineLimit(1)
+ } else {
+ Text(historyListViewModel.callLogs[index].subject)
+ .default_text_style(styleSize: 14)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .lineLimit(1)
+ }
+
+ HStack {
+ Image(historyListViewModel.getCallIconResId(callStatus: historyListViewModel.callLogs[index].status, isOutgoing: historyListViewModel.callLogs[index].isOutgoing))
+ .resizable()
+ .frame(
+ width: historyListViewModel.getCallIconResId(callStatus: historyListViewModel.callLogs[index].status, isOutgoing: historyListViewModel.callLogs[index].isOutgoing).contains("rejected") ? 12 : 8,
+ height: historyListViewModel.getCallIconResId(callStatus: historyListViewModel.callLogs[index].status, isOutgoing: historyListViewModel.callLogs[index].isOutgoing).contains("rejected") ? 6 : 8)
+ Text(historyListViewModel.getCallTime(startDate: historyListViewModel.callLogs[index].startDate))
+ .default_text_style_300(styleSize: 12)
+ .frame(maxWidth: .infinity, alignment: .leading)
+
+ Spacer()
+ }
+
+ Spacer()
+ }
+
+ if !historyListViewModel.callLogs[index].isConf {
+ Image("phone")
+ .resizable()
+ .frame(width: 25, height: 25)
+ .padding(.all, 10)
+ .padding(.trailing, 5)
+ .highPriorityGesture(
+ TapGesture()
+ .onEnded { _ in
+ withAnimation {
+ doCall(index: index)
+ historyViewModel.displayedCall = nil
+ }
+ }
+ )
+ }
+ }
+ }
+ .frame(height: 50)
+ .buttonStyle(.borderless)
+ .listRowInsets(EdgeInsets(top: 6, leading: 20, bottom: 6, trailing: 20))
+ .listRowSeparator(.hidden)
+ .background(.white)
+ .onTapGesture {
+ withAnimation {
+ historyViewModel.displayedCall = historyListViewModel.callLogs[index]
+ }
+ }
+ .onLongPressGesture(minimumDuration: 0.2) {
+ historyViewModel.selectedCall = historyListViewModel.callLogs[index]
+ showingSheet.toggle()
+ }
+ }
+ }
+ .safeAreaInset(edge: .top, content: {
+ Spacer()
+ .frame(height: 12)
+ })
+ .listStyle(.plain)
+ .overlay(
+ VStack {
+ if historyListViewModel.callLogs.isEmpty {
+ Spacer()
+ Image("illus-belledonne")
+ .resizable()
+ .scaledToFit()
+ .clipped()
+ .padding(.all)
+ Text(!text.isEmpty ? "list_filter_no_result_found" : "history_list_empty_history")
+ .default_text_style_800(styleSize: 16)
+ .multilineTextAlignment(.center)
+ Spacer()
+ Spacer()
+ }
+ }
+ .padding(.all)
+ )
+ }
+ .navigationTitle("")
+ .navigationBarHidden(true)
+ }
+
+ func doCall(index: Int) {
+ telecomManager.doCallOrJoinConf(address: historyListViewModel.callLogs[index].addressLinphone)
+ }
+}
+
+#Preview {
+ HistoryListFragment(historyListViewModel: HistoryListViewModel(), historyViewModel: HistoryViewModel(), showingSheet: .constant(false), text: .constant(""))
+}
+
+// swiftlint:enable line_length
diff --git a/Linphone/UI/Main/History/Fragments/StartCallFragment.swift b/Linphone/UI/Main/History/Fragments/StartCallFragment.swift
new file mode 100644
index 000000000..e15c6f520
--- /dev/null
+++ b/Linphone/UI/Main/History/Fragments/StartCallFragment.swift
@@ -0,0 +1,488 @@
+/*
+ * 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 .
+ */
+
+import SwiftUI
+import linphonesw
+
+// swiftlint:disable type_body_length
+struct StartCallFragment: View {
+
+ @ObservedObject private var sharedMainViewModel = SharedMainViewModel.shared
+
+ @ObservedObject var contactsManager = ContactsManager.shared
+ @ObservedObject var magicSearch = MagicSearchSingleton.shared
+ @ObservedObject private var telecomManager = TelecomManager.shared
+
+ @ObservedObject var callViewModel: CallViewModel
+ @ObservedObject var startCallViewModel: StartCallViewModel
+
+ @Binding var isShowStartCallFragment: Bool
+ @Binding var showingDialer: Bool
+
+ @FocusState var isSearchFieldFocused: Bool
+ @State private var delayedColor = Color.white
+
+ @FocusState var isMessageTextFocused: Bool
+
+ var resetCallView: () -> Void
+
+ var body: some View {
+ NavigationView {
+ ZStack {
+ VStack(spacing: 1) {
+
+ Rectangle()
+ .foregroundColor(delayedColor)
+ .edgesIgnoringSafeArea(.top)
+ .frame(height: 0)
+ .task(delayColor)
+
+ HStack {
+ Image("caret-left")
+ .renderingMode(.template)
+ .resizable()
+ .foregroundStyle(Color.orangeMain500)
+ .frame(width: 25, height: 25, alignment: .leading)
+ .padding(.all, 10)
+ .padding(.top, 2)
+ .padding(.leading, -10)
+ .onTapGesture {
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
+ magicSearch.searchForContacts(
+ sourceFlags: MagicSearch.Source.Friends.rawValue | MagicSearch.Source.LdapServers.rawValue)
+
+ if callViewModel.isTransferInsteadCall == true {
+ callViewModel.isTransferInsteadCall = false
+ }
+
+ resetCallView()
+ }
+
+ startCallViewModel.searchField = ""
+ magicSearch.currentFilterSuggestions = ""
+ delayColorDismiss()
+ withAnimation {
+ isShowStartCallFragment.toggle()
+ }
+ }
+
+ Text(!callViewModel.isTransferInsteadCall ? "history_call_start_title" : "Transfer call to")
+ .multilineTextAlignment(.leading)
+ .default_text_style_orange_800(styleSize: 16)
+
+ Spacer()
+
+ }
+ .frame(maxWidth: .infinity)
+ .frame(height: 50)
+ .padding(.horizontal)
+ .padding(.bottom, 4)
+ .background(.white)
+
+ VStack(spacing: 0) {
+ ZStack(alignment: .trailing) {
+ TextField("history_call_start_search_bar_filter_hint", text: $startCallViewModel.searchField)
+ .default_text_style(styleSize: 15)
+ .frame(height: 25)
+ .focused($isSearchFieldFocused)
+ .padding(.horizontal, 30)
+ .onChange(of: startCallViewModel.searchField) { newValue in
+ magicSearch.currentFilterSuggestions = newValue
+ magicSearch.searchForSuggestions()
+ }
+ .simultaneousGesture(TapGesture().onEnded {
+ showingDialer = false
+ })
+
+ HStack {
+ Button(action: {
+ }, label: {
+ Image("magnifying-glass")
+ .renderingMode(.template)
+ .resizable()
+ .foregroundStyle(Color.grayMain2c500)
+ .frame(width: 25, height: 25)
+ })
+
+ Spacer()
+
+ if startCallViewModel.searchField.isEmpty {
+ Button(action: {
+ if !showingDialer {
+ isSearchFieldFocused = false
+
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
+ showingDialer = true
+ }
+ } else {
+ showingDialer = false
+
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
+ isSearchFieldFocused = true
+ }
+ }
+ }, label: {
+ Image(!showingDialer ? "dialer" : "keyboard")
+ .renderingMode(.template)
+ .resizable()
+ .foregroundStyle(Color.grayMain2c500)
+ .frame(width: 25, height: 25)
+ })
+ } else {
+ Button(action: {
+ startCallViewModel.searchField = ""
+ magicSearch.currentFilterSuggestions = ""
+ magicSearch.searchForSuggestions()
+ }, label: {
+ Image("x")
+ .renderingMode(.template)
+ .resizable()
+ .foregroundStyle(Color.grayMain2c500)
+ .frame(width: 25, height: 25)
+ })
+ }
+ }
+ }
+ .padding(.horizontal, 15)
+ .padding(.vertical, 10)
+ .cornerRadius(60)
+ .overlay(
+ RoundedRectangle(cornerRadius: 60)
+ .inset(by: 0.5)
+ .stroke(isSearchFieldFocused ? Color.orangeMain500 : Color.gray200, lineWidth: 1)
+ )
+ .padding(.vertical)
+ .padding(.horizontal)
+
+ NavigationLink(destination: {
+ StartGroupCallFragment(startCallViewModel: startCallViewModel)
+ }, label: {
+ HStack {
+ HStack(alignment: .center) {
+ Image("meetings")
+ .renderingMode(.template)
+ .resizable()
+ .foregroundStyle(.white)
+ .frame(width: 20, height: 20, alignment: .leading)
+ }
+ .padding(16)
+ .background(Color.orangeMain500)
+ .cornerRadius(40)
+
+ Text("history_call_start_create_group_call")
+ .foregroundStyle(.black)
+ .default_text_style_800(styleSize: 16)
+
+ Spacer()
+
+ Image("caret-right")
+ .renderingMode(.template)
+ .resizable()
+ .foregroundStyle(Color.grayMain2c500)
+ .frame(width: 25, height: 25, alignment: .leading)
+ }
+ })
+ .padding(.vertical, 10)
+ .padding(.horizontal, 20)
+ .background(
+ LinearGradient(gradient: Gradient(colors: [.grayMain2c100, .white]), startPoint: .leading, endPoint: .trailing)
+ .padding(.vertical, 10)
+ .padding(.horizontal, 40)
+ )
+
+ ScrollView {
+ if !ContactsManager.shared.lastSearch.isEmpty {
+ HStack(alignment: .center) {
+ Text("All contacts")
+ .default_text_style_800(styleSize: 16)
+
+ Spacer()
+ }
+ .padding(.vertical, 10)
+ .padding(.horizontal, 16)
+ }
+
+ ContactsListFragment(contactViewModel: ContactViewModel(), contactsListViewModel: ContactsListViewModel(), showingSheet: .constant(false)
+ , startCallFunc: { addr in
+ if callViewModel.isTransferInsteadCall {
+ showingDialer = false
+
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
+ magicSearch.searchForContacts(
+ sourceFlags: MagicSearch.Source.Friends.rawValue | MagicSearch.Source.LdapServers.rawValue)
+
+ if callViewModel.isTransferInsteadCall == true {
+ callViewModel.isTransferInsteadCall = false
+ }
+
+ resetCallView()
+ }
+
+ startCallViewModel.searchField = ""
+ magicSearch.currentFilterSuggestions = ""
+ delayColorDismiss()
+
+ withAnimation {
+ isShowStartCallFragment.toggle()
+ callViewModel.blindTransferCallTo(toAddress: addr)
+ }
+ } else {
+ showingDialer = false
+
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
+ magicSearch.searchForContacts(
+ sourceFlags: MagicSearch.Source.Friends.rawValue | MagicSearch.Source.LdapServers.rawValue)
+
+ if callViewModel.isTransferInsteadCall == true {
+ callViewModel.isTransferInsteadCall = false
+ }
+
+ resetCallView()
+ }
+
+ startCallViewModel.searchField = ""
+ magicSearch.currentFilterSuggestions = ""
+ delayColorDismiss()
+
+ withAnimation {
+ isShowStartCallFragment.toggle()
+ telecomManager.doCallOrJoinConf(address: addr)
+ }
+ }
+ })
+ .padding(.horizontal, 16)
+
+ if !contactsManager.lastSearchSuggestions.isEmpty {
+ HStack(alignment: .center) {
+ Text("Suggestions")
+ .default_text_style_800(styleSize: 16)
+
+ Spacer()
+ }
+ .padding(.vertical, 10)
+ .padding(.horizontal, 16)
+
+ suggestionsList
+ }
+ }
+ }
+ .frame(maxWidth: .infinity)
+ }
+ .background(.white)
+
+ if !startCallViewModel.participants.isEmpty {
+ startCallPopup
+ .background(.black.opacity(0.65))
+ .onAppear {
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.6) {
+ isMessageTextFocused = true
+ }
+ }
+ }
+
+ if startCallViewModel.operationInProgress {
+ PopupLoadingView()
+ .background(.black.opacity(0.65))
+ .onDisappear {
+ isShowStartCallFragment.toggle()
+ }
+ }
+ }
+ .navigationBarHidden(true)
+ }
+ }
+
+ @Sendable private func delayColor() async {
+ try? await Task.sleep(nanoseconds: 250_000_000)
+ delayedColor = Color.orangeMain500
+ }
+
+ func delayColorDismiss() {
+ Task {
+ try? await Task.sleep(nanoseconds: 80_000_000)
+ delayedColor = .white
+ }
+ }
+
+ var suggestionsList: some View {
+ ForEach(0...
+ */
+
+import SwiftUI
+
+struct StartGroupCallFragment: View {
+ @ObservedObject var startCallViewModel: StartCallViewModel
+ @State var addParticipantsViewModel = AddParticipantsViewModel()
+
+ var body: some View {
+ AddParticipantsFragment(addParticipantsViewModel: addParticipantsViewModel, confirmAddParticipantsFunc: startCallViewModel.addParticipants)
+ .onAppear {
+ addParticipantsViewModel.participantsToAdd = startCallViewModel.participants
+ }
+ }
+}
+
+#Preview {
+ StartGroupCallFragment(startCallViewModel: StartCallViewModel())
+}
diff --git a/Linphone/UI/Main/History/HistoryView.swift b/Linphone/UI/Main/History/HistoryView.swift
new file mode 100644
index 000000000..b60bbadba
--- /dev/null
+++ b/Linphone/UI/Main/History/HistoryView.swift
@@ -0,0 +1,83 @@
+/*
+ * Copyright (c) 2010-2023 Belledonne Communications SARL.
+ *
+ * This file is part of Linphone
+ *
+ * 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 .
+ */
+
+import SwiftUI
+import linphonesw
+
+struct HistoryView: View {
+
+ @ObservedObject var historyListViewModel: HistoryListViewModel
+ @ObservedObject var historyViewModel: HistoryViewModel
+ @ObservedObject var contactViewModel: ContactViewModel
+ @ObservedObject var editContactViewModel: EditContactViewModel
+
+ @Binding var index: Int
+ @Binding var isShowStartCallFragment: Bool
+ @Binding var isShowEditContactFragment: Bool
+ @Binding var text: String
+
+ var body: some View {
+ NavigationView {
+ ZStack(alignment: .bottomTrailing) {
+ HistoryFragment(
+ historyListViewModel: historyListViewModel,
+ historyViewModel: historyViewModel,
+ contactViewModel: contactViewModel,
+ editContactViewModel: editContactViewModel,
+ index: $index,
+ isShowEditContactFragment: $isShowEditContactFragment,
+ text: $text
+ )
+
+ Button {
+ withAnimation {
+ isShowStartCallFragment.toggle()
+ }
+
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
+ MagicSearchSingleton.shared.searchForSuggestions()
+ }
+ } label: {
+ Image("phone-plus")
+ .renderingMode(.template)
+ .foregroundStyle(.white)
+ .padding()
+ .background(Color.orangeMain500)
+ .clipShape(Circle())
+ .shadow(color: .black.opacity(0.2), radius: 4)
+
+ }
+ .padding()
+ }
+ }
+ .navigationViewStyle(.stack)
+ }
+}
+
+#Preview {
+ HistoryFragment(
+ historyListViewModel: HistoryListViewModel(),
+ historyViewModel: HistoryViewModel(),
+ contactViewModel: ContactViewModel(),
+ editContactViewModel: EditContactViewModel(),
+ index: .constant(1),
+ isShowEditContactFragment: .constant(false),
+ text: .constant("")
+ )
+}
diff --git a/Linphone/UI/Main/History/Model/HistoryModel.swift b/Linphone/UI/Main/History/Model/HistoryModel.swift
new file mode 100644
index 000000000..92cd1a3eb
--- /dev/null
+++ b/Linphone/UI/Main/History/Model/HistoryModel.swift
@@ -0,0 +1,142 @@
+/*
+ * Copyright (c) 2010-2023 Belledonne Communications SARL.
+ *
+ * This file is part of Linphone
+ *
+ * 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 .
+ */
+
+import Foundation
+import linphonesw
+
+class HistoryModel: ObservableObject {
+
+ private var coreContext = CoreContext.shared
+
+ static let TAG = "[History Model]"
+
+ var callLog: CallLog
+
+ var id: String
+ @Published var subject: String
+ @Published var isConf: Bool
+ @Published var addressLinphone: Address
+ @Published var address: String
+ @Published var addressName: String
+ @Published var isOutgoing: Bool
+ @Published var status: Call.Status
+ @Published var startDate: time_t
+ @Published var duration: Int
+ @Published var addressFriend: Friend?
+ @Published var avatarModel: ContactAvatarModel?
+
+ init(callLog: CallLog) {
+ self.callLog = callLog
+ self.id = ""
+ self.subject = ""
+ self.isConf = false
+
+ self.addressLinphone = callLog.dir == .Outgoing && callLog.toAddress != nil ? callLog.toAddress! : callLog.fromAddress!
+ self.address = ""
+
+ self.addressName = ""
+
+ self.isOutgoing = false
+
+ self.status = .Success
+
+ self.startDate = 0
+
+ self.duration = 0
+
+ self.initValue(callLog: callLog)
+ }
+
+ func initValue(callLog: CallLog) {
+ coreContext.doOnCoreQueue { _ in
+ let callLogTmp = callLog
+ let idTmp = callLog.callId ?? ""
+ let subjectTmp = callLog.conferenceInfo != nil && callLog.conferenceInfo!.subject != nil ? callLog.conferenceInfo!.subject! : ""
+ let isConfTmp = callLog.conferenceInfo != nil
+
+ let addressLinphoneTmp = callLog.dir == .Outgoing && callLog.toAddress != nil ? callLog.toAddress! : callLog.fromAddress!
+
+ let addressNameTmp = callLog.conferenceInfo != nil && callLog.conferenceInfo!.subject != nil
+ ? callLog.conferenceInfo!.subject!
+ : (addressLinphoneTmp.username != nil ? addressLinphoneTmp.username ?? "" : addressLinphoneTmp.displayName ?? "")
+
+ let addressTmp = addressLinphoneTmp.asStringUriOnly()
+
+ let isOutgoingTmp = callLog.dir == .Outgoing
+
+ let statusTmp = callLog.status
+
+ let startDateTmp = callLog.startDate
+
+ let durationTmp = callLog.duration
+
+ DispatchQueue.main.async {
+ self.callLog = callLogTmp
+ self.id = idTmp
+ self.subject = subjectTmp
+ self.isConf = isConfTmp
+
+ self.addressLinphone = addressLinphoneTmp
+ self.address = addressTmp
+
+ self.addressName = addressNameTmp
+
+ self.isOutgoing = isOutgoingTmp
+
+ self.status = statusTmp
+
+ self.startDate = startDateTmp
+
+ self.duration = durationTmp
+ }
+
+ self.refreshAvatarModel()
+ }
+ }
+
+ func refreshAvatarModel() {
+ coreContext.doOnCoreQueue { _ in
+ let addressFriendTmp = ContactsManager.shared.getFriendWithAddress(
+ address: self.callLog.dir == .Outgoing ? self.callLog.toAddress! : self.callLog.fromAddress!
+ )
+ if addressFriendTmp != nil {
+ self.addressFriend = addressFriendTmp
+
+ let addressNameTmp = self.addressName
+
+ let avatarModelTmp = addressFriendTmp != nil
+ ? ContactsManager.shared.avatarListModel.first(where: {
+ $0.friend!.name == addressFriendTmp!.name
+ && $0.friend!.address!.asStringUriOnly() == addressFriendTmp!.address!.asStringUriOnly()
+ }) ?? ContactAvatarModel(friend: nil, name: self.addressName, address: self.address, withPresence: false)
+ : ContactAvatarModel(friend: nil, name: self.addressName, address: self.address, withPresence: false)
+
+ DispatchQueue.main.async {
+ self.addressFriend = addressFriendTmp
+ self.addressName = addressFriendTmp!.name ?? addressNameTmp
+ self.avatarModel = avatarModelTmp
+ }
+ } else {
+ DispatchQueue.main.async {
+ self.avatarModel = ContactAvatarModel(friend: nil, name: self.addressName, address: self.address, withPresence: false)
+ }
+ }
+ }
+ }
+}
diff --git a/Linphone/UI/Main/History/ViewModel/HistoryListViewModel.swift b/Linphone/UI/Main/History/ViewModel/HistoryListViewModel.swift
new file mode 100644
index 000000000..bc3cd9798
--- /dev/null
+++ b/Linphone/UI/Main/History/ViewModel/HistoryListViewModel.swift
@@ -0,0 +1,253 @@
+/*
+ * 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 .
+ */
+
+import linphonesw
+import Combine
+
+class HistoryListViewModel: ObservableObject {
+
+ private var coreContext = CoreContext.shared
+
+ @Published var callLogs: [HistoryModel] = []
+ var callLogsTmp: [HistoryModel] = []
+
+ var callLogsAddressToDelete = ""
+ var callLogCoreDelegate: CoreDelegate?
+
+ @Published var missedCallsCount: Int = 0
+
+ init() {
+ computeCallLogsList()
+ updateMissedCallsCount()
+ }
+
+ func computeCallLogsList() {
+ coreContext.doOnCoreQueue { core in
+ let account = core.defaultAccount
+ let logs = account?.callLogs != nil ? account!.callLogs : core.callLogs
+
+ var callLogsBis: [HistoryModel] = []
+ var callLogsTmpBis: [HistoryModel] = []
+
+ logs.forEach { log in
+ let history = HistoryModel(callLog: log)
+ callLogsBis.append(history)
+ callLogsTmpBis.append(history)
+ }
+
+ DispatchQueue.main.async {
+ self.callLogs.removeAll()
+ self.callLogsTmp.removeAll()
+
+ self.callLogs = callLogsBis
+ self.callLogsTmp = callLogsTmpBis
+ }
+
+ self.callLogCoreDelegate = CoreDelegateStub(onCallLogUpdated: { (_: Core, _: CallLog) in
+ let account = core.defaultAccount
+ let logs = account?.callLogs != nil ? account!.callLogs : core.callLogs
+
+ var callLogsBis: [HistoryModel] = []
+ var callLogsTmpBis: [HistoryModel] = []
+
+ logs.forEach { log in
+ let history = HistoryModel(callLog: log)
+ callLogsBis.append(history)
+ callLogsTmpBis.append(history)
+ }
+
+ DispatchQueue.main.async {
+ self.callLogs.removeAll()
+ self.callLogsTmp.removeAll()
+
+ self.callLogs = callLogsBis
+ self.callLogsTmp = callLogsTmpBis
+ }
+
+ self.updateMissedCallsCount()
+ })
+ core.addDelegate(delegate: self.callLogCoreDelegate!)
+ }
+ }
+
+ func resetMissedCallsCount() {
+ coreContext.doOnCoreQueue { core in
+ let account = core.defaultAccount
+ if account != nil {
+ account?.resetMissedCallsCount()
+ DispatchQueue.main.async {
+ self.missedCallsCount = 0
+ }
+ } else {
+ DispatchQueue.main.async {
+ self.missedCallsCount = 0
+ }
+ }
+ }
+ }
+
+ func updateMissedCallsCount() {
+ coreContext.doOnCoreQueue { core in
+ let account = core.defaultAccount
+ if account != nil {
+ let count = account?.missedCallsCount != nil ? account!.missedCallsCount : core.missedCallsCount
+
+ DispatchQueue.main.async {
+ self.missedCallsCount = count
+ }
+ } else {
+ DispatchQueue.main.async {
+ self.missedCallsCount = 0
+ }
+ }
+ }
+ }
+
+ func getCallIconResId(callStatus: Call.Status, isOutgoing: Bool) -> String {
+ switch callStatus {
+ case Call.Status.Missed:
+ if isOutgoing {
+ "outgoing-call-missed"
+ } else {
+ "incoming-call-missed"
+ }
+
+ case Call.Status.Success:
+ if isOutgoing {
+ "outgoing-call"
+ } else {
+ "incoming-call"
+ }
+
+ default:
+ if isOutgoing {
+ "outgoing-call-rejected"
+ } else {
+ "incoming-call-rejected"
+ }
+ }
+ }
+
+ func getCallText(callStatus: Call.Status, isOutgoing: Bool) -> String {
+ switch callStatus {
+ case Call.Status.Missed:
+ if isOutgoing {
+ "Outgoing Call"
+ } else {
+ "Missed Call"
+ }
+
+ case Call.Status.Success:
+ if isOutgoing {
+ "Outgoing Call"
+ } else {
+ "Incoming Call"
+ }
+
+ default:
+ if isOutgoing {
+ "Outgoing Call"
+ } else {
+ "Incoming Call"
+ }
+ }
+ }
+
+ func getCallTime(startDate: time_t) -> String {
+ let timeInterval = TimeInterval(startDate)
+
+ let myNSDate = Date(timeIntervalSince1970: timeInterval)
+
+ if Calendar.current.isDateInToday(myNSDate) {
+ let formatter = DateFormatter()
+ formatter.dateFormat = Locale.current.identifier == "fr_FR" ? "HH:mm" : "h:mm a"
+ return "Today | " + formatter.string(from: myNSDate)
+ } else if Calendar.current.isDateInYesterday(myNSDate) {
+ let formatter = DateFormatter()
+ formatter.dateFormat = Locale.current.identifier == "fr_FR" ? "HH:mm" : "h:mm a"
+ return "Yesterday | " + formatter.string(from: myNSDate)
+ } else if Calendar.current.isDate(myNSDate, equalTo: .now, toGranularity: .year) {
+ let formatter = DateFormatter()
+ formatter.dateFormat = Locale.current.identifier == "fr_FR" ? "dd/MM | HH:mm" : "MM/dd | h:mm a"
+ return formatter.string(from: myNSDate)
+ } else {
+ let formatter = DateFormatter()
+ formatter.dateFormat = Locale.current.identifier == "fr_FR" ? "dd/MM/yy | HH:mm" : "MM/dd/yy | h:mm a"
+ return formatter.string(from: myNSDate)
+ }
+ }
+
+ func filterCallLogs(filter: String) {
+ callLogs.removeAll()
+ callLogsTmp.forEach { callLog in
+ if callLog.addressName.lowercased().contains(filter.lowercased()) {
+ callLogs.append(callLog)
+ }
+ }
+ }
+
+ func resetFilterCallLogs() {
+ callLogs = callLogsTmp
+ }
+
+ func removeCallLogs() {
+ if callLogsAddressToDelete.isEmpty {
+ coreContext.doOnCoreQueue { core in
+ let account = core.defaultAccount
+ if account != nil {
+ account!.clearCallLogs()
+ } else {
+ core.clearCallLogs()
+ }
+
+ DispatchQueue.main.async {
+ self.callLogs.removeAll()
+ self.callLogsTmp.removeAll()
+ }
+ }
+ } else {
+ removeCallLogsWithAddress()
+ callLogsAddressToDelete = ""
+ }
+ }
+
+ func removeCallLogsWithAddress() {
+ self.callLogs.filter { $0.address == callLogsAddressToDelete || $0.address == callLogsAddressToDelete }.forEach { historyModel in
+ removeCallLog(historyModel: historyModel)
+ }
+ }
+
+ func removeCallLog(historyModel: HistoryModel) {
+ let index = self.callLogs.firstIndex(where: {$0.id == historyModel.id})
+ self.callLogs.remove(at: index!)
+
+ let indexTmp = self.callLogsTmp.firstIndex(where: {$0.id == historyModel.id})
+ self.callLogsTmp.remove(at: indexTmp!)
+
+ coreContext.doOnCoreQueue { core in
+ core.removeCallLog(callLog: historyModel.callLog)
+ }
+ }
+
+ func refreshHistoryAvatarModel() {
+ callLogs.forEach { historyModel in
+ historyModel.refreshAvatarModel()
+ }
+ }
+}
diff --git a/Linphone/UI/Main/History/ViewModel/HistoryViewModel.swift b/Linphone/UI/Main/History/ViewModel/HistoryViewModel.swift
new file mode 100644
index 000000000..04c763272
--- /dev/null
+++ b/Linphone/UI/Main/History/ViewModel/HistoryViewModel.swift
@@ -0,0 +1,31 @@
+/*
+ * 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 .
+ */
+
+import Foundation
+import linphonesw
+import Combine
+
+class HistoryViewModel: ObservableObject {
+
+ @Published var displayedCall: HistoryModel?
+
+ var selectedCall: HistoryModel?
+
+ init() {}
+}
diff --git a/Linphone/UI/Main/History/ViewModel/StartCallViewModel.swift b/Linphone/UI/Main/History/ViewModel/StartCallViewModel.swift
new file mode 100644
index 000000000..20bd0d262
--- /dev/null
+++ b/Linphone/UI/Main/History/ViewModel/StartCallViewModel.swift
@@ -0,0 +1,175 @@
+/*
+ * 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 .
+ */
+
+import linphonesw
+import Combine
+
+// swiftlint:disable line_length
+class StartCallViewModel: ObservableObject {
+
+ static let TAG = "[StartCallViewModel]"
+
+ private var coreContext = CoreContext.shared
+
+ @Published var searchField: String = ""
+
+ var domain: String = ""
+
+ @Published var messageText: String = ""
+
+ @Published var participants: [SelectedAddressModel] = []
+
+ @Published var operationInProgress: Bool = false
+
+ private var conferenceScheduler: ConferenceScheduler?
+ private var conferenceSchedulerDelegate: ConferenceSchedulerDelegate?
+
+ init() {
+ coreContext.doOnCoreQueue { core in
+ self.domain = core.defaultAccount?.params?.domain ?? ""
+ }
+ }
+
+ func addParticipants(participantsToAdd: [SelectedAddressModel]) {
+ var list = participants
+ for selectedAddr in participantsToAdd {
+ if let found = list.first(where: { $0.address.weakEqual(address2: selectedAddr.address) }) {
+ Log.info("\(StartCallViewModel.TAG) Participant \(found.address.asStringUriOnly()) already in list, skipping")
+ continue
+ }
+
+ list.append(selectedAddr)
+ Log.info("\(StartCallViewModel.TAG) Added participant \(selectedAddr.address.asStringUriOnly())")
+ }
+ Log.info("\(StartCallViewModel.TAG) [\(list.count - participants.count) participants added, now there are \(list.count) participants in list")
+
+ participants = list
+ }
+
+ func createGroupCall() {
+ coreContext.doOnCoreQueue { core in
+ let account = core.defaultAccount
+ if account == nil {
+ Log.error(
+ "\(StartCallViewModel.TAG) No default account found, can't create group call!"
+ )
+ return
+ }
+
+ DispatchQueue.main.async {
+ self.operationInProgress = true
+ }
+
+ do {
+ let conferenceInfo = try Factory.Instance.createConferenceInfo()
+ conferenceInfo.organizer = account!.params?.identityAddress
+ conferenceInfo.subject = self.messageText
+
+ var participantsList: [ParticipantInfo] = []
+ self.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(
+ "\(StartCallViewModel.TAG) Can't create ParticipantInfo: \(error)"
+ )
+ }
+ }
+
+ DispatchQueue.main.async {
+ self.participants.removeAll()
+ }
+
+ conferenceInfo.addParticipantInfos(participantInfos: participantsList)
+
+ Log.info(
+ "\(StartCallViewModel.TAG) Creating group call with subject \(self.messageText) and \(participantsList.count) participant(s)"
+ )
+
+ self.conferenceScheduler = try core.createConferenceScheduler(account: account)
+ if self.conferenceScheduler != nil {
+ self.conferenceAddDelegate(core: core, conferenceScheduler: self.conferenceScheduler!)
+ // Will trigger the conference creation/update automatically
+ self.conferenceScheduler!.info = conferenceInfo
+ }
+ } catch let error {
+ Log.error(
+ "\(StartCallViewModel.TAG) createGroupCall: \(error)"
+ )
+ }
+ }
+ }
+
+ func conferenceAddDelegate(core: Core, conferenceScheduler: ConferenceScheduler) {
+ self.conferenceSchedulerDelegate = ConferenceSchedulerDelegateStub(onStateChanged: { (conferenceScheduler: ConferenceScheduler, state: ConferenceScheduler.State) in
+ Log.info("\(StartCallViewModel.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(
+ "\(StartCallViewModel.TAG) Conference info created, address is \(conferenceAddress?.asStringUriOnly() ?? "Error conference address")"
+ )
+
+ self.startVideoCall(core: core, conferenceAddress: conferenceAddress!)
+ } else {
+ Log.error("\(StartCallViewModel.TAG) Conference info URI is null!")
+
+ ToastViewModel.shared.toastMessage = "Failed_to_create_group_call_error"
+ ToastViewModel.shared.displayToast = true
+ }
+
+ DispatchQueue.main.async {
+ self.operationInProgress = false
+ }
+ } else if state == ConferenceScheduler.State.Error {
+ conferenceScheduler.removeDelegate(delegate: self.conferenceSchedulerDelegate!)
+ self.conferenceSchedulerDelegate = nil
+ Log.error("\(StartCallViewModel.TAG) Failed to create group call!")
+
+ ToastViewModel.shared.toastMessage = "Failed_to_create_group_call_error"
+ ToastViewModel.shared.displayToast = true
+
+ DispatchQueue.main.async {
+ self.operationInProgress = false
+ }
+ }
+ })
+ conferenceScheduler.addDelegate(delegate: self.conferenceSchedulerDelegate!)
+ }
+
+ func startVideoCall(core: Core, conferenceAddress: Address) {
+ TelecomManager.shared.doCallWithCore(addr: conferenceAddress, isVideo: true, isConference: true)
+ }
+
+ func interpretAndStartCall() {
+ CoreContext.shared.doOnCoreQueue { core in
+ let address = core.interpretUrl(url: self.searchField, applyInternationalPrefix: true)
+ if address != nil {
+ TelecomManager.shared.doCallOrJoinConf(address: address!)
+ }
+ }
+ }
+}
+// swiftlint:enable line_length
diff --git a/Linphone/UI/Main/Meetings/Fragments/AddParticipantsFragment.swift b/Linphone/UI/Main/Meetings/Fragments/AddParticipantsFragment.swift
new file mode 100644
index 000000000..6d45dc1cc
--- /dev/null
+++ b/Linphone/UI/Main/Meetings/Fragments/AddParticipantsFragment.swift
@@ -0,0 +1,297 @@
+/*
+ * Copyright (c) 2010-2024 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 .
+ */
+
+import SwiftUI
+import Foundation
+import linphonesw
+
+struct AddParticipantsFragment: View {
+
+ @Environment(\.dismiss) var dismiss
+
+ private var idiom: UIUserInterfaceIdiom { UIDevice.current.userInterfaceIdiom }
+ @State private var orientation = UIDevice.current.orientation
+
+ @ObservedObject var contactsManager = ContactsManager.shared
+ @ObservedObject var magicSearch = MagicSearchSingleton.shared
+ @ObservedObject var addParticipantsViewModel: AddParticipantsViewModel
+ var confirmAddParticipantsFunc: ([SelectedAddressModel]) -> Void
+
+ @FocusState var isSearchFieldFocused: Bool
+
+ var body: some View {
+ ZStack(alignment: .bottomTrailing) {
+ VStack(spacing: 16) {
+ if #available(iOS 16.0, *) {
+ Rectangle()
+ .foregroundColor(Color.orangeMain500)
+ .edgesIgnoringSafeArea(.top)
+ .frame(height: 0)
+ } else if idiom != .pad && !(orientation == .landscapeLeft || orientation == .landscapeRight
+ || UIScreen.main.bounds.size.width > UIScreen.main.bounds.size.height) {
+ Rectangle()
+ .foregroundColor(Color.orangeMain500)
+ .edgesIgnoringSafeArea(.top)
+ .frame(height: 1)
+ }
+ HStack {
+ Image("caret-left")
+ .renderingMode(.template)
+ .resizable()
+ .foregroundStyle(Color.orangeMain500)
+ .frame(width: 25, height: 25, alignment: .leading)
+ .padding(.all, 10)
+ .padding(.top, 2)
+ .padding(.leading, -10)
+ .onTapGesture {
+ addParticipantsViewModel.reset()
+ dismiss()
+ }
+
+ VStack(alignment: .leading, spacing: 3) {
+ Text("Add participants")
+ .multilineTextAlignment(.leading)
+ .default_text_style_orange_800(styleSize: 16)
+ .padding(.top, 20)
+ Text("\($addParticipantsViewModel.participantsToAdd.count) selected participants")
+ .default_text_style_300(styleSize: 12)
+ }
+ Spacer()
+ }
+ .frame(maxWidth: .infinity)
+ .frame(height: 50)
+ .padding(.horizontal)
+ .padding(.bottom, 4)
+ .background(.white)
+
+ ScrollView(.horizontal) {
+ HStack {
+ ForEach(0...
+ */
+
+import SwiftUI
+import linphonesw
+import UniformTypeIdentifiers
+
+struct MeetingFragment: View {
+
+ @Environment(\.dismiss) var dismiss
+
+ private var idiom: UIUserInterfaceIdiom { UIDevice.current.userInterfaceIdiom }
+ @State private var orientation = UIDevice.current.orientation
+
+ @ObservedObject var meetingViewModel: MeetingViewModel
+ @ObservedObject var meetingsListViewModel: MeetingsListViewModel
+
+ @State private var showDatePicker = false
+ @State private var showTimePicker = false
+ @State private var showEventEditView = false
+
+ @State var selectedDate = Date.now
+ @State var setFromDate: Bool = true
+ @State var selectedHours: Int = 0
+ @State var selectedMinutes: Int = 0
+
+ @State var addParticipantsViewModel = AddParticipantsViewModel()
+ @Binding var isShowScheduleMeetingFragment: Bool
+ @Binding var isShowSendCancelMeetingNotificationPopup: Bool
+
+ @ViewBuilder
+ func getParticipantLine(participant: SelectedAddressModel) -> some View {
+ HStack(spacing: 0) {
+ Avatar(contactAvatarModel: participant.avatarModel, avatarSize: 50)
+ .padding(.leading, 10)
+
+ Text(participant.avatarModel.name)
+ .default_text_style(styleSize: 14)
+ .padding(.leading, 10)
+ .padding(.trailing, 40)
+
+ Text("Organizer")
+ .font(Font.custom("NotoSans-Light", size: 12))
+ .foregroundStyle(Color.grayMain2c600)
+ .opacity(participant.isOrganizer ? 1 : 0)
+ }.padding(.bottom, 5)
+ }
+
+ var body: some View {
+ NavigationView {
+ ZStack(alignment: .bottomTrailing) {
+ VStack(spacing: 0) {
+ if #available(iOS 16.0, *) {
+ Rectangle()
+ .foregroundColor(Color.orangeMain500)
+ .edgesIgnoringSafeArea(.top)
+ .frame(height: 0)
+ } else if idiom != .pad && !(orientation == .landscapeLeft || orientation == .landscapeRight
+ || UIScreen.main.bounds.size.width > UIScreen.main.bounds.size.height) {
+ Rectangle()
+ .foregroundColor(Color.orangeMain500)
+ .edgesIgnoringSafeArea(.top)
+ .frame(height: 1)
+ }
+
+ HStack {
+ Image("caret-left")
+ .renderingMode(.template)
+ .resizable()
+ .foregroundStyle(Color.orangeMain500)
+ .frame(width: 25, height: 25, alignment: .leading)
+ .padding(.all, 10)
+ .padding(.leading, -10)
+ .onTapGesture {
+ withAnimation {
+ meetingViewModel.displayedMeeting = nil
+ }
+ }
+ Spacer()
+ if meetingViewModel.myself != nil && meetingViewModel.myself!.isOrganizer {
+ Image("pencil-simple")
+ .renderingMode(.template)
+ .resizable()
+ .foregroundStyle(Color.orangeMain500)
+ .frame(width: 25, height: 25, alignment: .leading)
+ .padding(.trailing, 5)
+ .onTapGesture {
+ withAnimation {
+ isShowScheduleMeetingFragment.toggle()
+ }
+ }
+ }
+
+ Menu {
+ Button {
+ if #available(iOS 17.0, *) {
+ withAnimation {
+ showEventEditView.toggle()
+ }
+ } else {
+ meetingViewModel.addMeetingToCalendar()
+ }
+ } label: {
+ HStack {
+ Image("calendar")
+ .renderingMode(.template)
+ .resizable()
+ .frame(width: 25, height: 25, alignment: .leading)
+ Text("Add to calendar")
+ .default_text_style(styleSize: 16)
+ Spacer()
+ }
+ }
+ Button(role: .destructive) {
+ withAnimation {
+ meetingsListViewModel.selectedMeetingToDelete = meetingViewModel.displayedMeeting
+ if let myself = meetingViewModel.myself, myself.isOrganizer == true {
+ isShowSendCancelMeetingNotificationPopup.toggle()
+ } else {
+ // If we're not organizer, directly delete the conference
+ meetingViewModel.displayedMeeting = nil
+ meetingsListViewModel.deleteSelectedMeeting()
+ }
+ }
+ } label: {
+ HStack {
+ Image("trash-simple")
+ .renderingMode(.template)
+ .resizable()
+ .frame(width: 25, height: 25, alignment: .leading)
+ Text("Delete this meeting")
+ .foregroundStyle(Color.redDanger500)
+ .default_text_style(styleSize: 16)
+ Spacer()
+ }
+ }
+ } label: {
+ Image("dots-three-vertical")
+ .renderingMode(.template)
+ .resizable()
+ .foregroundStyle(Color.orangeMain500)
+ .frame(width: 25, height: 25, alignment: .leading)
+ }
+ }
+ .frame(maxWidth: .infinity)
+ .frame(height: 50)
+ .padding(.horizontal)
+ .padding(.bottom, 5)
+ .background(.white)
+
+ ScrollView(.vertical) {
+ HStack(alignment: .center, spacing: 10) {
+ Image("video-conference")
+ .renderingMode(.template)
+ .resizable()
+ .foregroundStyle(Color.grayMain2c800)
+ .frame(width: 24, height: 24)
+ .padding(.leading, 15)
+ Text(meetingViewModel.subject)
+ .fontWeight(.bold)
+ .default_text_style(styleSize: 20)
+ .frame(height: 29, alignment: .leading)
+ Spacer()
+ }.padding(.bottom, 5)
+
+ Rectangle()
+ .foregroundStyle(.clear)
+ .frame(height: 1)
+ .background(Color.gray200)
+
+ HStack(alignment: .center, spacing: 10) {
+ Image("video-camera")
+ .renderingMode(.template)
+ .resizable()
+ .foregroundStyle(Color.grayMain2c800)
+ .frame(width: 24, height: 24)
+ .padding(.leading, 15)
+ Text(meetingViewModel.conferenceUri)
+ .underline()
+ .lineLimit(1)
+ .default_text_style(styleSize: 14)
+ Spacer()
+
+ Button(action: {
+ UIPasteboard.general.setValue(
+ meetingViewModel.conferenceUri,
+ forPasteboardType: UTType.plainText.identifier
+ )
+
+ DispatchQueue.main.async {
+ ToastViewModel.shared.toastMessage = "Success_address_copied_into_clipboard"
+ ToastViewModel.shared.displayToast = true
+ }
+ }, label: {
+ HStack {
+ Image("share-network")
+ .renderingMode(.template)
+ .resizable()
+ .foregroundStyle(Color.grayMain2c800)
+ .frame(width: 25, height: 25)
+ .padding(.trailing, 15)
+ }
+ })
+ }
+
+ HStack(alignment: .center, spacing: 10) {
+ Image("clock")
+ .renderingMode(.template)
+ .resizable()
+ .foregroundStyle(Color.grayMain2c800)
+ .frame(width: 24, height: 24)
+ .padding(.leading, 15)
+ Text(meetingViewModel.getFullDateString())
+ .default_text_style(styleSize: 14)
+ Spacer()
+ }
+
+ HStack(alignment: .center, spacing: 10) {
+ Image("earth")
+ .renderingMode(.template)
+ .resizable()
+ .foregroundStyle(Color.grayMain2c800)
+ .frame(width: 24, height: 24)
+ .padding(.leading, 15)
+ Text(meetingViewModel.selectedTimezone.formattedString())
+ .default_text_style(styleSize: 14)
+ Spacer()
+ }
+
+ Rectangle()
+ .foregroundStyle(.clear)
+ .frame(height: 1)
+ .background(Color.gray200)
+
+ if !meetingViewModel.description.isEmpty {
+ HStack(alignment: .top, spacing: 10) {
+ Image("note")
+ .renderingMode(.template)
+ .resizable()
+ .foregroundStyle(Color.grayMain2c600)
+ .frame(width: 24, height: 24)
+ .padding(.leading, 15)
+
+ Text(meetingViewModel.description)
+ .default_text_style(styleSize: 14)
+ Spacer()
+ }.padding(.vertical, 10)
+ Rectangle()
+ .foregroundStyle(.clear)
+ .frame(height: 1)
+ .background(Color.gray200)
+ }
+
+ HStack(alignment: .top, spacing: 10) {
+ Image("users")
+ .renderingMode(.template)
+ .resizable()
+ .foregroundStyle(Color.grayMain2c600)
+ .frame(width: 24, height: 24)
+ .padding(.leading, 15)
+
+ ScrollView {
+ VStack(alignment: .leading, spacing: 0) {
+ if meetingViewModel.myself != nil && meetingViewModel.myself!.isOrganizer {
+ getParticipantLine(participant: meetingViewModel.myself!)
+ }
+ ForEach(0...
+ */
+
+import SwiftUI
+import linphonesw
+
+struct MeetingsFragment: View {
+
+ @ObservedObject var meetingsListViewModel: MeetingsListViewModel
+ @ObservedObject var meetingViewModel: MeetingViewModel
+
+ private var idiom: UIUserInterfaceIdiom { UIDevice.current.userInterfaceIdiom }
+
+ @Binding var showingSheet: Bool
+ @Binding var text: String
+
+ @ViewBuilder
+ func createMonthLine(model: MeetingsListItemModel) -> some View {
+ Text(model.monthStr)
+ .fontWeight(.bold)
+ .padding(5)
+ .default_text_style_500(styleSize: 22)
+ }
+
+ @ViewBuilder
+ func createWeekLine(model: MeetingsListItemModel) -> some View {
+ Text(model.weekStr)
+ .padding(.leading, 50)
+ .padding(.vertical, 10)
+ .default_text_style_500(styleSize: 14)
+ }
+ @ViewBuilder
+ func createMeetingLine(model: MeetingsListItemModel) -> some View {
+ VStack(alignment: .leading, spacing: 0) {
+ if model.isToday {
+ Text("No meeting today")
+ .fontWeight(.bold)
+ .default_text_style_500(styleSize: 15)
+ } else {
+ HStack(alignment: .center) {
+ Image("video-conference")
+ .renderingMode(.template)
+ .resizable()
+ .foregroundStyle(Color.grayMain2c600)
+ .frame(width: 24, height: 24)
+ .padding(.bottom, -5)
+ Text(model.model!.subject)
+ .fontWeight(.bold)
+ .padding(.trailing, 5)
+ .padding(.top, 5)
+ .default_text_style_500(styleSize: 15)
+ }
+ if model.model!.confInfo.state != ConferenceInfo.State.Cancelled {
+ Text(model.model!.time)
+ // this time string is formatted for the current device timezone, we use the selected timezone only when displaying details
+ .default_text_style_500(styleSize: 15)
+ } else {
+ Text("Cancelled")
+ .foregroundStyle(Color.redDanger500)
+ .default_text_style_500(styleSize: 15)
+ }
+ }
+ }
+ .padding(.leading, 30)
+ .frame(height: 63)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .background(.white)
+ .clipShape(RoundedRectangle(cornerRadius: 10))
+ .shadow(color: .black.opacity(0.2), radius: 4)
+ .onTapGesture {
+ withAnimation {
+ if let meetingModel = model.model {
+ meetingViewModel.loadExistingMeeting(meeting: meetingModel)
+ }
+ }
+ }
+ .onLongPressGesture(minimumDuration: 0.2) {
+ if let meetingModel = model.model {
+ meetingsListViewModel.selectedMeetingToDelete = meetingModel
+ showingSheet.toggle()
+ }
+ }
+ }
+
+ var body: some View {
+ VStack {
+ ScrollViewReader { proxyReader in
+ List(0...
+ */
+
+import SwiftUI
+import linphonesw
+import Contacts
+
+struct MeetingsListBottomSheet: View {
+
+ @Environment(\.dismiss) var dismiss
+
+ private var idiom: UIUserInterfaceIdiom { UIDevice.current.userInterfaceIdiom }
+ @State private var orientation = UIDevice.current.orientation
+
+ @ObservedObject var meetingsListViewModel: MeetingsListViewModel
+ @Binding var showingSheet: Bool
+ @Binding var isShowSendCancelMeetingNotificationPopup: Bool
+
+ var body: some View {
+ VStack(alignment: .leading) {
+ if idiom != .pad && (orientation == .landscapeLeft
+ || orientation == .landscapeRight
+ || UIScreen.main.bounds.size.width > UIScreen.main.bounds.size.height) {
+ Spacer()
+ HStack {
+ Spacer()
+ Button("Close") {
+ if #available(iOS 16.0, *) {
+ showingSheet.toggle()
+ } else {
+ showingSheet.toggle()
+ dismiss()
+ }
+ }
+ }
+ .padding(.trailing)
+ }
+
+ Button {
+ CoreContext.shared.doOnCoreQueue { core in
+ if let organizerUri = self.meetingsListViewModel.selectedMeetingToDelete?.confInfo.organizer {
+ if core.defaultAccount?.contactAddress?.weakEqual(address2: organizerUri) ?? false {
+ // If we are the organizer, display popup for sending
+ DispatchQueue.main.async {
+ self.isShowSendCancelMeetingNotificationPopup = true
+ }
+ } else {
+ // If we are not the organizer, delete meeting locally without popup
+ meetingsListViewModel.deleteSelectedMeeting()
+ }
+ }
+ }
+ if #available(iOS 16.0, *) {
+ if idiom != .pad {
+ showingSheet.toggle()
+ } else {
+ showingSheet.toggle()
+ dismiss()
+ }
+ } else {
+ showingSheet.toggle()
+ dismiss()
+ }
+ } label: {
+ HStack {
+ Image("trash-simple")
+ .renderingMode(.template)
+ .resizable()
+ .foregroundStyle(Color.redDanger500)
+ .frame(width: 25, height: 25, alignment: .leading)
+ .padding(.all, 10)
+ Text("Delete this meeting")
+ .foregroundStyle(Color.redDanger500)
+ .default_text_style(styleSize: 16)
+ Spacer()
+ }
+ .frame(maxHeight: .infinity)
+ }
+ .padding(.horizontal, 30)
+ .background(Color.gray100)
+
+ }
+ .background(Color.gray100)
+ .frame(maxWidth: .infinity)
+ .onRotate { newOrientation in
+ orientation = newOrientation
+ }
+ }
+}
diff --git a/Linphone/UI/Main/Meetings/Fragments/MeetingsListFragment.swift b/Linphone/UI/Main/Meetings/Fragments/MeetingsListFragment.swift
new file mode 100644
index 000000000..b52060053
--- /dev/null
+++ b/Linphone/UI/Main/Meetings/Fragments/MeetingsListFragment.swift
@@ -0,0 +1,38 @@
+/*
+ * Copyright (c) 2010-2024 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 .
+ */
+
+import SwiftUI
+import linphonesw
+
+struct MeetingsListFragment: View {
+
+ @ObservedObject var meetingsListViewModel: MeetingsListViewModel
+
+ @Binding var showingSheet: Bool
+
+ var body: some View {
+ VStack {
+
+ }
+ }
+}
+
+#Preview {
+ MeetingsListFragment(meetingsListViewModel: MeetingsListViewModel(), showingSheet: .constant(false))
+}
diff --git a/Linphone/UI/Main/Meetings/Fragments/ScheduleMeetingFragment.swift b/Linphone/UI/Main/Meetings/Fragments/ScheduleMeetingFragment.swift
new file mode 100644
index 000000000..59e18b074
--- /dev/null
+++ b/Linphone/UI/Main/Meetings/Fragments/ScheduleMeetingFragment.swift
@@ -0,0 +1,509 @@
+/*
+ * Copyright (c) 2010-2024 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 .
+ */
+
+import SwiftUI
+
+// swiftlint:disable type_body_length
+struct ScheduleMeetingFragment: View {
+
+ @Environment(\.dismiss) var dismiss
+
+ private var idiom: UIUserInterfaceIdiom { UIDevice.current.userInterfaceIdiom }
+ @State private var orientation = UIDevice.current.orientation
+
+ @ObservedObject var meetingViewModel: MeetingViewModel
+ @ObservedObject var meetingsListViewModel: MeetingsListViewModel
+
+ @State private var delayedColor = Color.white
+ @State private var showDatePicker = false
+ @State private var showTimePicker = false
+ @State private var showTimeZonePicker = false
+
+ @Binding var isShowScheduleMeetingFragment: Bool
+
+ @State var selectedDate = Date.now
+ @State var setFromDate: Bool = true
+ @State var selectedHours: Int = 0
+ @State var selectedMinutes: Int = 0
+
+ @State var addParticipantsViewModel = AddParticipantsViewModel()
+ @FocusState var isDescriptionTextFocused: Bool
+ @FocusState var isSubjectTextFocused: Bool
+
+ var body: some View {
+ NavigationView {
+ ZStack(alignment: .bottomTrailing) {
+ VStack(spacing: 10) {
+ if #available(iOS 16.0, *) {
+ Rectangle()
+ .foregroundColor(delayedColor)
+ .edgesIgnoringSafeArea(.top)
+ .frame(height: 0)
+ .task(delayColor)
+ } else if idiom != .pad && !(orientation == .landscapeLeft || orientation == .landscapeRight
+ || UIScreen.main.bounds.size.width > UIScreen.main.bounds.size.height) {
+ Rectangle()
+ .foregroundColor(delayedColor)
+ .edgesIgnoringSafeArea(.top)
+ .frame(height: 1)
+ .task(delayColor)
+ }
+
+ HStack {
+ Image("caret-left")
+ .renderingMode(.template)
+ .resizable()
+ .foregroundStyle(Color.orangeMain500)
+ .frame(width: 25, height: 25, alignment: .leading)
+ .padding(.all, 10)
+ .padding(.leading, -10)
+ .onTapGesture {
+ withAnimation {
+ if let meeting = meetingViewModel.displayedMeeting {
+ // reload meeting to cancel change from edit
+ meetingViewModel.loadExistingMeeting(meeting: meeting)
+ }
+ isShowScheduleMeetingFragment.toggle()
+ }
+ }
+
+ Text("\(meetingViewModel.displayedMeeting != nil ? "Edit" : "New") meeting" )
+ .multilineTextAlignment(.leading)
+ .default_text_style_orange_800(styleSize: 16)
+
+ Spacer()
+ }
+ .frame(maxWidth: .infinity)
+ .frame(height: 50)
+ .padding(.horizontal)
+ .background(.white)
+
+ ScrollView(.vertical) {
+ HStack(alignment: .center, spacing: 10) {
+ Image("video-conference")
+ .renderingMode(.template)
+ .resizable()
+ .foregroundStyle(Color.grayMain2c800)
+ .frame(width: 24, height: 24)
+ .padding(.leading, 15)
+ TextField("Subject", text: $meetingViewModel.subject)
+ .focused($isSubjectTextFocused)
+ .default_text_style_700(styleSize: 20)
+ .frame(height: 29, alignment: .leading)
+ Spacer()
+ }
+
+ Rectangle()
+ .foregroundStyle(.clear)
+ .frame(height: 1)
+ .background(Color.gray200)
+
+ HStack(alignment: .center, spacing: 10) {
+ Image("clock")
+ .renderingMode(.template)
+ .resizable()
+ .foregroundStyle(Color.grayMain2c800)
+ .frame(width: 24, height: 24)
+ .padding(.leading, 15)
+ Text(meetingViewModel.fromDateStr)
+ .fontWeight(.bold)
+ .default_text_style_500(styleSize: 16)
+ .onTapGesture {
+ setFromDate = true
+ selectedDate = meetingViewModel.fromDate
+ showDatePicker.toggle()
+ }
+ Spacer()
+ }.padding(.bottom, -5)
+
+ HStack(spacing: 10) {
+ Text(meetingViewModel.fromTime)
+ .fontWeight(.bold)
+ .padding(.leading, 50)
+ .frame(height: 29, alignment: .leading)
+ .default_text_style_500(styleSize: 16)
+ .onTapGesture {
+ setFromDate = true
+ selectedDate = meetingViewModel.fromDate
+ showTimePicker.toggle()
+ }
+ Text(meetingViewModel.toTime)
+ .fontWeight(.bold)
+ .padding(.leading, 10)
+ .frame(height: 29, alignment: .leading)
+ .default_text_style_500(styleSize: 16)
+ .onTapGesture {
+ setFromDate = false
+ selectedDate = meetingViewModel.toDate
+ showTimePicker.toggle()
+ }
+ Spacer()
+ }
+
+
+ HStack(alignment: .center, spacing: 10) {
+ Image("earth")
+ .renderingMode(.template)
+ .resizable()
+ .foregroundStyle(Color.grayMain2c800)
+ .frame(width: 24, height: 24)
+ .padding(.leading, 15)
+ Text("Time Zone: \(meetingViewModel.selectedTimezone.formattedString())")
+ .fontWeight(.bold)
+ .default_text_style_500(styleSize: 15)
+ .onTapGesture {
+ showTimeZonePicker.toggle()
+ }
+ Spacer()
+ }
+
+ /*
+ Image("arrow-clockwise")
+ .renderingMode(.template)
+ .resizable()
+ .foregroundStyle(Color.grayMain2c800)
+ .frame(width: 24, height: 24)
+ .padding(.leading, 15)
+ //Picker(selection:, label:("))
+ .fontWeight(.bold)
+ .padding(.leading, 5)
+ .default_text_style_500(styleSize: 16)
+ Spacer()
+ }
+ */
+
+ Rectangle()
+ .foregroundStyle(.clear)
+ .frame(height: 1)
+ .background(Color.gray200)
+
+ HStack(alignment: .top, spacing: 10) {
+ Image("note")
+ .renderingMode(.template)
+ .resizable()
+ .foregroundStyle(Color.grayMain2c600)
+ .frame(width: 24, height: 24)
+ .padding(.leading, 16)
+
+ if #available(iOS 16.0, *) {
+ TextField("Add a description", text: $meetingViewModel.description, axis: .vertical)
+ .default_text_style(styleSize: 15)
+ .focused($isDescriptionTextFocused)
+ .padding(.vertical, 5)
+ } else {
+ ZStack(alignment: .leading) {
+ TextEditor(text: $meetingViewModel.description)
+ .multilineTextAlignment(.leading)
+ .frame(maxHeight: 160)
+ .fixedSize(horizontal: false, vertical: true)
+ .default_text_style(styleSize: 15)
+ .focused($isDescriptionTextFocused)
+
+ if meetingViewModel.description.isEmpty {
+ Text("Add a description")
+ .padding(.leading, 5)
+ .foregroundStyle(Color.gray300)
+ .default_text_style(styleSize: 15)
+ }
+ }
+ .onTapGesture {
+ isDescriptionTextFocused = true
+ }
+ }
+ }.frame(maxHeight: 200)
+
+ Rectangle()
+ .foregroundStyle(.clear)
+ .frame(height: 1)
+ .background(Color.gray200)
+
+ VStack {
+ NavigationLink(destination: {
+ AddParticipantsFragment(addParticipantsViewModel: addParticipantsViewModel, confirmAddParticipantsFunc: meetingViewModel.addParticipants)
+ .onAppear {
+ addParticipantsViewModel.participantsToAdd = meetingViewModel.participants
+ }
+ }, label: {
+ HStack(alignment: .center, spacing: 10) {
+ Image("users")
+ .renderingMode(.template)
+ .resizable()
+ .foregroundStyle(Color.grayMain2c600)
+ .frame(width: 24, height: 24)
+ .padding(.leading, 16)
+
+ Text("Add participants")
+ .default_text_style_700(styleSize: 16)
+ .frame(height: 29, alignment: .leading)
+ Spacer()
+ }
+ })
+
+ if !meetingViewModel.participants.isEmpty {
+ ScrollView {
+ ForEach(0.. some View {
+ GeometryReader { geometry in
+ VStack(alignment: .leading) {
+ Text("Select \(setFromDate ? "start" : "end") \(isTimeSelection ? "time" : "date")")
+ .default_text_style_800(styleSize: 16)
+ .frame(alignment: .leading)
+ .padding(.bottom, 2)
+
+ DatePicker(
+ "",
+ selection: $selectedDate,
+ in: Date.now...,
+ displayedComponents: isTimeSelection ? [.hourAndMinute] : [.date]
+ )
+ .if(isTimeSelection) { view in
+ view.datePickerStyle(.wheel)
+ }
+ .datePickerStyle(.graphical)
+ .tint(Color.orangeMain500)
+ .padding(.bottom, 20)
+ .default_text_style(styleSize: 15)
+
+ HStack {
+ Spacer()
+ Text("Cancel")
+ .default_text_style_orange_500(styleSize: 16)
+ .onTapGesture {
+ if isTimeSelection {
+ showTimePicker.toggle()
+ } else {
+ showDatePicker.toggle()
+ }
+ }
+ Text("Ok")
+ .default_text_style_orange_500(styleSize: 16)
+ .onTapGesture {
+ pickDate()
+ if isTimeSelection {
+ showTimePicker.toggle()
+ } else {
+ showDatePicker.toggle()
+ }
+ }
+ }
+ }
+ .padding(.horizontal, 20)
+ .padding(.vertical, 20)
+ .background(.white)
+ .cornerRadius(20)
+ .padding(.horizontal)
+ .frame(maxHeight: .infinity)
+ .shadow(color: Color.orangeMain500, radius: 0, x: 0, y: 2)
+ // .frame(maxWidth: sharedMainViewModel.maxWidth)
+ .position(x: geometry.size.width / 2, y: geometry.size.height / 2)
+ }
+ .background(.black.opacity(0.65))
+ }
+
+ func pickDate() {
+ let duration = min(meetingViewModel.fromDate.distance(to: meetingViewModel.toDate), 86400) // Limit auto correction of dates to 24h
+ if setFromDate {
+ meetingViewModel.fromDate = selectedDate
+ // If new startdate is after previous end date, bump up the end date
+ if selectedDate > meetingViewModel.toDate {
+ meetingViewModel.toDate = Calendar.current.date(byAdding: .second, value: Int(duration), to: selectedDate)!
+ }
+ } else {
+ meetingViewModel.toDate = selectedDate
+ if selectedDate < meetingViewModel.fromDate {
+ // If new end date is before the previous start date, bump down the start date to the earlier possible from current time
+ if Date.now.distance(to: selectedDate) < duration {
+ meetingViewModel.fromDate = Date.now
+ } else {
+ meetingViewModel.fromDate = Calendar.current.date(byAdding: .second, value: (-1)*Int(duration), to: selectedDate)!
+ }
+ }
+ }
+ meetingViewModel.computeDateLabels()
+ meetingViewModel.computeTimeLabels()
+ }
+
+ @Sendable private func delayColor() async {
+ try? await Task.sleep(nanoseconds: 250_000_000)
+ delayedColor = Color.orangeMain500
+ }
+
+ func delayColorDismiss() {
+ Task {
+ try? await Task.sleep(nanoseconds: 80_000_000)
+ delayedColor = .white
+ }
+ }
+}
+
+#Preview {
+ ScheduleMeetingFragment(meetingViewModel: MeetingViewModel()
+ , meetingsListViewModel: MeetingsListViewModel()
+ , isShowScheduleMeetingFragment: .constant(true))
+}
+
+// swiftlint:enable type_body_length
diff --git a/Linphone/UI/Main/Meetings/MeetingsView.swift b/Linphone/UI/Main/Meetings/MeetingsView.swift
new file mode 100644
index 000000000..a4f37074e
--- /dev/null
+++ b/Linphone/UI/Main/Meetings/MeetingsView.swift
@@ -0,0 +1,89 @@
+/*
+ * Copyright (c) 2010-2024 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 .
+ */
+
+import SwiftUI
+
+struct MeetingsView: View {
+ private var idiom: UIUserInterfaceIdiom { UIDevice.current.userInterfaceIdiom }
+
+ @ObservedObject var meetingsListViewModel: MeetingsListViewModel
+ @ObservedObject var meetingViewModel: MeetingViewModel
+
+ @Binding var isShowScheduleMeetingFragment: Bool
+ @Binding var isShowSendCancelMeetingNotificationPopup: Bool
+
+ @State private var showingSheet = false
+ @Binding var text: String
+
+ var body: some View {
+ NavigationView {
+ ZStack(alignment: .bottomTrailing) {
+
+ if #available(iOS 16.0, *), idiom != .pad {
+ MeetingsFragment(meetingsListViewModel: meetingsListViewModel, meetingViewModel: meetingViewModel, showingSheet: $showingSheet, text: $text)
+ .sheet(isPresented: $showingSheet) {
+ MeetingsListBottomSheet(
+ meetingsListViewModel: meetingsListViewModel,
+ showingSheet: $showingSheet,
+ isShowSendCancelMeetingNotificationPopup: $isShowSendCancelMeetingNotificationPopup
+ )
+ .presentationDetents([.fraction(0.1)])
+ }
+ } else {
+ MeetingsFragment(meetingsListViewModel: meetingsListViewModel, meetingViewModel: meetingViewModel, showingSheet: $showingSheet, text: $text)
+ .halfSheet(showSheet: $showingSheet) {
+ MeetingsListBottomSheet(
+ meetingsListViewModel: meetingsListViewModel,
+ showingSheet: $showingSheet,
+ isShowSendCancelMeetingNotificationPopup: $isShowSendCancelMeetingNotificationPopup
+ )
+ } onDismiss: {}
+ }
+
+ Button {
+ withAnimation {
+ meetingViewModel.resetViewModelData()
+ isShowScheduleMeetingFragment.toggle()
+ }
+ } label: {
+ Image("meeting_plus")
+ .renderingMode(.template)
+ .foregroundStyle(.white)
+ .padding()
+ .background(Color.orangeMain500)
+ .clipShape(Circle())
+ .shadow(color: .black.opacity(0.2), radius: 4)
+
+ }
+ .padding()
+ }
+ }
+ .navigationViewStyle(.stack)
+ }
+}
+
+#Preview {
+ MeetingsView(
+ meetingsListViewModel: MeetingsListViewModel(),
+ meetingViewModel: MeetingViewModel(),
+ isShowScheduleMeetingFragment: .constant(false),
+ isShowSendCancelMeetingNotificationPopup: .constant(false),
+ text: .constant("")
+ )
+}
diff --git a/Linphone/UI/Main/Meetings/Models/MeetingModel.swift b/Linphone/UI/Main/Meetings/Models/MeetingModel.swift
new file mode 100644
index 000000000..d363a2dcd
--- /dev/null
+++ b/Linphone/UI/Main/Meetings/Models/MeetingModel.swift
@@ -0,0 +1,70 @@
+/*
+ * Copyright (c) 2010-2024 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 .
+ */
+
+import linphonesw
+
+class MeetingModel: ObservableObject {
+
+ var confInfo: ConferenceInfo
+ var id: String
+ var meetingDate: Date
+ var endDate: Date
+ var isToday: Bool
+ var isAfterToday: Bool
+
+ private let startTime: String
+ private let endTime: String
+ var time: String // "$startTime - $endTime"
+ var day: String
+ var dayNumber: String
+
+ @Published var isBroadcast: Bool
+ @Published var subject: String
+ @Published var address: String
+
+ init(conferenceInfo: ConferenceInfo) {
+ confInfo = conferenceInfo
+ id = confInfo.uri?.asStringUriOnly() ?? ""
+ meetingDate = Date(timeIntervalSince1970: TimeInterval(confInfo.dateTime))
+
+ let formatter = DateFormatter()
+ formatter.dateFormat = Locale.current.identifier == "fr_FR" ? "HH:mm" : "h:mm a"
+ startTime = formatter.string(from: meetingDate)
+ endDate = Calendar.current.date(byAdding: .minute, value: Int(confInfo.duration), to: meetingDate)!
+ endTime = formatter.string(from: endDate)
+ time = "\(startTime) - \(endTime)"
+
+ day = meetingDate.formatted(Date.FormatStyle().weekday(.abbreviated))
+ dayNumber = meetingDate.formatted(Date.FormatStyle().day(.twoDigits))
+
+ isToday = Calendar.current.isDateInToday(meetingDate)
+ if isToday {
+ isAfterToday = false
+ } else {
+ isAfterToday = meetingDate > Date.now
+ }
+
+ // If at least one participant is listener, we are in broadcast mode
+ isBroadcast = confInfo.participantInfos.firstIndex(where: {$0.role == Participant.Role.Listener}) != nil
+
+ subject = confInfo.subject ?? ""
+
+ address = confInfo.uri?.asStringUriOnly() ?? ""
+ }
+}
diff --git a/Linphone/UI/Main/Meetings/Models/MeetingsListItemModel.swift b/Linphone/UI/Main/Meetings/Models/MeetingsListItemModel.swift
new file mode 100644
index 000000000..644b118d5
--- /dev/null
+++ b/Linphone/UI/Main/Meetings/Models/MeetingsListItemModel.swift
@@ -0,0 +1,87 @@
+/*
+ * Copyright (c) 2010-2024 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 .
+ */
+import Foundation
+
+extension String {
+ func capitalizingFirstLetter() -> String {
+ return prefix(1).capitalized + dropFirst()
+ }
+
+ mutating func capitalizeFirstLetter() {
+ self = self.capitalizingFirstLetter()
+ }
+}
+
+class MeetingsListItemModel {
+ let model: MeetingModel? // if NIL, consider that we are using the fake TodayModel
+ var monthStr: String = ""
+ var weekStr: String = ""
+ var weekDayStr: String = ""
+ var dayStr: String = ""
+
+ var isToday = true
+
+ init(meetingModel: MeetingModel?) {
+ model = meetingModel
+ if let mod = meetingModel {
+ monthStr = createMonthString(date: mod.meetingDate)
+ weekStr = createWeekString(date: mod.meetingDate)
+ weekDayStr = createWeekDayString(date: mod.meetingDate)
+ dayStr = createDayString(date: mod.meetingDate)
+ isToday = false
+ } else {
+ monthStr = createMonthString(date: Date.now)
+ weekStr = createWeekString(date: Date.now)
+ weekDayStr = createWeekDayString(date: Date.now)
+ dayStr = createDayString(date: Date.now)
+ }
+ }
+
+ func createMonthString(date: Date) -> String {
+ return "\(date.formatted(Date.FormatStyle().month(.wide))) \(date.formatted(Date.FormatStyle().year()))".capitalized
+ }
+
+ func createWeekString(date: Date) -> String {
+ let calendar = Calendar.current
+ let firstDayOfWeekIdx = calendar.firstWeekday
+ let dateIndex = calendar.component(.weekday, from: date)
+ let weekStartDate = calendar.date(byAdding: .day, value: -(dateIndex - firstDayOfWeekIdx % 7), to: date)!
+ let weekFirstDay = weekStartDate.formatted(Date.FormatStyle().day(.twoDigits))
+ let firstMonth = weekStartDate.formatted(Date.FormatStyle().month(.wide)).capitalizingFirstLetter()
+
+ let weekEndDate = calendar.date(byAdding: .day, value: 6, to: weekStartDate)!
+ let weekEndDay = weekEndDate.formatted(Date.FormatStyle().day(.twoDigits))
+
+ let isDifferentMonth = calendar.component(.month, from: weekStartDate) != calendar.component(.month, from: weekEndDate)
+ if isDifferentMonth {
+ let lastMonth = weekEndDate.formatted(Date.FormatStyle().month(.wide)).capitalizingFirstLetter()
+ return "\(weekFirstDay) \(firstMonth) - \(weekEndDay) \(lastMonth)"
+ } else {
+ return "\(weekFirstDay) - \(weekEndDay) \(firstMonth)"
+ }
+ }
+
+ func createWeekDayString(date: Date) -> String {
+ return date.formatted(Date.FormatStyle().weekday(.abbreviated)).capitalized
+ }
+
+ func createDayString(date: Date) -> String {
+ return date.formatted(Date.FormatStyle().day(.twoDigits))
+ }
+}
diff --git a/Linphone/UI/Main/Meetings/ViewModel/EventEditViewController.swift b/Linphone/UI/Main/Meetings/ViewModel/EventEditViewController.swift
new file mode 100644
index 000000000..01e66ae82
--- /dev/null
+++ b/Linphone/UI/Main/Meetings/ViewModel/EventEditViewController.swift
@@ -0,0 +1,54 @@
+/*
+ * Copyright (c) 2010-2024 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 .
+ */
+
+import EventKitUI
+import SwiftUI
+
+struct EventEditViewController: UIViewControllerRepresentable {
+
+ @Environment(\.presentationMode) var presentationMode
+ let meetingViewModel: MeetingViewModel
+
+ class Coordinator: NSObject, EKEventEditViewDelegate {
+ var parent: EventEditViewController
+
+ init(_ controller: EventEditViewController) {
+ self.parent = controller
+ }
+
+ func eventEditViewController(_ controller: EKEventEditViewController, didCompleteWith action: EKEventEditViewAction) {
+ parent.presentationMode.wrappedValue.dismiss()
+ }
+ }
+
+ func makeCoordinator() -> Coordinator {
+ return Coordinator(self)
+ }
+
+ typealias UIViewControllerType = EKEventEditViewController
+ func makeUIViewController(context: Context) -> EKEventEditViewController {
+ let eventEditViewController = EKEventEditViewController()
+ eventEditViewController.event = meetingViewModel.createMeetingEKEvent()
+ eventEditViewController.eventStore = meetingViewModel.eventStore
+ eventEditViewController.editViewDelegate = context.coordinator
+ return eventEditViewController
+ }
+
+ func updateUIViewController(_ uiViewController: EKEventEditViewController, context: Context) {}
+}
diff --git a/Linphone/UI/Main/Meetings/ViewModel/MeetingViewModel.swift b/Linphone/UI/Main/Meetings/ViewModel/MeetingViewModel.swift
new file mode 100644
index 000000000..2bfbc9007
--- /dev/null
+++ b/Linphone/UI/Main/Meetings/ViewModel/MeetingViewModel.swift
@@ -0,0 +1,392 @@
+/*
+ * Copyright (c) 2010-2024 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 .
+ */
+
+import Foundation
+import linphonesw
+import Combine
+import EventKit
+
+// swiftlint:disable type_body_length
+class MeetingViewModel: ObservableObject {
+ static let TAG = "[MeetingViewModel]"
+ let eventStore: EKEventStore = EKEventStore()
+
+ @Published var isBroadcastSelected: Bool = false
+ @Published var showBroadcastHelp: Bool = false
+ @Published var subject: String = ""
+ @Published var description: String = ""
+ @Published var fromDateStr: String = ""
+ @Published var fromTime: String = ""
+ @Published var toDateStr: String = ""
+ @Published var toTime: String = ""
+ @Published var sendInvitations: Bool = true
+ @Published var participants: [SelectedAddressModel] = []
+ @Published var operationInProgress: Bool = false
+ @Published var conferenceCreatedEvent: Bool = false
+ @Published var conferenceUri: String = ""
+
+ @Published var selectedTimezoneIdx = 0
+ var selectedTimezone = TimeZone.current
+ var knownTimezones: [String] = []
+
+ var conferenceScheduler: ConferenceScheduler?
+ private var mSchedulerDelegate: ConferenceSchedulerDelegate?
+ var conferenceInfoToEdit: ConferenceInfo?
+ @Published var displayedMeeting: MeetingModel? // if nil, then we are currently creating a new meeting
+ @Published var myself: SelectedAddressModel?
+ @Published var fromDate: Date
+ @Published var toDate: Date
+ @Published var errorMsg: String = ""
+
+ init() {
+ fromDate = Calendar.current.date(byAdding: .hour, value: 1, to: Date.now)!
+ toDate = Calendar.current.date(byAdding: .hour, value: 2, to: Date.now)!
+
+ var tzIds = TimeZone.knownTimeZoneIdentifiers
+ tzIds.sort(by: {
+ let gmtOffset0 = TimeZone(identifier: $0)!.secondsFromGMT()
+ let gmtOffset1 = TimeZone(identifier: $1)!.secondsFromGMT()
+ if gmtOffset0 == gmtOffset1 {
+ return $0 < $1 // sort by name if same GMT offset
+ } else {
+ return gmtOffset0 < gmtOffset1
+ }
+ })
+ knownTimezones = tzIds
+ selectedTimezoneIdx = knownTimezones.firstIndex(where: {$0 == selectedTimezone.identifier}) ?? 0
+ computeDateLabels()
+ computeTimeLabels()
+ }
+
+ func resetViewModelData() {
+ isBroadcastSelected = false
+ showBroadcastHelp = false
+ subject = ""
+ description = ""
+ sendInvitations = true
+ participants = []
+ operationInProgress = false
+ conferenceCreatedEvent = false
+ conferenceUri = ""
+ fromDate = Calendar.current.date(byAdding: .hour, value: 1, to: Date.now)!
+ toDate = Calendar.current.date(byAdding: .hour, value: 2, to: Date.now)!
+ selectedTimezone = TimeZone.current
+ selectedTimezoneIdx = knownTimezones.firstIndex(where: {$0 == selectedTimezone.identifier}) ?? 0
+ computeDateLabels()
+ computeTimeLabels()
+ }
+
+ func updateTimezone(timeZone: TimeZone) {
+ selectedTimezone = timeZone
+ computeDateLabels()
+ computeTimeLabels()
+ }
+
+ func computeDateLabels() {
+ let formatter = DateFormatter()
+ formatter.timeZone = selectedTimezone
+ formatter.dateFormat = "EEEE d MMM"
+
+ fromDateStr = formatter.string(from: fromDate)
+ Log.info("\(MeetingViewModel.TAG) computed start date is \(fromDateStr)")
+ toDateStr = formatter.string(from: toDate)
+ Log.info("\(MeetingViewModel.TAG)) computed end date is \(toDateStr)")
+ }
+
+ func computeTimeLabels() {
+ let formatter = DateFormatter()
+ formatter.dateFormat = Locale.current.identifier == "fr_FR" ? "HH:mm" : "h:mm a"
+ formatter.timeZone = selectedTimezone
+ fromTime = formatter.string(from: fromDate)
+ toTime = formatter.string(from: toDate)
+ }
+
+ func getFullDateString() -> String {
+ let formatter = DateFormatter()
+ formatter.dateFormat = "EEE d MMM yyyy"
+ return "\(formatter.string(from: fromDate)) | \(fromTime) - \(toTime)"
+ }
+
+ func addParticipants(participantsToAdd: [SelectedAddressModel]) {
+ var list = participants
+ for selectedAddr in participantsToAdd {
+ if let found = list.first(where: { $0.address.weakEqual(address2: selectedAddr.address) }) {
+ Log.info("\(MeetingViewModel.TAG) Participant \(found.address.asStringUriOnly()) already in list, skipping")
+ continue
+ }
+
+ list.append(selectedAddr)
+ Log.info("\(MeetingViewModel.TAG) Added participant \(selectedAddr.address.asStringUriOnly())")
+ }
+ Log.info("\(MeetingViewModel.TAG) [\(list.count - participants.count) participants added, now there are \(list.count) participants in list")
+
+ participants = list
+ }
+
+ private func fillConferenceInfo(confInfo: ConferenceInfo) {
+ confInfo.subject = self.subject
+ confInfo.description = self.description
+ confInfo.dateTime = time_t(self.fromDate.timeIntervalSince1970)
+ confInfo.duration = UInt(self.fromDate.distance(to: self.toDate) / 60)
+
+ let participantsList = self.participants
+ var participantsInfoList: [ParticipantInfo] = []
+ for participant in participantsList {
+ if let info = try? Factory.Instance.createParticipantInfo(address: participant.address) {
+ // For meetings, all participants must have Speaker role
+ info.role = Participant.Role.Speaker
+ participantsInfoList.append(info)
+ } else {
+ Log.error("\(MeetingViewModel.TAG) Failed to create Participant Info from address \(participant.address.asStringUriOnly())")
+ }
+ }
+ confInfo.participantInfos = participantsInfoList
+ }
+
+ private func sendIcsInvitation(core: Core) {
+ if let chatRoomParams = try? core.createDefaultChatRoomParams() {
+ chatRoomParams.groupEnabled = false
+ chatRoomParams.backend = ChatRoom.Backend.FlexisipChat
+ chatRoomParams.encryptionEnabled = true
+ chatRoomParams.subject = "Meeting ics"
+ self.conferenceScheduler?.sendInvitations(chatRoomParams: chatRoomParams)
+ } else {
+ Log.error("\(MeetingViewModel.TAG) Failed to create default chatroom parameters. This should not happen")
+ }
+ }
+
+ private func resetConferenceSchedulerAndListeners(core: Core) {
+ self.mSchedulerDelegate = nil
+ self.conferenceScheduler = try? core.createConferenceScheduler()
+
+ self.mSchedulerDelegate = ConferenceSchedulerDelegateStub(onStateChanged: { (_: ConferenceScheduler, state: ConferenceScheduler.State) in
+ Log.info("\(MeetingViewModel.TAG) Conference state changed \(state)")
+ if state == ConferenceScheduler.State.Error {
+ DispatchQueue.main.async {
+ self.operationInProgress = false
+ self.errorMsg = (self.displayedMeeting != nil) ? "Could not edit conference" : "Could not create conference"
+ // TODO: show error toast
+ }
+ } else if state == ConferenceScheduler.State.Ready {
+ let conferenceAddress = self.conferenceScheduler?.info?.uri
+ if let confInfoToEdit = self.conferenceInfoToEdit {
+ Log.info("\(MeetingViewModel.TAG) Conference info \(confInfoToEdit.uri?.asStringUriOnly() ?? "'nil'") has been updated")
+ } else {
+ Log.info("\(MeetingViewModel.TAG) Conference info created, address will be \(conferenceAddress?.asStringUriOnly() ?? "'nil'")")
+ }
+
+ if self.sendInvitations {
+ Log.info("\(MeetingViewModel.TAG) User asked for invitations to be sent, let's do it")
+ self.sendIcsInvitation(core: core)
+ } else {
+ Log.info("\(MeetingViewModel.TAG) User didn't asked for invitations to be sent")
+ DispatchQueue.main.async {
+ self.operationInProgress = false
+ self.conferenceCreatedEvent = true
+ }
+ }
+ } else if state == ConferenceScheduler.State.Updating {
+ self.sendIcsInvitation(core: core)
+ }
+ }, onInvitationsSent: { (_: ConferenceScheduler, failedInvitations: [Address]) in
+
+ if failedInvitations.isEmpty {
+ Log.info("\(MeetingViewModel.TAG) All invitations have been sent")
+ } else if failedInvitations.count == self.participants.count {
+ Log.error("\(MeetingViewModel.TAG) No invitation sent!")
+ DispatchQueue.main.async {
+ ToastViewModel.shared.toastMessage = "Failed_meeting_invitations_not_sent"
+ ToastViewModel.shared.displayToast = true
+ }
+ } else {
+ var failInvList = ""
+ for failInv in failedInvitations {
+ if !failInvList.isEmpty {
+ failInvList += ", "
+ }
+ failInvList.append(failInv.asStringUriOnly())
+ }
+ Log.warn("\(MeetingViewModel.TAG) \(failedInvitations.count) invitations couldn't have been sent to: \(failInvList)")
+ DispatchQueue.main.async {
+ ToastViewModel.shared.toastMessage = "Error: \(failedInvitations.count) invitations couldn't be sent to \(failInvList)"
+ ToastViewModel.shared.displayToast = true
+ }
+ }
+
+ DispatchQueue.main.async {
+ self.operationInProgress = false
+ self.conferenceCreatedEvent = true
+ }
+ })
+ self.conferenceScheduler?.addDelegate(delegate: self.mSchedulerDelegate!)
+ }
+
+ func schedule() {
+ guard !subject.isEmpty && !participants.isEmpty else {
+ Log.error("\(MeetingViewModel.TAG) Either no subject was set or no participant was selected, can't schedule meeting.")
+ DispatchQueue.main.async {
+ ToastViewModel.shared.toastMessage = "Failed_no_subject_or_participant"
+ ToastViewModel.shared.displayToast = true
+ }
+ return
+ }
+
+ guard CoreContext.shared.networkStatusIsConnected else {
+ DispatchQueue.main.async {
+ ToastViewModel.shared.toastMessage = "Unavailable_network"
+ ToastViewModel.shared.displayToast = true
+ }
+ return
+ }
+
+ operationInProgress = true
+ CoreContext.shared.doOnCoreQueue { core in
+ Log.info("\(MeetingViewModel.TAG) Scheduling \(self.isBroadcastSelected ? "broadcast" : "meeting")")
+
+ if let conferenceInfo = (self.displayedMeeting != nil ? self.displayedMeeting!.confInfo : try? Factory.Instance.createConferenceInfo()) {
+ let localAccount = core.defaultAccount
+ conferenceInfo.organizer = localAccount?.params?.identityAddress
+ self.fillConferenceInfo(confInfo: conferenceInfo)
+ self.resetConferenceSchedulerAndListeners(core: core)
+ self.conferenceScheduler?.account = localAccount
+ // Will trigger the conference creation automatically
+ self.conferenceScheduler?.info = conferenceInfo
+ }
+ }
+ }
+
+ func update() {
+ self.operationInProgress = true
+ CoreContext.shared.doOnCoreQueue { core in
+ Log.info("\(MeetingViewModel.TAG) Updating \(self.isBroadcastSelected ? "broadcast" : "meeting")")
+
+ if let conferenceInfo = self.conferenceInfoToEdit {
+ self.fillConferenceInfo(confInfo: conferenceInfo)
+ self.resetConferenceSchedulerAndListeners(core: core)
+
+ // Will trigger the conference update automatically
+ self.conferenceScheduler?.info = conferenceInfo
+ } else {
+ Log.error("No conference info to edit found!")
+ return
+ }
+ }
+ }
+
+ // Warning: must be called from core queue. Removed the dispatchQueue.main.async in order to have the animation properly trigger.
+ func loadExistingMeeting(meeting: MeetingModel) {
+ self.resetViewModelData()
+ self.subject = meeting.confInfo.subject ?? ""
+ self.description = meeting.confInfo.description ?? ""
+ self.fromDate = meeting.meetingDate
+ self.toDate = meeting.endDate
+ self.participants = []
+
+ CoreContext.shared.doOnCoreQueue { core in
+ let organizer = meeting.confInfo.organizer
+ var organizerFound = false
+
+ if let myAddr = core.defaultAccount?.contactAddress {
+ let isOrganizer = (organizer != nil) ? myAddr.weakEqual(address2: organizer!) : false
+ organizerFound = organizerFound || isOrganizer
+ ContactAvatarModel.getAvatarModelFromAddress(address: myAddr) { avatarResult in
+ DispatchQueue.main.async {
+ self.myself = SelectedAddressModel(addr: myAddr, avModel: avatarResult, isOrg: isOrganizer)
+ }
+ }
+ }
+
+ for pInfo in meeting.confInfo.participantInfos {
+ if let addr = pInfo.address {
+ let isOrganizer = (organizer != nil) ? addr.weakEqual(address2: organizer!) : false
+ organizerFound = organizerFound || isOrganizer
+ ContactAvatarModel.getAvatarModelFromAddress(address: addr) { avatarResult in
+ DispatchQueue.main.async {
+ self.participants.append(SelectedAddressModel(addr: addr, avModel: avatarResult, isOrg: isOrganizer))
+ }
+ }
+ }
+ }
+
+ // if didn't find organizer, add him
+ if !organizerFound, let org = organizer {
+ ContactAvatarModel.getAvatarModelFromAddress(address: org) { avatarResult in
+ DispatchQueue.main.async {
+ self.participants.append(SelectedAddressModel(addr: org, avModel: avatarResult, isOrg: true))
+ }
+ }
+ }
+ }
+
+ self.conferenceUri = meeting.confInfo.uri?.asStringUriOnly() ?? ""
+ self.computeDateLabels()
+ self.computeTimeLabels()
+ self.displayedMeeting = meeting
+ }
+
+ func cancelMeetingWithNotifications(meeting: MeetingModel) {
+ CoreContext.shared.doOnCoreQueue { core in
+ self.resetConferenceSchedulerAndListeners(core: core)
+ self.conferenceScheduler?.cancelConference(conferenceInfo: meeting.confInfo)
+ }
+ }
+
+ func createMeetingEKEvent() -> EKEvent {
+ let event: EKEvent = EKEvent(eventStore: eventStore)
+ event.title = subject
+ event.startDate = fromDate
+ event.endDate = toDate
+ event.notes = description
+ event.calendar = eventStore.defaultCalendarForNewEvents
+ event.location = "Linphone video meeting"
+ return event
+ }
+ // For iOS 16 and below
+ func addMeetingToCalendar() {
+ eventStore.requestAccess(to: .event, completion: { (granted: Bool, error: (any Error)?) in
+ if !granted {
+ Log.error("\(MeetingViewModel.TAG) Failed to add meeting to calendar: access not granted")
+ } else if error == nil {
+ let event = self.createMeetingEKEvent()
+ do {
+ try self.eventStore.save(event, span: .thisEvent)
+ Log.info("\(MeetingViewModel.TAG) Meeting '\(self.subject)' added to calendar")
+ ToastViewModel.shared.toastMessage = "Meeting_added_to_calendar"
+ ToastViewModel.shared.displayToast = true
+ } catch let error as NSError {
+ Log.error("\(MeetingViewModel.TAG) Failed to add meeting to calendar: \(error)")
+ ToastViewModel.shared.toastMessage = "Error: \(error)"
+ ToastViewModel.shared.displayToast = true
+ }
+ } else {
+ Log.error("\(MeetingViewModel.TAG) Failed to add meeting to calendar: \(error?.localizedDescription ?? "")")
+ }
+ })
+ }
+
+ func joinMeeting(addressUri: String) {
+ CoreContext.shared.doOnCoreQueue { _ in
+ if let address = try? Factory.Instance.createAddress(addr: addressUri) {
+ TelecomManager.shared.doCallOrJoinConf(address: address)
+ }
+ }
+ }
+}
+
+// swiftlint:enable type_body_length
diff --git a/Linphone/UI/Main/Meetings/ViewModel/MeetingsListViewModel.swift b/Linphone/UI/Main/Meetings/ViewModel/MeetingsListViewModel.swift
new file mode 100644
index 000000000..1a89b6bfa
--- /dev/null
+++ b/Linphone/UI/Main/Meetings/ViewModel/MeetingsListViewModel.swift
@@ -0,0 +1,148 @@
+/*
+ * Copyright (c) 2010-2024 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 .
+ */
+
+import Foundation
+import linphonesw
+import Combine
+
+class MeetingsListViewModel: ObservableObject {
+ static let TAG = "[Meetings ListViewModel]"
+ static let ScrollToTodayNotification = Notification.Name("ScrollToToday")
+
+ private var coreContext = CoreContext.shared
+ private var mMeetingsListCoreDelegate: CoreDelegate?
+ var selectedMeetingToDelete: MeetingModel?
+
+ @Published var meetingsList: [MeetingsListItemModel] = []
+ @Published var currentFilter = ""
+ @Published var todayIdx = 0
+
+ init() {
+ coreContext.doOnCoreQueue { core in
+ self.mMeetingsListCoreDelegate = CoreDelegateStub(onConferenceInfoReceived: { (_: Core, conferenceInfo: ConferenceInfo) in
+ Log.info("\(MeetingsListViewModel.TAG) Conference info received [\(conferenceInfo.uri?.asStringUriOnly() ?? "NIL")")
+ self.computeMeetingsList()
+ })
+ core.addDelegate(delegate: self.mMeetingsListCoreDelegate!)
+ }
+ computeMeetingsList()
+ }
+
+ func computeMeetingsList() {
+ let filter = self.currentFilter.uppercased()
+ let isFiltering = !filter.isEmpty
+
+ coreContext.doOnCoreQueue { core in
+ var confInfoList: [ConferenceInfo] = []
+
+ if let account = core.defaultAccount {
+ confInfoList = account.conferenceInformationList
+ }
+ if confInfoList.isEmpty {
+ confInfoList = core.conferenceInformationList
+ }
+
+ var meetingsListTmp: [MeetingsListItemModel] = []
+ var meetingForTodayFound = false
+ var currentIdx = 0
+ var todayIdx = 0
+ for confInfo in confInfoList {
+ if confInfo.duration == 0 { continue }// This isn't a scheduled conference, don't display it
+ var add = true
+ if !filter.isEmpty {
+ let organizerCheck = confInfo.organizer?.asStringUriOnly().range(of: filter, options: .caseInsensitive) != nil
+ let subjectCheck = confInfo.subject?.range(of: filter, options: .caseInsensitive) != nil
+ let descriptionCheck = confInfo.description?.range(of: filter, options: .caseInsensitive) != nil
+ let participantsCheck = confInfo.participantInfos.first(
+ where: {$0.address?.asStringUriOnly().range(of: filter, options: .caseInsensitive) != nil}
+ ) != nil
+
+ add = organizerCheck || subjectCheck || descriptionCheck || participantsCheck
+ }
+
+ if add {
+ let model = MeetingModel(conferenceInfo: confInfo)
+
+ if !meetingForTodayFound && !isFiltering {
+ if model.isToday {
+ meetingForTodayFound = true
+ todayIdx = currentIdx
+ } else if model.isAfterToday {
+ // If no meeting was found for today, insert "Today" fake model before the next meeting to come
+ meetingsListTmp.append(MeetingsListItemModel(meetingModel: nil))
+ meetingForTodayFound = true
+ todayIdx = currentIdx
+ }
+ }
+
+ var matchFilter = !isFiltering
+ if isFiltering {
+ matchFilter = matchFilter || confInfo.subject?.uppercased().contains(filter) ?? false
+ matchFilter = matchFilter || confInfo.description?.uppercased().contains(filter) ?? false
+ matchFilter = matchFilter || confInfo.organizer?.asStringUriOnly().uppercased().contains(filter) ?? false
+ for pInfo in confInfo.participantInfos {
+ matchFilter = matchFilter || pInfo.address?.asStringUriOnly().uppercased().contains(filter) ?? false
+ }
+ }
+
+ if matchFilter {
+ meetingsListTmp.append(MeetingsListItemModel(meetingModel: model))
+ currentIdx += 1
+ }
+ }
+ }
+
+ if !meetingForTodayFound && !meetingsListTmp.isEmpty {
+ // All meetings in the list happened in the past, add "Today" fake model at the end
+ meetingsListTmp.append(MeetingsListItemModel(meetingModel: nil))
+ todayIdx = currentIdx
+ }
+
+ DispatchQueue.main.sync {
+ self.todayIdx = todayIdx
+ self.meetingsList = meetingsListTmp
+ }
+ }
+ }
+
+ func deleteSelectedMeeting() {
+ guard let meetingToDelete = selectedMeetingToDelete else {
+ Log.error("\(MeetingsListViewModel.TAG) Could not delete meeting because none was selected")
+ return
+ }
+
+ coreContext.doOnCoreQueue { core in
+ core.deleteConferenceInformation(conferenceInfo: meetingToDelete.confInfo)
+ }
+
+ if let index = self.meetingsList.firstIndex(where: { $0.model?.address == meetingToDelete.address }) {
+ if self.todayIdx > index {
+ // bump todayIdx one place up
+ self.todayIdx -= 1
+ }
+ self.meetingsList.remove(at: index)
+ if self.meetingsList.count == 1 && self.meetingsList[0].model == nil {
+ // Only remaining meeting is the fake TodayMeeting, remove it too
+ meetingsList.removeAll()
+ }
+ ToastViewModel.shared.toastMessage = "Success_toast_meeting_deleted"
+ ToastViewModel.shared.displayToast = true
+ }
+ }
+}
diff --git a/Linphone/UI/Main/Viewmodel/AccountModel.swift b/Linphone/UI/Main/Viewmodel/AccountModel.swift
new file mode 100644
index 000000000..a920fecfc
--- /dev/null
+++ b/Linphone/UI/Main/Viewmodel/AccountModel.swift
@@ -0,0 +1,114 @@
+/*
+ * Copyright (c) 2010-2024 Belledonne Communications SARL.
+ *
+ * This file is part of Linphone
+ *
+ * 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 .
+ */
+
+import Foundation
+import linphonesw
+import SwiftUI
+import Combine
+
+class AccountModel: ObservableObject {
+ let account: Account
+ @Published var humanReadableRegistrationState: String = ""
+ @Published var registrationStateAssociatedUIColor: Color = .clear
+ @Published var notificationsCount: Int = 0
+ @Published var isDefaultAccount: Bool = false
+ @Published var displayName: String = ""
+ @Published var address: String = ""
+
+ private var accountDelegate: AccountDelegate?
+ private var coreDelegate: CoreDelegate?
+
+ init(account: Account, core: Core) {
+ self.account = account
+
+ accountDelegate = AccountDelegateStub(onRegistrationStateChanged: { (_: Account, _: RegistrationState, _: String) in
+ self.update()
+ })
+ account.addDelegate(delegate: accountDelegate!)
+
+ coreDelegate = CoreDelegateStub(onCallStateChanged: { (_: Core, _: Call, _: Call.State, _: String) in
+ self.computeNotificationsCount()
+ }, onMessagesReceived: { (_: Core, _: ChatRoom, _: [ChatMessage]) in
+ self.computeNotificationsCount()
+ }, onChatRoomRead: { (_: Core, _: ChatRoom) in
+ self.computeNotificationsCount()
+ })
+ core.addDelegate(delegate: coreDelegate!)
+
+ CoreContext.shared.doOnCoreQueue { _ in
+ self.update()
+ }
+ }
+
+ deinit {
+ if let delegate = accountDelegate {
+ account.removeDelegate(delegate: delegate)
+ }
+ if let delegate = coreDelegate {
+ CoreContext.shared.doOnCoreQueue { core in
+ core.removeDelegate(delegate: delegate)
+ }
+ }
+ }
+
+ private func update() {
+ let state = account.state
+ var isDefault: Bool = false
+ if let defaultAccount = account.core?.defaultAccount {
+ isDefault = (defaultAccount == account)
+ }
+ let displayName = account.displayName()
+ let address = account.params?.identityAddress?.asString()
+ DispatchQueue.main.async { [self] in
+ switch state {
+ case .Cleared, .None:
+ humanReadableRegistrationState = "drawer_menu_account_connection_status_cleared".localized()
+ registrationStateAssociatedUIColor = .orangeWarning600
+ case .Progress:
+ humanReadableRegistrationState = "drawer_menu_account_connection_status_progress".localized()
+ registrationStateAssociatedUIColor = .greenSuccess500
+ case .Failed:
+ humanReadableRegistrationState = "drawer_menu_account_connection_status_failed".localized()
+ registrationStateAssociatedUIColor = .redDanger500
+ case .Ok:
+ humanReadableRegistrationState = "drawer_menu_account_connection_status_connected".localized()
+ registrationStateAssociatedUIColor = .greenSuccess500
+ case .Refreshing:
+ humanReadableRegistrationState = "drawer_menu_account_connection_status_refreshing".localized()
+ registrationStateAssociatedUIColor = .grayMain2c500
+ }
+ isDefaultAccount = isDefault
+ self.displayName = displayName
+ address.map {self.address = $0}
+ }
+ }
+
+ private func computeNotificationsCount() {
+ let count = account.unreadChatMessageCount + account.missedCallsCount
+ DispatchQueue.main.async { [self] in
+ notificationsCount = count
+ }
+ }
+
+ func refreshRegiter() {
+ CoreContext.shared.doOnCoreQueue { _ in
+ self.account.refreshRegister()
+ }
+ }
+}
diff --git a/Linphone/UI/Main/Viewmodel/AddParticipantsViewModel.swift b/Linphone/UI/Main/Viewmodel/AddParticipantsViewModel.swift
new file mode 100644
index 000000000..67ee27ff8
--- /dev/null
+++ b/Linphone/UI/Main/Viewmodel/AddParticipantsViewModel.swift
@@ -0,0 +1,60 @@
+/*
+ * Copyright (c) 2010-2024 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 .
+ */
+
+import Foundation
+import linphonesw
+import Combine
+
+class SelectedAddressModel: ObservableObject {
+ var address: Address
+ var avatarModel: ContactAvatarModel
+ var isOrganizer: Bool = false
+
+ init (addr: Address, avModel: ContactAvatarModel, isOrg: Bool = false) {
+ address = addr
+ avatarModel = avModel
+ isOrganizer = isOrg
+ }
+}
+
+class AddParticipantsViewModel: ObservableObject {
+ static let TAG = "[AddParticipantsViewModel]"
+
+ @Published var participantsToAdd: [SelectedAddressModel] = []
+ @Published var searchField: String = ""
+
+ func selectParticipant(addr: Address) {
+ if let idx = participantsToAdd.firstIndex(where: {$0.address.weakEqual(address2: addr)}) {
+ Log.info("[\(AddParticipantsViewModel.TAG)] Removing participant \(addr.asStringUriOnly()) from selection")
+ participantsToAdd.remove(at: idx)
+ } else {
+ Log.info("[\(AddParticipantsViewModel.TAG)] Adding participant \(addr.asStringUriOnly()) to selection")
+ ContactAvatarModel.getAvatarModelFromAddress(address: addr) { avatarResult in
+ DispatchQueue.main.async {
+ self.participantsToAdd.append(SelectedAddressModel(addr: addr, avModel: avatarResult))
+ }
+ }
+ }
+ }
+
+ func reset() {
+ participantsToAdd = []
+ searchField = ""
+ }
+}
diff --git a/Linphone/UI/Main/Viewmodel/SharedMainViewModel.swift b/Linphone/UI/Main/Viewmodel/SharedMainViewModel.swift
new file mode 100644
index 000000000..abb48824b
--- /dev/null
+++ b/Linphone/UI/Main/Viewmodel/SharedMainViewModel.swift
@@ -0,0 +1,85 @@
+/*
+ * Copyright (c) 2010-2023 Belledonne Communications SARL.
+ *
+ * This file is part of Linphone
+ *
+ * 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 .
+ */
+
+import linphonesw
+
+class SharedMainViewModel: ObservableObject {
+
+ static let shared = SharedMainViewModel()
+
+ @Published var welcomeViewDisplayed = false
+ @Published var generalTermsAccepted = false
+ @Published var displayProfileMode = false
+
+ let welcomeViewKey = "welcome_view"
+ let generalTermsKey = "general_terms"
+ let displayProfileModeKey = "display_profile_mode"
+
+ var maxWidth = 400.0
+
+ private init() {
+ let preferences = UserDefaults.standard
+
+ if preferences.object(forKey: welcomeViewKey) == nil {
+ preferences.set(welcomeViewDisplayed, forKey: welcomeViewKey)
+ } else {
+ welcomeViewDisplayed = preferences.bool(forKey: welcomeViewKey)
+ }
+
+ if preferences.object(forKey: generalTermsKey) == nil {
+ preferences.set(generalTermsAccepted, forKey: generalTermsKey)
+ } else {
+ generalTermsAccepted = preferences.bool(forKey: generalTermsKey)
+ }
+
+ if preferences.object(forKey: displayProfileModeKey) == nil {
+ preferences.set(displayProfileMode, forKey: displayProfileModeKey)
+ } else {
+ displayProfileMode = preferences.bool(forKey: displayProfileModeKey)
+ }
+ }
+
+ func changeWelcomeView() {
+ let preferences = UserDefaults.standard
+
+ welcomeViewDisplayed = true
+ preferences.set(welcomeViewDisplayed, forKey: welcomeViewKey)
+ }
+
+ func changeGeneralTerms() {
+ let preferences = UserDefaults.standard
+
+ generalTermsAccepted = true
+ preferences.set(generalTermsAccepted, forKey: generalTermsKey)
+ }
+
+ func changeDisplayProfileMode() {
+ let preferences = UserDefaults.standard
+
+ displayProfileMode = true
+ preferences.set(displayProfileMode, forKey: displayProfileModeKey)
+ }
+
+ func changeHideProfileMode() {
+ let preferences = UserDefaults.standard
+
+ displayProfileMode = false
+ preferences.set(displayProfileMode, forKey: displayProfileModeKey)
+ }
+}
diff --git a/Linphone/UI/Main/Viewmodel/ToastViewModel.swift b/Linphone/UI/Main/Viewmodel/ToastViewModel.swift
new file mode 100644
index 000000000..b046c5a06
--- /dev/null
+++ b/Linphone/UI/Main/Viewmodel/ToastViewModel.swift
@@ -0,0 +1,31 @@
+/*
+ * Copyright (c) 2010-2023 Belledonne Communications SARL.
+ *
+ * This file is part of Linphone
+ *
+ * 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 .
+ */
+
+import Foundation
+
+class ToastViewModel: ObservableObject {
+
+ static let shared = ToastViewModel()
+
+ var toastMessage: String = ""
+ @Published var displayToast = false
+
+ private init() {
+ }
+}
diff --git a/Linphone/UI/Welcome/Fragments/WelcomePage1Fragment.swift b/Linphone/UI/Welcome/Fragments/WelcomePage1Fragment.swift
new file mode 100644
index 000000000..aabdc6503
--- /dev/null
+++ b/Linphone/UI/Welcome/Fragments/WelcomePage1Fragment.swift
@@ -0,0 +1,52 @@
+/*
+ * Copyright (c) 2010-2023 Belledonne Communications SARL.
+ *
+ * This file is part of Linphone
+ *
+ * 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 .
+ */
+
+import Foundation
+import SwiftUI
+
+struct WelcomePage1Fragment: View {
+
+ var body: some View {
+ VStack {
+ Spacer()
+ VStack {
+ Image("linphone")
+ .renderingMode(.template)
+ .resizable()
+ .foregroundStyle(Color.orangeMain500)
+ .frame(width: 100, height: 100)
+ Text("Linphone")
+ .welcome_text_style_gray_800(styleSize: 30)
+ .padding(.bottom, 20)
+ Text("Une application de communication **sécurisée**, **open source** et **française**.")
+ .welcome_text_style_gray(styleSize: 15)
+ .multilineTextAlignment(.center)
+
+ }
+ Spacer()
+ Spacer()
+ }
+ .frame(maxWidth: .infinity)
+ .padding(.horizontal, 20)
+ }
+}
+
+#Preview {
+ WelcomePage1Fragment()
+}
diff --git a/Linphone/UI/Welcome/Fragments/WelcomePage2Fragment.swift b/Linphone/UI/Welcome/Fragments/WelcomePage2Fragment.swift
new file mode 100644
index 000000000..b8cb97f3f
--- /dev/null
+++ b/Linphone/UI/Welcome/Fragments/WelcomePage2Fragment.swift
@@ -0,0 +1,51 @@
+/*
+ * Copyright (c) 2010-2023 Belledonne Communications SARL.
+ *
+ * This file is part of Linphone
+ *
+ * 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 .
+ */
+
+import Foundation
+import SwiftUI
+
+struct WelcomePage2Fragment: View {
+
+ var body: some View {
+ VStack {
+ Spacer()
+ VStack {
+ Image("secured")
+ .renderingMode(.template)
+ .resizable()
+ .foregroundStyle(Color.orangeMain500)
+ .frame(width: 70, height: 100)
+ Text("Sécurisé")
+ .welcome_text_style_gray_800(styleSize: 30)
+ .padding(.bottom, 20)
+ Text("Vos communications sont en sécurité grâce aux **Chiffrement de bout en bout**.")
+ .welcome_text_style_gray(styleSize: 15)
+ .multilineTextAlignment(.center)
+
+ }
+ Spacer()
+ Spacer()
+ }
+ .frame(maxWidth: .infinity)
+ }
+}
+
+#Preview {
+ WelcomePage2Fragment()
+}
diff --git a/Linphone/UI/Welcome/Fragments/WelcomePage3Fragment.swift b/Linphone/UI/Welcome/Fragments/WelcomePage3Fragment.swift
new file mode 100644
index 000000000..1f9256037
--- /dev/null
+++ b/Linphone/UI/Welcome/Fragments/WelcomePage3Fragment.swift
@@ -0,0 +1,51 @@
+/*
+ * Copyright (c) 2010-2023 Belledonne Communications SARL.
+ *
+ * This file is part of Linphone
+ *
+ * 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 .
+ */
+
+import Foundation
+import SwiftUI
+
+struct WelcomePage3Fragment: View {
+
+ var body: some View {
+ VStack {
+ Spacer()
+ VStack {
+ Image("open-source")
+ .renderingMode(.template)
+ .resizable()
+ .foregroundStyle(Color.orangeMain500)
+ .frame(width: 100, height: 100)
+ Text("Open source")
+ .welcome_text_style_gray_800(styleSize: 30)
+ .padding(.bottom, 20)
+ Text("Une application open source et un **service gratuit** depuis **2001**.")
+ .welcome_text_style_gray(styleSize: 15)
+ .multilineTextAlignment(.center)
+
+ }
+ Spacer()
+ Spacer()
+ }
+ .frame(maxWidth: .infinity)
+ }
+}
+
+#Preview {
+ WelcomePage3Fragment()
+}
diff --git a/Linphone/UI/Welcome/WelcomeView.swift b/Linphone/UI/Welcome/WelcomeView.swift
new file mode 100644
index 000000000..a5d341422
--- /dev/null
+++ b/Linphone/UI/Welcome/WelcomeView.swift
@@ -0,0 +1,164 @@
+/*
+ * Copyright (c) 2010-2023 Belledonne Communications SARL.
+ *
+ * This file is part of Linphone
+ *
+ * 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 .
+ */
+
+import SwiftUI
+
+struct WelcomeView: View {
+
+ @ObservedObject private var sharedMainViewModel = SharedMainViewModel.shared
+
+ @State private var index = 0
+
+ var body: some View {
+ NavigationView {
+ GeometryReader { geometry in
+ ScrollView {
+ VStack {
+ ZStack {
+ Image("mountain")
+ .resizable()
+ .scaledToFill()
+ .frame(width: geometry.size.width, height: 100)
+ .clipped()
+
+ VStack(alignment: .trailing) {
+ NavigationLink(destination: {
+ PermissionsFragment()
+ }, label: {
+ Text("Skip")
+ .underline()
+ .default_text_style_600(styleSize: 15)
+
+ })
+ .padding(.top, -35)
+ .padding(.trailing, 20)
+ .simultaneousGesture(
+ TapGesture().onEnded {
+ self.index = 2
+ }
+ )
+ Text("Welcome")
+ .welcome_text_style_white_800(styleSize: 35)
+ .padding(.trailing, 100)
+ .frame(width: geometry.size.width)
+ .padding(.bottom, -25)
+ Text("to Linphone")
+ .welcome_text_style_white_800(styleSize: 25)
+ .padding(.leading, 100)
+ .frame(width: geometry.size.width)
+ .padding(.bottom, -10)
+ }
+ .frame(width: geometry.size.width)
+ }
+ .padding(.top, 35)
+ .padding(.bottom, 10)
+
+ Spacer()
+
+ VStack {
+ TabView(selection: $index) {
+ ForEach((0..<3), id: \.self) { index in
+ if index == 0 {
+ WelcomePage1Fragment()
+ } else if index == 1 {
+ WelcomePage2Fragment()
+ } else if index == 2 {
+ WelcomePage3Fragment()
+ } else {
+ WelcomePage1Fragment()
+ }
+ }
+ }
+ .tabViewStyle(PageTabViewStyle(indexDisplayMode: .always))
+ .frame(minHeight: 300)
+ .onAppear {
+ setupAppearance()
+ }
+ }
+
+ Spacer()
+
+ if index == 2 {
+ NavigationLink(destination: {
+ PermissionsFragment()
+ }, label: {
+ Text("Start")
+ .default_text_style_white_600(styleSize: 20)
+ .frame(height: 35)
+ .frame(maxWidth: .infinity)
+
+ })
+ .padding(.horizontal, 20)
+ .padding(.vertical, 10)
+ .background(Color.orangeMain500)
+ .cornerRadius(60)
+ .padding(.horizontal)
+ .padding(.bottom, geometry.safeAreaInsets.bottom.isEqual(to: 0.0) ? 20 : 0)
+ .frame(maxWidth: sharedMainViewModel.maxWidth)
+ } else {
+ Button(action: {
+ withAnimation {
+ index += 1
+ }
+ }, label: {
+ Text("Next")
+ .default_text_style_white_600(styleSize: 20)
+ .frame(height: 35)
+ .frame(maxWidth: .infinity)
+ })
+ .padding(.horizontal, 20)
+ .padding(.vertical, 10)
+ .background(Color.orangeMain500)
+ .cornerRadius(60)
+ .padding(.horizontal)
+ .padding(.bottom, geometry.safeAreaInsets.bottom.isEqual(to: 0.0) ? 20 : 0)
+ .frame(maxWidth: sharedMainViewModel.maxWidth)
+ }
+ }
+ .frame(minHeight: geometry.size.height)
+ }
+ }
+ .navigationTitle("")
+ .navigationBarHidden(true)
+ }
+ .navigationViewStyle(StackNavigationViewStyle())
+ }
+
+ func setupAppearance() {
+ UIPageControl.appearance().currentPageIndicatorTintColor = UIColor(Color.orangeMain500)
+ if #available(iOS 16.0, *) {
+
+ let dotCurrentImage = UIImage(named: "current-dot")
+ let dotImage = UIImage(named: "dot")
+
+ UIPageControl.appearance().setCurrentPageIndicatorImage(dotCurrentImage, forPage: 0)
+ UIPageControl.appearance().setCurrentPageIndicatorImage(dotCurrentImage, forPage: 1)
+ UIPageControl.appearance().setCurrentPageIndicatorImage(dotCurrentImage, forPage: 2)
+
+ UIPageControl.appearance().setIndicatorImage(dotImage, forPage: 0)
+ UIPageControl.appearance().setIndicatorImage(dotImage, forPage: 1)
+ UIPageControl.appearance().setIndicatorImage(dotImage, forPage: 2)
+ }
+ UIPageControl.appearance().pageIndicatorTintColor = UIColor(Color.grayMain2c200)
+ }
+}
+
+#Preview {
+ WelcomeView()
+}
diff --git a/Linphone/Utils/ActivityIndicator.swift b/Linphone/Utils/ActivityIndicator.swift
new file mode 100644
index 000000000..d7b386fbe
--- /dev/null
+++ b/Linphone/Utils/ActivityIndicator.swift
@@ -0,0 +1,44 @@
+/*
+ * Copyright (c) 2010-2024 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 .
+ */
+
+import SwiftUI
+
+struct ActivityIndicator: View {
+
+ let style = StrokeStyle(lineWidth: 3, lineCap: .round)
+ @State var animate = false
+ let color: Color
+
+ var body: some View {
+ ZStack {
+ Circle()
+ .trim(from: 0, to: 0.7)
+ .stroke(
+ AngularGradient(gradient: .init(colors: [color, color.opacity(0.5)]), center: .center), style: style)
+ .rotationEffect(Angle(degrees: animate ? 360: 0))
+ .animation(Animation.linear(duration: 0.7).repeatForever(autoreverses: false), value: UUID())
+ }.onAppear {
+ self.animate.toggle()
+ }
+ }
+}
+
+#Preview {
+ ActivityIndicator(color: .white)
+}
diff --git a/Linphone/Utils/AudioRouteUtils.swift b/Linphone/Utils/AudioRouteUtils.swift
new file mode 100644
index 000000000..01c418383
--- /dev/null
+++ b/Linphone/Utils/AudioRouteUtils.swift
@@ -0,0 +1,194 @@
+/*
+ * Copyright (c) 2010-2023 Belledonne Communications SARL.
+ *
+ * This file is part of linphone-android
+ * (see https://www.linphone.org).
+ *
+ * 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 .
+ */
+// swiftlint:disable line_length
+
+import Foundation
+import AVFoundation
+import linphonesw
+
+class AudioRouteUtils {
+
+ static private func applyAudioRouteChange( core: Core, call: Call?, types: [AudioDevice.Kind], output: Bool = true) {
+ let typesNames = types.map { String(describing: $0) }.joined(separator: "/")
+
+ let currentCall = core.callsNb > 0 ? (call != nil) ? call : core.currentCall != nil ? core.currentCall : core.calls[0] : nil
+ if currentCall == nil {
+ print("[Audio Route Helper] No call found, setting audio route on Core")
+ }
+ let conference = call?.conference
+ let capability = output ? AudioDevice.Capabilities.CapabilityPlay : AudioDevice.Capabilities.CapabilityRecord
+
+ var found = false
+
+ core.audioDevices.forEach { (audioDevice) in
+ print("[Audio Route Helper] registered core audio devices are : [\(audioDevice.deviceName)] [\(audioDevice.type)] [\(audioDevice.capabilities)] ")
+ }
+
+ core.audioDevices.forEach { (audioDevice) in
+ if !found && types.contains(audioDevice.type) && audioDevice.hasCapability(capability: capability) {
+ if conference != nil && conference?.isIn == true {
+ print("[Audio Route Helper] Found [\(audioDevice.type)] \(output ? "playback" : "recorder") audio device [\(audioDevice.deviceName)], routing conference audio to it")
+ if output {
+ conference?.outputAudioDevice = audioDevice
+ } else {
+ conference?.inputAudioDevice = audioDevice
+ }
+ } else if currentCall != nil {
+ print("[Audio Route Helper] Found [\(audioDevice.type)] \(output ? "playback" : "recorder") audio device [\(audioDevice.deviceName)], routing call audio to it")
+ if output {
+ currentCall?.outputAudioDevice = audioDevice
+ } else {
+ currentCall?.inputAudioDevice = audioDevice
+ }
+ } else {
+ print("[Audio Route Helper] Found [\(audioDevice.type)] \(output ? "playback" : "recorder") audio device [\(audioDevice.deviceName)], changing core default audio device")
+ if output {
+ core.outputAudioDevice = audioDevice
+ } else {
+ core.inputAudioDevice = audioDevice
+ }
+ }
+ found = true
+ }
+ }
+ if !found {
+ print("[Audio Route Helper] Couldn't find \(typesNames) audio device")
+ }
+ }
+
+ static private func changeCaptureDeviceToMatchAudioRoute(core: Core, call: Call?, types: [AudioDevice.Kind]) {
+ switch types.first {
+ case .Bluetooth: if isBluetoothAudioRecorderAvailable(core: core) {
+ print("[Audio Route Helper] Bluetooth device is able to record audio, also change input audio device")
+ applyAudioRouteChange(core: core, call: call, types: [AudioDevice.Kind.Bluetooth], output: false)
+ }
+ case .Headset, .Headphones: if isHeadsetAudioRecorderAvailable(core: core) {
+ print("[Audio Route Helper] Headphones/headset device is able to record audio, also change input audio device")
+ applyAudioRouteChange(core: core, call: call, types: [AudioDevice.Kind.Headphones, AudioDevice.Kind.Headset], output: false)
+ }
+ default: applyAudioRouteChange(core: core, call: call, types: [AudioDevice.Kind.Microphone], output: false)
+ }
+ }
+
+ static private func routeAudioTo(core: Core, call: Call?, types: [AudioDevice.Kind]) {
+ let currentCall = call != nil ? call : core.currentCall != nil ? core.currentCall : (core.callsNb > 0 ? core.calls[0] : nil)
+ if call != nil || currentCall != nil {
+ let callToUse = call != nil ? call : currentCall
+ applyAudioRouteChange(core: core, call: callToUse, types: types)
+ changeCaptureDeviceToMatchAudioRoute(core: core, call: callToUse, types: types)
+ } else {
+ applyAudioRouteChange(core: core, call: call, types: types)
+ changeCaptureDeviceToMatchAudioRoute(core: core, call: call, types: types)
+ }
+ }
+
+ static func routeAudioToEarpiece(core: Core, call: Call? = nil) {
+ routeAudioTo(core: core, call: call, types: [AudioDevice.Kind.Microphone]) // on iOS Earpiece = Microphone
+ }
+
+ static func routeAudioToSpeaker(core: Core, call: Call? = nil) {
+ routeAudioTo(core: core, call: call, types: [AudioDevice.Kind.Speaker])
+ }
+
+ static func routeAudioToSpeaker(core: Core) {
+ routeAudioTo(core: core, call: nil, types: [AudioDevice.Kind.Speaker])
+ }
+
+ static func routeAudioToBluetooth(core: Core, call: Call? = nil) {
+ routeAudioTo(core: core, call: call, types: [AudioDevice.Kind.Bluetooth])
+ }
+
+ static func routeAudioToHeadset(core: Core, call: Call? = nil) {
+ routeAudioTo(core: core, call: call, types: [AudioDevice.Kind.Headphones, AudioDevice.Kind.Headset])
+ }
+
+ static func isSpeakerAudioRouteCurrentlyUsed(core: Core, call: Call? = nil) -> Bool {
+
+ let currentCall = core.callsNb > 0 ? (call != nil) ? call : core.currentCall != nil ? core.currentCall : core.calls[0] : nil
+ if currentCall == nil {
+ print("[Audio Route Helper] No call found, setting audio route on Core")
+ }
+
+ let audioDevice = currentCall != nil ? currentCall!.outputAudioDevice : core.outputAudioDevice
+ print("[Audio Route Helper] Playback audio currently in use is [\(audioDevice?.deviceName ?? "n/a")] with type (\(audioDevice?.type ?? .Unknown)")
+ return audioDevice?.type == AudioDevice.Kind.Speaker
+ }
+
+ static func isBluetoothAudioRouteCurrentlyUsed(core: Core, call: Call? = nil) -> Bool {
+ if core.callsNb == 0 {
+ print("[Audio Route Helper] No call found, so bluetooth audio route isn't used")
+ return false
+ }
+ let currentCall = call != nil ? call : core.currentCall != nil ? core.currentCall : core.calls[0]
+ let audioDevice = currentCall != nil ? currentCall!.outputAudioDevice : core.outputAudioDevice
+ print("[Audio Route Helper] Playback audio device currently in use is [\(audioDevice?.deviceName ?? "n/a")] with type (\(audioDevice?.type ?? .Unknown)")
+ return audioDevice?.type == AudioDevice.Kind.Bluetooth
+ }
+
+ static func isBluetoothAudioRouteAvailable(core: Core) -> Bool {
+ if let device = core.audioDevices.first(where: { $0.type == AudioDevice.Kind.Bluetooth && $0.hasCapability(capability: .CapabilityPlay) }) {
+ print("[Audio Route Helper] Found bluetooth audio device [\(device.deviceName)]")
+ return true
+ }
+ return false
+ }
+
+ static private func isBluetoothAudioRecorderAvailable(core: Core) -> Bool {
+ if let device = core.audioDevices.first(where: { $0.type == AudioDevice.Kind.Bluetooth && $0.hasCapability(capability: .CapabilityRecord) }) {
+ print("[Audio Route Helper] Found bluetooth audio recorder [\(device.deviceName)]")
+ return true
+ }
+ return false
+ }
+
+ static func isHeadsetAudioRouteAvailable(core: Core) -> Bool {
+ if let device = core.audioDevices.first(where: { ($0.type == AudioDevice.Kind.Headset||$0.type == AudioDevice.Kind.Headphones) && $0.hasCapability(capability: .CapabilityPlay) }) {
+ print("[Audio Route Helper] Found headset/headphones audio device [\(device.deviceName)]")
+ return true
+ }
+ return false
+ }
+
+ static private func isHeadsetAudioRecorderAvailable(core: Core) -> Bool {
+ if let device = core.audioDevices.first(where: { ($0.type == AudioDevice.Kind.Headset||$0.type == AudioDevice.Kind.Headphones) && $0.hasCapability(capability: .CapabilityRecord) }) {
+ print("[Audio Route Helper] Found headset/headphones audio recorder [\(device.deviceName)]")
+ return true
+ }
+ return false
+ }
+
+ static func isReceiverEnabled(core: Core) -> Bool {
+ if let outputDevice = core.outputAudioDevice {
+ return outputDevice.type == AudioDevice.Kind.Microphone
+ }
+ return false
+ }
+
+ static func isBluetoothAvailable(core: Core) -> Bool {
+ for device in core.audioDevices {
+ if device.type == AudioDevice.Kind.Bluetooth || device.type == AudioDevice.Kind.BluetoothA2DP {
+ return true
+ }
+ }
+ return false
+ }
+
+}
+// swiftlint:enable line_length
diff --git a/Linphone/Utils/Avatar.swift b/Linphone/Utils/Avatar.swift
new file mode 100644
index 000000000..a87908184
--- /dev/null
+++ b/Linphone/Utils/Avatar.swift
@@ -0,0 +1,94 @@
+/*
+ * 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 .
+ */
+
+import SwiftUI
+import linphonesw
+
+struct Avatar: View {
+ @State var id = UUID()
+
+ private var contactsManager = ContactsManager.shared
+
+ @ObservedObject var contactAvatarModel: ContactAvatarModel
+
+ let avatarSize: CGFloat
+ let hidePresence: Bool
+
+ init(contactAvatarModel: ContactAvatarModel, avatarSize: CGFloat, hidePresence: Bool = false) {
+ self.contactAvatarModel = contactAvatarModel
+ self.avatarSize = avatarSize
+ self.hidePresence = hidePresence
+ }
+
+ var body: some View {
+ if contactAvatarModel.friend != nil && contactAvatarModel.friend!.photo != nil {
+ AsyncImage(url: ContactsManager.shared.getImagePath(friendPhotoPath: contactAvatarModel.friend!.photo!)) { image in
+ switch image {
+ case .empty:
+ ProgressView()
+ .frame(width: avatarSize, height: avatarSize)
+ case .success(let image):
+ ZStack {
+ image
+ .resizable()
+ .aspectRatio(contentMode: .fill)
+ .frame(width: avatarSize, height: avatarSize)
+ .clipShape(Circle())
+ HStack {
+ Spacer()
+ VStack {
+ Spacer()
+ if !hidePresence && (contactAvatarModel.presenceStatus == .Online || contactAvatarModel.presenceStatus == .Busy) {
+ Image(contactAvatarModel.presenceStatus == .Online ? "presence-online" : "presence-busy")
+ .resizable()
+ .frame(width: avatarSize/4, height: avatarSize/4)
+ .padding(.trailing, avatarSize == 50 || avatarSize == 35 ? 1 : 3)
+ .padding(.bottom, avatarSize == 50 || avatarSize == 35 ? 1 : 3)
+ }
+ }
+ }
+ .frame(width: avatarSize, height: avatarSize)
+ }
+ case .failure:
+ Image("profil-picture-default")
+ .resizable()
+ .frame(width: avatarSize, height: avatarSize)
+ .clipShape(Circle())
+ @unknown default:
+ EmptyView()
+ }
+ }
+ .id(id)
+ } else if !contactAvatarModel.name.isEmpty {
+ Image(uiImage: contactsManager.textToImage(
+ firstName: contactAvatarModel.name,
+ lastName: contactAvatarModel.name.components(separatedBy: " ").count > 1
+ ? contactAvatarModel.name.components(separatedBy: " ")[1]
+ : ""))
+ .resizable()
+ .frame(width: avatarSize, height: avatarSize)
+ .clipShape(Circle())
+ } else {
+ Image("profil-picture-default")
+ .resizable()
+ .frame(width: avatarSize, height: avatarSize)
+ .clipShape(Circle())
+ }
+ }
+}
diff --git a/Linphone/Utils/EditContactController.swift b/Linphone/Utils/EditContactController.swift
new file mode 100644
index 000000000..10950fb85
--- /dev/null
+++ b/Linphone/Utils/EditContactController.swift
@@ -0,0 +1,97 @@
+/*
+ * 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 .
+ */
+
+import SwiftUI
+import ContactsUI
+import linphonesw
+
+struct EditContactView: UIViewControllerRepresentable {
+
+ class Coordinator: NSObject, CNContactViewControllerDelegate, UINavigationControllerDelegate {
+ func contactViewController(_ viewController: CNContactViewController, didCompleteWith contact: CNContact?) {
+ if let cnc = contact {
+ DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
+ self.parent.contact = cnc
+
+ let newContact = Contact(
+ identifier: cnc.identifier,
+ firstName: cnc.givenName,
+ lastName: cnc.familyName,
+ organizationName: cnc.organizationName,
+ jobTitle: "",
+ displayName: cnc.nickname,
+ sipAddresses: cnc.instantMessageAddresses.map { $0.value.service == "SIP" ? $0.value.username : "" },
+ phoneNumbers: cnc.phoneNumbers.map { PhoneNumber(numLabel: $0.label ?? "", num: $0.value.stringValue)},
+ imageData: ""
+ )
+
+ let imageThumbnail = UIImage(data: contact!.thumbnailImageData ?? Data())
+ ContactsManager.shared.saveImage(
+ image: imageThumbnail
+ ?? ContactsManager.shared.textToImage(
+ firstName: cnc.givenName.isEmpty
+ && cnc.familyName.isEmpty
+ && cnc.phoneNumbers.first?.value.stringValue != nil
+ ? cnc.phoneNumbers.first!.value.stringValue
+ : cnc.givenName, lastName: cnc.familyName),
+ name: cnc.givenName + cnc.familyName,
+ prefix: ((imageThumbnail == nil) ? "-default" : ""),
+ contact: newContact,
+ linphoneFriend: false,
+ existingFriend: ContactsManager.shared.getFriendWithContact(contact: newContact)) {
+ MagicSearchSingleton.shared.searchForContacts(sourceFlags: MagicSearch.Source.Friends.rawValue | MagicSearch.Source.LdapServers.rawValue)
+ }
+ }
+ }
+ viewController.dismiss(animated: true, completion: {})
+ }
+
+ func contactViewController(_ viewController: CNContactViewController, shouldPerformDefaultActionFor property: CNContactProperty) -> Bool {
+ return true
+ }
+
+ var parent: EditContactView
+
+ init(_ parent: EditContactView) {
+ self.parent = parent
+ }
+ }
+
+ @Binding var contact: CNContact?
+
+ init(contact: Binding) {
+ self._contact = contact
+ }
+
+ typealias UIViewControllerType = CNContactViewController
+
+ func makeCoordinator() -> Coordinator {
+ return Coordinator(self)
+ }
+
+ func makeUIViewController(context: UIViewControllerRepresentableContext) -> EditContactView.UIViewControllerType {
+ let vcontact = contact != nil ? CNContactViewController(for: contact!) : CNContactViewController(forNewContact: CNContact())
+ vcontact.isEditing = true
+ vcontact.delegate = context.coordinator
+ return vcontact
+ }
+
+ func updateUIViewController(_ uiViewController: EditContactView.UIViewControllerType, context: UIViewControllerRepresentableContext) {
+ }
+}
diff --git a/Linphone/Utils/Extensions/AccountExtension.swift b/Linphone/Utils/Extensions/AccountExtension.swift
new file mode 100644
index 000000000..7ae58904b
--- /dev/null
+++ b/Linphone/Utils/Extensions/AccountExtension.swift
@@ -0,0 +1,44 @@
+/*
+ * Copyright (c) 2010-2024 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 .
+ */
+
+import Foundation
+import linphonesw
+
+extension Account {
+ func displayName() -> String {
+ guard let address = params?.identityAddress else {
+ return ""
+ }
+ if address.displayName != nil && !address.displayName!.isEmpty {
+ return address.displayName!
+ }
+ if address.username != nil && !address.username!.isEmpty {
+ return address.username!
+ }
+ return address.asStringUriOnly()
+ }
+
+ static func == (lhs: Account, rhs: Account) -> Bool {
+ if lhs.params != nil && lhs.params?.identityAddress != nil && rhs.params != nil && rhs.params?.identityAddress != nil {
+ return lhs.params?.identityAddress?.asString() == rhs.params?.identityAddress?.asString()
+ } else {
+ return false
+ }
+ }
+}
diff --git a/Linphone/Utils/Extensions/BundleExtenion.swift b/Linphone/Utils/Extensions/BundleExtenion.swift
new file mode 100644
index 000000000..1acc59de5
--- /dev/null
+++ b/Linphone/Utils/Extensions/BundleExtenion.swift
@@ -0,0 +1,26 @@
+/*
+ * Copyright (c) 2010-2024 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 .
+ */
+
+import Foundation
+
+extension Bundle {
+ var displayName: String {
+ return object(forInfoDictionaryKey: "CFBundleDisplayName") as? String ?? "Linphone"
+ }
+}
diff --git a/Linphone/Utils/Extensions/ColorExtension.swift b/Linphone/Utils/Extensions/ColorExtension.swift
new file mode 100644
index 000000000..19fc96c2e
--- /dev/null
+++ b/Linphone/Utils/Extensions/ColorExtension.swift
@@ -0,0 +1,91 @@
+/*
+ * Copyright (c) 2010-2023 Belledonne Communications SARL.
+ *
+ * This file is part of Linphone
+ *
+ * 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 .
+ */
+
+import Foundation
+import SwiftUI
+
+extension Color {
+
+ static let transparentColor = Color(hex: "#00000000")
+ static let black = Color(hex: "#000000")
+ static let white = Color(hex: "#FFFFFF")
+
+ static let orangeMain700 = Color(hex: "#B72D00")
+ static let orangeMain500 = Color(hex: "#FF5E00")
+ static let orangeMain300 = Color(hex: "#FFB266")
+ static let orangeMain100 = Color(hex: "#FFEACB")
+ static let orangeMain100Alpha50 = Color(hex: "#80FFEACB")
+
+ static let grayMain2c800 = Color(hex: "#22334D")
+ static let grayMain2c800Alpha65 = Color(hex: "#A622334D")
+ static let grayMain2c700 = Color(hex: "#364860")
+ static let grayMain2c600 = Color(hex: "#4E6074")
+ static let grayMain2c500 = Color(hex: "#6C7A87")
+ static let grayMain2c400 = Color(hex: "#9AABB5")
+ static let grayMain2c300 = Color(hex: "#C0D1D9")
+ static let grayMain2c200 = Color(hex: "#DFECF2")
+ static let grayMain2c100 = Color(hex: "#EEF6F8")
+
+ static let gray100 = Color(hex: "#F9F9F9")
+ static let gray200 = Color(hex: "#EDEDED")
+ static let gray300 = Color(hex: "#C9C9C9")
+ static let gray400 = Color(hex: "#949494")
+ static let gray500 = Color(hex: "#4E4E4E")
+ static let gray600 = Color(hex: "#2E3030")
+ static let gray900 = Color(hex: "#070707")
+
+ static let redDanger200 = Color(hex: "#F5CCBE")
+ static let redDanger500 = Color(hex: "#DD5F5F")
+ static let redDanger700 = Color(hex: "#9E3548")
+
+ static let greenSuccess500 = Color(hex: "#4FAE80")
+ static let greenSuccess700 = Color(hex: "#377D71")
+ static let greenSuccess200 = Color(hex: "#ACF5C1")
+
+ static let blueInfo500 = Color(hex: "#4AA8FF")
+
+ static let orangeWarning600 = Color(hex: "#DBB820")
+
+ static let orangeAway = Color(hex: "#FFA645")
+
+ init(hex: String) {
+ let hex = hex.trimmingCharacters(in: CharacterSet.alphanumerics.inverted)
+ var int: UInt64 = 0
+ Scanner(string: hex).scanHexInt64(&int)
+ let alpha, red, green, blue: UInt64
+ switch hex.count {
+ case 3: // RGB (12-bit)
+ (alpha, red, green, blue) = (255, (int >> 8) * 17, (int >> 4 & 0xF) * 17, (int & 0xF) * 17)
+ case 6: // RGB (24-bit)
+ (alpha, red, green, blue) = (255, int >> 16, int >> 8 & 0xFF, int & 0xFF)
+ case 8: // ARGB (32-bit)
+ (alpha, red, green, blue) = (int >> 24, int >> 16 & 0xFF, int >> 8 & 0xFF, int & 0xFF)
+ default:
+ (alpha, red, green, blue) = (1, 1, 1, 0)
+ }
+
+ self.init(
+ .sRGB,
+ red: Double(red) / 255,
+ green: Double(green) / 255,
+ blue: Double(blue) / 255,
+ opacity: Double(alpha) / 255
+ )
+ }
+}
diff --git a/Linphone/Utils/Extensions/ConfigExtension.swift b/Linphone/Utils/Extensions/ConfigExtension.swift
new file mode 100644
index 000000000..697b25221
--- /dev/null
+++ b/Linphone/Utils/Extensions/ConfigExtension.swift
@@ -0,0 +1,63 @@
+/*
+* Copyright (c) 2010-2023 Belledonne Communications SARL.
+*
+* This file is part of linphone
+*
+* 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 .
+*/
+
+import Foundation
+import linphonesw
+
+// Singleton that manages the Shared Config between app and app extension.
+
+extension Config {
+
+ private static var _instance: Config?
+
+ public func getDouble(section: String, key: String, defaultValue: Double) -> Double {
+ if self.hasEntry(section: section, key: key) != 1 {
+ return defaultValue
+ }
+ let stringValue = self.getString(section: section, key: key, defaultString: "")
+ return Double(stringValue) ?? defaultValue
+ }
+
+ public static func get() -> Config {
+ if _instance == nil {
+ let factoryPath = FileUtil.bundleFilePath("linphonerc-factory")!
+ _instance = Config.newForSharedCore(appGroupId: Config.appGroupName, configFilename: "linphonerc", factoryConfigFilename: factoryPath)!
+ }
+ return _instance!
+ }
+
+ public func getString(section: String, key: String) -> String? {
+ return hasEntry(section: section, key: key) == 1 ? getString(section: section, key: key, defaultString: "") : nil
+ }
+
+ static let appGroupName = "group.org.linphone.phone.msgNotification"
+ // Needs to be the same name in App Group (capabilities in ALL targets - app & extensions - content + service), can't be stored in the Config itself the Config needs this value to get created
+ static let teamID = Config.get().getString(section: "app", key: "team_id", defaultString: "")
+ static let earlymediaContentExtCatIdentifier = Config.get().getString(section: "app", key: "extension_category", defaultString: "")
+
+ // Default values in app
+ static let serveraddress = Config.get().getString(section: "app", key: "server", defaultString: "")
+ static let defaultUsername = Config.get().getString(section: "app", key: "user", defaultString: "")
+ static let defaultPass = Config.get().getString(section: "app", key: "pass", defaultString: "")
+
+ static let pushNotificationsInterval = Config.get().getInt(section: "net", key: "pn-call-remote-push-interval", defaultValue: 3)
+
+ static let voiceRecordingMaxDuration = Config.get().getInt(section: "app", key: "voice_recording_max_duration", defaultValue: 600000)
+
+}
diff --git a/Linphone/Utils/Extensions/DecodableExtension.swift b/Linphone/Utils/Extensions/DecodableExtension.swift
new file mode 100644
index 000000000..5b6b7c93e
--- /dev/null
+++ b/Linphone/Utils/Extensions/DecodableExtension.swift
@@ -0,0 +1,28 @@
+/*
+ * 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 .
+ */
+
+import Foundation
+
+extension Decodable {
+ init?(dictionary: [String: Any]) {
+ guard let data = try? JSONSerialization.data(withJSONObject: dictionary) else { return nil }
+ guard let object = try? JSONDecoder().decode(Self.self, from: data) else { return nil }
+ self = object
+ }
+}
diff --git a/Linphone/Utils/Extensions/EncodableExtension.swift b/Linphone/Utils/Extensions/EncodableExtension.swift
new file mode 100644
index 000000000..08eea64a4
--- /dev/null
+++ b/Linphone/Utils/Extensions/EncodableExtension.swift
@@ -0,0 +1,27 @@
+/*
+ * 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 .
+ */
+
+import Foundation
+
+extension Encodable {
+ var asDictionary: [String: Any]? {
+ guard let data = try? JSONEncoder().encode(self) else { return nil }
+ return try? JSONSerialization.jsonObject(with: data) as? [String: Any]
+ }
+}
diff --git a/Linphone/Utils/Extensions/IntExtension.swift b/Linphone/Utils/Extensions/IntExtension.swift
new file mode 100644
index 000000000..4067dcac5
--- /dev/null
+++ b/Linphone/Utils/Extensions/IntExtension.swift
@@ -0,0 +1,77 @@
+/*
+ * Copyright (c) 2010-2023 Belledonne Communications SARL.
+ *
+ * This file is part of Linphone
+ *
+ * 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 .
+ */
+
+import Foundation
+
+// swiftlint:disable large_tuple
+extension Int {
+
+ public func hmsFrom() -> (Int, Int, Int) {
+ return (self / 3600, (self % 3600) / 60, (self % 3600) % 60)
+ }
+
+ public func convertDurationToString() -> String {
+ var duration = ""
+ let (hour, minute, second) = self.hmsFrom()
+ if hour > 0 {
+ duration = self.getHour(hour: hour)
+ }
+ return "\(duration)\(self.getMinute(minute: minute))\(self.getSecond(second: second))"
+ }
+
+ public func formatBytes() -> String {
+ let byteCountFormatter = ByteCountFormatter()
+ byteCountFormatter.allowedUnits = [.useKB, .useMB, .useGB] // Allows KB, MB and KB
+ byteCountFormatter.countStyle = .file // Use file size style
+ byteCountFormatter.isAdaptive = true // Adjusts automatically to appropriate unit
+ return byteCountFormatter.string(fromByteCount: Int64(self))
+ }
+
+ private func getHour(hour: Int) -> String {
+ var duration = "\(hour):"
+ if hour < 10 {
+ duration = "0\(hour):"
+ }
+ return duration
+ }
+
+ private func getMinute(minute: Int) -> String {
+ if minute == 0 {
+ return "00:"
+ }
+
+ if minute < 10 {
+ return "0\(minute):"
+ }
+
+ return "\(minute):"
+ }
+
+ private func getSecond(second: Int) -> String {
+ if second == 0 {
+ return "00"
+ }
+
+ if second < 10 {
+ return "0\(second)"
+ }
+ return "\(second)"
+ }
+}
+// swiftlint:enable large_tuple
diff --git a/Linphone/Utils/Extensions/StringExtension.swift b/Linphone/Utils/Extensions/StringExtension.swift
new file mode 100644
index 000000000..c3db23298
--- /dev/null
+++ b/Linphone/Utils/Extensions/StringExtension.swift
@@ -0,0 +1,40 @@
+/*
+ * Copyright (c) 2010-2024 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 .
+ */
+
+import Foundation
+
+extension String {
+ func localized(comment: String? = nil) -> String {
+ return NSLocalizedString(self, comment: comment != nil ? comment! : self)
+ }
+}
+
+extension String {
+ var isOnlyEmojis: Bool {
+ let filteredText = self.filter { !$0.isWhitespace }
+ return !filteredText.isEmpty && filteredText.allSatisfy { $0.isEmoji }
+ }
+}
+
+extension Character {
+ var isEmoji: Bool {
+ guard let scalar = unicodeScalars.first else { return false }
+ return scalar.properties.isEmoji && (scalar.value > 0x238C || unicodeScalars.count > 1)
+ }
+}
diff --git a/Linphone/Utils/Extensions/TextExtension.swift b/Linphone/Utils/Extensions/TextExtension.swift
new file mode 100644
index 000000000..e48d97e57
--- /dev/null
+++ b/Linphone/Utils/Extensions/TextExtension.swift
@@ -0,0 +1,170 @@
+/*
+ * Copyright (c) 2010-2023 Belledonne Communications SARL.
+ *
+ * This file is part of Linphone
+ *
+ * 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 .
+ */
+
+import Foundation
+import SwiftUI
+
+let cssWeightToFontWeightName = [
+ 100: "UltraLight",
+ 200: "Thin",
+ 300: "Light",
+ 400: "Regular",
+ 500: "Medium",
+ 600: "SemiBold",
+ 700: "Bold",
+ 800: "Heavy",
+ 900: "Black"
+]
+
+extension View {
+
+ func default_text_style_300(styleSize: CGFloat) -> some View {
+ self.font(Font.custom("NotoSans-Light", size: styleSize))
+ .foregroundStyle(Color.grayMain2c600)
+ }
+
+ func default_text_style(styleSize: CGFloat) -> some View {
+ self.font(Font.custom("NotoSans-Regular", size: styleSize))
+ .foregroundStyle(Color.grayMain2c600)
+ }
+
+ func default_text_style_500(styleSize: CGFloat) -> some View {
+ self.font(Font.custom("NotoSans-Medium", size: styleSize))
+ .foregroundStyle(Color.grayMain2c600)
+ }
+
+ func default_text_style_600(styleSize: CGFloat) -> some View {
+ self.font(Font.custom("NotoSans-SemiBold", size: styleSize))
+ .foregroundStyle(Color.grayMain2c600)
+ }
+
+ func default_text_style_700(styleSize: CGFloat) -> some View {
+ self.font(Font.custom("NotoSans-Bold", size: styleSize))
+ .foregroundStyle(Color.grayMain2c600)
+ }
+
+ func default_text_style_800(styleSize: CGFloat) -> some View {
+ self.font(Font.custom("NotoSans-ExtraBold", size: styleSize))
+ .foregroundStyle(Color.grayMain2c600)
+ }
+
+ func default_text_style_white_300(styleSize: CGFloat) -> some View {
+ self.font(Font.custom("NotoSans-Light", size: styleSize))
+ .foregroundStyle(Color.white)
+ }
+
+ func default_text_style_white(styleSize: CGFloat) -> some View {
+ self.font(Font.custom("NotoSans-Regular", size: styleSize))
+ .foregroundStyle(Color.white)
+ }
+
+ func default_text_style_white_500(styleSize: CGFloat) -> some View {
+ self.font(Font.custom("NotoSans-Medium", size: styleSize))
+ .foregroundStyle(Color.white)
+ }
+
+ func default_text_style_white_600(styleSize: CGFloat) -> some View {
+ self.font(Font.custom("NotoSans-SemiBold", size: styleSize))
+ .foregroundStyle(Color.white)
+ }
+
+ func default_text_style_white_700(styleSize: CGFloat) -> some View {
+ self.font(Font.custom("NotoSans-Bold", size: styleSize))
+ .foregroundStyle(Color.white)
+ }
+
+ func default_text_style_white_800(styleSize: CGFloat) -> some View {
+ self.font(Font.custom("NotoSans-ExtraBold", size: styleSize))
+ .foregroundStyle(Color.white)
+ }
+
+ func default_text_style_orange_300(styleSize: CGFloat) -> some View {
+ self.font(Font.custom("NotoSans-Light", size: styleSize))
+ .foregroundStyle(Color.orangeMain500)
+ }
+
+ func default_text_style_orange(styleSize: CGFloat) -> some View {
+ self.font(Font.custom("NotoSans-Regular", size: styleSize))
+ .foregroundStyle(Color.orangeMain500)
+ }
+
+ func default_text_style_orange_500(styleSize: CGFloat) -> some View {
+ self.font(Font.custom("NotoSans-Medium", size: styleSize))
+ .foregroundStyle(Color.orangeMain500)
+ }
+
+ func default_text_style_orange_600(styleSize: CGFloat) -> some View {
+ self.font(Font.custom("NotoSans-SemiBold", size: styleSize))
+ .foregroundStyle(Color.orangeMain500)
+ }
+
+ func default_text_style_orange_700(styleSize: CGFloat) -> some View {
+ self.font(Font.custom("NotoSans-Bold", size: styleSize))
+ .foregroundStyle(Color.orangeMain500)
+ }
+
+ func default_text_style_orange_800(styleSize: CGFloat) -> some View {
+ self.font(Font.custom("NotoSans-ExtraBold", size: styleSize))
+ .foregroundStyle(Color.orangeMain500)
+ }
+
+ func welcome_text_style_white_800(styleSize: CGFloat) -> some View {
+ self.font(Font.custom("NotoSans-ExtraBold", size: styleSize))
+ .foregroundStyle(Color.white)
+ }
+
+ func welcome_text_style_gray_800(styleSize: CGFloat) -> some View {
+ self.font(Font.custom("NotoSans-ExtraBold", size: styleSize))
+ .foregroundStyle(Color.grayMain2c600)
+ }
+
+ func welcome_text_style_gray(styleSize: CGFloat) -> some View {
+ self.font(Font.custom("NotoSans-Regular", size: styleSize))
+ .foregroundStyle(Color.grayMain2c600)
+ }
+
+ func profile_mode_text_style_gray_800(styleSize: CGFloat) -> some View {
+ self.font(Font.custom("NotoSans-ExtraBold", size: styleSize))
+ .foregroundStyle(Color.gray900)
+ }
+
+ func profile_mode_text_style_gray(styleSize: CGFloat) -> some View {
+ self.font(Font.custom("NotoSans-Regular", size: styleSize))
+ .foregroundStyle(Color.grayMain2c600)
+ }
+
+ func contact_text_style_500(styleSize: CGFloat) -> some View {
+ self.font(Font.custom("NotoSans-Medium", size: styleSize))
+ .foregroundStyle(Color.grayMain2c400)
+ }
+
+ func default_text_style_grey_400(styleSize: CGFloat) -> some View {
+ self.font(Font.custom("NotoSans", size: styleSize))
+ .foregroundStyle(Color.grayMain2c700)
+ }
+
+ func default_text_style_uncolored(styleSize: CGFloat) -> some View {
+ self.font(Font.custom("NotoSans-Light", size: styleSize))
+ }
+
+ func text_style(fontSize: CGFloat, fontWeight: Int, fontColor: Color) -> some View {
+ return self.font(Font.custom("NotoSans-"+cssWeightToFontWeightName[fontWeight]!, size: fontSize))
+ .foregroundStyle(fontColor)
+ }
+}
diff --git a/Linphone/Utils/Extensions/TimeZoneExtension.swift b/Linphone/Utils/Extensions/TimeZoneExtension.swift
new file mode 100644
index 000000000..6a97070f3
--- /dev/null
+++ b/Linphone/Utils/Extensions/TimeZoneExtension.swift
@@ -0,0 +1,28 @@
+/*
+ * Copyright (c) 2010-2024 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 .
+ */
+
+import Foundation
+
+extension TimeZone {
+ // Format timezone identifier as a string of the form : GMT{+,-}[-12, 12]:00 - {Identifier}
+ func formattedString() -> String {
+ let gmtOffset = self.secondsFromGMT()/3600
+ return "GMT\(gmtOffset >= 0 ? "+" : "")\(gmtOffset):00 - \(self.identifier)"
+ }
+}
diff --git a/Linphone/Utils/Extensions/UIApplicationExtension.swift b/Linphone/Utils/Extensions/UIApplicationExtension.swift
new file mode 100644
index 000000000..158b25515
--- /dev/null
+++ b/Linphone/Utils/Extensions/UIApplicationExtension.swift
@@ -0,0 +1,42 @@
+/*
+ * 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 .
+ */
+
+import Foundation
+import UIKit
+
+extension UIApplication {
+ class func getTopMostViewController() -> UIViewController? {
+ if let scenes = UIApplication.shared.connectedScenes.first as? UIWindowScene {
+ let keyWindow = scenes.windows.filter {$0.isKeyWindow}.first
+ if var topController = keyWindow?.rootViewController {
+ while let presentedViewController = topController.presentedViewController {
+ topController = presentedViewController
+ }
+ return topController
+ } else {
+ return nil
+ }
+ }
+ return nil
+ }
+
+ func endEditing() {
+ sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil)
+ }
+}
diff --git a/Linphone/Utils/Extensions/URLExtension.swift b/Linphone/Utils/Extensions/URLExtension.swift
new file mode 100644
index 000000000..8a548a0b3
--- /dev/null
+++ b/Linphone/Utils/Extensions/URLExtension.swift
@@ -0,0 +1,32 @@
+/*
+ * Copyright (c) 2010-2020 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 .
+ */
+
+import Foundation
+
+extension URL {
+ func withNewScheme(_ value: String) -> URL? {
+ let components = NSURLComponents.init(url: self, resolvingAgainstBaseURL: true)
+ components?.scheme = value
+ return components?.url
+ }
+ var resourceSpecifier: String {
+ let nrl: NSURL = self as NSURL
+ return nrl.resourceSpecifier ?? self.absoluteString
+ }
+}
diff --git a/Linphone/Utils/Extensions/ViewExtension.swift b/Linphone/Utils/Extensions/ViewExtension.swift
new file mode 100644
index 000000000..ef1dbeff7
--- /dev/null
+++ b/Linphone/Utils/Extensions/ViewExtension.swift
@@ -0,0 +1,37 @@
+/*
+ * Copyright (c) 2010-2020 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 .
+ */
+
+import SwiftUI
+
+extension View {
+ /// Applies the given transform if the given condition evaluates to `true`.
+ /// - Parameters:
+ /// - condition: The condition to evaluate.
+ /// - transform: The transform to apply to the source `View`.
+ /// - Returns: Either the original `View` or the modified `View` if the condition is `true`.
+ @ViewBuilder func `if`(_ condition: Bool, transform: (Self) -> Content) -> some View {
+ if condition {
+ transform(self)
+ } else {
+ self
+ }
+ }
+
+ func apply(@ViewBuilder _ block: (Self) -> V) -> V { block(self) }
+}
diff --git a/Linphone/Utils/FileUtils.swift b/Linphone/Utils/FileUtils.swift
new file mode 100644
index 000000000..d81046753
--- /dev/null
+++ b/Linphone/Utils/FileUtils.swift
@@ -0,0 +1,228 @@
+/*
+ * 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 .
+ */
+
+import UIKit
+import linphonesw
+import UniformTypeIdentifiers
+
+class FileUtil: NSObject {
+
+ public enum MimeType {
+ case plainText
+ case pdf
+ case image
+ case video
+ case audio
+ case unknown
+ }
+
+ public class func bundleFilePath(_ file: NSString) -> String? {
+ return Bundle.main.path(forResource: file.deletingPathExtension, ofType: file.pathExtension)
+ }
+
+ public class func bundleFilePathAsUrl(_ file: NSString) -> URL? {
+ if let bPath = bundleFilePath(file) {
+ return URL.init(fileURLWithPath: bPath)
+ }
+ return nil
+ }
+
+ public class func documentsDirectory() -> URL {
+ let paths = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)
+ let documentsDirectory = paths[0]
+ return documentsDirectory
+ }
+
+ public class func libraryDirectory() -> URL {
+ let paths = FileManager.default.urls(for: .libraryDirectory, in: .userDomainMask)
+ let documentsDirectory = paths[0]
+ return documentsDirectory
+ }
+
+ public class func sharedContainerUrl() -> URL {
+ return FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: Config.appGroupName)!
+ }
+
+ public class func ensureDirectoryExists(path: String) {
+ if !FileManager.default.fileExists(atPath: path) {
+ do {
+ try FileManager.default.createDirectory(atPath: path, withIntermediateDirectories: true, attributes: nil)
+ } catch {
+ print(error)
+ }
+ }
+ }
+
+ public class func ensureFileExists(path: String) {
+ if !FileManager.default.fileExists(atPath: path) {
+ FileManager.default.createFile(atPath: path, contents: nil, attributes: nil)
+ }
+ }
+
+ public class func fileExists(path: String) -> Bool {
+ return FileManager.default.fileExists(atPath: path)
+ }
+
+ public class func fileExistsAndIsNotEmpty(path: String) -> Bool {
+ guard FileManager.default.fileExists(atPath: path) else {return false}
+ do {
+ let attribute = try FileManager.default.attributesOfItem(atPath: path)
+ if let size = attribute[FileAttributeKey.size] as? NSNumber {
+ return size.doubleValue > 0
+ } else {
+ return false
+ }
+ } catch {
+ print(error)
+ return false
+ }
+ }
+
+ public class func write(string: String, toPath: String) {
+ do {
+ try string.write(to: URL(fileURLWithPath: toPath), atomically: true, encoding: String.Encoding.utf8)
+ } catch {
+ print(error)
+ }
+ }
+
+ public class func delete(path: String) {
+ do {
+ try FileManager.default.removeItem(atPath: path)
+ } catch {
+ print(error)
+ }
+ }
+
+ public class func copy(_ fromPath: String, _ toPath: String, overWrite: Bool) {
+ do {
+ if overWrite && fileExists(path: toPath) {
+ delete(path: toPath)
+ }
+ try FileManager.default.copyItem(at: URL(fileURLWithPath: fromPath), to: URL(fileURLWithPath: toPath))
+ } catch {
+ print(error)
+ }
+ }
+
+ // For debugging
+
+ public class func showListOfFilesInSharedDir() {
+ let fileManager = FileManager.default
+ do {
+ let fileURLs = try fileManager.contentsOfDirectory(at: FileUtil.sharedContainerUrl(), includingPropertiesForKeys: nil)
+ fileURLs.forEach { print($0) }
+ } catch {
+ print("Error while enumerating files \(error.localizedDescription)")
+ }
+ }
+
+ public class func isExtensionImage(path: String) -> Bool {
+ let extensionName = getExtensionFromFileName(fileName: path)
+ let typeExtension = getMimeTypeFromExtension(urlString: extensionName)
+ return getMimeType(type: typeExtension) == MimeType.image
+ }
+
+ public class func getExtensionFromFileName(fileName: String) -> String {
+ let url: URL? = URL(string: fileName)
+ let urlExtension: String? = url?.pathExtension
+
+ return urlExtension?.lowercased() ?? ""
+ }
+
+ public class func getMimeTypeFromExtension(urlString: String?) -> String? {
+ if urlString == nil || urlString!.isEmpty {
+ return nil
+ }
+
+ return urlString!.mimeType()
+ }
+
+ public class func getMimeType(type: String?) -> MimeType {
+ if type == nil || type!.isEmpty {
+ return MimeType.unknown
+ }
+
+ switch type {
+ case let str where str!.starts(with: "image/"):
+ return MimeType.image
+ case let str where str!.starts(with: "text/"):
+ return MimeType.plainText
+ case let str where str!.starts(with: "/log"):
+ return MimeType.plainText
+ case let str where str!.starts(with: "video/"):
+ return MimeType.video
+ case let str where str!.starts(with: "audio/"):
+ return MimeType.audio
+ case let str where str!.starts(with: "application/pdf"):
+ return MimeType.pdf
+ default:
+ return MimeType.unknown
+ }
+ }
+
+ public class func getFileStoragePath(
+ fileName: String,
+ isImage: Bool = false,
+ overrideExisting: Bool = false
+ ) -> String {
+ return getFileStorageDir(fileName: fileName, isPicture: isImage)
+ }
+
+ public class func getFileStorageDir(fileName: String, isPicture: Bool = false) -> String {
+ return Factory.Instance.getDownloadDir(context: nil) + fileName
+ }
+}
+
+extension NSURL {
+ public func mimeType() -> String {
+ if let pathExt = self.pathExtension,
+ let mimeType = UTType(filenameExtension: pathExt)?.preferredMIMEType {
+ return mimeType
+ } else {
+ return "application/octet-stream"
+ }
+ }
+}
+
+extension URL {
+ public func mimeType() -> String {
+ if let mimeType = UTType(filenameExtension: self.pathExtension)?.preferredMIMEType {
+ return mimeType
+ } else {
+ return "application/octet-stream"
+ }
+ }
+}
+
+extension NSString {
+ public func mimeType() -> String {
+ if let mimeType = UTType(filenameExtension: self.pathExtension)?.preferredMIMEType {
+ return mimeType
+ } else {
+ return "application/octet-stream"
+ }
+ }
+}
+
+extension String {
+ public func mimeType() -> String {
+ return (self as NSString).mimeType()
+ }
+}
diff --git a/Linphone/Utils/LinphoneUtils.swift b/Linphone/Utils/LinphoneUtils.swift
new file mode 100644
index 000000000..98fc32bdc
--- /dev/null
+++ b/Linphone/Utils/LinphoneUtils.swift
@@ -0,0 +1,73 @@
+/*
+ * 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 .
+ */
+
+import Foundation
+import linphonesw
+
+class LinphoneUtils: NSObject {
+ 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)
+ return !oneToOne && conference
+ }
+
+ public class func getChatIconState(chatState: Int) -> String {
+ return switch chatState {
+ case ChatMessage.State.Displayed.rawValue, ChatMessage.State.FileTransferDone.rawValue:
+ "checks"
+ case ChatMessage.State.DeliveredToUser.rawValue:
+ "check"
+ case ChatMessage.State.Delivered.rawValue:
+ "envelope-simple"
+ case ChatMessage.State.NotDelivered.rawValue, ChatMessage.State.FileTransferError.rawValue:
+ "warning-circle"
+ case ChatMessage.State.InProgress.rawValue, ChatMessage.State.FileTransferInProgress.rawValue:
+ "animated-in-progress"
+ default:
+ "animated-in-progress"
+ }
+ }
+
+ public class func getChatRoomId(room: ChatRoom) -> String {
+ return getChatRoomId(localAddress: room.localAddress!, remoteAddress: room.peerAddress!)
+ }
+
+ public class func getChatRoomId(localAddress: Address, remoteAddress: Address) -> String {
+ let localSipUri = localAddress.clone()
+ localSipUri!.clean()
+ let remoteSipUri = remoteAddress.clone()
+ remoteSipUri!.clean()
+ return getChatRoomId(localSipUri: localSipUri!.asStringUriOnly(), remoteSipUri: remoteSipUri!.asStringUriOnly())
+ }
+
+ public class func getChatRoomId(localSipUri: String, remoteSipUri: String) -> String {
+ return "\(localSipUri)#~#\(remoteSipUri)"
+ }
+
+ public class func applyInternationalPrefix(core: Core, account: Account? = nil) -> Bool {
+ return account?.params?.useInternationalPrefixForCallsAndChats == true
+ || core.defaultAccount?.params?.useInternationalPrefixForCallsAndChats == true
+ }
+
+ public class func isEndToEndEncryptedChatAvailable(core: Core) -> Bool {
+ return core.limeX3DhEnabled &&
+ core.defaultAccount?.params?.limeServerUrl != nil &&
+ core.defaultAccount?.params?.conferenceFactoryUri != nil
+ }
+}
diff --git a/Linphone/Utils/Log.swift b/Linphone/Utils/Log.swift
new file mode 100644
index 000000000..a2351724c
--- /dev/null
+++ b/Linphone/Utils/Log.swift
@@ -0,0 +1,120 @@
+/*
+* Copyright (c) 2010-2023 Belledonne Communications SARL.
+*
+* This file is part of linphone
+*
+* 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 .
+*/
+// swiftlint:disable line_length
+
+// Singleton instance that logs both info from the App and from the core, using the core log level. ([app] log_level parameter in linphonerc-factory-app
+
+import UIKit
+import os
+import linphonesw
+import linphone
+#if USE_CRASHLYTICS
+import Firebase
+#endif
+
+class Log: LoggingServiceDelegate {
+
+ static let instance = Log()
+
+ var debugEnabled = true // Todo : bind to app parameters
+ var service = LoggingService.Instance
+
+ private init() {
+ service.domain = Bundle.main.bundleIdentifier!
+ Core.setLogCollectionPath(path: Factory.Instance.getDownloadDir(context: UnsafeMutablePointer(mutating: (Config.appGroupName as NSString).utf8String)))
+ Core.enableLogCollection(state: LogCollectionState.Enabled)
+ setMask()
+ LoggingService.Instance.addDelegate(delegate: self)
+
+ }
+
+ func setMask() {
+ if debugEnabled {
+ LoggingService.Instance.logLevelMask = UInt(LogLevel.Fatal.rawValue + LogLevel.Error.rawValue + LogLevel.Warning.rawValue + LogLevel.Message.rawValue + LogLevel.Trace.rawValue + LogLevel.Debug.rawValue)
+ } else {
+ LoggingService.Instance.logLevelMask = UInt(LogLevel.Fatal.rawValue + LogLevel.Error.rawValue + LogLevel.Warning.rawValue)
+ }
+ }
+
+ let levelToStrings: [Int: String] =
+ [LogLevel.Debug.rawValue: "Debug"
+ , LogLevel.Trace.rawValue: "Trace"
+ , LogLevel.Message.rawValue: "Message"
+ , LogLevel.Warning.rawValue: "Warning"
+ , LogLevel.Error.rawValue: "Error"
+ , LogLevel.Fatal.rawValue: "Fatal"]
+
+ let levelToOSleLogLevel: [Int: OSLogType] =
+ [LogLevel.Debug.rawValue: .debug,
+ LogLevel.Trace.rawValue: .info,
+ LogLevel.Message.rawValue: .info,
+ LogLevel.Warning.rawValue: .error,
+ LogLevel.Error.rawValue: .error,
+ LogLevel.Fatal.rawValue: .fault]
+
+ public class func debug(_ message: String) {
+ instance.service.debug(message: message)
+ }
+ public class func info(_ message: String) {
+ instance.service.message(message: message)
+ }
+ public class func warn(_ message: String) {
+ instance.service.warning(message: message)
+ }
+ public class func error(_ message: String) {
+ instance.service.error(message: message)
+ }
+ public class func fatal(_ message: String) {
+ instance.service.fatal(message: message)
+ }
+
+ private func output(_ message: String, _ level: Int, _ domain: String = Bundle.main.bundleIdentifier!) {
+ let log = "[\(domain)][\(levelToStrings[level] ?? "Unkown")] \(message)\n"
+ if #available(iOS 10.0, *) {
+ os_log("%{public}@", type: levelToOSleLogLevel[level] ?? .info, log)
+ } else {
+ NSLog(log)
+ }
+#if USE_CRASHLYTICS
+ Crashlytics.crashlytics().log(log)
+#endif
+ }
+
+ func onLogMessageWritten(logService: linphonesw.LoggingService, domain: String, level: linphonesw.LogLevel, message: String) {
+ output(message, level.rawValue, domain)
+ }
+
+ public class func stackTrace() {
+ Thread.callStackSymbols.forEach { print($0) }
+ }
+
+ // Debug
+ public class func cdlog(_ message: String) {
+ info("cdes>\(message)")
+ }
+ public class func bmlog(_ message: String) {
+ info("bmar>\(message)")
+ }
+ public class func qelog(_ message: String) {
+ info("qarg>\(message)")
+ }
+
+}
+
+// swiftlint:enable line_length
diff --git a/Linphone/Utils/MagicSearchSingleton.swift b/Linphone/Utils/MagicSearchSingleton.swift
new file mode 100644
index 000000000..e12596f2b
--- /dev/null
+++ b/Linphone/Utils/MagicSearchSingleton.swift
@@ -0,0 +1,157 @@
+/*
+ * 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 .
+ */
+
+import linphonesw
+import Combine
+
+final class MagicSearchSingleton: ObservableObject {
+
+ static let shared = MagicSearchSingleton()
+ private var coreContext = CoreContext.shared
+ private var contactsManager = ContactsManager.shared
+
+ private var magicSearch: MagicSearch!
+
+ var currentFilter: String = ""
+ var previousFilter: String?
+
+ var currentFilterSuggestions: String = ""
+ var previousFilterSuggestions: String?
+
+ var needUpdateLastSearchContacts = false
+
+ private var limitSearchToLinphoneAccounts = true
+
+ @Published var allContact = false
+ private var domainDefaultAccount = ""
+
+ var searchDelegate: MagicSearchDelegate?
+
+ func destroyMagicSearch() {
+ magicSearch = nil
+ }
+
+ private init() {
+ coreContext.doOnCoreQueue { core in
+ self.domainDefaultAccount = core.defaultAccount?.params?.domain ?? ""
+
+ self.magicSearch = try? core.createMagicSearch()
+ self.magicSearch.limitedSearch = false
+
+ self.searchDelegate = MagicSearchDelegateStub(onSearchResultsReceived: { (magicSearch: MagicSearch) in
+ self.needUpdateLastSearchContacts = true
+
+ var lastSearchFriend: [SearchResult] = []
+ var lastSearchSuggestions: [SearchResult] = []
+
+ magicSearch.lastSearch.forEach { searchResult in
+ if searchResult.friend != nil {
+ lastSearchFriend.append(searchResult)
+ } else {
+ lastSearchSuggestions.append(searchResult)
+ }
+ }
+ lastSearchSuggestions.sort(by: {
+ $0.address!.asStringUriOnly() < $1.address!.asStringUriOnly()
+ })
+ let sortedLastSearch = lastSearchFriend.sorted(by: {
+ $0.friend!.name!.lowercased().folding(options: .diacriticInsensitive, locale: .current)
+ <
+ $1.friend!.name!.lowercased().folding(options: .diacriticInsensitive, locale: .current)
+ })
+
+ var addedAvatarListModel: [ContactAvatarModel] = []
+ sortedLastSearch.forEach { searchResult in
+ if searchResult.friend != nil {
+ addedAvatarListModel.append(
+ ContactAvatarModel(
+ friend: searchResult.friend!,
+ name: searchResult.friend?.name ?? "",
+ address: searchResult.friend?.address?.clone()?.asStringUriOnly() ?? "",
+ withPresence: true
+ )
+ )
+ }
+ }
+
+ DispatchQueue.main.async {
+ self.contactsManager.lastSearch = sortedLastSearch
+ self.contactsManager.lastSearchSuggestions = lastSearchSuggestions
+
+ self.contactsManager.avatarListModel.forEach { contactAvatarModel in
+ contactAvatarModel.removeFriendDelegate()
+ }
+ self.contactsManager.avatarListModel.removeAll()
+ self.contactsManager.avatarListModel += addedAvatarListModel
+
+ NotificationCenter.default.post(name: NSNotification.Name("ContactLoaded"), object: nil)
+ }
+ })
+ self.magicSearch.addDelegate(delegate: self.searchDelegate!)
+ }
+ }
+
+ func searchForContacts(sourceFlags: Int) {
+ coreContext.doOnCoreQueue { _ in
+ var needResetCache = false
+
+ DispatchQueue.main.sync {
+ if let oldFilter = self.previousFilter {
+ if oldFilter.count > self.currentFilter.count || oldFilter != self.currentFilter {
+ needResetCache = true
+ }
+ }
+ self.previousFilter = self.currentFilter
+ }
+ if needResetCache {
+ self.magicSearch.resetSearchCache()
+ }
+
+ self.magicSearch.getContactsListAsync(
+ filter: self.currentFilter,
+ domain: self.allContact ? "" : self.domainDefaultAccount,
+ sourceFlags: sourceFlags,
+ aggregation: MagicSearch.Aggregation.Friend)
+ }
+ }
+
+ func searchForSuggestions() {
+ coreContext.doOnCoreQueue { _ in
+ var needResetCache = false
+
+ DispatchQueue.main.sync {
+ if let oldFilter = self.previousFilterSuggestions {
+ if oldFilter.count > self.currentFilterSuggestions.count || oldFilter != self.currentFilterSuggestions {
+ needResetCache = true
+ }
+ }
+ self.previousFilterSuggestions = self.currentFilterSuggestions
+ }
+ if needResetCache {
+ self.magicSearch.resetSearchCache()
+ }
+
+ self.magicSearch.getContactsListAsync(
+ filter: self.currentFilterSuggestions,
+ domain: self.domainDefaultAccount,
+ sourceFlags: MagicSearch.Source.All.rawValue,
+ aggregation: MagicSearch.Aggregation.Friend)
+ }
+ }
+}
diff --git a/Linphone/Utils/PermissionManager.swift b/Linphone/Utils/PermissionManager.swift
new file mode 100644
index 000000000..dab08971b
--- /dev/null
+++ b/Linphone/Utils/PermissionManager.swift
@@ -0,0 +1,129 @@
+/*
+ * Copyright (c) 2010-2023 Belledonne Communications SARL.
+ *
+ * This file is part of Linphone
+ *
+ * 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 .
+ */
+
+import Foundation
+import Photos
+import Contacts
+import UserNotifications
+import SwiftUI
+import Network
+
+class PermissionManager: ObservableObject {
+
+ static let shared = PermissionManager()
+
+ @Published var pushPermissionGranted = false
+ @Published var photoLibraryPermissionGranted = false
+ @Published var cameraPermissionGranted = false
+ @Published var contactsPermissionGranted = false
+ @Published var microphonePermissionGranted = false
+ @Published var allPermissionsHaveBeenDisplayed = false
+
+ private init() {}
+
+ func getPermissions() {
+ 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(completion: @escaping () -> Void) {
+ let options: UNAuthorizationOptions = [.alert, .sound, .badge]
+ UNUserNotificationCenter.current().requestAuthorization(options: options) { (granted, error) in
+ if let error = error {
+ Log.error("Unexpected error when asking for Push permission : \(error.localizedDescription)")
+ }
+ DispatchQueue.main.async {
+ self.pushPermissionGranted = granted
+ }
+ completion()
+ }
+ }
+
+ func microphoneRequestPermission() {
+ AVAudioSession.sharedInstance().requestRecordPermission({ granted in
+ DispatchQueue.main.async {
+ self.microphonePermissionGranted = granted
+ }
+ })
+ }
+
+ func photoLibraryRequestPermission() {
+ PHPhotoLibrary.requestAuthorization(for: .readWrite, handler: {status in
+ DispatchQueue.main.async {
+ self.photoLibraryPermissionGranted = (status == .authorized || status == .limited || status == .restricted)
+ }
+ })
+ }
+
+ func cameraRequestPermission() {
+ AVCaptureDevice.requestAccess(for: .video, completionHandler: {accessGranted in
+ DispatchQueue.main.async {
+ self.cameraPermissionGranted = accessGranted
+ }
+ })
+ }
+
+ 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
+ }
+ }
+}
diff --git a/Linphone/Utils/PhotoPicker.swift b/Linphone/Utils/PhotoPicker.swift
new file mode 100644
index 000000000..16a309adf
--- /dev/null
+++ b/Linphone/Utils/PhotoPicker.swift
@@ -0,0 +1,177 @@
+/*
+ * 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 .
+ */
+
+import SwiftUI
+import PhotosUI
+
+// swiftlint:disable line_length
+struct PhotoPicker: UIViewControllerRepresentable {
+ typealias UIViewControllerType = PHPickerViewController
+
+ let filter: PHPickerFilter?
+ var limit: Int = 0
+ let onComplete: ([PHPickerResult]) -> Void
+
+ func makeUIViewController(context: Context) -> PHPickerViewController {
+
+ var configuration = PHPickerConfiguration()
+ if filter != nil {
+ configuration.filter = filter
+ }
+ configuration.selectionLimit = limit
+
+ let controller = PHPickerViewController(configuration: configuration)
+
+ controller.delegate = context.coordinator
+ return controller
+ }
+
+ static func convertToUIImageArray(fromResults results: [PHPickerResult], onComplete: @escaping ([UIImage]?, Error?) -> Void) {
+ var images = [UIImage]()
+
+ let dispatchGroup = DispatchGroup()
+ for result in results {
+ dispatchGroup.enter()
+ let itemProvider = result.itemProvider
+ if itemProvider.canLoadObject(ofClass: UIImage.self) {
+ itemProvider.loadObject(ofClass: UIImage.self) { (imageOrNil, errorOrNil) in
+ if let error = errorOrNil {
+ onComplete(nil, error)
+ }
+ if let image = imageOrNil as? UIImage {
+ images.append(image)
+ }
+ dispatchGroup.leave()
+ }
+ }
+ }
+ dispatchGroup.notify(queue: .main) {
+ onComplete(images, nil)
+ }
+ }
+
+ static func convertToAttachmentArray(fromResults results: [PHPickerResult], onComplete: @escaping ([Attachment]?, Error?) -> Void) {
+ var medias = [Attachment]()
+
+ let dispatchGroup = DispatchGroup()
+ for result in results {
+ dispatchGroup.enter()
+ let itemProvider = result.itemProvider
+ if itemProvider.hasItemConformingToTypeIdentifier(UTType.image.identifier) {
+ itemProvider.loadFileRepresentation(forTypeIdentifier: UTType.image.identifier) { urlFile, error in
+ if urlFile != nil {
+ do {
+ let dataResult = try Data(contentsOf: urlFile!)
+ let urlImage = self.saveMedia(name: urlFile!.lastPathComponent, data: dataResult, type: .image)
+ if urlImage != nil {
+ let attachment = Attachment(id: UUID().uuidString, name: urlFile!.lastPathComponent, url: urlImage!, type: .image)
+ medias.append(attachment)
+ }
+ } catch {
+
+ }
+ } else {
+ Log.error("Could not load file representation: \(error?.localizedDescription ?? "unknown error")")
+ }
+
+ dispatchGroup.leave()
+ }
+ } else if itemProvider.hasItemConformingToTypeIdentifier(UTType.movie.identifier) {
+ itemProvider.loadFileRepresentation(forTypeIdentifier: UTType.movie.identifier) { urlFile, error in
+ if urlFile != nil {
+ do {
+ let dataResult = try Data(contentsOf: urlFile!)
+ let urlImage = self.saveMedia(name: urlFile!.lastPathComponent, data: dataResult, type: .video)
+ let urlThumbnail = getURLThumbnail(name: urlFile!.lastPathComponent)
+
+ if urlImage != nil {
+ let attachment = Attachment(id: UUID().uuidString, name: urlFile!.lastPathComponent, thumbnail: urlThumbnail, full: urlImage!, type: .video)
+ medias.append(attachment)
+ }
+ } catch {
+
+ }
+ } else {
+ Log.error("Could not load file representation: \(error?.localizedDescription ?? "unknown error")")
+ }
+ dispatchGroup.leave()
+ }
+ }
+ }
+
+ dispatchGroup.notify(queue: .main) {
+ onComplete(medias, nil)
+ }
+ }
+
+ static func saveMedia(name: String, data: Data, type: AttachmentType) -> URL? {
+ do {
+ let path = FileManager.default.temporaryDirectory.appendingPathComponent((name.addingPercentEncoding(withAllowedCharacters: .urlHostAllowed) ?? ""))
+
+ _ = try data.write(to: path)
+
+ if type == .video {
+ let asset = AVURLAsset(url: path, options: nil)
+ let imgGenerator = AVAssetImageGenerator(asset: asset)
+ imgGenerator.appliesPreferredTrackTransform = true
+ let cgImage = try imgGenerator.copyCGImage(at: CMTimeMake(value: 0, timescale: 1), actualTime: nil)
+ let thumbnail = UIImage(cgImage: cgImage)
+
+ guard let data = thumbnail.jpegData(compressionQuality: 1) ?? thumbnail.pngData() else {
+ return nil
+ }
+
+ let urlName = FileManager.default.temporaryDirectory.appendingPathComponent("preview_" + (name.addingPercentEncoding(withAllowedCharacters: .urlHostAllowed) ?? "") + ".png")
+
+ _ = try data.write(to: urlName)
+ }
+
+ return path
+ } catch let error {
+ print("*** Error generating thumbnail: \(error.localizedDescription)")
+ return nil
+ }
+ }
+
+ static func getURLThumbnail(name: String) -> URL {
+ return FileManager.default.temporaryDirectory.appendingPathComponent("preview_" + (name.addingPercentEncoding(withAllowedCharacters: .urlHostAllowed) ?? "") + ".png")
+ }
+
+ func updateUIViewController(_ uiViewController: PHPickerViewController, context: Context) {}
+
+ func makeCoordinator() -> Coordinator {
+ return Coordinator(self)
+ }
+
+ class Coordinator: PHPickerViewControllerDelegate {
+
+ private let parent: PhotoPicker
+
+ init(_ parent: PhotoPicker) {
+ self.parent = parent
+ }
+
+ func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) {
+ picker.dismiss(animated: true)
+ parent.onComplete(results)
+ }
+ }
+}
+
+// swiftlint:enable line_length
diff --git a/Linphone/Utils/ShareSheetController.swift b/Linphone/Utils/ShareSheetController.swift
new file mode 100644
index 000000000..8a512713c
--- /dev/null
+++ b/Linphone/Utils/ShareSheetController.swift
@@ -0,0 +1,91 @@
+/*
+ * 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 .
+ */
+
+import Foundation
+import SwiftUI
+import linphonesw
+
+struct ShareSheet: UIViewControllerRepresentable {
+ typealias Callback = (_ activityType: UIActivity.ActivityType?, _ completed: Bool, _ returnedItems: [Any]?, _ error: Error?) -> Void
+
+ let friendToShare: Friend
+ var activityItems: [Any] = []
+ let applicationActivities: [UIActivity]? = nil
+ let excludedActivityTypes: [UIActivity.ActivityType]? = nil
+ let callback: Callback? = nil
+
+ func makeUIViewController(context: Context) -> UIActivityViewController {
+ let directoryURL = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first
+
+ if directoryURL != nil {
+ if friendToShare.name != nil {
+ let filename = friendToShare.name!.replacingOccurrences(of: " ", with: "")
+
+ let fileURL = directoryURL!
+ .appendingPathComponent(filename)
+ .appendingPathExtension("vcf")
+
+ if friendToShare.vcard != nil {
+ try? friendToShare.vcard!.asVcard4String().write(to: fileURL, atomically: false, encoding: String.Encoding.utf8)
+
+ let controller = UIActivityViewController(
+ activityItems: [fileURL],
+ applicationActivities: applicationActivities
+ )
+ controller.excludedActivityTypes = excludedActivityTypes
+ controller.completionWithItemsHandler = callback
+ return controller
+ }
+ }
+ }
+
+ let controller = UIActivityViewController(
+ activityItems: activityItems,
+ applicationActivities: applicationActivities)
+ controller.excludedActivityTypes = excludedActivityTypes
+ controller.completionWithItemsHandler = callback
+ return controller
+ }
+
+ func updateUIViewController(_ uiViewController: UIActivityViewController, context: Context) {
+ // nothing to do here
+ }
+
+ func shareContacts(friend: String) {
+
+ let directoryURL = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first
+
+ if directoryURL != nil {
+ let filename = NSUUID().uuidString
+
+ let fileURL = directoryURL!
+ .appendingPathComponent(filename)
+ .appendingPathExtension("vcf")
+
+ try? friend.write(to: fileURL, atomically: false, encoding: String.Encoding.utf8)
+ }
+
+ /*
+ let activityViewController = UIActivityViewController(
+ activityItems: [fileURL],
+ applicationActivities: nil
+ )
+ */
+ }
+}
diff --git a/Linphone/Utils/SingleSignOn/AuthState.swift b/Linphone/Utils/SingleSignOn/AuthState.swift
new file mode 100644
index 000000000..849415089
--- /dev/null
+++ b/Linphone/Utils/SingleSignOn/AuthState.swift
@@ -0,0 +1,44 @@
+/*
+ * 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 .
+ */
+
+import Foundation
+import AppAuth
+
+class AuthState: Encodable, Decodable {
+ var accessToken: String?
+ var refreshToken: String?
+ var tokenEndpointUri: String?
+ var accessTokenExpirationTime: Date?
+ var isAuthorized: Bool
+
+ init(oidAuthState: OIDAuthState) {
+ accessToken = oidAuthState.lastTokenResponse?.accessToken
+ refreshToken = oidAuthState.refreshToken
+ tokenEndpointUri = oidAuthState.lastTokenResponse?.request.configuration.tokenEndpoint.absoluteString
+ accessTokenExpirationTime = oidAuthState.getAccessTokenExpirationTime()
+ isAuthorized = oidAuthState.isAuthorized
+ }
+
+ func update(tokenResponse: OIDTokenResponse) {
+ accessToken = tokenResponse.accessToken
+ refreshToken = tokenResponse.refreshToken
+ tokenEndpointUri = tokenResponse.request.configuration.tokenEndpoint.absoluteString
+ accessTokenExpirationTime = tokenResponse.accessTokenExpirationDate
+ }
+}
diff --git a/Linphone/Utils/SingleSignOn/OIDAuthStateExtension.swift b/Linphone/Utils/SingleSignOn/OIDAuthStateExtension.swift
new file mode 100644
index 000000000..c68409782
--- /dev/null
+++ b/Linphone/Utils/SingleSignOn/OIDAuthStateExtension.swift
@@ -0,0 +1,36 @@
+/*
+ * 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 .
+ */
+
+import Foundation
+import AppAuth
+
+extension OIDAuthState {
+ public func getAccessTokenExpirationTime() -> Date? {
+ if authorizationError != nil {
+ return nil
+ }
+ if lastTokenResponse?.accessToken != nil {
+ return lastTokenResponse?.accessTokenExpirationDate
+ }
+ if lastAuthorizationResponse.accessToken != nil {
+ return lastAuthorizationResponse.accessTokenExpirationDate
+ }
+ return nil
+ }
+}
diff --git a/Linphone/Utils/SingleSignOn/SingleSignOnManager.swift b/Linphone/Utils/SingleSignOn/SingleSignOnManager.swift
new file mode 100644
index 000000000..4d3afe523
--- /dev/null
+++ b/Linphone/Utils/SingleSignOn/SingleSignOnManager.swift
@@ -0,0 +1,178 @@
+/*
+ * 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 .
+ */
+
+import Foundation
+import linphonesw
+import AppAuth
+
+class SingleSignOnManager {
+
+ static let shared = SingleSignOnManager()
+
+ private let TAG = "[SSO]"
+ private let clientId = "linphone"
+ private let userDefaultSSOKey = "sso-authstate"
+ let ssoRedirectUri = URL(string: "org.linphone:/openidcallback")!
+ private var singleSignOnUrl = ""
+ private var username: String = ""
+ private var authState: AuthState?
+ private var authService: OIDAuthorizationService?
+ var currentAuthorizationFlow: OIDExternalUserAgentSession?
+
+ func persistedAuthState() -> AuthState? {
+ if let persistentAuthState = UserDefaults.standard.object(forKey: userDefaultSSOKey), let fromDictionary = persistentAuthState as? [String: Any] {
+ return AuthState(dictionary: fromDictionary)
+ } else {
+ return nil
+ }
+ }
+
+ func persistAuthState() {
+ if let authState = authState {
+ UserDefaults.standard.set(authState.asDictionary, forKey: userDefaultSSOKey)
+ }
+ }
+
+ func setUp(ssoUrl: String, user: String = "") {
+ singleSignOnUrl = ssoUrl
+ username = user
+ Log.info("\(TAG) Setting up SSO environment for username \(username) and URL \(singleSignOnUrl)")
+ authState = persistedAuthState()
+ updateTokenInfo()
+ }
+
+ private func updateTokenInfo() {
+ Log.info("\(TAG) Updating token info")
+ if authState?.isAuthorized == true {
+ Log.info("\(TAG) User is already authenticated!")
+ if let expiration = authState?.accessTokenExpirationTime {
+ if expiration < Date() {
+ Log.warn("\(TAG) Access token is expired")
+ performRefreshToken()
+ } else {
+ Log.info("\(TAG) Access token valid, expires \(expiration)")
+ storeTokensInAuthInfo()
+ }
+ } else {
+ Log.warn("\(TAG) Access token expiration info not available")
+ singleSignOn()
+ }
+ } else {
+ Log.warn("\(TAG) User isn't authenticated yet")
+ singleSignOn()
+ }
+ }
+
+ private func performRefreshToken() {
+ Log.info("\(TAG) Refreshing token")
+ if let issuer = URL(string: singleSignOnUrl) {
+ OIDAuthorizationService.discoverConfiguration(forIssuer: issuer) { configuration, error in
+ guard let configuration = configuration, let refreshToken = self.authState?.refreshToken else {
+ Log.error("\(self.TAG) Error retrieving discovery document: \(error?.localizedDescription ?? "Unknown error")")
+ return
+ }
+ let request = OIDTokenRequest(
+ configuration: configuration,
+ grantType: OIDGrantTypeRefreshToken,
+ authorizationCode: nil,
+ redirectURL: nil,
+ clientID: self.clientId,
+ clientSecret: nil,
+ scope: nil,
+ refreshToken: refreshToken,
+ codeVerifier: nil,
+ additionalParameters: nil)
+
+ OIDAuthorizationService.perform(request) { tokenResponse, error in
+ if error != nil {
+ Log.error("\(self.TAG) Error occured refreshing token \(String(describing: error))")
+ self.authState = nil
+ self.singleSignOn()
+ return
+ }
+ if let tokenResponse = tokenResponse, tokenResponse.accessToken != nil {
+ Log.info("\(self.TAG) Refreshed token \(String(describing: tokenResponse.accessToken))")
+ self.authState?.update(tokenResponse: tokenResponse)
+ self.storeTokensInAuthInfo()
+ } else {
+ Log.info("\(self.TAG) refresh token response or access token is empty")
+ self.authState = nil
+ self.singleSignOn()
+ }
+ }
+ }
+ }
+ }
+
+ private func singleSignOn() {
+ if let issuer = URL(string: singleSignOnUrl) {
+ OIDAuthorizationService.discoverConfiguration(forIssuer: issuer) { configuration, error in
+ guard let configuration = configuration else {
+ Log.error("\(self.TAG) Error retrieving discovery document: \(error?.localizedDescription ?? "Unknown error")")
+ return
+ }
+
+ let request = OIDAuthorizationRequest(configuration: configuration,
+ clientId: self.clientId,
+ scopes: ["offline_access"],
+ redirectURL: self.ssoRedirectUri,
+ responseType: OIDResponseTypeCode,
+ additionalParameters: ["login_hint": self.username])
+
+ Log.info("\(self.TAG) Initiating authorization request with scope: \(request.scope ?? "nil")")
+ if let viewController = UIApplication.getTopMostViewController() {
+ self.currentAuthorizationFlow =
+ OIDAuthState.authState(byPresenting: request, presenting: viewController) { authState, error in
+ if let authState = authState {
+ self.authState = AuthState(oidAuthState: authState)
+ self.persistAuthState()
+ Log.info("\(self.TAG) Got authorization tokens. Access token: " +
+ "\(authState.lastTokenResponse?.accessToken ?? "nil")")
+ self.storeTokensInAuthInfo()
+ } else {
+ Log.info("\(self.TAG) Authorization error: \(error?.localizedDescription ?? "Unknown error")")
+ self.authState = nil
+ }
+ }
+ }
+ }
+ }
+ }
+
+ private func storeTokensInAuthInfo() {
+ CoreContext.shared.doOnCoreQueue { core in
+ if let expire = self.authState?.accessTokenExpirationTime?.timeIntervalSince1970,
+ let accessToken = self.authState?.accessToken,
+ let lAccessToken = try?Factory.Instance.createBearerToken(token: accessToken, expirationTime: Int(expire)),
+ let refreshToken = self.authState?.refreshToken,
+ let lRefreshToken = try?Factory.Instance.createBearerToken(token: refreshToken, expirationTime: Int(expire)),
+ let authInfo = CoreContext.shared.bearerAuthInfoPendingPasswordUpdate {
+ authInfo.accessToken = lAccessToken
+ authInfo.refreshToken = lRefreshToken
+ authInfo.tokenEndpointUri = self.authState?.tokenEndpointUri
+ authInfo.clientId = self.clientId
+ core.addAuthInfo(info: authInfo)
+ Log.info("\(self.TAG) Auth info added username=\(self.username) access token=\(accessToken) refresh token=\(refreshToken) expire=\(expire)")
+ core.refreshRegisters()
+ } else {
+ Log.warn("\(self.TAG) Unable to store SSO details in auth info")
+ }
+ }
+ }
+}
diff --git a/Linphone/Utils/TouchFeedback.swift b/Linphone/Utils/TouchFeedback.swift
new file mode 100644
index 000000000..846e1bc8f
--- /dev/null
+++ b/Linphone/Utils/TouchFeedback.swift
@@ -0,0 +1,30 @@
+/*
+ * 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 .
+ */
+
+import SwiftUI
+import CoreHaptics
+import AudioToolbox
+
+func touchFeedback() {
+ if CHHapticEngine.capabilitiesForHardware().supportsHaptics {
+ UIImpactFeedbackGenerator().impactOccurred()
+ } else {
+ AudioServicesPlaySystemSound(1519) // 1520 and 1521 are gradually stronger
+ }
+ }
diff --git a/Linphone/Utils/URIHandler.swift b/Linphone/Utils/URIHandler.swift
new file mode 100644
index 000000000..7706dc530
--- /dev/null
+++ b/Linphone/Utils/URIHandler.swift
@@ -0,0 +1,128 @@
+/*
+ * Copyright (c) 2010-2024 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 .
+ */
+
+import Foundation
+import linphonesw
+import Combine
+
+class URIHandler {
+
+ // Need to cover all Info.plist URL schemes.
+ private static let callSchemes = ["sip", "sip-linphone", "linphone-sip", "tel", "callto"]
+ private static let secureCallSchemes = ["sips", "sips-linphone", "linphone-sips"]
+ private static let configurationSchemes = ["linphone-config"]
+
+ private static var uriHandlerCoreDelegate: CoreDelegateStub?
+
+ static func addCoreDelegate() {
+ uriHandlerCoreDelegate = CoreDelegateStub(
+ onCallStateChanged: { (_: Core, _: Call, state: Call.State, _: String) in
+ if state == .Error {
+ toast("Failed_uri_handler_call_failed")
+ CoreContext.shared.removeCoreDelegateStub(delegate: uriHandlerCoreDelegate!)
+ }
+ if state == .End {
+ CoreContext.shared.removeCoreDelegateStub(delegate: uriHandlerCoreDelegate!)
+ }
+ },
+ onConfiguringStatus: { (_: Core, state: ConfiguringState, _: String) in
+ if state == .Failed {
+ toast("Failed_uri_handler_config_failed")
+ CoreContext.shared.removeCoreDelegateStub(delegate: uriHandlerCoreDelegate!)
+ }
+ if state == .Successful {
+ toast("uri_handler_config_success")
+ CoreContext.shared.removeCoreDelegateStub(delegate: uriHandlerCoreDelegate!)
+ }
+ })
+ CoreContext.shared.addCoreDelegateStub(delegate: uriHandlerCoreDelegate!)
+ }
+
+ static func handleURL(url: URL) {
+ Log.info("[URIHandler] handleURL: \(url)")
+ if let scheme = url.scheme {
+ if secureCallSchemes.contains(scheme) {
+ initiateCall(url: url, withScheme: "sips")
+ } else if callSchemes.contains(scheme) {
+ initiateCall(url: url, withScheme: "sip")
+ } else if configurationSchemes.contains(scheme) {
+ initiateConfiguration(url: url)
+ } else if scheme == SingleSignOnManager.shared.ssoRedirectUri.scheme {
+ continueSSO(url: url)
+ } else {
+ Log.error("[URIHandler] unhandled URL \(url) (check Info.plist)")
+ }
+ } else {
+ Log.error("[URIHandler] invalid scheme for URL \(url)")
+ }
+ }
+
+ private static func initiateCall(url: URL, withScheme newScheme: String) {
+ CoreContext.shared.performActionOnCoreQueueWhenCoreIsStarted { core in
+ if let newSchemeUrl = url.withNewScheme(newScheme),
+ let address = core.interpretUrl(url: newSchemeUrl.absoluteString,
+ applyInternationalPrefix: LinphoneUtils.applyInternationalPrefix(core: core)) {
+ Log.info("[URIHandler] initiating call to address : \(address.asString())")
+ addCoreDelegate()
+ TelecomManager.shared.doCallWithCore(addr: address, isVideo: false, isConference: false)
+ } else {
+ Log.error("[URIHandler] unable to call with \(url.resourceSpecifier)")
+ toast("Failed_uri_handler_bad_call_address")
+ }
+ }
+ }
+
+ private static func initiateConfiguration(url: URL) {
+ if autoRemoteProvisioningOnConfigUriHandler() {
+ CoreContext.shared.performActionOnCoreQueueWhenCoreIsStarted { core in
+ Log.info("[URIHandler] provisioning app with URI: \(url.resourceSpecifier)")
+ do {
+ addCoreDelegate()
+ core.config?.setString(section: "misc", key: "config-uri", value: url.resourceSpecifier)
+ try core.setProvisioninguri(newValue: url.resourceSpecifier)
+ core.stop()
+ try core.start()
+ } catch {
+ Log.error("[URIHandler] unable to configure the app with \(url.resourceSpecifier) \(error)")
+ toast("Failed_uri_handler_bad_config_address")
+ }
+ }
+ } else {
+ Log.warn("[URIHandler] received configuration request, but automatic provisioning is disabled.")
+ }
+ }
+
+ private static func continueSSO(url: URL) {
+ if let authorizationFlow = SingleSignOnManager.shared.currentAuthorizationFlow,
+ authorizationFlow.resumeExternalUserAgentFlow(with: url) {
+ SingleSignOnManager.shared.currentAuthorizationFlow = nil
+ }
+ }
+
+ private static func autoRemoteProvisioningOnConfigUriHandler() -> Bool {
+ return Config.get().getBool(section: "app", key: "auto_apply_provisioning_config_uri_handler", defaultValue: true)
+ }
+
+ private static func toast(_ message: String) {
+ DispatchQueue.main.async {
+ ToastViewModel.shared.toastMessage = message
+ ToastViewModel.shared.displayToast = true
+ }
+ }
+}
diff --git a/Podfile b/Podfile
new file mode 100644
index 000000000..196e3f81f
--- /dev/null
+++ b/Podfile
@@ -0,0 +1,75 @@
+# Uncomment the next line to define a global platform for your project
+platform :ios, '15.0'
+source "https://gitlab.linphone.org/BC/public/podspec.git"
+source "https://github.com/CocoaPods/Specs.git"
+
+def basic_pods
+ if ENV['PODFILE_PATH'].nil?
+ pod 'linphone-sdk', '~> 5.4.0-alpha'
+ else
+ pod 'linphone-sdk', :path => ENV['PODFILE_PATH'] # local sdk
+ end
+
+ crashlytics
+end
+
+def crashlytics
+ if not ENV['USE_CRASHLYTICS'].nil?
+ pod 'Firebase/Analytics'
+ pod 'Firebase/Crashlytics'
+ end
+end
+
+target 'Linphone' do
+ # Comment the next line if you don't want to use dynamic frameworks
+ use_frameworks!
+
+ # Pods for Linphone
+ pod 'SwiftLint'
+ pod 'AppAuth'
+ basic_pods
+
+end
+
+target 'msgNotificationService' do
+ # Uncomment the next line if you're using Swift or would like to use dynamic frameworks
+ use_frameworks!
+
+ # Pods for messagesNotification
+ basic_pods
+
+end
+
+post_install do |installer|
+ app_project = Xcodeproj::Project.open(Dir.glob("*.xcodeproj")[0])
+ app_project.native_targets.each do |target|
+ target.build_configurations.each do |config|
+ if target.name == "Linphone" || target.name == 'msgNotificationService' || target.name == 'msgNotificationContent'
+ if ENV['USE_CRASHLYTICS'].nil?
+ if config.name == "Debug" then
+ config.build_settings['GCC_PREPROCESSOR_DEFINITIONS'] = '$(inherited) DEBUG=1'
+ else
+ config.build_settings['GCC_PREPROCESSOR_DEFINITIONS'] = '$(inherited)'
+ end
+ config.build_settings['OTHER_SWIFT_FLAGS'] = '$(inherited)'
+ else
+ # activate crashlytics
+ if config.name == "Debug" then
+ config.build_settings['GCC_PREPROCESSOR_DEFINITIONS'] = '$(inherited) DEBUG=1 USE_CRASHLYTICS=1'
+ else
+ config.build_settings['GCC_PREPROCESSOR_DEFINITIONS'] = '$(inherited) USE_CRASHLYTICS=1'
+ end
+ config.build_settings['OTHER_SWIFT_FLAGS'] = '$(inherited) -DUSE_CRASHLYTICS'
+ end
+ end
+
+ app_project.save
+ end
+ end
+ installer.pods_project.targets.each do |target|
+ target.build_configurations.each do |config|
+ config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '15.0'
+ end
+ end
+end
+
diff --git a/README.md b/README.md
index 71536668a..045429ddd 100644
--- a/README.md
+++ b/README.md
@@ -112,37 +112,3 @@ To activate it:
```
- Then open `linphone.xcworkspace` with Xcode to build and run the app.
-
-# Quick UI reference
-
-- The app is contained in a window, which resides in the MainStoryboard file.
-- The delegate is set to LinphoneAppDelegate in main.m, in the UIApplicationMain() by passing its class
-- Basic layout:
-
- MainStoryboard
- |
- | (rootViewController)
- |
- PhoneMainView ---> view |--> app background
- | |
- | |--> statusbar background
- |
- | (mainViewController)
- |
- UICompositeView : TPMultilayout
- |
- |---> view |--> statusBar
- |
- |--> contentView
- |
- |--> tabBar
-
-
-When the application is started, the phoneMainView gets asked to transition to the Dialer view or the Assistant view.
-PhoneMainView exposes the -changeCurrentView: method, which will setup its
-Any Linphone view is actually presented in the UICompositeView, with or without a statusBar and tabBar.
-
-The UICompositeView consists of 3 areas laid out vertically. From top to bottom: StatusBar, Content and TabBar.
-The TabBar is usually the UIMainBar, which is used as a navigation controller: clicking on each of the buttons will trigger
-a transition to another "view".
-
diff --git a/msgNotificationService/GoogleService-Info.plist b/msgNotificationService/GoogleService-Info.plist
new file mode 100644
index 000000000..b493c102e
--- /dev/null
+++ b/msgNotificationService/GoogleService-Info.plist
@@ -0,0 +1,36 @@
+
+
+
+
+ CLIENT_ID
+ 221368768663-b8e48em01it3pt04vp1k0ddrgrcrju65.apps.googleusercontent.com
+ REVERSED_CLIENT_ID
+ com.googleusercontent.apps.221368768663-b8e48em01it3pt04vp1k0ddrgrcrju65
+ API_KEY
+ AIzaSyDJTtlRCM7IqdVUU2dSIYq2YIsTz6bqnkI
+ GCM_SENDER_ID
+ 221368768663
+ PLIST_VERSION
+ 1
+ BUNDLE_ID
+ org.linphone.phone.msgNotificationService
+ PROJECT_ID
+ linphone-iphone
+ STORAGE_BUCKET
+ linphone-iphone.appspot.com
+ IS_ADS_ENABLED
+
+ IS_ANALYTICS_ENABLED
+
+ IS_APPINVITE_ENABLED
+
+ IS_GCM_ENABLED
+
+ IS_SIGNIN_ENABLED
+
+ GOOGLE_APP_ID
+ 1:221368768663:ios:ccf2c32eadcd3a0f9431d2
+ DATABASE_URL
+ https://linphone-iphone.firebaseio.com
+
+
\ No newline at end of file
diff --git a/msgNotificationService/Info.plist b/msgNotificationService/Info.plist
new file mode 100644
index 000000000..59a92df5e
--- /dev/null
+++ b/msgNotificationService/Info.plist
@@ -0,0 +1,31 @@
+
+
+
+
+ CFBundleDevelopmentRegion
+ $(DEVELOPMENT_LANGUAGE)
+ CFBundleDisplayName
+ msgNotificationService
+ CFBundleExecutable
+ $(EXECUTABLE_NAME)
+ CFBundleIdentifier
+ $(PRODUCT_BUNDLE_IDENTIFIER)
+ CFBundleInfoDictionaryVersion
+ 6.0
+ CFBundleName
+ $(PRODUCT_NAME)
+ CFBundlePackageType
+ $(PRODUCT_BUNDLE_PACKAGE_TYPE)
+ CFBundleShortVersionString
+ $(MARKETING_VERSION)
+ CFBundleVersion
+ $(CURRENT_PROJECT_VERSION)
+ NSExtension
+
+ NSExtensionPointIdentifier
+ com.apple.usernotifications.service
+ NSExtensionPrincipalClass
+ $(PRODUCT_MODULE_NAME).NotificationService
+
+
+
diff --git a/msgNotificationService/NotificationService.swift b/msgNotificationService/NotificationService.swift
new file mode 100644
index 000000000..d8e77cbc8
--- /dev/null
+++ b/msgNotificationService/NotificationService.swift
@@ -0,0 +1,283 @@
+/*
+ * Copyright (c) 2010-2020 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 .
+ */
+
+// swiftlint:disable identifier_name
+
+import UserNotifications
+import linphonesw
+#if USE_CRASHLYTICS
+import Firebase
+#endif
+
+var LINPHONE_DUMMY_SUBJECT = "dummy subject"
+
+extension String {
+ func getDisplayNameFromSipAddress(lc: Core) -> String? {
+ Log.info("looking for display name for \(self)")
+
+ let defaults = UserDefaults.init(suiteName: Config.appGroupName)
+ let addressBook = defaults?.dictionary(forKey: "addressBook")
+
+ if addressBook == nil {
+ Log.info("address book not found in userDefaults")
+ return nil
+ }
+
+ var usePrefix = true
+ if let account = lc.defaultAccount, let params = account.params {
+ usePrefix = params.useInternationalPrefixForCallsAndChats
+ }
+
+ if let simpleAddr = lc.interpretUrl(url: self, applyInternationalPrefix: usePrefix) {
+ simpleAddr.clean()
+ let nomalSipaddr = simpleAddr.asString()
+ if let displayName = addressBook?[nomalSipaddr] as? String {
+ Log.info("display name for \(self): \(displayName)")
+ return displayName
+ }
+ }
+
+ Log.info("display name for \(self) not found in userDefaults")
+ return nil
+ }
+}
+
+struct MsgData: Codable {
+ var from: String?
+ var body: String?
+ var subtitle: String?
+ var callId: String?
+ var localAddr: String?
+ var peerAddr: String?
+}
+
+class NotificationService: UNNotificationServiceExtension {
+
+ var contentHandler: ((UNNotificationContent) -> Void)?
+ var bestAttemptContent: UNMutableNotificationContent?
+
+ var lc: Core?
+
+ override init() {
+ super.init()
+#if USE_CRASHLYTICS
+ FirebaseApp.configure()
+#endif
+ }
+
+ override func didReceive(_ request: UNNotificationRequest, withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) {
+
+ self.contentHandler = contentHandler
+ bestAttemptContent = (request.content.mutableCopy() as? UNMutableNotificationContent)
+
+ LoggingService.Instance.logLevel = LogLevel.Debug
+ Factory.Instance.logCollectionPath = Factory.Instance.getConfigDir(context: nil)
+ Factory.Instance.enableLogCollection(state: LogCollectionState.Enabled)
+ Log.info("[msgNotificationService] start msgNotificationService extension")
+ /*
+ if (VFSUtil.vfsEnabled(groupName: Config.appGroupName) && !VFSUtil.activateVFS()) {
+ VFSUtil.log("[VFS] Error unable to activate.", .error)
+ }
+ */
+ if let bestAttemptContent = bestAttemptContent {
+ createCore()
+
+ // if !lc!.config!.getBool(section: "app", key: "disable_chat_feature", defaultValue: false) {
+ Log.info("received push payload : \(bestAttemptContent.userInfo.debugDescription)")
+
+ /*
+ let defaults = UserDefaults.init(suiteName: Config.appGroupName)
+ if let chatroomsPushStatus = defaults?.dictionary(forKey: "chatroomsPushStatus") {
+ let aps = bestAttemptContent.userInfo["aps"] as? NSDictionary
+ let alert = aps?["alert"] as? NSDictionary
+ let fromAddresses = alert?["loc-args"] as? [String]
+
+ if let from = fromAddresses?.first {
+ if ((chatroomsPushStatus[from] as? String) == "disabled") {
+ NotificationService.log.message(message: "message comes from a muted chatroom, ignore it")
+ contentHandler(UNNotificationContent())
+ }
+ }
+ }
+ */
+ if let chatRoomInviteAddr = bestAttemptContent.userInfo["chat-room-addr"] as? String, !chatRoomInviteAddr.isEmpty {
+ Log.info("fetch chat room for invite, addr: \(chatRoomInviteAddr)")
+ let chatRoom = lc!.getNewChatRoomFromConfAddr(chatRoomAddr: chatRoomInviteAddr)
+
+ if let chatRoom = chatRoom {
+ stopCore()
+ Log.info("chat room invite received")
+ bestAttemptContent.title = NSLocalizedString("GC_MSG", comment: "")
+ if chatRoom.hasCapability(mask: ChatRoom.Capabilities.OneToOne.rawValue) {
+ if chatRoom.peerAddress != nil {
+ if chatRoom.peerAddress!.displayName != nil && chatRoom.peerAddress!.displayName!.isEmpty != true {
+ bestAttemptContent.body = chatRoom.peerAddress!.displayName!
+ } else if chatRoom.peerAddress!.username != nil {
+ bestAttemptContent.body = chatRoom.peerAddress!.username!
+ } else {
+ bestAttemptContent.body = "Peer Address Error"
+ }
+ } else {
+ bestAttemptContent.body = "Peer Address Error"
+ }
+ } else {
+ bestAttemptContent.body = chatRoom.subject!
+ }
+ contentHandler(bestAttemptContent)
+ return
+ }
+ } else if let callId = bestAttemptContent.userInfo["call-id"] as? String {
+ Log.info("fetch msg for callid ["+callId+"]")
+ let message = lc!.getNewMessageFromCallid(callId: callId)
+
+ if let message = message {
+ let msgData = parseMessage(message: message)
+
+ // Extension only upates app's badge when main shared core is Off = extension's core is On.
+ // Otherwise, the app will update the badge.
+ if lc?.globalState == GlobalState.On, let badge = updateBadge() as NSNumber? {
+ bestAttemptContent.badge = badge
+ }
+
+ stopCore()
+ bestAttemptContent.sound = UNNotificationSound(named: UNNotificationSoundName(rawValue: "msg.caf"))
+ bestAttemptContent.title = NSLocalizedString("Message received", comment: "")
+ if let subtitle = msgData?.subtitle {
+ bestAttemptContent.subtitle = subtitle
+ }
+ if let body = msgData?.body {
+ bestAttemptContent.body = body
+ }
+
+ bestAttemptContent.categoryIdentifier = "msg_cat"
+
+ bestAttemptContent.userInfo.updateValue(msgData?.callId as Any, forKey: "CallId")
+ bestAttemptContent.userInfo.updateValue(msgData?.from as Any, forKey: "from")
+ bestAttemptContent.userInfo.updateValue(msgData?.peerAddr as Any, forKey: "peer_addr")
+ bestAttemptContent.userInfo.updateValue(msgData?.localAddr as Any, forKey: "local_addr")
+ if message.reactionContent != " " {
+ contentHandler(bestAttemptContent)
+ } else {
+ contentHandler(UNNotificationContent())
+ }
+
+ return
+ } else {
+ Log.info("Message not found for callid ["+callId+"]")
+ }
+ }
+ // }
+ serviceExtensionTimeWillExpire()
+ }
+
+ }
+
+ override func serviceExtensionTimeWillExpire() {
+ // Called just before the extension will be terminated by the system.
+ // Use this as an opportunity to deliver your "best attempt" at modified content, otherwise the original push payload will be used.
+ Log.warn("serviceExtensionTimeWillExpire")
+ stopCore()
+ if let contentHandler = contentHandler, let bestAttemptContent = bestAttemptContent {
+ NSLog("[msgNotificationService] serviceExtensionTimeWillExpire")
+ bestAttemptContent.categoryIdentifier = "app_active"
+
+ if let chatRoomInviteAddr = bestAttemptContent.userInfo["chat-room-addr"] as? String, !chatRoomInviteAddr.isEmpty {
+ bestAttemptContent.title = NSLocalizedString("GC_MSG", comment: "")
+ bestAttemptContent.body = ""
+ bestAttemptContent.sound = UNNotificationSound(named: UNNotificationSoundName("msg.caf"))
+ } else {
+ bestAttemptContent.title = NSLocalizedString("Message received", comment: "")
+ bestAttemptContent.body = NSLocalizedString("IM_MSG", comment: "")
+ }
+ contentHandler(bestAttemptContent)
+ }
+ }
+
+ func parseMessage(message: PushNotificationMessage) -> MsgData? {
+
+ var content = ""
+ if message.isConferenceInvitationNew {
+ content = NSLocalizedString("📅 You are invited to a meeting", comment: "")
+ } else if message.isConferenceInvitationUpdate {
+ content = NSLocalizedString("📅 Meeting has been modified", comment: "")
+ } else if message.isConferenceInvitationCancellation {
+ content = NSLocalizedString("📅 Meeting has been cancelled", comment: "")
+ } else {
+ content = message.isText ? message.textContent! : "🗻"
+ }
+
+ let fromAddr = message.fromAddr?.username
+ let callId = message.callId
+ let localUri = message.localAddr?.asStringUriOnly()
+ let peerUri = message.peerAddr?.asStringUriOnly()
+ let reactionContent = message.reactionContent
+ let from: String
+ if let fromDisplayName = message.fromAddr?.asStringUriOnly().getDisplayNameFromSipAddress(lc: lc!) {
+ from = fromDisplayName
+ } else {
+ from = fromAddr!
+ }
+
+ var msgData = MsgData(from: fromAddr, body: "", subtitle: "", callId: callId, localAddr: localUri, peerAddr: peerUri)
+
+ if let showMsg = lc!.config?.getBool(section: "app", key: "show_msg_in_notif", defaultValue: true), showMsg == true {
+ msgData.subtitle = message.subject ?? from
+ if reactionContent == nil {
+ msgData.body = (message.subject != nil ? "\(from): " : "") + content
+ } else {
+ msgData.body = from + NSLocalizedString(" has reacted by ", comment: "") + reactionContent! + NSLocalizedString(" to: ", comment: "") + content
+ }
+ } else {
+ if let subject = message.subject {
+ msgData.body = subject + ": " + from
+ } else {
+ msgData.body = from
+ }
+ }
+
+ Log.info("received msg size : \(content.count) \n")
+ return msgData
+ }
+
+ func createCore() {
+ Log.info("[msgNotificationService] create core")
+
+ lc = try? Factory.Instance.createSharedCoreWithConfig(config: Config.get(), systemContext: nil, appGroupId: Config.appGroupName, mainCore: false)
+ }
+
+ func stopCore() {
+ Log.info("stop core")
+ if let lc = lc {
+ lc.stop()
+ }
+ }
+
+ func updateBadge() -> Int {
+ var count = 0
+ count += lc!.unreadChatMessageCount
+ count += lc!.missedCallsCount
+ count += lc!.callsNb
+ Log.info("badge: \(count)\n")
+
+ return count
+ }
+
+}
+
+// swiftlint:enable identifier_name
diff --git a/msgNotificationService/msgNotificationService.entitlements b/msgNotificationService/msgNotificationService.entitlements
new file mode 100644
index 000000000..31f88ecaf
--- /dev/null
+++ b/msgNotificationService/msgNotificationService.entitlements
@@ -0,0 +1,16 @@
+
+
+
+
+ com.apple.developer.usernotifications.filtering
+
+ com.apple.security.application-groups
+
+ group.org.linphone.phone.msgNotification
+
+ keychain-access-groups
+
+ $(AppIdentifierPrefix)org.linphone.phone
+
+
+