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 +
+
+ zrtp + 1 +
+
+ 1 +
+
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 +
+
+ srtp + 0 +
+
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 + + +