From f1c2a672a573295626635ce7cdd43df4e7d56e3d Mon Sep 17 00:00:00 2001 From: Quentin Arguillere Date: Fri, 22 Sep 2023 12:15:50 +0000 Subject: [PATCH 001/486] Initial commit --- README.md | 92 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 92 insertions(+) create mode 100644 README.md diff --git a/README.md b/README.md new file mode 100644 index 000000000..263c52093 --- /dev/null +++ b/README.md @@ -0,0 +1,92 @@ +# linphone-iphone-6.0 + + + +## Getting started + +To make it easy for you to get started with GitLab, here's a list of recommended next steps. + +Already a pro? Just edit this README.md and make it your own. Want to make it easy? [Use the template at the bottom](#editing-this-readme)! + +## Add your files + +- [ ] [Create](https://docs.gitlab.com/ee/user/project/repository/web_editor.html#create-a-file) or [upload](https://docs.gitlab.com/ee/user/project/repository/web_editor.html#upload-a-file) files +- [ ] [Add files using the command line](https://docs.gitlab.com/ee/gitlab-basics/add-file.html#add-a-file-using-the-command-line) or push an existing Git repository with the following command: + +``` +cd existing_repo +git remote add origin https://gitlab.linphone.org/BC/private/linphone-iphone-6.0.git +git branch -M main +git push -uf origin main +``` + +## Integrate with your tools + +- [ ] [Set up project integrations](https://gitlab.linphone.org/BC/private/linphone-iphone-6.0/-/settings/integrations) + +## Collaborate with your team + +- [ ] [Invite team members and collaborators](https://docs.gitlab.com/ee/user/project/members/) +- [ ] [Create a new merge request](https://docs.gitlab.com/ee/user/project/merge_requests/creating_merge_requests.html) +- [ ] [Automatically close issues from merge requests](https://docs.gitlab.com/ee/user/project/issues/managing_issues.html#closing-issues-automatically) +- [ ] [Enable merge request approvals](https://docs.gitlab.com/ee/user/project/merge_requests/approvals/) +- [ ] [Automatically merge when pipeline succeeds](https://docs.gitlab.com/ee/user/project/merge_requests/merge_when_pipeline_succeeds.html) + +## Test and Deploy + +Use the built-in continuous integration in GitLab. + +- [ ] [Get started with GitLab CI/CD](https://docs.gitlab.com/ee/ci/quick_start/index.html) +- [ ] [Analyze your code for known vulnerabilities with Static Application Security Testing(SAST)](https://docs.gitlab.com/ee/user/application_security/sast/) +- [ ] [Deploy to Kubernetes, Amazon EC2, or Amazon ECS using Auto Deploy](https://docs.gitlab.com/ee/topics/autodevops/requirements.html) +- [ ] [Use pull-based deployments for improved Kubernetes management](https://docs.gitlab.com/ee/user/clusters/agent/) +- [ ] [Set up protected environments](https://docs.gitlab.com/ee/ci/environments/protected_environments.html) + +*** + +# Editing this README + +When you're ready to make this README your own, just edit this file and use the handy template below (or feel free to structure it however you want - this is just a starting point!). Thank you to [makeareadme.com](https://www.makeareadme.com/) for this template. + +## Suggestions for a good README +Every project is different, so consider which of these sections apply to yours. The sections used in the template are suggestions for most open source projects. Also keep in mind that while a README can be too long and detailed, too long is better than too short. If you think your README is too long, consider utilizing another form of documentation rather than cutting out information. + +## Name +Choose a self-explaining name for your project. + +## Description +Let people know what your project can do specifically. Provide context and add a link to any reference visitors might be unfamiliar with. A list of Features or a Background subsection can also be added here. If there are alternatives to your project, this is a good place to list differentiating factors. + +## Badges +On some READMEs, you may see small images that convey metadata, such as whether or not all the tests are passing for the project. You can use Shields to add some to your README. Many services also have instructions for adding a badge. + +## Visuals +Depending on what you are making, it can be a good idea to include screenshots or even a video (you'll frequently see GIFs rather than actual videos). Tools like ttygif can help, but check out Asciinema for a more sophisticated method. + +## Installation +Within a particular ecosystem, there may be a common way of installing things, such as using Yarn, NuGet, or Homebrew. However, consider the possibility that whoever is reading your README is a novice and would like more guidance. Listing specific steps helps remove ambiguity and gets people to using your project as quickly as possible. If it only runs in a specific context like a particular programming language version or operating system or has dependencies that have to be installed manually, also add a Requirements subsection. + +## Usage +Use examples liberally, and show the expected output if you can. It's helpful to have inline the smallest example of usage that you can demonstrate, while providing links to more sophisticated examples if they are too long to reasonably include in the README. + +## Support +Tell people where they can go to for help. It can be any combination of an issue tracker, a chat room, an email address, etc. + +## Roadmap +If you have ideas for releases in the future, it is a good idea to list them in the README. + +## Contributing +State if you are open to contributions and what your requirements are for accepting them. + +For people who want to make changes to your project, it's helpful to have some documentation on how to get started. Perhaps there is a script that they should run or some environment variables that they need to set. Make these steps explicit. These instructions could also be useful to your future self. + +You can also document commands to lint the code or run tests. These steps help to ensure high code quality and reduce the likelihood that the changes inadvertently break something. Having instructions for running tests is especially helpful if it requires external setup, such as starting a Selenium server for testing in a browser. + +## Authors and acknowledgment +Show your appreciation to those who have contributed to the project. + +## License +For open source projects, say how it is licensed. + +## Project status +If you have run out of energy or time for your project, put a note at the top of the README saying that development has slowed down or stopped completely. Someone may choose to fork your project or volunteer to step in as a maintainer or owner, allowing your project to keep going. You can also make an explicit request for maintainers. From daeec87404c039fd0976b1416df0a1106d0657c2 Mon Sep 17 00:00:00 2001 From: "benoit.martins" Date: Fri, 22 Sep 2023 14:49:56 +0200 Subject: [PATCH 002/486] Added Xcode Project --- Linphone.xcodeproj/project.pbxproj | 433 ++++++++++++++++++ .../contents.xcworkspacedata | 7 + .../xcshareddata/IDEWorkspaceChecks.plist | 8 + .../UserInterfaceState.xcuserstate | Bin 0 -> 13710 bytes .../xcschemes/xcschememanagement.plist | 14 + Linphone.xcworkspace/contents.xcworkspacedata | 10 + .../xcshareddata/IDEWorkspaceChecks.plist | 8 + .../xcshareddata/WorkspaceSettings.xcsettings | 5 + .../UserInterfaceState.xcuserstate | Bin 0 -> 43728 bytes .../WorkspaceSettings.xcsettings | 14 + Linphone/.DS_Store | Bin 0 -> 6148 bytes .../AccentColor.colorset/Contents.json | 11 + .../AppIcon.appiconset/Contents.json | 63 +++ Linphone/Assets.xcassets/Contents.json | 6 + Linphone/Linphone.entitlements | 10 + Linphone/LinphoneApp.swift | 29 ++ .../Preview Assets.xcassets/Contents.json | 6 + Linphone/core/CoreContext.swift | 54 +++ Linphone/ui/.DS_Store | Bin 0 -> 6148 bytes Linphone/ui/assistant/AssistantView.swift | 90 ++++ .../viewmodel/AccountLoginViewModel.swift | 110 +++++ Linphone/ui/main/ContentView.swift | 33 ++ Podfile | 25 + Podfile.lock | 21 + 24 files changed, 957 insertions(+) create mode 100644 Linphone.xcodeproj/project.pbxproj create mode 100644 Linphone.xcodeproj/project.xcworkspace/contents.xcworkspacedata create mode 100644 Linphone.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist create mode 100644 Linphone.xcodeproj/project.xcworkspace/xcuserdata/martinsb.xcuserdatad/UserInterfaceState.xcuserstate create mode 100644 Linphone.xcodeproj/xcuserdata/martinsb.xcuserdatad/xcschemes/xcschememanagement.plist create mode 100644 Linphone.xcworkspace/contents.xcworkspacedata create mode 100644 Linphone.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist create mode 100644 Linphone.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings create mode 100644 Linphone.xcworkspace/xcuserdata/martinsb.xcuserdatad/UserInterfaceState.xcuserstate create mode 100644 Linphone.xcworkspace/xcuserdata/martinsb.xcuserdatad/WorkspaceSettings.xcsettings create mode 100644 Linphone/.DS_Store create mode 100644 Linphone/Assets.xcassets/AccentColor.colorset/Contents.json create mode 100644 Linphone/Assets.xcassets/AppIcon.appiconset/Contents.json create mode 100644 Linphone/Assets.xcassets/Contents.json create mode 100644 Linphone/Linphone.entitlements create mode 100644 Linphone/LinphoneApp.swift create mode 100644 Linphone/Preview Content/Preview Assets.xcassets/Contents.json create mode 100644 Linphone/core/CoreContext.swift create mode 100644 Linphone/ui/.DS_Store create mode 100644 Linphone/ui/assistant/AssistantView.swift create mode 100644 Linphone/ui/assistant/viewmodel/AccountLoginViewModel.swift create mode 100644 Linphone/ui/main/ContentView.swift create mode 100644 Podfile create mode 100644 Podfile.lock diff --git a/Linphone.xcodeproj/project.pbxproj b/Linphone.xcodeproj/project.pbxproj new file mode 100644 index 000000000..d58787b09 --- /dev/null +++ b/Linphone.xcodeproj/project.pbxproj @@ -0,0 +1,433 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 56; + objects = { + +/* Begin PBXBuildFile section */ + 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 */; }; +/* End PBXBuildFile section */ + +/* Begin PBXFileReference section */ + 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 = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + D719ABB02ABC67BF00B41C10 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 1CF963E4850D8355137FE34F /* Pods */ = { + isa = PBXGroup; + children = ( + ); + path = Pods; + sourceTree = ""; + }; + D719ABAA2ABC67BF00B41C10 = { + isa = PBXGroup; + children = ( + D719ABB52ABC67BF00B41C10 /* Linphone */, + D719ABB42ABC67BF00B41C10 /* Products */, + 1CF963E4850D8355137FE34F /* Pods */, + ); + sourceTree = ""; + }; + D719ABB42ABC67BF00B41C10 /* Products */ = { + isa = PBXGroup; + children = ( + D719ABB32ABC67BF00B41C10 /* Linphone.app */, + ); + name = Products; + sourceTree = ""; + }; + D719ABB52ABC67BF00B41C10 /* Linphone */ = { + isa = PBXGroup; + children = ( + D719ABC72ABC6FB200B41C10 /* core */, + D719ABC52ABC6EE800B41C10 /* ui */, + D719ABB62ABC67BF00B41C10 /* LinphoneApp.swift */, + D719ABBA2ABC67BF00B41C10 /* Assets.xcassets */, + D719ABBC2ABC67BF00B41C10 /* Linphone.entitlements */, + D719ABBD2ABC67BF00B41C10 /* Preview Content */, + ); + path = Linphone; + sourceTree = ""; + }; + D719ABBD2ABC67BF00B41C10 /* Preview Content */ = { + isa = PBXGroup; + children = ( + D719ABBE2ABC67BF00B41C10 /* Preview Assets.xcassets */, + ); + path = "Preview Content"; + sourceTree = ""; + }; + D719ABC52ABC6EE800B41C10 /* ui */ = { + isa = PBXGroup; + children = ( + D719ABCA2ABC761800B41C10 /* assistant */, + D719ABC62ABC6F0200B41C10 /* main */, + ); + path = ui; + sourceTree = ""; + }; + D719ABC62ABC6F0200B41C10 /* main */ = { + isa = PBXGroup; + children = ( + D719ABD02ABC7C4F00B41C10 /* viewmodel */, + D719ABB82ABC67BF00B41C10 /* ContentView.swift */, + ); + path = main; + sourceTree = ""; + }; + D719ABC72ABC6FB200B41C10 /* core */ = { + isa = PBXGroup; + children = ( + D719ABC82ABC6FD700B41C10 /* CoreContext.swift */, + ); + path = core; + sourceTree = ""; + }; + D719ABCA2ABC761800B41C10 /* assistant */ = { + isa = PBXGroup; + children = ( + D719ABCD2ABC777600B41C10 /* viewmodel */, + D719ABCB2ABC769C00B41C10 /* AssistantView.swift */, + ); + path = assistant; + sourceTree = ""; + }; + D719ABCD2ABC777600B41C10 /* viewmodel */ = { + isa = PBXGroup; + children = ( + D719ABCE2ABC779A00B41C10 /* AccountLoginViewModel.swift */, + ); + path = viewmodel; + sourceTree = ""; + }; + D719ABD02ABC7C4F00B41C10 /* viewmodel */ = { + isa = PBXGroup; + children = ( + ); + path = viewmodel; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + D719ABB22ABC67BF00B41C10 /* Linphone */ = { + isa = PBXNativeTarget; + buildConfigurationList = D719ABC22ABC67BF00B41C10 /* Build configuration list for PBXNativeTarget "Linphone" */; + buildPhases = ( + D719ABAF2ABC67BF00B41C10 /* Sources */, + D719ABB02ABC67BF00B41C10 /* Frameworks */, + D719ABB12ABC67BF00B41C10 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + 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 = { + 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 */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + D719ABB12ABC67BF00B41C10 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + D719ABBF2ABC67BF00B41C10 /* Preview Assets.xcassets in Resources */, + D719ABBB2ABC67BF00B41C10 /* Assets.xcassets in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + D719ABAF2ABC67BF00B41C10 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + D719ABB92ABC67BF00B41C10 /* ContentView.swift in Sources */, + D719ABC92ABC6FD700B41C10 /* CoreContext.swift in Sources */, + D719ABCF2ABC779A00B41C10 /* AccountLoginViewModel.swift in Sources */, + D719ABB72ABC67BF00B41C10 /* LinphoneApp.swift in Sources */, + D719ABCC2ABC769C00B41C10 /* AssistantView.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin XCBuildConfiguration section */ + D719ABC02ABC67BF00B41C10 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = 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; + 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 = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_ENTITLEMENTS = Linphone/Linphone.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEAD_CODE_STRIPPING = YES; + DEVELOPMENT_ASSET_PATHS = "\"Linphone/Preview Content\""; + DEVELOPMENT_TEAM = Z2V957B3D6; + ENABLE_HARDENED_RUNTIME = YES; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES; + "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = 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[sdk=iphoneos*]" = UIStatusBarStyleDefault; + "INFOPLIST_KEY_UIStatusBarStyle[sdk=iphonesimulator*]" = UIStatusBarStyleDefault; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + 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 = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = org.linphone.phone.Linphone; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = auto; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + D719ABC42ABC67BF00B41C10 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_ENTITLEMENTS = Linphone/Linphone.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEAD_CODE_STRIPPING = YES; + DEVELOPMENT_ASSET_PATHS = "\"Linphone/Preview Content\""; + DEVELOPMENT_TEAM = Z2V957B3D6; + ENABLE_HARDENED_RUNTIME = YES; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES; + "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = 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[sdk=iphoneos*]" = UIStatusBarStyleDefault; + "INFOPLIST_KEY_UIStatusBarStyle[sdk=iphonesimulator*]" = UIStatusBarStyleDefault; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + 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 = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = org.linphone.phone.Linphone; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = auto; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 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/project.xcworkspace/xcuserdata/martinsb.xcuserdatad/UserInterfaceState.xcuserstate b/Linphone.xcodeproj/project.xcworkspace/xcuserdata/martinsb.xcuserdatad/UserInterfaceState.xcuserstate new file mode 100644 index 0000000000000000000000000000000000000000..2b2a4d04aa06e3f26fd9b969621fefd5ae829222 GIT binary patch literal 13710 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 literal 0 HcmV?d00001 diff --git a/Linphone.xcodeproj/xcuserdata/martinsb.xcuserdatad/xcschemes/xcschememanagement.plist b/Linphone.xcodeproj/xcuserdata/martinsb.xcuserdatad/xcschemes/xcschememanagement.plist new file mode 100644 index 000000000..31e3717b8 --- /dev/null +++ b/Linphone.xcodeproj/xcuserdata/martinsb.xcuserdatad/xcschemes/xcschememanagement.plist @@ -0,0 +1,14 @@ + + + + + SchemeUserState + + Linphone.xcscheme_^#shared#^_ + + orderHint + 0 + + + + diff --git a/Linphone.xcworkspace/contents.xcworkspacedata b/Linphone.xcworkspace/contents.xcworkspacedata new file mode 100644 index 000000000..c96eedd87 --- /dev/null +++ b/Linphone.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,10 @@ + + + + + + + diff --git a/Linphone.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/Linphone.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 000000000..18d981003 --- /dev/null +++ b/Linphone.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/Linphone.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/Linphone.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 000000000..0c67376eb --- /dev/null +++ b/Linphone.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,5 @@ + + + + + diff --git a/Linphone.xcworkspace/xcuserdata/martinsb.xcuserdatad/UserInterfaceState.xcuserstate b/Linphone.xcworkspace/xcuserdata/martinsb.xcuserdatad/UserInterfaceState.xcuserstate new file mode 100644 index 0000000000000000000000000000000000000000..ad787a03f6985fe28758e544e02ffe5c84513224 GIT binary patch literal 43728 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 literal 0 HcmV?d00001 diff --git a/Linphone.xcworkspace/xcuserdata/martinsb.xcuserdatad/WorkspaceSettings.xcsettings b/Linphone.xcworkspace/xcuserdata/martinsb.xcuserdatad/WorkspaceSettings.xcsettings new file mode 100644 index 000000000..bbfef027f --- /dev/null +++ b/Linphone.xcworkspace/xcuserdata/martinsb.xcuserdatad/WorkspaceSettings.xcsettings @@ -0,0 +1,14 @@ + + + + + BuildLocationStyle + UseAppPreferences + CustomBuildLocationType + RelativeToDerivedData + DerivedDataLocationStyle + Default + ShowSharedSchemesAutomaticallyEnabled + + + diff --git a/Linphone/.DS_Store b/Linphone/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..ec9d1dc224bc482e05b77058533914b4c2cd94a1 GIT binary patch literal 6148 zcmeHK!EVz)5S>i}b*NB-M5SJkEOCuOXiKGvi<6cE5(k748~_EoG1S7{jbf)piX!<8 zAH$U|;a@nxo84`dIP}5+p=d{%eY3kWW6yJTH%mlfFdaQ4Y7>zMWo+I;^9SL1))i^k z5sni&M?pDt=`)>DwBqds90QJl|BV4&yW7;qy3Hw}#r1nN@-$PYai(H;@;1?jc7JJs zg>3_?UD1S+n%}qU{Ngen+kD^SahB#qulGYVHkvnXZhD*EmiI0^Q)O60#WWv8lQ-OZ zsZH4cDLa?yi>{0Wb~P=xEndP`7*};)gcF%5`4MC6 zzeCR`B}rpC11}P+cA*m7rqB%bx$Wo6jEQ@QUS#&pN~S)tj*%~?64^dbQOm&ZwU}X> zp7WQX3wS4p@#$EFo@hN literal 0 HcmV?d00001 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/Contents.json b/Linphone/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 000000000..532cd729c --- /dev/null +++ b/Linphone/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,63 @@ +{ + "images" : [ + { + "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/Linphone.entitlements b/Linphone/Linphone.entitlements new file mode 100644 index 000000000..f2ef3ae02 --- /dev/null +++ b/Linphone/Linphone.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.security.app-sandbox + + com.apple.security.files.user-selected.read-only + + + diff --git a/Linphone/LinphoneApp.swift b/Linphone/LinphoneApp.swift new file mode 100644 index 000000000..6536d8c3d --- /dev/null +++ b/Linphone/LinphoneApp.swift @@ -0,0 +1,29 @@ +/* +* 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 + +@main +struct LinphoneApp: App { + var body: some Scene { + WindowGroup { + ContentView() + } + } +} 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/core/CoreContext.swift b/Linphone/core/CoreContext.swift new file mode 100644 index 000000000..fbc995456 --- /dev/null +++ b/Linphone/core/CoreContext.swift @@ -0,0 +1,54 @@ +/* +* 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 CoreContext : ObservableObject { + + static let shared = CoreContext() + + var mCore: Core! + var mRegistrationDelegate : CoreDelegate! + + var coreVersion: String = Core.getVersion + @Published var loggedIn: Bool = false + + private init() { + + LoggingService.Instance.logLevel = LogLevel.Debug + + try? mCore = Factory.Instance.createCore(configPath: "", factoryConfigPath: "", systemContext: nil) + try? mCore.start() + + // Create a Core listener to listen for the callback we need + // In this case, we want to know about the account registration status + mRegistrationDelegate = CoreDelegateStub(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. + NSLog("New registration state is \(state) for user id \( String(describing: account.params?.identityAddress?.asString()))\n") + if (state == .Ok) { + self.loggedIn = true + } else if (state == .Cleared) { + self.loggedIn = false + } + }) + mCore.addDelegate(delegate: mRegistrationDelegate) + } +} diff --git a/Linphone/ui/.DS_Store b/Linphone/ui/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..9bbad94a3ae4f85c2f8481ca6cd960f65801c524 GIT binary patch literal 6148 zcmeHKO>fgc5S>i}wN)YIP^2DL>NSGUmP%DGZfFm^Vgv_3!6s3$aCfWNA%`fE&+U)t zm0!Z&DQ|YS6;Z;qL}4qWUbKB1}b-t zifYQpP<>u5^_GTpRDj1W#+X&O3i!kL*{^wNb7c9~IDx;obB1UoBcE1Rf$=lHDvPS_ z^?s?=M*GRr&1f^)iaw{`>@=;jdQ$bX@kg$`v$o9d<1Bw)jHZLm_8V*JtT3aoBNW3C zx_tUjn4z8a?ZgZ#$4z}AilcbY*_qAuULSS!{=wnAt7k`hy{5@;62NHXhLe PlZ}9rK?+geQ5E. +*/ + +import SwiftUI + +struct AssistantView: View { + + var coreContext = CoreContext.shared + @ObservedObject var accountLoginViewModel : AccountLoginViewModel + + var body: some View { + VStack { + HStack { + Text("Username:") + .font(.title) + TextField("", text : $accountLoginViewModel.username) + .textFieldStyle(RoundedBorderTextFieldStyle()) + .disabled(coreContext.loggedIn) + } + HStack { + Text("Password:") + .font(.title) + TextField("", text : $accountLoginViewModel.passwd) + .textFieldStyle(RoundedBorderTextFieldStyle()) + .disabled(coreContext.loggedIn) + } + HStack { + Text("Domain:") + .font(.title) + TextField("", text : $accountLoginViewModel.domain) + .textFieldStyle(RoundedBorderTextFieldStyle()) + .disabled(coreContext.loggedIn) + } + Picker(selection: $accountLoginViewModel.transportType, label: Text("Transport:")) { + Text("TLS").tag("TLS") + Text("TCP").tag("TCP") + Text("UDP").tag("UDP") + }.pickerStyle(SegmentedPickerStyle()).padding() + VStack { + HStack { + Button(action: { + if (self.coreContext.loggedIn) + { + self.accountLoginViewModel.unregister() + self.accountLoginViewModel.delete() + } else { + self.accountLoginViewModel.login() + } + }) + { + Text(coreContext.loggedIn ? "Log out & \ndelete account" : "Create & \nlog in account") + .font(.largeTitle) + .foregroundColor(Color.white) + .frame(width: 220.0, height: 90) + .background(Color.gray) + } + + } + HStack { + Text("Login State : ") + .font(.footnote) + Text(coreContext.loggedIn ? "Looged in" : "Unregistered") + .font(.footnote) + .foregroundColor(coreContext.loggedIn ? Color.green : Color.black) + }.padding(.top, 10.0) + } + Group { + Spacer() + Text("Core Version is \(coreContext.coreVersion)") + } + } + .padding() + } +} diff --git a/Linphone/ui/assistant/viewmodel/AccountLoginViewModel.swift b/Linphone/ui/assistant/viewmodel/AccountLoginViewModel.swift new file mode 100644 index 000000000..4ae39cee8 --- /dev/null +++ b/Linphone/ui/assistant/viewmodel/AccountLoginViewModel.swift @@ -0,0 +1,110 @@ +/* +* 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 AccountLoginViewModel : ObservableObject { + + var coreContext = CoreContext.shared + @Published var username : String = "user" + @Published var passwd : String = "pwd" + @Published var domain : String = "sip.example.org" + @Published var transportType : String = "TLS" + + init() {} + + func login() { + do { + // Get the transport protocol to use. + // TLS is strongly recommended + // Only use UDP if you don't have the choice + var transport : TransportType + if (transportType == "TLS") { transport = TransportType.Tls } + else if (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: username, userid: "", passwd: passwd, ha1: "", realm: "", domain: 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 coreContext.mCore!.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:" + username + "@" + 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:" + 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 + + // Now that our AccountParams is configured, we can create the Account object + let account = try coreContext.mCore!.createAccount(params: accountParams) + + // Now let's add our objects to the Core + coreContext.mCore!.addAuthInfo(info: authInfo) + try coreContext.mCore!.addAccount(account: account) + + // Also set the newly added account as default + coreContext.mCore!.defaultAccount = account + + } catch { NSLog(error.localizedDescription) } + } + + func unregister() { + // Here we will disable the registration of our Account + if let account = coreContext.mCore!.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() { + // To completely remove an Account + if let account = coreContext.mCore!.defaultAccount { + coreContext.mCore!.removeAccount(account: account) + + // To remove all accounts use + coreContext.mCore!.clearAccounts() + + // Same for auth info + coreContext.mCore!.clearAllAuthInfo() + } + } +} diff --git a/Linphone/ui/main/ContentView.swift b/Linphone/ui/main/ContentView.swift new file mode 100644 index 000000000..083a0011d --- /dev/null +++ b/Linphone/ui/main/ContentView.swift @@ -0,0 +1,33 @@ +/* +* 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 ContentView: View { + + var body: some View { + AssistantView(accountLoginViewModel: AccountLoginViewModel()) + } +} + +struct ContentView_Previews: PreviewProvider { + static var previews: some View { + AssistantView(accountLoginViewModel: AccountLoginViewModel()) + } +} diff --git a/Podfile b/Podfile new file mode 100644 index 000000000..2a03bdc6d --- /dev/null +++ b/Podfile @@ -0,0 +1,25 @@ +# 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.3.0-alpha' + else + pod 'linphone-sdk', :path => ENV['PODFILE_PATH'] # local sdk + end + +end + + + +target 'Linphone' do + # Comment the next line if you don't want to use dynamic frameworks + use_frameworks! + + # Pods for LoginTutorial + pod 'SwiftLint' + basic_pods + +end diff --git a/Podfile.lock b/Podfile.lock new file mode 100644 index 000000000..d2285071e --- /dev/null +++ b/Podfile.lock @@ -0,0 +1,21 @@ +PODS: + - "linphone-sdk (5.3.0-alpha.173+990473d73)" + - SwiftLint (0.52.4) + +DEPENDENCIES: + - linphone-sdk (~> 5.3.0-alpha) + - SwiftLint + +SPEC REPOS: + https://github.com/CocoaPods/Specs.git: + - SwiftLint + https://gitlab.linphone.org/BC/public/podspec.git: + - linphone-sdk + +SPEC CHECKSUMS: + linphone-sdk: 9f39eda481406fdc22ebb406e40ee56e6c12fcc6 + SwiftLint: 1cc5cd61ba9bacb2194e340aeb47a2a37fda00b3 + +PODFILE CHECKSUM: bd475905201c295afc656eeac0cc62b9ab7159d5 + +COCOAPODS: 1.12.1 From 551f2b5068a4a04b1866d55d7152befc5f345a6e Mon Sep 17 00:00:00 2001 From: QuentinArguillere Date: Fri, 22 Sep 2023 16:07:45 +0200 Subject: [PATCH 003/486] Remove Linphone.xcworkspace from repository --- Linphone.xcworkspace/contents.xcworkspacedata | 10 ---------- .../xcshareddata/IDEWorkspaceChecks.plist | 8 -------- .../xcshareddata/WorkspaceSettings.xcsettings | 5 ----- .../UserInterfaceState.xcuserstate | Bin 43728 -> 0 bytes .../WorkspaceSettings.xcsettings | 14 -------------- 5 files changed, 37 deletions(-) delete mode 100644 Linphone.xcworkspace/contents.xcworkspacedata delete mode 100644 Linphone.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist delete mode 100644 Linphone.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings delete mode 100644 Linphone.xcworkspace/xcuserdata/martinsb.xcuserdatad/UserInterfaceState.xcuserstate delete mode 100644 Linphone.xcworkspace/xcuserdata/martinsb.xcuserdatad/WorkspaceSettings.xcsettings diff --git a/Linphone.xcworkspace/contents.xcworkspacedata b/Linphone.xcworkspace/contents.xcworkspacedata deleted file mode 100644 index c96eedd87..000000000 --- a/Linphone.xcworkspace/contents.xcworkspacedata +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - diff --git a/Linphone.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/Linphone.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist deleted file mode 100644 index 18d981003..000000000 --- a/Linphone.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist +++ /dev/null @@ -1,8 +0,0 @@ - - - - - IDEDidComputeMac32BitWarning - - - diff --git a/Linphone.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/Linphone.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings deleted file mode 100644 index 0c67376eb..000000000 --- a/Linphone.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/Linphone.xcworkspace/xcuserdata/martinsb.xcuserdatad/UserInterfaceState.xcuserstate b/Linphone.xcworkspace/xcuserdata/martinsb.xcuserdatad/UserInterfaceState.xcuserstate deleted file mode 100644 index ad787a03f6985fe28758e544e02ffe5c84513224..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 43728 zcmeEv2YeLO_W#^FJ9P)xBqXFk8c9e(dJm}(5<)_HClEujKqQUqhGv_KfQU2&L=csP zB1J^(3JQu9QE3+JC{`2;ie3Ka&g`ZDBJcg)d%yqheH63F&fGfpobNs7+;h%MYHMn= zSR*2iF^Ito%NQ7r;TeIED~HZ7w_6%pTdIcEwbxH=gm0xot*vd9Lt7_ZX|A_g92vA= zeXVIgeo3x*wz(n0R_HJzGE!NIwa#i*(M|_;H)CWJW)S1bxG^Eja3+)qW5SsTCX$I_ zqM00KER)ORG2@tgrhqAAikM=igsEdDG4)IXV`e5Z?Tm%7G9AneW)7n=gt>uP$Sh`- zFt;$Pm^+v|nY)Ci(R_3xT7;IOWoQLjiEcw{&>iSb zv>tV$`_TjFQM3m=fu2Ioq8HI|^cH#>y@TFG@1gh62j~lQ3Vn&bLT53L`{9Au6}#cV zcnJ2z0XP_k;BXv;qj4fm#wj=zm*WatiK}om9*=AA1UwPf;wx|io`#!nGqzwWz8YVH zuf+t<#n2=dVXf>O_A2&zb{;#QUBKSJE@PLoH?wQmPIfE1 zgT0r%pM98poPCmgn%&Pn&%V!oz@A_~WItk0vLCabu%EJ@v7fVFp&jh^><{d(>~98x zfiv(1!5|t;1_y(q!NcHd2sVTn!VS@ec*6)onqib7(=f&`$xv@-FqjRK4O0wL4UL8? z4bu!whGxTb!z{yW!yJQZAch+aiwrjzRvB(JtTxQYlE^Y(2k=w-W;_l}j;2z{2;&yX;xTm@O+zZ?h?kIPRdyjjc`+z&aeawB%v%G=l zc%Bz{k(YRxH}VSKkMGaB@@~94@4@@<{`@dLjF03;@X34%pUP+OnS3^1#+UOId?jDS zSM!tj$@~<48sEgX@m78ge=UCM4?uw7aD}ALZi?uv;G0DE%b;Ed3(=D*Yz?F8v{$lNlMwoUF+G zfVC-)kXmmCXHV!cc7z2%I#&lzbainpS zG1EBOm}Sg1jxpvK#~DkF)yDD0dSipJ$=Gb1Z(Lx!!MMXb=Jz0#_*Dbtm9#iCf14rPXNwX#6DL0PEWs4P-$QdTLqDr=Oj z$~I-Ya*wh@xlh@pJf!Sa_9{;+ua*sM>1b*?%JgIUGXoeC8bqJpg3}sWocIMB02~OGE3-toFKQ zbFC@0cYmdIlSZ|+Sle5hn#}FB@$qq)Sy`hpLn9O7GDBmsBH}|~4jG{ti4l>p(NP(r zGDeQ9H3f9H2(?GnwO7lbXyGjn=6pjV)7}%y6gHG^}?|`WR~SONz}FYioNQ z6xXDj%sOjbt;q|DX4G3jOzM6VeTQap>*=+%wP{jadyc)=T9a$fJ#9q2Zh2gFRcKUP z)Nt>_h=^;3dtViq7!@(xJ2E06;+kvh4>^WOWrDXdu}mBj&m=I3OcFDKNoG>i{^|hL zq&ldMs*^fUbyi)r!Xr*+GMJIfC?=B`4d1fiDZ8pgYOz|bR;X3*ZGsw7Ycj!zN~X5X z%&(i#IHeBet6%E=n$d2CSvx?fGHYW~qqWg&sc3AlPSuO$v{>3eMr>{GDb`(6D^^q9 zXr7sAp42gA3M`w+X`H#awS9JWdtKYqo~!mTboVd;x*OflXlqxpPrsC@W`Z{{WlT9! z!BjF;s+;PrdZ?c2;7!bUriPioOk`@+A?k27UCmIl==8iR>suSlVbES~qcyC1Y+>Dt zS8MY4lOkC_i!k#Na}&(a+|XSYMB(iA){Zv)1_;iUT9aRQ8~Lr)#(J|w>rMN!yPka( zQN=9tSf4 z5gf*pS7)(yw@WYAnq10SCSSC$tKNpWp)99wJNEI+WM(lg>({G8k1$s;S2Ncz*H-Ij z(E?mpssCxV)Z~|BHUizZw$!!H?t)U+RpyrzXc~e})_ulP(h8&k1?_#zW#%z1oy_%W zU?($Q9Y#Tivcg5{9044=ky*stRBLjF#iRUJNOhNDb6dOF0+3TjN1+C*K?M$mwUg|6 z4Z^H}s~%rh)o3+0YcmBB1<(nZs3iGTnjV;!VvTRf;^BP;) zrna`2i#r-io9h}|Ow5r1CWn3(>)Sl5t*(V?TH5HMGoGDzqU1_^7}|E~*CoqGoLvT8 zX1x!Szg)H&>bdpnl#h6L4!-PatHLZ$&8y!g7`wMm6>JIcVC!0}RWpE1n}Lv;j`$7r zzwG9IS#cLM7pOI-_#6oe4!O*#Fmcuys)p)S!>aPlGrcvgIua2X^$#>+fJS0sH^6jE z=tz7*;$>F-F!}3yw?owt{bT8K{^Mt1bVLQJJGJv*w4%XMaqFn%S|P zV@eBK8!Si0=H|s-E}g90I05SB_oKafA1N#C^Que(6hRX@7N2g3jt=DSasvJ?WkeEK7HR zFw4xw$yTr$IDnK8#-xBfU@|j}X#;)#I?(lRVwQuhzna;`JjOiDJkK0pUS&RHzGZ%4 z&LM<&_Fxo0;n_yL_!_Qf;X^8F{-qu}hc}Z<_WKTH_mP$YlXql4J0E^Z*xe@kkeHts6m9Tr~myGMM(gF8u1sj+Z zbThC6{jJOzCb$ch@isM74eMgoGPkSYY8u_l+@^P!+h-TnQQ;0YHA{~;H$mUI3hS(J zy~W;;1VjgB5fi3G%pxeip-OL{5iK@Dm`l+q$ zFq$f@(Nc4Jb5%2KWK^BSTut}Y25p?x+HGwxYKod5s*IYfrXGXV_A>jJr+^Q3gSG1! z7=;-KsIk7$n%QV+YpR=FVx_HD!sBYDOIm7$fvF>)dLrFDp9A)Jp$epbi&jics%&Zn z`KuwPMPJMV%uAX*&$gI)V;g|u4r;r){#`Hn3iIlD&p@9)blk-pqO{uQ0d?Oz?|RRJ zYk|E|Z~7SXTCHhF@5igv?y~}2%%0Xrv4dNtFlYh4`*GFd5wx=+7I34xYj&=~ad@ofl$71O)gJk$80;t}YE@J*8 z!sBPmSClC}XTD%gF<+|L>KHXg9lMb^&3w&#!<<%g)jV|^e8M#TBf{fP%+I>;cnN{d z{^);a&QT%o4>i9NF>1kI34zGKEJFNW34w(LrY(JCKwjwg&&z=8>2CMIlKkwOyF|H5 zl6rPKqjKSHw_bGc2usP$E2|veFr{s_4*kf!BlPdPBQSIP(%aItYqlqXWTY^`oye$` zbfSK0DP1+64gjvgcJpL&d%L+|6adx~P?$!~f?bq~jyai@BUIBz4#;u6TBdOl8i<@H zc0+SUdwbpN4RFncxehD=+Sl%p+dFnc9<*b3wX%~L2C5)*>_N3aOF>K1Y@3cuwSp7C{CTI)~Z*ib?PLwUTsj#>ST3_I(0ir zU^b&9Gy-IT6qJh6P&&#$Bh^OO8m6gDYO~s+E+S|-LF)**gP{BAIKA!qT#tTP0?=yB zp<+GkK%m$Lk2FsPIcZdDb6Z_|qouV))3C~5U2IQAud3@^18h2sCf3hxGFSJ$>78kw zT-VWLEu<$b7Agq!Rbr^-*w)_I44RUCINAwJL*HWXbnUmzb=qjPtr}`O*p+DQDqXRO zneI$8xvs*i?M?uIc0y-;Z zcA^sKD8Hop3FVnvrf3F$Uf0U&nmWuTq+Y30WEmLe&Th+O>rtof{*B2WEAgVCx z4fdE4y0-?MLz|HGZq$rgP%COf(@{IJsMA%eI#ZpkUZq~6UZ;|yXa<@Ivz!HFJg2&+ z_cCoWm$0^OcYe!b}PtFqgtCfnp^TankP|N(b3$}aKS}~`l%qWg8iqx zxUOZ28AzXMY&O~(-2(Orr~;Stx-bc|-dvTnu^G&Vbv%*HAViz6VXTcB-%S3IS?rJH|O@@)E;K( z3#?t}89Fs;{=Ka7I%7YF96QnT>P>2y9k~vm!%XmI^b$IVUPiB=SJ5GLvARTEsxDKP zt2b{(N6=Ap3_f3n{qh!d1wmJ;w-B_9?wo!03kMyQ0T#hpZO3o@+8EeBKoEe7u&qw8 zp*I+8BBMjbQ!vVHY-!MpgM(qa?PzMCn{FYT3>2F|$FNvoGo#jei;1<5`2_kH1Yz_c z`Uss=SE{SjTQ{Ij(5L8Abu~ee@IT!x9VtR*SZtkZRx%)V`xXi}HP``VB6BuqHI4Q5 zEBb>vjjWr|*XSGcE&2|fLEo#lsn4mO5o97LmY@kLc@zDJenLN^Uw~SEMZcln(I4m> z=9DwF3el${N%xPjw)hveS2gVPwF3)?WH52s$mAsoMtP=a+Zr zbC>qh7d$-d+0R^)7XN&j#^_28Qb>7-}{ZDx82_WtLTEV;g2-cdgi+w3ue; zv14g3oW1C!E_nP~a3CJW>{h$f4JxR;Ie&sWnXNNhnp*1`EIsJb4KviZ1Q>JP;55F( z!*S@vfuakCQ5L)ehTup5j!mF$oevxrj^!whVFut>W;c$8Uh^PL^+!V1k( z>MR|W60Phg;6@vB;WRv!3El=)rwlw2j{@7%Xq<(!@fe(=ZddP7cc}NOJJtKtUF!Yn z1L}j@a4r?48Gl@W3vm(rE&-|hAx*TdQ6E$HsgJ_nPw8^HdrxorXu@lZmPz*XPTR= zb^3DX;&b=2uv0__hEtXFfe~_!`lPy79Xg&)Q-e{nWuhHibP-!0(RIiQ zP~afU@=G`A8l}C>dH4p}=6t+BeOle$tIe{>F#q#gD+emD4hzzWe;Hm5f)MO``k;FO zd>7VCkQcpo0bb)3fY+_$K6qY=c!1L|B2{p@g)(bm=n{m|v1+`}5my`rOB=0AaQ7xv!^ zbRO0K<`w$_?ZpQu4D7>C;ivI_{0x2;KZl>kFW?u|!|D{uD-Pm zzhr}fSMeb*bNb_>00VE^VBmfABmjGj`tjc|pq&hdSpeDoWiW7p!oY{>J9ZfOn8Ls( z_*43$zN@~c{ScFzePzGGUsDh`t$xsnzfn(ICJ20wf7C(XEP%j=lwp4c5co(PdLH9D zu#Me@Cwq_QSgbKSi-6fb`SZG^lUf^QU%;rWgwC->Rsp%HKRW;z^-EyXAJxy*X9*gh zo;{CKSx45Xn^V76zv#oMtQ&}RtUDZ%c(Q}pA*`2rO8rXxTK!i2PCe7jwXCEGBVXz= ziw7LSwsy2w^IE4gwoo$#HJ&u};a@gTt8kiD=sAwG$t&z|?dCVy&F)i6yD-yByR=`r zEHGeGG%)x<2Ltmz0S2I1b&n>TD$Lp$YWSsQ+k+NtiiQ?H*jAX$Vv7JR*lczTn*+yA zxojRgj?HHa*h2Lu^=I`L^;h*b^>_6T^&CMAL1-IWtm6e+&Q`FMj6Yk=3?m5ZfI$#X zkdYvTI@$F58#Mm)putWB&|n)0Vr{6wg2`ti+YCpA@I#P+AWr`T0m8!+W?$zHb|zp4 zJA)vhlbuD7czNi-Ud>(y2*6&0p{Atn>0(tpn;;nwiq$ljzQ z$RhP=g8Bo5u&VkbZQ=a-U~s%B310{+w*aiHWLE*KtY&Y+Fx`Rb7J@tpa-hf(Ly*V$ z&~iJw&JHaExe?^p7hJlKbrZXR-NO(oH#|$Fgs3wVPqrwHTw zeW1bM2hd;`YVbD%7y=DIzzGB;5j29JWP(z3$T)PkCG)u()4~7au3xJ5Bm7shZh#1-dL<|;#mHrS^Kv1Fn2_Q1W zY3t_}C*xuP3fmdujyt!64ZtU zu=D6%y4`gO8(Uh;4Ysd-Y2WlQj2PA$?$9x89mTMkOAP)()Y^c~F}n?$F^12rhHV7Z zfu;oF_Cx?J_%)TF#y)mk!##!_`uQZYnV=~I)!GgYb<3{d0g74=8Xht{YM>kf*J@i_n_7W9kuHIU8rR^NHr*eCQ}uv+iUeG(rmwIyQT=uqwv)WZ_sv4L7DfD zT0u3tmEkQoCNaEC&@^yUFucbMFuZU00K7!%JHWA_#d?9=iJ&HemR_(PXaVdZCk-EK zy9lg@I*V>1AOSb)o5+Rhamw&z&w6wa)Y5l7zN71L#_+x22g6wd?5@C4Z3Imx2zDe( z_j*9L^k`H&@U-s^y$(0+mo7_^j&YS6Xyl6&JE<8ITvma=gPTp?wkkb z$qnX)5Hyn@P#Qq;ht<4_AmHq42m;Q&jv$pFvYqqRVVxU_4{-q$*0~@G>vQd}K99os zLLJr@{|)Q)VEk?{>+~4*pSC}92>|O{B0<;NV4X|;Q&_j5EH{c94Uo>k#y-E3gN=Q` zcoR+u?MJA!`d#}7TDUgwli~m)Ztmm& zBW|IGq7HEC-0g3por!9I$8{jbE^Y<~{0S$#Yr0Q|xjEdmjLQb@D(-3yICUjKs|dPv z19u%Wj3WfCRtFJun>us?wA$kWQbWJ>#0{C?t7bNeEiaD_Gt}ud*pDpa<_xGA-9^lja$R5CFo9q z?jmSCL7f}9b=)0jE(c$`2)bJZK;Ei`ROXo{Yrfu9#f?*@YJXMh=OMbY6n*^NPp;b= z$v*JS+;-aA-P{&#D+e3z27)#cw27e28v)TaaFe8OfztQV;dZ^Lw%P%MYPuGDYJd74|9)F z!gz$BEuC7($9Lic+o6K2S91fkaz4Q~@URoN&r){0wJ_7sdCCKO*N=DCe&k=MF zK|6cM=tb`3i>>P`u&##)+CkU#UUg{q68NRhU#~5veGsp4$1hg@EvWwvLHE)6yVRjQ zD+dj1(Ha0Z0DTc_>GGDgYHNFh`;ZyHeZ-xl$E*!-24CR7-*LVpX6!$kT;P4vAW?!4 zf`0uWn8wj*AcV%ay17H7%MdSbA72Pj2?!kK7aS5C8WtK10VB~du;BS6w&+3ddMK%H zH=A3)*Bm5`oJ??D$uG(3zT7Lwa6)$_>Xi;B#U)no;TutWuD4yF`*6^bE}3>t>mLVXPSW3_8j2#sbIw~U} zDn2nfA!<}?G!%=C@Q#d(iSv$(iHPt{h)DFNL6|fK)0+lhYVnueu<#eTdXY=eUPe|% zV&ce%sL-s;42Vq4fXLL0h{UYWj8V~vS&`B4i7}a@q1dR=S)-$~;^IQ%GBZX(v6#fr zjOfvEp;563S&^egL(D-`JS^cQVnQ!{w+xmyDWTQm2{|_!A+WHGnF_&$Es$x$+j}76 z4N;-=n-#v(&vGbZW@bWEjs-p&Aub2V)DghCd*yVB(>GAN`vdhs(c^KCLKsxmn6VTJ zE*^N?nXUHg%z*$^=c9S!M&}oR$G$5to5rO&6O@(ZWa@5W&~x8gB|x5fge!e;5%JNP zQCSh8S#g=sp)nBJ$N<>LTQhbmX%jrETRz@r}qH5 z$}77g8jn?vA6+vcbYd;IL)gh#A7<|wwlL)(eMpq8GH)_@=9*xByo}3?#)u`=V-5=a`f*#bx z#80?S0r%~ZOt5ur;6C9#W3B^iqRwRY{qhU$EY-qJabI#@ai_Vjxo@~{x$n3$-1poM z1U*a;Z2q9nKSt2w1nnjWWa=jff<1mOLHo9IKXN~DKXbpp?)n?|I|m+IJi{Y`o+9Wl zVQ(PpBG_=*b%cG0un(&-gng8-k7;5-aYu{Iy9?l!`s```C7K_(E}UvMDN9ysJNPbH zYV=_AlKQEz9f3=S{uSB)X$BlcZHPW_BnWuZ+FBuO)uNREpSXrvQ$!wwM^QibCWy#t z=-JBj)<%N!RD)K!(K;La~dNrw*vbVLm-yb zTGw|#njbxlCWm0=0d{Y~?gm};dFzw4cSq-HTN!=;4_OX2@Fw1YcO(c@gZ%_OvwDtsc!Ra6!X;%hL|* z1NP*Hfc%G6sn>S$UIaa-h8*F2nE|{XKa^r7^%eG`QI^R~)DZE!My7lKAE;BLxr-0z zjk_lh7!;|kZj^QO8ZIo@#_gm~!<{!20)tXW+HNqapzn&r5{3QG6*8F&WA_NohHT(pE-X!R) zF20t(f*{aE-c>_-Ss{7NYm=bkwWgi3LT6F;zEJu>MUSNOeL}U7b$i9zeYJX=Rr@$> z^f|xMPmR+oG&TV0nNy;o1GFy?C6E#o82}#s_RH`my%-%AV9}f!wd;}bwr>TRb7M+m zL|8=cSzM`~-Mn3Ey7&L*Sw!`nMJ!B0w@UKOe2X5QW4|z+Z|{4dgP#e|#Dgv1{Z1Zi zlqdSG<2Cvd03&ejpB#|&UA?AD#WaLvmeiI2blG)mUgfW+6bNKpdu}HqnlX6G+J+i5Rcw;beG=j=e4Gcf2>)W zb0D04T)f?ldb_7;P04@K?uGu=7aOXLTJGk-n6rW3!f)lll=BrqrwRIc1Ah;{gTI#` zz}BA$`lSaQ?SN&IaBSw93l__cReg(V$3U$e78~E|ZD=ZKttmvKslMfF`&^@fie5!C zi;)9ooKJ2@iL zPH_A9{gj-a;z7LmfuOTpXchmgdM!aesv#A&iI(fb2HU`&P6%Sg_?P&DwWh&+ik$yo zH}Eg>uTTT&rHftMPyf&Rw@>CUf21!`yvDx;raqvJH-JKpYsB%JMkT**xHJVrbDPY8xXm>#nEod1G9#qS~5Krl-%)`;bQ&bJ!1Oc{Hq*8YIM z;m=TNd71}uJWDXw#edKLKrm0RpoWy|kLS;ZU)4QzduzY)f6&%`<9{buB3K3}6&Qe0 zY@`dJAJj%`S_sIvuoE`bwSl;7iw^SY`;7EwY)e}}ZK5R^-Ra5%UXTGK1VIo5=%*jS z{RtkhK`;u60AEZ5yAZ4eC+i|qUoN)e_W867D#^u)UxHQw^&$6x4;x05G=jmsWr{uv zZJ`AxSZHA&!4AC^S{Nj_3T||v;f52zpq2nC{EvW?uJH#QQxCP-Rz(;h`1D<4!52;v zurmZu!m2pwbLcbPi(j?3H%tiX!<52sVFVL=uMjGP8MX=$LZlESL<=!OtPm%}3kgD^ zkVLQ>!5;Jw91kYgi(nsu{Rs9aIFR5VfqyvO!ln>#eC;g=3i;X=h23jS zWB>6jD!e;937n9%WEeI-!5)u0p+xV%v)1$7l(&DZ57>HWh2GC=y582XT2tXa)>ij}gXi^^u4KXty_?`#)0ls< z8|`WIPVLlt7FX%b53e=-yPV+!RqrCK*7UCq;}PcTO-KCm568Y3JyA-%$Yqh5#bKuPUl|5Qix2q*BFw z1gF{jKm_m`qq(lMfAHY-e|CLTcwE@SxO57;3C`#go*;N6HFy4X7^3i$@QmgIBJ2kr z5Il-XcF$4CF7pDnK2x*b-o9>i!a?Cx%^yQ}1^h8^_9ZIYsqr=814@mr3vUQ-3de=F zgtvuvgm;Da1lZ_73>ZsrF2Q*Ok0Usr-~xgR2`(bIc$;v-MvWiqp)JDalp0Iy)L7OX z+Jb2&Q;>fy4D{?))h-q!N`uG#4vX#O_|!KnR``*U<4**a+R5=(AjgY`w%FEM#G(O6 zQUo=pyi?=|uDCps6eW>5oQpDqwcyH2hPBvxaul64Iur*|I;;jd{Ift7dkqgU5YBQ$ zPjRp~MD!B9MIX^u^b?1Q{$c>ZH3Ux}cp|~I1YbdL9l?_bt|z#GU^Bs!w~LUjnEn%o z<3nN?1xGQGlH(LRIW~ev9d6O#vHib~9{*1{N{i`0x?%>wQ*ERxX8x5&otP_vXI!TU zKy+oN2tYLLGD){sETg1bLP@s?NY`+KSOG551UFv*r<(34j@M$j?Nm8YoCNBxSSwy3 z0?oA&+(z*94b1J}h<`J9O4m@AX@Is%xjx&~ahoT!CG32^>5KSU+q<78w&-lsOxeiN zB~}}%aAZlFxjMey8CaU;RkUnWy*5w}yO*h;AZf(N<`n1(5aUnES?-qSAeL7fmE zpoF*p@chq0mh3ei7oVkcxLe#KJ|R9S?iKfmPl->9`^9GnUP$nb1TP}^CW03eyoBJT z1TQ0aIl(t?6Q8ru;Q{d_@gM*>xX%%Mi=7T{CHQuN*8%I{JN~A_e|;#N_ztDRcL`o$ zr^63`4#gAVhxCWwl>~!Hi+(~Bi^0CypNU^kO8lJQ)t%xgg27w%a)e#+YwGF3>y!HZgw~bUXNCSW_ zB~IccK@ufNk|m?0Nc|)*Y_2D`lVD({4Fm%_Z6bIx!FLn9h2X8*z`sqyM`@tsjOH@_ zk}IXlZFai6hv54lG6cwS*WYCMuO~|>0LW4T8|HQ!SxUhgJfgX_2ui1UOPQb2YQs+B@6u_ zcpt$}>7M|TKBj2JIKfR_k9Nbt)9ze4b<1Ro;!Fu_L%K1%ShZBnO=E;s2$Dhb|mLhx&Lx_q#MRZ3RxTtHS1*u&o0G3gDR7GI~d_}(Qd*=xKjeGaAp={@Ov=>zG6 z^r7^TbW-|Q`b7Fv`i$TY2tGmZhXj8_@JWI{CioMAKP4D$fjIKTcIgWnL7v8kq;IHc zKsrN7@|2wv?{(9_Pyc;H`G3kZAY-6k84Ofk+UQs2fqrE{7U>VcUlDv-{{;GV zHQB@LGDHkAh_v*-fKh*_Bf8cR-A?2c_UM7gDgn!A6g=w>%V# z0UxcIuiT&!OxvQ=TQyCTu^#_9yIs{~SA9FE7y9 zVLq?}>u`xN+XuW@h6A|m@)CKeyi8s$-z?uEuaH;DtK?hd)r55->_Eag6V`>W;QtRU z!fu3hC#(lyJqbH_yKFm!lkdQX=%VZ}#Lf=hu%EC)b#Aa_?*DH(`uM-KpX{QH zb3cI;czX6w`5|DOi|6F9lgA$UNyMqQXPQelcs0($zBCc!<>2Hjk{>Iwr zFaK})`!~?vSW17ngw3(jU;ZWOugq9M>93ryxt+#J!scBj{nZ$2DLKtBPNeiV?yt$o zY@Djo-xNxJ1%F+k#W2_%4&w~tOyexV77@0XuqA{oC2SdCVe}P* zh0#|LwtAa!j*b4V)zgO?=TiC`Z>PTrdIE96*8NR?{|5S7PU-Jv!q(X7ZzZL_3sQ*N zVd-`woRWA>2D*Yzbhy?*)oWqmps_s(RSm#I{odS^f&3R zD?Dg?1RRWw4;ddOYy)8@cNrfwK1SH7gq_*P57+pl=D)^5xM}TW`3`~u)t|qdMDxa{ zF)85VX|`JIFDbEvT?ubq>g7^s+^_cvZ(C{q*Y^s3dO7v2En#(SZH@37MsnUjbhE$l zIpbl(FurI!V0_7V(D<_P72~VMLj*FxvR4vz8ey9V+e{z>EZa)hHo{IPZ2LCj z5uHbwX5DgWguEMswdg#mf~K!~*RdUc^XR{UM?a%H`Z-~(b{_o_c=S&-T;mzzS)Fx$ zpsX_kJR_)is2R*d7pSUsZu?CUC}I3={KI%oVHBibg;fj+r|<-F{Ia0C&LQkoguR-u z*AVtv!d^!pZ7@r=DPmv3P)w9CY{|NM5yrf~3FBW+7|IYJ48@DE_Vind@1+Ps2~vh@ zgrS5`!k7<)p@dPwU?B(Bc`)q2erlr)C0dCE$X6i9e_^K*N7x%7+YZu{@t%L<5#BGFKL)f*1g*;Hzjh4|(jZ+#S zj}l}LfZ$gMq{@VMf7QcFx9hC%9z00R=+gUg4t;SeB<`T^mOJl8MI*dt1>Us{DQ@%{ zC7HQe8YLQ4)u(NH^d;o_&`WA>XtF|j8F;g1Rd?m?+xm#I;1xqnkTSrz_jqVbQ4@7! zgj53OhqYhiN@~LlYlAjF+pDs=gWxYKa=uK9l!w5Y^SX+R3yUdlETnG&uhxG>#tx6N z6F`H~2;pvuS(&U%QD8s4gRrpk-$mH<8_+PJqiFu0lqNuWw9gR&5 z6_GKcqaz~fO5HL6>*DBX3fNYxyr0-_8 zY*6MZ*DJ6uZYAtCm?WJW&7RPA3Vr&9_Yk(WYq=ppE?saWBz7w?!%G^q90sPKOPALB z(DH-%rRyh{Op&*Ji54p~b?NzoH{7h;qChscYQo+_*d1y}J>%_<8E-B7m9m;U39q0x zo}cwf{%5mZ9Tuh1c6e!_igudZjEd7h&%r^#5s>7_FC`-f-WEQtq@uE>R?l#BR8Nfs zDQj%=8d^_tFhSOr3@g0#cT$JdY*Df5sEmsv0`o zRZew+loMHy*XMOD(N=#w(-vH#Ie4J2$yrShH%U{=bmvNgR8C{+EESCn)~QgAVwzui zfc|(L7MfH+ghWn*fS$uZ#DW)1&W6`uHkaklK1RYzYL4~~86FxIuBEr4*LvFRCS6GE z)%Q-X3A#9HiK0Y0v&dMF4Uh>;rCFkk8z4QDs;K=`XVnR~Rd0e$qPsF=frGqNrL^fR za~;K<5=c}6v3{0bMIcF$UZgCi%mVS@-M5ao^iNIiOoOyU18LtQM|*UQ%2Wrarmn1P zNOR;!d-Z^-_LiFf+FILtrwAL=eV68y>$ziMO#BhZkW_TBoJD<;`VZD_BVi|J`wuRwW&ajs%kyUS)b?LHNN;FSE2NTi8k8knota1uc*@! zHFZCc2AvMOCP8&b;B@}=3-S%+F-1%XWTEO{u4d*li{ZWDw=;JzcQIR;``}&L`(HHOJ-oc|IQkymF!w9GTkag@wRghzgV(G%;(^!&@)*&~Ma7V}XcD{`eipt8 z&x1@wx8Zg0BBF=!Zu|;9j8Edv@i+Jk{)z3!+TOo}kqtiTZNF{5z{rXov;)dor4!x> z2!OIqxkFKvyOi}BChjEceE^Gu-9^~@H%m2WuCh_t1j%OSf=>;&8LDXdx~2-Cve0gZZtQLF7MnHLpu7ypCO}uE8Y|7soKD|3aTZ=Z1>4W#&|$Br zD>psr$`L5hjqzoMGm%UZlLk~+&eSjyAwK0j;goPj_(SAGSyaUSqLb(>x{B^%hFB?@ zMJxC`&jNqvtHoiril|$&T_sI4BO1ljTCW9?rSu$!p~u@@{#L z{GxnJeqDZ3eoKBweh*CHr{u5XujOy$Gx85cS7V%Utg*&8*SO4hn{lmio$*fNdgE5( zcH<7?PU9})1E8F~XZ%rdRJ;^##Ygc|hAZ(PM~+j9mGR09*pe403zeIcCCV~ooAQ8i zNI9Y$b7*(C%3-6!c86n*iH;*3D;;Ycn;fS*S{yqZ=Qv*Nc&(%AxXy8#<0Fpy9G`Z4 z#_>7F7aR{e9(8=p@eRl0j&D1D@8sm<6p{IP9Hj*bo#{UGpBEzes%hNAU|-xK%arb21X7{8dyBAVqojQ8wM^O zxN6|T19uO6XW+ZejB`I{$N}l>>g?|9r<}#U7vM*-t|S-mt0?V zebx1_>rvM)T)%WZ?fQ-Dcdp;No^}1n^;g$F+>o2Wjdu%jD|egfW^udKZI9bAx6j;u zbnoXL;hy4Nba>43Sm3eHW0A*VkEI^VJ#O(>>Cx$NkH`HU4|+W8@uuPWPPQdA;X+&l@~%^jz$@#`AX1 zJ3R05+~WC==OdnvdG7Xn#`9&*Bc8`Re;+Ij_8uHNIA?Ip;MT#j2QMAmHF)FT&4afL z-ZuE2!S@b+WQg;SAwv>|qz$PW(mG_;kn4xcA9BNx-9w%ka%jkFLrx6&!pqsq-D|X0 zzE_b~iC3A|B(GMl>0TDE4zHPBv%RkJTIjXNYq8fdubaJAdfn=Eo7V=fN4#G4dfV$$ zZ^2vgcJUtU4T(U#{k(&{hkJ*6M|sD1=Xlq6w|LL=p69*5d!hFt?zs$tTOF!Dq71RG%w-ntWP(+I-r5tUgP8migT5v%+VU&uX7FK3zWBeRlZl z^x5U}fX_odPy0OM^PJBMKJWN^?sLlLE1$1@zV}7GtS{#)_zv`S@pbie_YL$7@(u9~ z^_}fI*Y`HxJA5DT-Q)YD?>^s;eZTYl#gF$B{bWDI&)Ltz&)YA~Z?d1oZ=v5Jzr}t_ z{g(UP;1A{|f&q|MC8_ z{O9{G@?Y$~)PIHlD*x5~Yy3C)Kj{C2|C9dv{P+7G^gr(Zp8p5_ANqgl|GEDu|F8Vd z1mFNEz%d{wAS_^Xz~q3b0apez1+)aT1+)iP17-v)3%EI8MZl_n)d6b)ZV%WPusvW$ zz|MeO0S^T13wS!34uMXA)qzt28v~~W zHV4iLTo!n9;EKRifvW@81l}HaN8nw7oq-zy9}Rpwa8KZqf%^iV4tyr?xxg0!4+I_z zd?oPIFnL(mu)<;1VQYsyIqcnGzXiDk`3Ct11qKBLMFourN(o8}$_N@2G&(3ds5Gb| zs4A!?XkyS6LDvK=3R)I)bI^*Q+k(~xtqZy{Xh+a}L5~GJ7xYrl%R#RO9SeFr=*^(F zf<6yA9Xue|A=oL{Ie1X8Td-&FkYMlN#NZLZDZy#M8Ns81M+fHzj}M*@TpL^$Tpw%> zo)X*`JR^8k@SNbQgVzS%9lSMod+?6nM}nUTelGZh-~++Of=>j06#Q}Ur@`L`pAG&g z_?HkNL<%v6^b2th@eCOf;vKRuWL3!OkToHfXAwP!v67pNfx#4IyJKTGC+VJ|}^M`L8{_5~Ep^l-Eq4}W| zq1B-iLTf{(hE5N)hRzI~6M9YPb)h8m`q1T}D?)D#T@$)4^v=-Tp)Z8K9C|49Na!1( zZ-u@S`d;W)q2Gl59QtdR7}hUrK$v6LpfI;E&#)n3p<$!LCWkeKO$%!Yn;vEfn-MlU z?5ePpVXMQ|hOG;`Gi-fWSJ?Kjhr=EV+Y`1o?CG#)!=4X27WPKi@vyhUz76{$oC(L_ zhH!^)r*P-+LE%Hg1Hy-e2ZzUpCx(v*PYE9zJ}$fn!cm4Orzn>wuPC3Wu&C&$*rAz6zvx65j{9MIC^+=Sad{mN_1LuM)auY!sz1Y(&+N&`sm5gQ=_kpek}UA z=og|7M86#UR`j>g-$(x#{Y&)kF-!~_!^cQ5O3Z*5$C#j);W1&5Y8~Y+O=Ya$H(mW?WWWPF!wWb=-uwE90ieS>tBL&50v% z^Wql7EsR?fcURn#aqq-^9QR$^k8wZ8{T7en*?2x)j2{%A5I-V5B|a^FWPE0PR(wu; zUc5PeYW%eL=J?k5>G78MtK)BoUlhM2etG&|i{BK#C4O7{J@NbEUygq@{&4)! z_z&Vgj6WIwN&K1kAL4(E|2ctAkP?gu{SsUf{1W^V0uzD~;t~=Pk`j^=ni4t^W+hye za81I3gwBMG33n&FkZ>U3V8W{jM-q-Dyq<78;hlsZ5`IefHR1P!bBQRCO;i#GCAudL zPV`RnOAJUHmKd8DpO~09B5_RO*u=cV{KWBz6B26^>k?ZM+Y;Lot%)RYUgCnpg^4#O z-kG>Qu`6+7;;zIO5?@MuCGl|LvBWnL-%5Np@q@&V52HTSnYI;?5DBBQ}hPktf!K=RAUuO`2j{Au#J6hn$l+=`r zl+2Xul$?|aDU(yCrL?3>PqCz^DL14nN?DS!JY{vt+LU!EccpZu>`d8{@@mTQln+zB zPC1+MbINZi=TiApDb<+TKh>1#n;McDnwpe4Dm5!LCp9m%Ahj}eeCov1D^lxI&8aP^ zvr?~5y*+hH>aNrWQy)ovJoSmx=TcutJ&<}Z^_A2^si)J7X<=#EX{BiuY1L^H(rVM{ z(mK-Srp-@Vn08az(zNAi8`E~A-JkYQ+M{WYr#+wcO4{MHV`*=sy_a?(?W43$(mqT3 zHtm;mA-#XPL;Aq;iaXHUq!BD+3&a&}{OQ+8|ig6tc!7iTZaz9oBQ_Ui1l*>_~$mED!SDf{m1-Pvzu zf1Lf>7^g8=V=BjVj9EQq>zHT893J!Ln77BgH|E5c&&He@b9&6TW6tCZ%*o5?$eEin zKj(&=MLA1ymgU@*vp#2Q&OJE~ z%GmU=*<;6!9XGacZ1LD@#y&pw$6O{?%2jfmbBE;mrZ%FWL$$}P>U$gR$u zkUKSZT5e13^jvH1%-q?zTXH|hbI%)_wI&s#1~{11UFuA}| z&`~h6U{1jk1qTWa7Q9k$xX`)Kw{U1-Kw(hf)WVL!s|&9yoLe}*aB<7^mx$|Mf-}LEqcD_K+(aXV?}Ql z{ZRBr@sMKw;?c$9i#v+v7GGaHuXsW6isE&}cNKRPZz|qWysP-Z;zx=fFMgtUZ}I7p zppsD~<4YP!rj%S+(o}LyiCS`f$%2v_OI|K{z2u{k&q_{}oGv+2a<=5>l3z=4se9?r z(#X=d(!|o_(u`6tgp`gcol<&Z>5|g5rJbc4OYbh-R=TV7!O}-cA1~cgdaBI5EVFD} zSz%d8Sy|cSvc|HevevTpvR!45mpxy0uqV=07E8Ug zs6|Dk2os;0BB+ShN>R~jwe410 zyLP?Gs&sjFf76tS!XJyXLOv+5oOvxlN!%a}*d*FFipRD*SFe^W+GV6TS1F#D?42%QEf)l_=U;_9Fm;%lR)4@gH z63_`|gKm%l8PEe3f$PCd;1+NzxC7h`?gjUQ)!-%YJMb#l1l|VkfIor%0G~qbp*~PQ zXdv`1Gz1z3jf7&MIA|<10h$cWhti?Xpv6!I1VA7JLk`FZWkW>kRDT$%gw8_`?Qhv9 z*&X(fy~18=|Iz-y{?Pu&{>0t_N5P%ouJ9Xh54a~B4G)Hg!Xx1K;n8q1yadjI?Jxpk zFahVlH0*~%un8}POW`lzjc_@<72XB!f%m}&;lpqv{1oYo^hWw21CVGW1{scwLSm74 zBnbf#7(o#XA&?v-7x5r@h!^o8pCc=gRY)1K23e16KsF;=kV@n$WCv1*+;?mrRD#r~+GujS)743+2LVKX^paalobTB#;9gdDiC!rsq)6fKTCTd4>P!{D;0rjCO zs-ZesjDCrhqZQ~jbQih@-H#qbYtX-=zo5UOzoUPkkI`r7ztNY@HqQ3W4$i*L{?38U zXy<#*80T>3C}*rQ&N;?8&Y9-Sb*^+Ca^7&Ya}9CLbRjO)WxAHRELV|hwQG}Wi>uPL z-L=cL$FpJVIcU^G(=4y7eU~RGXSVycg))jjl>yHh>hGQeKiC8=~4NJgg zV{@@&ECmBF9P?uuR)B@DrC0G&}*Hh0n&*@kMwB4&V?D<9>V@ zUW6CpCHNYA9sVW05#NI!!B6A$_yznj-iTkrZ{XkK5Ac5zuM=+)J&E2#U!p%ThwYxL?SVVNFwGDpArj*g~VbagRl`IQA$)1x7_XB!`#Vk!X0*3xOcnvxevMz zyHB|5+~?hw+*jOGMbDbhm#}856Cg(IC27+NX{kG zNRV`pPBNR!A!(8&IZ`K!$Sq_gxt-ia?j;Y9hsmSlG4cd?io8zVByW*Fkax+S$$R9l zQoU)wbIX_bEsc32{HJeJJ=25BCV#-cAC>MoOBt=oVltKk4 zoeEN6Y8h2Y9i)y@-%#IDHPmUUj;g0_P`9Yxs6VJj)Kls?^^$Hwx1+n${pqpv1bQ+Z zPfw?3(24Y1I+;$T)98h?o2KYonx#2fpuMz@_R|4crwuwpm(!={d%2x+V{_AU{kfZR zPv+jq{fp_ybY{9SZ!mvjqM5J0``Lr+*K8GgoISx-v$gCQwvPS96XhAM;j+qqp_6<5vGa%Z`E?h5xEca>}6ZgS80K71@cg`dtR@U!?NKABJD=kpLx@;V>n z!~Al-kT2#-_%ePi{{_F1FXyZHuU& zC|nbog!{rXp+#&fz9L46-Nf!<53#5Cwm4Eu5;2hzy`n7oMO`#RQ(P*p7AwRm@wj+W ztP#(M=fn%*WwAlLDmIA^#Yf^3@tN2xz7SiyZM^NguX>}ruX%@fXL>Pj*t^r);BA(A zOXH;ll1&07SaL|D#7lzYm1IegRB5?XC>2X3Qkk?yIxN*lbyB@_QEHGHrE5}?bYFTV zy^vbuwsHr#qufdEBKMVt${)$oGC2uL#AX#_Q<>}$dc@n{c=Fo zWkU|h#NGn95RRwNjx}DaVzQN{w+HV{`~~ z40H-~3G@vN4U7tm4tyAx5SSc@4@?Ur2T}r`1{MSk1WpD{1!@ClwW-=C8l)kbQ_I%e z8l^EBrwN**DO#~sqLpf^wYA!MZG*N+E7!Ja+qCW4S?yka*ZkQ0w0t3dZT`{xru;wk zw)!i2l>VCjrruZYrw`Bv>F?@;^*DXJK2e{dPuFMYiTWIUk?zn%UDo}&rWfcTeW@PN zi}V%xDt)`YOW&jK(+}u}^doweeoQ~1SL-$U-GX)nLkeaW5CugA2MQVr9vj__K1M%d zpb>41Fvb|;j0wghV~P=PBpIp3d?VdhV%QAOup6|&8mf_R7=~#qGa|-XqukhQoHpu= z^Ts9PiqU9XH*OlYjl0Ir#&hFkuuZUC@ReXxuv4&W@Qq-PV6R~B;Jd*|!L%R|RDvsm z+k*RpXM=Zwk3$_oiJ{~W9Lf%PLV-{?v^-Q8Dh`!|z6fm$m4~*5z6$LK)rRhbUYeuL zab~`jth?s zPYF*Ae;l3}P7Kcre-=R_ScHpck%CAlvNRHj6h&4lv?Wx=YMG}ll~uto&EzeRI#T3 diff --git a/Linphone.xcworkspace/xcuserdata/martinsb.xcuserdatad/WorkspaceSettings.xcsettings b/Linphone.xcworkspace/xcuserdata/martinsb.xcuserdatad/WorkspaceSettings.xcsettings deleted file mode 100644 index bbfef027f..000000000 --- a/Linphone.xcworkspace/xcuserdata/martinsb.xcuserdatad/WorkspaceSettings.xcsettings +++ /dev/null @@ -1,14 +0,0 @@ - - - - - BuildLocationStyle - UseAppPreferences - CustomBuildLocationType - RelativeToDerivedData - DerivedDataLocationStyle - Default - ShowSharedSchemesAutomaticallyEnabled - - - From 3eb289b2741a14a209148bb9305ec45454cae5ef Mon Sep 17 00:00:00 2001 From: QuentinArguillere Date: Fri, 22 Sep 2023 16:13:06 +0200 Subject: [PATCH 004/486] Remove Podfile.lock and Pods dependense from xcodeproj --- Linphone.xcodeproj/project.pbxproj | 8 -------- Podfile.lock | 21 --------------------- 2 files changed, 29 deletions(-) delete mode 100644 Podfile.lock diff --git a/Linphone.xcodeproj/project.pbxproj b/Linphone.xcodeproj/project.pbxproj index d58787b09..ef763dfca 100644 --- a/Linphone.xcodeproj/project.pbxproj +++ b/Linphone.xcodeproj/project.pbxproj @@ -39,19 +39,11 @@ /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ - 1CF963E4850D8355137FE34F /* Pods */ = { - isa = PBXGroup; - children = ( - ); - path = Pods; - sourceTree = ""; - }; D719ABAA2ABC67BF00B41C10 = { isa = PBXGroup; children = ( D719ABB52ABC67BF00B41C10 /* Linphone */, D719ABB42ABC67BF00B41C10 /* Products */, - 1CF963E4850D8355137FE34F /* Pods */, ); sourceTree = ""; }; diff --git a/Podfile.lock b/Podfile.lock deleted file mode 100644 index d2285071e..000000000 --- a/Podfile.lock +++ /dev/null @@ -1,21 +0,0 @@ -PODS: - - "linphone-sdk (5.3.0-alpha.173+990473d73)" - - SwiftLint (0.52.4) - -DEPENDENCIES: - - linphone-sdk (~> 5.3.0-alpha) - - SwiftLint - -SPEC REPOS: - https://github.com/CocoaPods/Specs.git: - - SwiftLint - https://gitlab.linphone.org/BC/public/podspec.git: - - linphone-sdk - -SPEC CHECKSUMS: - linphone-sdk: 9f39eda481406fdc22ebb406e40ee56e6c12fcc6 - SwiftLint: 1cc5cd61ba9bacb2194e340aeb47a2a37fda00b3 - -PODFILE CHECKSUM: bd475905201c295afc656eeac0cc62b9ab7159d5 - -COCOAPODS: 1.12.1 From 284739639aa77e325347f25559c289e0a4b8972a Mon Sep 17 00:00:00 2001 From: QuentinArguillere Date: Fri, 22 Sep 2023 16:15:10 +0200 Subject: [PATCH 005/486] Add gitignore --- .gitignore | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 .gitignore diff --git a/.gitignore b/.gitignore new file mode 100644 index 000000000..54453a934 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +Linphone.xcworkspace +Pods +Podfile.lock +xcuserdata/ +Pods/ +.DS_Store +build From 2e02db69a8e2f8011a3ee156a208123dca2cded0 Mon Sep 17 00:00:00 2001 From: QuentinArguillere Date: Fri, 22 Sep 2023 16:32:57 +0200 Subject: [PATCH 006/486] Change bundle identifier to org.linphone.phone --- Linphone.xcodeproj/project.pbxproj | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Linphone.xcodeproj/project.pbxproj b/Linphone.xcodeproj/project.pbxproj index ef763dfca..439addb8d 100644 --- a/Linphone.xcodeproj/project.pbxproj +++ b/Linphone.xcodeproj/project.pbxproj @@ -349,7 +349,7 @@ "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; MACOSX_DEPLOYMENT_TARGET = 13.3; MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = org.linphone.phone.Linphone; + PRODUCT_BUNDLE_IDENTIFIER = org.linphone.phone; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = auto; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx"; @@ -388,7 +388,7 @@ "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; MACOSX_DEPLOYMENT_TARGET = 13.3; MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = org.linphone.phone.Linphone; + PRODUCT_BUNDLE_IDENTIFIER = org.linphone.phone; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = auto; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx"; From 5f09c25eeefa4f171264f1284cc00e30b009c7dd Mon Sep 17 00:00:00 2001 From: QuentinArguillere Date: Fri, 22 Sep 2023 16:33:22 +0200 Subject: [PATCH 007/486] Fix typo in podfile --- Podfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Podfile b/Podfile index 2a03bdc6d..4ecc8821a 100644 --- a/Podfile +++ b/Podfile @@ -18,7 +18,7 @@ target 'Linphone' do # Comment the next line if you don't want to use dynamic frameworks use_frameworks! - # Pods for LoginTutorial + # Pods for Linphone pod 'SwiftLint' basic_pods From 0b72427247ddc51ee01415af68cac16cf74dad28 Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Mon, 25 Sep 2023 15:58:57 +0200 Subject: [PATCH 008/486] Delete xcschememanagement and change gitignore --- .gitignore | 1 + .../xcschemes/xcschememanagement.plist | 14 -------------- 2 files changed, 1 insertion(+), 14 deletions(-) delete mode 100644 Linphone.xcodeproj/xcuserdata/martinsb.xcuserdatad/xcschemes/xcschememanagement.plist diff --git a/.gitignore b/.gitignore index 54453a934..7226bb73f 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,4 @@ xcuserdata/ Pods/ .DS_Store build +Linphone.xcodeproj/xcuserdata \ No newline at end of file diff --git a/Linphone.xcodeproj/xcuserdata/martinsb.xcuserdatad/xcschemes/xcschememanagement.plist b/Linphone.xcodeproj/xcuserdata/martinsb.xcuserdatad/xcschemes/xcschememanagement.plist deleted file mode 100644 index 31e3717b8..000000000 --- a/Linphone.xcodeproj/xcuserdata/martinsb.xcuserdatad/xcschemes/xcschememanagement.plist +++ /dev/null @@ -1,14 +0,0 @@ - - - - - SchemeUserState - - Linphone.xcscheme_^#shared#^_ - - orderHint - 0 - - - - From 32452cbeeb71811479eecd63ea05eac9b69e0050 Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Mon, 25 Sep 2023 16:19:59 +0200 Subject: [PATCH 009/486] Delete UserInterfaceState.xcuserstate --- .../UserInterfaceState.xcuserstate | Bin 13710 -> 0 bytes 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 Linphone.xcodeproj/project.xcworkspace/xcuserdata/martinsb.xcuserdatad/UserInterfaceState.xcuserstate diff --git a/Linphone.xcodeproj/project.xcworkspace/xcuserdata/martinsb.xcuserdatad/UserInterfaceState.xcuserstate b/Linphone.xcodeproj/project.xcworkspace/xcuserdata/martinsb.xcuserdatad/UserInterfaceState.xcuserstate deleted file mode 100644 index 2b2a4d04aa06e3f26fd9b969621fefd5ae829222..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 13710 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 From 731b6bd66b33e683b4b5f1c97561ee46cc84c29d Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Mon, 25 Sep 2023 15:15:20 +0200 Subject: [PATCH 010/486] Fix CoreContext ObservableObject, add Noto Sans Font and Montain.svg --- Linphone.xcodeproj/project.pbxproj | 114 +++++++++++++++++- .../Mountain.imageset/Contents.json | 21 ++++ .../Mountain.imageset/Mountain.svg | 101 ++++++++++++++++ Linphone/Fonts/NotoSans-Bold.ttf | Bin 0 -> 557380 bytes Linphone/Fonts/NotoSans-ExtraBold.ttf | Bin 0 -> 557028 bytes Linphone/Fonts/NotoSans-Light.ttf | Bin 0 -> 554712 bytes Linphone/Fonts/NotoSans-Medium.ttf | Bin 0 -> 555264 bytes Linphone/Fonts/NotoSans-Regular.ttf | Bin 0 -> 556216 bytes Linphone/Fonts/NotoSans-SemiBold.ttf | Bin 0 -> 556932 bytes Linphone/Info.plist | 15 +++ Linphone/LinphoneApp.swift | 2 +- Linphone/core/CoreContext.swift | 2 +- Linphone/ui/assistant/AssistantView.swift | 19 ++- .../viewmodel/AccountLoginViewModel.swift | 5 +- Linphone/ui/main/ContentView.swift | 6 +- .../main/viewmodel/SharedMainViewModel.swift | 25 ++++ 16 files changed, 298 insertions(+), 12 deletions(-) create mode 100644 Linphone/Assets.xcassets/Mountain.imageset/Contents.json create mode 100644 Linphone/Assets.xcassets/Mountain.imageset/Mountain.svg create mode 100644 Linphone/Fonts/NotoSans-Bold.ttf create mode 100644 Linphone/Fonts/NotoSans-ExtraBold.ttf create mode 100644 Linphone/Fonts/NotoSans-Light.ttf create mode 100644 Linphone/Fonts/NotoSans-Medium.ttf create mode 100644 Linphone/Fonts/NotoSans-Regular.ttf create mode 100644 Linphone/Fonts/NotoSans-SemiBold.ttf create mode 100644 Linphone/Info.plist create mode 100644 Linphone/ui/main/viewmodel/SharedMainViewModel.swift diff --git a/Linphone.xcodeproj/project.pbxproj b/Linphone.xcodeproj/project.pbxproj index 439addb8d..c061efe6c 100644 --- a/Linphone.xcodeproj/project.pbxproj +++ b/Linphone.xcodeproj/project.pbxproj @@ -7,6 +7,7 @@ objects = { /* Begin PBXBuildFile section */ + 2B416B2E7C90375B792A28AE /* Pods_Linphone.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 2C08FB4788AD667D35BAE64D /* Pods_Linphone.framework */; }; 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 */; }; @@ -14,9 +15,18 @@ 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 */; }; + D7A2EDD62AC18115005D90FC /* SharedMainViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7A2EDD52AC18115005D90FC /* SharedMainViewModel.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 */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ + 2C08FB4788AD667D35BAE64D /* Pods_Linphone.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Linphone.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + BC39A28B26EDB00C91AB7756 /* Pods-Linphone.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Linphone.release.xcconfig"; path = "Target Support Files/Pods-Linphone/Pods-Linphone.release.xcconfig"; 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 = ""; }; @@ -26,6 +36,15 @@ 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 = ""; }; + D7A2EDD52AC18115005D90FC /* SharedMainViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SharedMainViewModel.swift; sourceTree = ""; }; + D7A2EDDA2AC19EEC005D90FC /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; 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 = ""; }; + FBDE73581C1DC4F98CC3DF3A /* Pods-Linphone.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Linphone.debug.xcconfig"; path = "Target Support Files/Pods-Linphone/Pods-Linphone.debug.xcconfig"; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -33,17 +52,37 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 2B416B2E7C90375B792A28AE /* Pods_Linphone.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + 9FFD5E6302DF1093E1CB7311 /* Frameworks */ = { + isa = PBXGroup; + children = ( + 2C08FB4788AD667D35BAE64D /* Pods_Linphone.framework */, + ); + name = Frameworks; + sourceTree = ""; + }; + A31AF2AB8C6A3D7B7EA3B424 /* Pods */ = { + isa = PBXGroup; + children = ( + FBDE73581C1DC4F98CC3DF3A /* Pods-Linphone.debug.xcconfig */, + BC39A28B26EDB00C91AB7756 /* Pods-Linphone.release.xcconfig */, + ); + path = Pods; + sourceTree = ""; + }; D719ABAA2ABC67BF00B41C10 = { isa = PBXGroup; children = ( D719ABB52ABC67BF00B41C10 /* Linphone */, D719ABB42ABC67BF00B41C10 /* Products */, + A31AF2AB8C6A3D7B7EA3B424 /* Pods */, + 9FFD5E6302DF1093E1CB7311 /* Frameworks */, ); sourceTree = ""; }; @@ -58,6 +97,8 @@ D719ABB52ABC67BF00B41C10 /* Linphone */ = { isa = PBXGroup; children = ( + D7D24D0C2AC1B4C700C6F35B /* Fonts */, + D7A2EDDA2AC19EEC005D90FC /* Info.plist */, D719ABC72ABC6FB200B41C10 /* core */, D719ABC52ABC6EE800B41C10 /* ui */, D719ABB62ABC67BF00B41C10 /* LinphoneApp.swift */, @@ -88,7 +129,7 @@ D719ABC62ABC6F0200B41C10 /* main */ = { isa = PBXGroup; children = ( - D719ABD02ABC7C4F00B41C10 /* viewmodel */, + D7A2EDD42AC180FE005D90FC /* viewmodel */, D719ABB82ABC67BF00B41C10 /* ContentView.swift */, ); path = main; @@ -119,13 +160,27 @@ path = viewmodel; sourceTree = ""; }; - D719ABD02ABC7C4F00B41C10 /* viewmodel */ = { + D7A2EDD42AC180FE005D90FC /* viewmodel */ = { isa = PBXGroup; children = ( + D7A2EDD52AC18115005D90FC /* SharedMainViewModel.swift */, ); path = viewmodel; sourceTree = ""; }; + D7D24D0C2AC1B4C700C6F35B /* Fonts */ = { + isa = PBXGroup; + children = ( + D7D24D112AC1B4E800C6F35B /* NotoSans-Bold.ttf */, + D7D24D122AC1B4E800C6F35B /* NotoSans-ExtraBold.ttf */, + D7D24D0F2AC1B4E800C6F35B /* NotoSans-Light.ttf */, + D7D24D0D2AC1B4E800C6F35B /* NotoSans-Medium.ttf */, + D7D24D0E2AC1B4E800C6F35B /* NotoSans-Regular.ttf */, + D7D24D102AC1B4E800C6F35B /* NotoSans-SemiBold.ttf */, + ); + path = Fonts; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -133,9 +188,11 @@ isa = PBXNativeTarget; buildConfigurationList = D719ABC22ABC67BF00B41C10 /* Build configuration list for PBXNativeTarget "Linphone" */; buildPhases = ( + 5387C360B232ECCFFE549C7A /* [CP] Check Pods Manifest.lock */, D719ABAF2ABC67BF00B41C10 /* Sources */, D719ABB02ABC67BF00B41C10 /* Frameworks */, D719ABB12ABC67BF00B41C10 /* Resources */, + C6890D9F62DE340F66542619 /* [CP] Embed Pods Frameworks */, ); buildRules = ( ); @@ -184,13 +241,61 @@ isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( + D7D24D142AC1B4E800C6F35B /* NotoSans-Regular.ttf in Resources */, + D7D24D182AC1B4E800C6F35B /* NotoSans-ExtraBold.ttf in Resources */, + D7D24D152AC1B4E800C6F35B /* NotoSans-Light.ttf 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 */, ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXResourcesBuildPhase section */ +/* Begin PBXShellScriptBuildPhase section */ + 5387C360B232ECCFFE549C7A /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-Linphone-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; + C6890D9F62DE340F66542619 /* [CP] Embed Pods Frameworks */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Linphone/Pods-Linphone-frameworks-${CONFIGURATION}-input-files.xcfilelist", + ); + name = "[CP] Embed Pods Frameworks"; + outputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Linphone/Pods-Linphone-frameworks-${CONFIGURATION}-output-files.xcfilelist", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Linphone/Pods-Linphone-frameworks.sh\"\n"; + showEnvVarsInLog = 0; + }; +/* End PBXShellScriptBuildPhase section */ + /* Begin PBXSourcesBuildPhase section */ D719ABAF2ABC67BF00B41C10 /* Sources */ = { isa = PBXSourcesBuildPhase; @@ -200,6 +305,7 @@ D719ABC92ABC6FD700B41C10 /* CoreContext.swift in Sources */, D719ABCF2ABC779A00B41C10 /* AccountLoginViewModel.swift in Sources */, D719ABB72ABC67BF00B41C10 /* LinphoneApp.swift in Sources */, + D7A2EDD62AC18115005D90FC /* SharedMainViewModel.swift in Sources */, D719ABCC2ABC769C00B41C10 /* AssistantView.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -322,6 +428,7 @@ }; D719ABC32ABC67BF00B41C10 /* Debug */ = { isa = XCBuildConfiguration; + baseConfigurationReference = FBDE73581C1DC4F98CC3DF3A /* Pods-Linphone.debug.xcconfig */; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; @@ -334,6 +441,7 @@ ENABLE_HARDENED_RUNTIME = YES; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = Linphone/Info.plist; "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES; "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES; "INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES; @@ -361,6 +469,7 @@ }; D719ABC42ABC67BF00B41C10 /* Release */ = { isa = XCBuildConfiguration; + baseConfigurationReference = BC39A28B26EDB00C91AB7756 /* Pods-Linphone.release.xcconfig */; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; @@ -373,6 +482,7 @@ ENABLE_HARDENED_RUNTIME = YES; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = Linphone/Info.plist; "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES; "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES; "INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES; diff --git a/Linphone/Assets.xcassets/Mountain.imageset/Contents.json b/Linphone/Assets.xcassets/Mountain.imageset/Contents.json new file mode 100644 index 000000000..646235f4e --- /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..e67609aff --- /dev/null +++ b/Linphone/Assets.xcassets/Mountain.imageset/Mountain.svg @@ -0,0 +1,101 @@ + + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + diff --git a/Linphone/Fonts/NotoSans-Bold.ttf b/Linphone/Fonts/NotoSans-Bold.ttf new file mode 100644 index 0000000000000000000000000000000000000000..0d19068399cc223086dad2312c0c1a6585538dbf GIT binary patch literal 557380 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 z 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%0As5@#R~$GRa+?~9 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 literal 0 HcmV?d00001 diff --git a/Linphone/Fonts/NotoSans-Light.ttf b/Linphone/Fonts/NotoSans-Light.ttf new file mode 100644 index 0000000000000000000000000000000000000000..8fde662806eb8d3cf2a0cc54e78fddf4067e5eba GIT binary patch literal 554712 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

+u7CH*45S0+6!$u)sUT?SbzQ~ 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

Ik?{njn%ud8(G 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%0D5;s}H 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 UsLgY3?9ifdVPA=M0jUv@AI(Numi9kYKD_O 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%0Ah58pD~<*D%s(#W-_;QwM+i?GJL5CsX1zel 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 + + + + UIAppFonts + + NotoSans-Light.ttf + NotoSans-Regular.ttf + NotoSans-Medium.ttf + NotoSans-SemiBold.ttf + NotoSans-Bold.ttf + NotoSans-ExtraBold.ttf + + + diff --git a/Linphone/LinphoneApp.swift b/Linphone/LinphoneApp.swift index 6536d8c3d..3aae5e76d 100644 --- a/Linphone/LinphoneApp.swift +++ b/Linphone/LinphoneApp.swift @@ -23,7 +23,7 @@ import SwiftUI struct LinphoneApp: App { var body: some Scene { WindowGroup { - ContentView() + ContentView(sharedMainViewModel: SharedMainViewModel()) } } } diff --git a/Linphone/core/CoreContext.swift b/Linphone/core/CoreContext.swift index fbc995456..c08a9cf93 100644 --- a/Linphone/core/CoreContext.swift +++ b/Linphone/core/CoreContext.swift @@ -27,7 +27,7 @@ class CoreContext : ObservableObject { var mRegistrationDelegate : CoreDelegate! var coreVersion: String = Core.getVersion - @Published var loggedIn: Bool = false + @Published var loggedIn : Bool = false private init() { diff --git a/Linphone/ui/assistant/AssistantView.swift b/Linphone/ui/assistant/AssistantView.swift index f6f4e355d..f3d64c140 100644 --- a/Linphone/ui/assistant/AssistantView.swift +++ b/Linphone/ui/assistant/AssistantView.swift @@ -21,11 +21,21 @@ import SwiftUI struct AssistantView: View { - var coreContext = CoreContext.shared + @ObservedObject private var coreContext = CoreContext.shared @ObservedObject var accountLoginViewModel : AccountLoginViewModel var body: some View { VStack { + ZStack { + Image("Mountain") + .resizable() + .frame(width: 1084, height: 108) + Text("Login") + .font(Font.custom("Noto Sans", size: 20)) + .foregroundColor(.white) + } + .padding(.top, 36) + .padding(.bottom, 16) HStack { Text("Username:") .font(.title) @@ -36,9 +46,10 @@ struct AssistantView: View { HStack { Text("Password:") .font(.title) - TextField("", text : $accountLoginViewModel.passwd) - .textFieldStyle(RoundedBorderTextFieldStyle()) - .disabled(coreContext.loggedIn) + + SecureField("", text : $accountLoginViewModel.passwd) + .textFieldStyle(RoundedBorderTextFieldStyle()) + .disabled(coreContext.loggedIn) } HStack { Text("Domain:") diff --git a/Linphone/ui/assistant/viewmodel/AccountLoginViewModel.swift b/Linphone/ui/assistant/viewmodel/AccountLoginViewModel.swift index 4ae39cee8..605b42032 100644 --- a/Linphone/ui/assistant/viewmodel/AccountLoginViewModel.swift +++ b/Linphone/ui/assistant/viewmodel/AccountLoginViewModel.swift @@ -21,9 +21,10 @@ import linphonesw class AccountLoginViewModel : ObservableObject { - var coreContext = CoreContext.shared + private var coreContext = CoreContext.shared + @Published var username : String = "user" - @Published var passwd : String = "pwd" + @Published var passwd : String = "" @Published var domain : String = "sip.example.org" @Published var transportType : String = "TLS" diff --git a/Linphone/ui/main/ContentView.swift b/Linphone/ui/main/ContentView.swift index 083a0011d..140bf99ee 100644 --- a/Linphone/ui/main/ContentView.swift +++ b/Linphone/ui/main/ContentView.swift @@ -21,13 +21,15 @@ import SwiftUI struct ContentView: View { + @ObservedObject var sharedMainViewModel : SharedMainViewModel + var body: some View { - AssistantView(accountLoginViewModel: AccountLoginViewModel()) + AssistantView(accountLoginViewModel: AccountLoginViewModel()) } } struct ContentView_Previews: PreviewProvider { static var previews: some View { - AssistantView(accountLoginViewModel: AccountLoginViewModel()) + AssistantView(accountLoginViewModel: AccountLoginViewModel()) } } diff --git a/Linphone/ui/main/viewmodel/SharedMainViewModel.swift b/Linphone/ui/main/viewmodel/SharedMainViewModel.swift new file mode 100644 index 000000000..ded17529c --- /dev/null +++ b/Linphone/ui/main/viewmodel/SharedMainViewModel.swift @@ -0,0 +1,25 @@ +/* +* 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 { + + init() {} +} From 392be31c5cdb33ed1f6a81025cb0986dbddb91e1 Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Tue, 26 Sep 2023 11:27:01 +0200 Subject: [PATCH 011/486] Add localizable strings for translation --- Linphone.xcodeproj/project.pbxproj | 8 +- Linphone/Localizable.xcstrings | 152 ++++++++++++++++++++++ Linphone/ui/assistant/AssistantView.swift | 8 +- 3 files changed, 165 insertions(+), 3 deletions(-) create mode 100644 Linphone/Localizable.xcstrings diff --git a/Linphone.xcodeproj/project.pbxproj b/Linphone.xcodeproj/project.pbxproj index c061efe6c..d810a9fc1 100644 --- a/Linphone.xcodeproj/project.pbxproj +++ b/Linphone.xcodeproj/project.pbxproj @@ -8,6 +8,7 @@ /* Begin PBXBuildFile section */ 2B416B2E7C90375B792A28AE /* Pods_Linphone.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 2C08FB4788AD667D35BAE64D /* Pods_Linphone.framework */; }; + D70C93DE2AC2D0F60063CA3B /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = D70C93DD2AC2D0F60063CA3B /* Localizable.xcstrings */; }; 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 */; }; @@ -27,6 +28,7 @@ /* Begin PBXFileReference section */ 2C08FB4788AD667D35BAE64D /* Pods_Linphone.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Linphone.framework; sourceTree = BUILT_PRODUCTS_DIR; }; BC39A28B26EDB00C91AB7756 /* Pods-Linphone.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Linphone.release.xcconfig"; path = "Target Support Files/Pods-Linphone/Pods-Linphone.release.xcconfig"; sourceTree = ""; }; + D70C93DD2AC2D0F60063CA3B /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; 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 = ""; }; @@ -97,14 +99,15 @@ D719ABB52ABC67BF00B41C10 /* Linphone */ = { isa = PBXGroup; children = ( - D7D24D0C2AC1B4C700C6F35B /* Fonts */, - D7A2EDDA2AC19EEC005D90FC /* Info.plist */, D719ABC72ABC6FB200B41C10 /* core */, D719ABC52ABC6EE800B41C10 /* ui */, D719ABB62ABC67BF00B41C10 /* LinphoneApp.swift */, D719ABBA2ABC67BF00B41C10 /* Assets.xcassets */, D719ABBC2ABC67BF00B41C10 /* Linphone.entitlements */, + D7A2EDDA2AC19EEC005D90FC /* Info.plist */, + D70C93DD2AC2D0F60063CA3B /* Localizable.xcstrings */, D719ABBD2ABC67BF00B41C10 /* Preview Content */, + D7D24D0C2AC1B4C700C6F35B /* Fonts */, ); path = Linphone; sourceTree = ""; @@ -249,6 +252,7 @@ D719ABBF2ABC67BF00B41C10 /* Preview Assets.xcassets in Resources */, D719ABBB2ABC67BF00B41C10 /* Assets.xcassets in Resources */, D7D24D132AC1B4E800C6F35B /* NotoSans-Medium.ttf in Resources */, + D70C93DE2AC2D0F60063CA3B /* Localizable.xcstrings in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/Linphone/Localizable.xcstrings b/Linphone/Localizable.xcstrings new file mode 100644 index 000000000..9bff8abe8 --- /dev/null +++ b/Linphone/Localizable.xcstrings @@ -0,0 +1,152 @@ +{ + "sourceLanguage" : "en", + "strings" : { + "" : { + + }, + "%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" + } + } + } + } + } + } + }, + "assistant_account_login" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Login" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Login en FR" + } + } + } + }, + "Core Version is %@" : { + + }, + "Create & \nlog in account" : { + + }, + "Domain:" : { + + }, + "Log out & \ndelete account" : { + + }, + "Login State : " : { + + }, + "Looged in" : { + + }, + "password" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Password" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Password en FR" + } + } + } + }, + "Password:" : { + + }, + "TCP" : { + + }, + "TLS" : { + + }, + "Transport:" : { + + }, + "UDP" : { + + }, + "Unregistered" : { + + }, + "username" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Username" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Username en FR" + } + } + } + }, + "Username:" : { + + } + }, + "version" : "1.0" +} \ No newline at end of file diff --git a/Linphone/ui/assistant/AssistantView.swift b/Linphone/ui/assistant/AssistantView.swift index f3d64c140..534a76a32 100644 --- a/Linphone/ui/assistant/AssistantView.swift +++ b/Linphone/ui/assistant/AssistantView.swift @@ -30,7 +30,7 @@ struct AssistantView: View { Image("Mountain") .resizable() .frame(width: 1084, height: 108) - Text("Login") + Text("assistant_account_login") .font(Font.custom("Noto Sans", size: 20)) .foregroundColor(.white) } @@ -99,3 +99,9 @@ struct AssistantView: View { .padding() } } + +struct AssistantView_Previews: PreviewProvider { + static var previews: some View { + AssistantView(accountLoginViewModel: AccountLoginViewModel()) + } +} From fb212eeb9f409f12b0ecd5d4d4e81719ff90d9e0 Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Tue, 26 Sep 2023 16:10:06 +0200 Subject: [PATCH 012/486] Change AssistantView --- Linphone/Localizable.xcstrings | 15 -- Linphone/ui/assistant/AssistantView.swift | 135 ++++++++++++++---- .../viewmodel/AccountLoginViewModel.swift | 5 +- Linphone/ui/main/ContentView.swift | 1 + 4 files changed, 108 insertions(+), 48 deletions(-) diff --git a/Linphone/Localizable.xcstrings b/Linphone/Localizable.xcstrings index 9bff8abe8..e80c25f45 100644 --- a/Linphone/Localizable.xcstrings +++ b/Linphone/Localizable.xcstrings @@ -79,9 +79,6 @@ }, "Create & \nlog in account" : { - }, - "Domain:" : { - }, "Log out & \ndelete account" : { @@ -108,21 +105,12 @@ } } } - }, - "Password:" : { - }, "TCP" : { }, "TLS" : { - }, - "Transport:" : { - - }, - "UDP" : { - }, "Unregistered" : { @@ -143,9 +131,6 @@ } } } - }, - "Username:" : { - } }, "version" : "1.0" diff --git a/Linphone/ui/assistant/AssistantView.swift b/Linphone/ui/assistant/AssistantView.swift index 534a76a32..c44ca1eb8 100644 --- a/Linphone/ui/assistant/AssistantView.swift +++ b/Linphone/ui/assistant/AssistantView.swift @@ -29,40 +29,78 @@ struct AssistantView: View { ZStack { Image("Mountain") .resizable() - .frame(width: 1084, height: 108) + .frame(width: 1080, height: 108) Text("assistant_account_login") - .font(Font.custom("Noto Sans", size: 20)) + .font(Font.custom("NotoSans-ExtraBold", size: 20)) .foregroundColor(.white) } - .padding(.top, 36) - .padding(.bottom, 16) - HStack { - Text("Username:") - .font(.title) - TextField("", text : $accountLoginViewModel.username) - .textFieldStyle(RoundedBorderTextFieldStyle()) - .disabled(coreContext.loggedIn) - } - HStack { - Text("Password:") - .font(.title) - - SecureField("", text : $accountLoginViewModel.passwd) - .textFieldStyle(RoundedBorderTextFieldStyle()) - .disabled(coreContext.loggedIn) - } - HStack { - Text("Domain:") - .font(.title) - TextField("", text : $accountLoginViewModel.domain) - .textFieldStyle(RoundedBorderTextFieldStyle()) - .disabled(coreContext.loggedIn) - } - Picker(selection: $accountLoginViewModel.transportType, label: Text("Transport:")) { - Text("TLS").tag("TLS") - Text("TCP").tag("TCP") - Text("UDP").tag("UDP") - }.pickerStyle(SegmentedPickerStyle()).padding() + .padding(.top, 35) + + HStack(alignment: .center, spacing: 0) { + VStack(alignment: .leading, spacing: 0) { + Text(String(localized: "username")+"*") + .font(Font.custom("Noto Sans", size: 15) + .weight(.bold)) + .padding(.bottom, 5) + + TextField("username", text : $accountLoginViewModel.username) + .font(Font.custom("Noto Sans", size: 15)) + .disabled(coreContext.loggedIn) + .frame(height: 20) + .padding(.horizontal, 20) + .padding(.vertical, 15) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color(red: 0.98, green: 0.98, blue: 0.98)) + .cornerRadius(60) + .overlay( + RoundedRectangle(cornerRadius: 63) + .inset(by: 0.5) + .stroke(Color(red: 0.93, green: 0.93, blue: 0.93), lineWidth: 1) + ) + .padding(.bottom, 15) + + Text(String(localized: "password")+"*") + .font(Font.custom("Noto Sans", size: 15) + .weight(.bold)) + .padding(.bottom, 5) + + SecureInputView(String(localized: "password"), text: $accountLoginViewModel.passwd) + .disabled(coreContext.loggedIn) + .frame(height: 20) + .padding(.horizontal, 20) + .padding(.vertical, 15) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color(red: 0.98, green: 0.98, blue: 0.98)) + .cornerRadius(63) + .overlay( + RoundedRectangle(cornerRadius: 63) + .inset(by: 0.5) + .stroke(Color(red: 0.93, green: 0.93, blue: 0.93), lineWidth: 1) + ) + .padding(.bottom, 32) + + Button(action: accountLoginViewModel.login) { + Text("assistant_account_login") + .font(Font.custom("NotoSans-ExtraBold", size: 20)) + .foregroundColor(.white) + } + .disabled(coreContext.loggedIn) + .padding(.horizontal, 20) + .padding(.vertical, 10) + .frame(maxWidth: .infinity, alignment: .center) + .background(Color(red: 1, green: 0.37, blue: 0)) + .cornerRadius(63) + .overlay( + RoundedRectangle(cornerRadius: 63) + .inset(by: 0.5) + .stroke(Color(red: 1, green: 0.37, blue: 0), lineWidth: 1) + ) + + } + } + .padding(.top, 5) + .padding(.bottom, 20) + VStack { HStack { Button(action: { @@ -101,7 +139,42 @@ struct AssistantView: View { } struct AssistantView_Previews: PreviewProvider { + static var previews: some View { AssistantView(accountLoginViewModel: AccountLoginViewModel()) } } + + +struct SecureInputView: View { + + @Binding private var text: String + @State private var isSecured: Bool = true + private var title: String + + init(_ title: String, text: Binding) { + self.title = title + self._text = text + } + + var body: some View { + ZStack(alignment: .trailing) { + Group { + if isSecured { + SecureField(title, text: $text) + .font(Font.custom("Noto Sans", size: 15)) + } else { + TextField(title, text: $text) + .font(Font.custom("Noto Sans", size: 15)) + } + }.padding(.trailing, 32) + + Button(action: { + isSecured.toggle() + }) { + Image(systemName: self.isSecured ? "eye.slash" : "eye") + .accentColor(.gray) + } + } + } +} diff --git a/Linphone/ui/assistant/viewmodel/AccountLoginViewModel.swift b/Linphone/ui/assistant/viewmodel/AccountLoginViewModel.swift index 605b42032..184a8e713 100644 --- a/Linphone/ui/assistant/viewmodel/AccountLoginViewModel.swift +++ b/Linphone/ui/assistant/viewmodel/AccountLoginViewModel.swift @@ -17,15 +17,16 @@ * along with this program. If not, see . */ +import SwiftUI import linphonesw class AccountLoginViewModel : ObservableObject { private var coreContext = CoreContext.shared - @Published var username : String = "user" + @Published var username : String = "" @Published var passwd : String = "" - @Published var domain : String = "sip.example.org" + @Published var domain : String = "sip.linphone.org" @Published var transportType : String = "TLS" init() {} diff --git a/Linphone/ui/main/ContentView.swift b/Linphone/ui/main/ContentView.swift index 140bf99ee..dd80aa860 100644 --- a/Linphone/ui/main/ContentView.swift +++ b/Linphone/ui/main/ContentView.swift @@ -29,6 +29,7 @@ struct ContentView: View { } struct ContentView_Previews: PreviewProvider { + static var previews: some View { AssistantView(accountLoginViewModel: AccountLoginViewModel()) } From e0d77cdb06ff7d787c3f4cf798d8cfa05081e475 Mon Sep 17 00:00:00 2001 From: "benoit.martins" Date: Thu, 28 Sep 2023 15:09:05 +0200 Subject: [PATCH 013/486] New Linphone styles, changes in Login view --- Linphone.xcodeproj/project.pbxproj | 24 +- .../eye-slash.imageset/Contents.json | 21 + .../eye-slash.imageset/eye-slash.svg | 1 + .../eye.imageset/Contents.json | 21 + Linphone/Assets.xcassets/eye.imageset/eye.svg | 1 + .../qr-code.imageset/Contents.json | 21 + .../qr-code.imageset/qr-code.svg | 1 + Linphone/Localizable.xcstrings | 23 +- Linphone/ui/assistant/AssistantView.swift | 392 ++++++++++-------- .../viewmodel/AccountLoginViewModel.swift | 2 +- Linphone/utils/ColorExtension.swift | 91 ++++ Linphone/utils/TextExtension.swift | 114 +++++ 12 files changed, 530 insertions(+), 182 deletions(-) create mode 100644 Linphone/Assets.xcassets/eye-slash.imageset/Contents.json create mode 100644 Linphone/Assets.xcassets/eye-slash.imageset/eye-slash.svg create mode 100644 Linphone/Assets.xcassets/eye.imageset/Contents.json create mode 100644 Linphone/Assets.xcassets/eye.imageset/eye.svg create mode 100644 Linphone/Assets.xcassets/qr-code.imageset/Contents.json create mode 100644 Linphone/Assets.xcassets/qr-code.imageset/qr-code.svg create mode 100644 Linphone/utils/ColorExtension.swift create mode 100644 Linphone/utils/TextExtension.swift diff --git a/Linphone.xcodeproj/project.pbxproj b/Linphone.xcodeproj/project.pbxproj index d810a9fc1..dea1d116b 100644 --- a/Linphone.xcodeproj/project.pbxproj +++ b/Linphone.xcodeproj/project.pbxproj @@ -9,6 +9,8 @@ /* Begin PBXBuildFile section */ 2B416B2E7C90375B792A28AE /* Pods_Linphone.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 2C08FB4788AD667D35BAE64D /* Pods_Linphone.framework */; }; D70C93DE2AC2D0F60063CA3B /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = D70C93DD2AC2D0F60063CA3B /* Localizable.xcstrings */; }; + D717071E2AC5922E0037746F /* ColorExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = D717071D2AC5922E0037746F /* ColorExtension.swift */; }; + D71707202AC5989C0037746F /* TextExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = D717071F2AC5989C0037746F /* TextExtension.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 */; }; @@ -29,6 +31,8 @@ 2C08FB4788AD667D35BAE64D /* Pods_Linphone.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Linphone.framework; sourceTree = BUILT_PRODUCTS_DIR; }; BC39A28B26EDB00C91AB7756 /* Pods-Linphone.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Linphone.release.xcconfig"; path = "Target Support Files/Pods-Linphone/Pods-Linphone.release.xcconfig"; sourceTree = ""; }; D70C93DD2AC2D0F60063CA3B /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; 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 = ""; }; 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 = ""; }; @@ -78,6 +82,15 @@ path = Pods; sourceTree = ""; }; + D717071C2AC591EF0037746F /* utils */ = { + isa = PBXGroup; + children = ( + D717071D2AC5922E0037746F /* ColorExtension.swift */, + D717071F2AC5989C0037746F /* TextExtension.swift */, + ); + path = utils; + sourceTree = ""; + }; D719ABAA2ABC67BF00B41C10 = { isa = PBXGroup; children = ( @@ -101,6 +114,7 @@ children = ( D719ABC72ABC6FB200B41C10 /* core */, D719ABC52ABC6EE800B41C10 /* ui */, + D717071C2AC591EF0037746F /* utils */, D719ABB62ABC67BF00B41C10 /* LinphoneApp.swift */, D719ABBA2ABC67BF00B41C10 /* Assets.xcassets */, D719ABBC2ABC67BF00B41C10 /* Linphone.entitlements */, @@ -174,12 +188,12 @@ 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 */, - D7D24D0F2AC1B4E800C6F35B /* NotoSans-Light.ttf */, - D7D24D0D2AC1B4E800C6F35B /* NotoSans-Medium.ttf */, - D7D24D0E2AC1B4E800C6F35B /* NotoSans-Regular.ttf */, - D7D24D102AC1B4E800C6F35B /* NotoSans-SemiBold.ttf */, ); path = Fonts; sourceTree = ""; @@ -305,11 +319,13 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + D71707202AC5989C0037746F /* TextExtension.swift in Sources */, D719ABB92ABC67BF00B41C10 /* ContentView.swift in Sources */, D719ABC92ABC6FD700B41C10 /* CoreContext.swift in Sources */, D719ABCF2ABC779A00B41C10 /* AccountLoginViewModel.swift in Sources */, D719ABB72ABC67BF00B41C10 /* LinphoneApp.swift in Sources */, D7A2EDD62AC18115005D90FC /* SharedMainViewModel.swift in Sources */, + D717071E2AC5922E0037746F /* ColorExtension.swift in Sources */, D719ABCC2ABC769C00B41C10 /* AssistantView.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; 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..6dc0e47a4 --- /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..36ed4da10 --- /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/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..d5cd44274 --- /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/Localizable.xcstrings b/Linphone/Localizable.xcstrings index e80c25f45..4b684b10d 100644 --- a/Linphone/Localizable.xcstrings +++ b/Linphone/Localizable.xcstrings @@ -3,6 +3,9 @@ "strings" : { "" : { + }, + " or " : { + }, "%lld Book (Example)" : { "extractionState" : "manual", @@ -74,19 +77,13 @@ } } }, - "Core Version is %@" : { + "Forgotten password?" : { }, - "Create & \nlog in account" : { + "Log out" : { }, - "Log out & \ndelete account" : { - - }, - "Login State : " : { - - }, - "Looged in" : { + "Not account yet?" : { }, "password" : { @@ -105,6 +102,12 @@ } } } + }, + "Register" : { + + }, + "Scan QR code" : { + }, "TCP" : { @@ -112,7 +115,7 @@ "TLS" : { }, - "Unregistered" : { + "Use SIP Account" : { }, "username" : { diff --git a/Linphone/ui/assistant/AssistantView.swift b/Linphone/ui/assistant/AssistantView.swift index c44ca1eb8..aa219ef92 100644 --- a/Linphone/ui/assistant/AssistantView.swift +++ b/Linphone/ui/assistant/AssistantView.swift @@ -1,180 +1,238 @@ /* -* 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 . -*/ + * 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 coreContext = CoreContext.shared - @ObservedObject var accountLoginViewModel : AccountLoginViewModel - - var body: some View { - VStack { - ZStack { - Image("Mountain") - .resizable() - .frame(width: 1080, height: 108) - Text("assistant_account_login") - .font(Font.custom("NotoSans-ExtraBold", size: 20)) - .foregroundColor(.white) - } - .padding(.top, 35) - - HStack(alignment: .center, spacing: 0) { - VStack(alignment: .leading, spacing: 0) { - Text(String(localized: "username")+"*") - .font(Font.custom("Noto Sans", size: 15) - .weight(.bold)) - .padding(.bottom, 5) - - TextField("username", text : $accountLoginViewModel.username) - .font(Font.custom("Noto Sans", size: 15)) - .disabled(coreContext.loggedIn) - .frame(height: 20) - .padding(.horizontal, 20) - .padding(.vertical, 15) - .frame(maxWidth: .infinity, alignment: .leading) - .background(Color(red: 0.98, green: 0.98, blue: 0.98)) - .cornerRadius(60) - .overlay( - RoundedRectangle(cornerRadius: 63) - .inset(by: 0.5) - .stroke(Color(red: 0.93, green: 0.93, blue: 0.93), lineWidth: 1) - ) - .padding(.bottom, 15) - - Text(String(localized: "password")+"*") - .font(Font.custom("Noto Sans", size: 15) - .weight(.bold)) - .padding(.bottom, 5) - - SecureInputView(String(localized: "password"), text: $accountLoginViewModel.passwd) - .disabled(coreContext.loggedIn) - .frame(height: 20) - .padding(.horizontal, 20) - .padding(.vertical, 15) - .frame(maxWidth: .infinity, alignment: .leading) - .background(Color(red: 0.98, green: 0.98, blue: 0.98)) - .cornerRadius(63) - .overlay( - RoundedRectangle(cornerRadius: 63) - .inset(by: 0.5) - .stroke(Color(red: 0.93, green: 0.93, blue: 0.93), lineWidth: 1) - ) - .padding(.bottom, 32) - - Button(action: accountLoginViewModel.login) { - Text("assistant_account_login") - .font(Font.custom("NotoSans-ExtraBold", size: 20)) - .foregroundColor(.white) - } - .disabled(coreContext.loggedIn) - .padding(.horizontal, 20) - .padding(.vertical, 10) - .frame(maxWidth: .infinity, alignment: .center) - .background(Color(red: 1, green: 0.37, blue: 0)) - .cornerRadius(63) - .overlay( - RoundedRectangle(cornerRadius: 63) - .inset(by: 0.5) - .stroke(Color(red: 1, green: 0.37, blue: 0), lineWidth: 1) - ) - - } - } - .padding(.top, 5) - .padding(.bottom, 20) - - VStack { - HStack { - Button(action: { - if (self.coreContext.loggedIn) - { - self.accountLoginViewModel.unregister() - self.accountLoginViewModel.delete() - } else { - self.accountLoginViewModel.login() - } - }) - { - Text(coreContext.loggedIn ? "Log out & \ndelete account" : "Create & \nlog in account") - .font(.largeTitle) - .foregroundColor(Color.white) - .frame(width: 220.0, height: 90) - .background(Color.gray) + + @ObservedObject private var coreContext = CoreContext.shared + @ObservedObject var accountLoginViewModel : AccountLoginViewModel + + @State private var isSecured: Bool = true + + @FocusState var isNameFocused:Bool + @FocusState var isPasswordFocused:Bool + + var body: some View { + GeometryReader { geometry in + ScrollView(.vertical) { + VStack { + ZStack { + Image("Mountain") + .resizable() + .scaledToFill() + .frame(width: geometry.size.width, height: 100) + .clipped() + 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) + .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.orange_main_500 : Color.gray_200, 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) + .frame(height: 25) + .focused($isPasswordFocused) + } + } + Button(action: { + isSecured.toggle() + }) { + Image(self.isSecured ? "eye-slash" : "eye") + .renderingMode(.template) + .resizable() + .foregroundStyle(Color.gray_main2_500) + .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.orange_main_500 : Color.gray_200, lineWidth: 1) + ) + .padding(.bottom) + + Button(action: { + if (self.coreContext.loggedIn){ + self.accountLoginViewModel.unregister() + self.accountLoginViewModel.delete() + } else { + self.accountLoginViewModel.login() + } + }) { + 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.orange_main_100 : Color.orange_main_500) + .cornerRadius(60) + .disabled(accountLoginViewModel.username.isEmpty || accountLoginViewModel.passwd.isEmpty) + .padding(.bottom) + + Button(action: { + + }) { + Text("Forgotten password?") + .underline() + .default_text_style_600(styleSize: 15) + .foregroundStyle(Color.gray_main2_500) + } + .frame(maxWidth: .infinity) + .padding(.bottom, 30) + + HStack { + VStack{ + Divider() + } + Text(" or ") + .default_text_style(styleSize: 15) + .foregroundStyle(Color.gray_main2_500) + VStack{ + Divider() + } + } + .padding(.bottom, 10) + + Button(action: { + + }) { + HStack { + Image("qr-code") + .renderingMode(.template) + .resizable() + .foregroundStyle(Color.orange_main_500) + .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.orange_main_500, lineWidth: 1) + ) + .padding(.bottom) + + Button(action: { + + }) { + Text("Use SIP 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.orange_main_500, lineWidth: 1) + ) + .padding(.bottom) + + HStack(alignment: .center) { + + Spacer() + + Text("Not account yet?") + .default_text_style(styleSize: 15) + .foregroundStyle(Color.gray_main2_700) + .padding(.horizontal, 10) + + Button(action: { + + }) { + Text("Register") + .default_text_style_orange_600(styleSize: 20) + .frame(height: 35) + } + .padding(.horizontal, 20) + .padding(.vertical, 10) + .cornerRadius(60) + .overlay( + RoundedRectangle(cornerRadius: 60) + .inset(by: 0.5) + .stroke(Color.orange_main_500, lineWidth: 1) + ) + .padding(.horizontal, 10) + + Spacer() + } + .padding(.bottom) + } + .padding(.horizontal, 20) } - HStack { - Text("Login State : ") - .font(.footnote) - Text(coreContext.loggedIn ? "Looged in" : "Unregistered") - .font(.footnote) - .foregroundColor(coreContext.loggedIn ? Color.green : Color.black) - }.padding(.top, 10.0) } - Group { - Spacer() - Text("Core Version is \(coreContext.coreVersion)") - } - } - .padding() - } + } + } } struct AssistantView_Previews: PreviewProvider { - - static var previews: some View { - AssistantView(accountLoginViewModel: AccountLoginViewModel()) - } -} - - -struct SecureInputView: View { - - @Binding private var text: String - @State private var isSecured: Bool = true - private var title: String - - init(_ title: String, text: Binding) { - self.title = title - self._text = text - } - - var body: some View { - ZStack(alignment: .trailing) { - Group { - if isSecured { - SecureField(title, text: $text) - .font(Font.custom("Noto Sans", size: 15)) - } else { - TextField(title, text: $text) - .font(Font.custom("Noto Sans", size: 15)) - } - }.padding(.trailing, 32) - - Button(action: { - isSecured.toggle() - }) { - Image(systemName: self.isSecured ? "eye.slash" : "eye") - .accentColor(.gray) - } - } - } + + static var previews: some View { + AssistantView(accountLoginViewModel: AccountLoginViewModel()) + } } diff --git a/Linphone/ui/assistant/viewmodel/AccountLoginViewModel.swift b/Linphone/ui/assistant/viewmodel/AccountLoginViewModel.swift index 184a8e713..3920bce13 100644 --- a/Linphone/ui/assistant/viewmodel/AccountLoginViewModel.swift +++ b/Linphone/ui/assistant/viewmodel/AccountLoginViewModel.swift @@ -17,8 +17,8 @@ * along with this program. If not, see . */ -import SwiftUI import linphonesw +import SwiftUI class AccountLoginViewModel : ObservableObject { diff --git a/Linphone/utils/ColorExtension.swift b/Linphone/utils/ColorExtension.swift new file mode 100644 index 000000000..4846fbfb1 --- /dev/null +++ b/Linphone/utils/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 transparent_color = Color(hex: "#00000000") + static let black = Color(hex: "#000000") + static let white = Color(hex: "#FFFFFF") + + static let orange_main_700 = Color(hex: "#B72D00") + static let orange_main_500 = Color(hex: "#FF5E00") + static let orange_main_300 = Color(hex: "#FFB266") + static let orange_main_100 = Color(hex: "#FFEACB") + static let orange_main_100_alpha_50 = Color(hex: "#80FFEACB") + + static let gray_main2_800 = Color(hex: "#22334D") + static let gray_main2_800_alpha_65 = Color(hex: "#A622334D") + static let gray_main2_700 = Color(hex: "#364860") + static let gray_main2_600 = Color(hex: "#4E6074") + static let gray_main2_500 = Color(hex: "#6C7A87") + static let gray_main2_400 = Color(hex: "#9AABB5") + static let gray_main2_300 = Color(hex: "#C0D1D9") + static let gray_main2_200 = Color(hex: "#DFECF2") + static let gray_main2_100 = Color(hex: "#EEF6F8") + + static let gray_100 = Color(hex: "#F9F9F9") + static let gray_200 = Color(hex: "#EDEDED") + static let gray_300 = Color(hex: "#C9C9C9") + static let gray_400 = Color(hex: "#949494") + static let gray_500 = Color(hex: "#4E4E4E") + static let gray_600 = Color(hex: "#2E3030") + static let gray_900 = Color(hex: "#070707") + + static let red_danger_200 = Color(hex: "#F5CCBE") + static let red_danger_500 = Color(hex: "#DD5F5F") + static let red_danger_700 = Color(hex: "#9E3548") + + static let green_success_500 = Color(hex: "#4FAE80") + static let green_success_700 = Color(hex: "#377D71") + static let green_success_200 = Color(hex: "#ACF5C1") + + static let blue_info_500 = Color(hex: "#4AA8FF") + + static let orange_warning_600 = Color(hex: "#DBB820") + + static let orange_away = Color(hex: "#FFA645") + + init(hex: String) { + let hex = hex.trimmingCharacters(in: CharacterSet.alphanumerics.inverted) + var int: UInt64 = 0 + Scanner(string: hex).scanHexInt64(&int) + let a, r, g, b: UInt64 + switch hex.count { + case 3: // RGB (12-bit) + (a, r, g, b) = (255, (int >> 8) * 17, (int >> 4 & 0xF) * 17, (int & 0xF) * 17) + case 6: // RGB (24-bit) + (a, r, g, b) = (255, int >> 16, int >> 8 & 0xFF, int & 0xFF) + case 8: // ARGB (32-bit) + (a, r, g, b) = (int >> 24, int >> 16 & 0xFF, int >> 8 & 0xFF, int & 0xFF) + default: + (a, r, g, b) = (1, 1, 1, 0) + } + + self.init( + .sRGB, + red: Double(r) / 255, + green: Double(g) / 255, + blue: Double(b) / 255, + opacity: Double(a) / 255 + ) + } +} diff --git a/Linphone/utils/TextExtension.swift b/Linphone/utils/TextExtension.swift new file mode 100644 index 000000000..b48850251 --- /dev/null +++ b/Linphone/utils/TextExtension.swift @@ -0,0 +1,114 @@ +/* + * 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 View { + + func default_text_style_300(styleSize: CGFloat) -> some View { + self.font(Font.custom("NotoSans-Light", size: styleSize)) + .foregroundStyle(Color.gray_main2_600) + } + + func default_text_style(styleSize: CGFloat) -> some View { + self.font(Font.custom("NotoSans-Regular", size: styleSize)) + .foregroundStyle(Color.gray_main2_600) + } + + func default_text_style_500(styleSize: CGFloat) -> some View { + self.font(Font.custom("NotoSans-Medium", size: styleSize)) + .foregroundStyle(Color.gray_main2_600) + } + + func default_text_style_600(styleSize: CGFloat) -> some View { + self.font(Font.custom("NotoSans-SemiBold", size: styleSize)) + .foregroundStyle(Color.gray_main2_600) + } + + func default_text_style_700(styleSize: CGFloat) -> some View { + self.font(Font.custom("NotoSans-Bold", size: styleSize)) + .foregroundStyle(Color.gray_main2_600) + } + + func default_text_style_800(styleSize: CGFloat) -> some View { + self.font(Font.custom("NotoSans-ExtraBold", size: styleSize)) + .foregroundStyle(Color.gray_main2_600) + } + + 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.orange_main_500) + } + + func default_text_style_orange(styleSize: CGFloat) -> some View { + self.font(Font.custom("NotoSans-Regular", size: styleSize)) + .foregroundStyle(Color.orange_main_500) + } + + func default_text_style_orange_500(styleSize: CGFloat) -> some View { + self.font(Font.custom("NotoSans-Medium", size: styleSize)) + .foregroundStyle(Color.orange_main_500) + } + + func default_text_style_orange_600(styleSize: CGFloat) -> some View { + self.font(Font.custom("NotoSans-SemiBold", size: styleSize)) + .foregroundStyle(Color.orange_main_500) + } + + func default_text_style_orange_700(styleSize: CGFloat) -> some View { + self.font(Font.custom("NotoSans-Bold", size: styleSize)) + .foregroundStyle(Color.orange_main_500) + } + + func default_text_style_orange_800(styleSize: CGFloat) -> some View { + self.font(Font.custom("NotoSans-ExtraBold", size: styleSize)) + .foregroundStyle(Color.orange_main_500) + } +} From ae5f3a6c41b76fd58d1d9530fc52a4d2b72f872d Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Sun, 1 Oct 2023 22:20:30 +0200 Subject: [PATCH 014/486] Add Welcome View --- Linphone.xcodeproj/project.pbxproj | 122 +++++++++++--- Linphone/.DS_Store | Bin 6148 -> 6148 bytes .../Contents.json | 2 +- .../Linphone.imageset/Linphone.svg | 4 + .../Mountain.imageset/Mountain.svg | 101 ------------ .../address-book.imageset/Contents.json | 21 +++ .../address-book.imageset/address-book.svg | 1 + .../current-dot.imageset/Contents.json | 21 +++ .../current-dot.imageset/current-dot.svg | 4 + .../dot.imageset/Contents.json | 21 +++ Linphone/Assets.xcassets/dot.imageset/dot.svg | 4 + .../mountain.imageset/Contents.json | 21 +++ .../mountain.imageset/mountain.svg | 14 ++ .../open-source.imageset/Contents.json | 21 +++ .../open-source.imageset/open-source.svg | 3 + .../phone.imageset/Contents.json | 21 +++ .../Assets.xcassets/phone.imageset/phone.svg | 1 + .../secure-image.imageset/Contents.json | 21 +++ .../secure-image.imageset/secure-image.svg | 6 + Linphone/{core => Core}/CoreContext.swift | 11 +- Linphone/Info.plist | 5 + Linphone/LinphoneApp.swift | 9 +- Linphone/Localizable.xcstrings | 70 ++++++++ Linphone/SplashScreen.swift | 38 +++++ .../Assistant}/AssistantView.swift | 11 +- .../Viewmodel}/AccountLoginViewModel.swift | 0 .../Main/Contacts/ContactsView.swift} | 21 ++- Linphone/UI/Main/ContentView.swift | 50 ++++++ Linphone/UI/Main/Fragments/PopupView.swift | 81 +++++++++ Linphone/UI/Main/History/HistoryView.swift | 27 +++ .../Main/Viewmodel/SharedMainViewModel.swift} | 33 ++-- .../Fragments/WelcomePage1Fragment.swift | 52 ++++++ .../Fragments/WelcomePage2Fragment.swift | 51 ++++++ .../Fragments/WelcomePage3Fragment.swift | 51 ++++++ Linphone/UI/Welcome/WelcomeView.swift | 154 ++++++++++++++++++ .../{utils => Utils}/ColorExtension.swift | 0 Linphone/Utils/PermissionManager.swift | 47 ++++++ Linphone/{utils => Utils}/TextExtension.swift | 15 ++ Linphone/ui/.DS_Store | Bin 6148 -> 0 bytes 39 files changed, 983 insertions(+), 152 deletions(-) rename Linphone/Assets.xcassets/{Mountain.imageset => Linphone.imageset}/Contents.json (88%) create mode 100644 Linphone/Assets.xcassets/Linphone.imageset/Linphone.svg delete mode 100644 Linphone/Assets.xcassets/Mountain.imageset/Mountain.svg create mode 100644 Linphone/Assets.xcassets/address-book.imageset/Contents.json create mode 100644 Linphone/Assets.xcassets/address-book.imageset/address-book.svg create mode 100644 Linphone/Assets.xcassets/current-dot.imageset/Contents.json create mode 100644 Linphone/Assets.xcassets/current-dot.imageset/current-dot.svg create mode 100644 Linphone/Assets.xcassets/dot.imageset/Contents.json create mode 100644 Linphone/Assets.xcassets/dot.imageset/dot.svg create mode 100644 Linphone/Assets.xcassets/mountain.imageset/Contents.json create mode 100644 Linphone/Assets.xcassets/mountain.imageset/mountain.svg create mode 100644 Linphone/Assets.xcassets/open-source.imageset/Contents.json create mode 100644 Linphone/Assets.xcassets/open-source.imageset/open-source.svg create mode 100644 Linphone/Assets.xcassets/phone.imageset/Contents.json create mode 100644 Linphone/Assets.xcassets/phone.imageset/phone.svg create mode 100644 Linphone/Assets.xcassets/secure-image.imageset/Contents.json create mode 100644 Linphone/Assets.xcassets/secure-image.imageset/secure-image.svg rename Linphone/{core => Core}/CoreContext.swift (84%) create mode 100644 Linphone/SplashScreen.swift rename Linphone/{ui/assistant => UI/Assistant}/AssistantView.swift (97%) rename Linphone/{ui/assistant/viewmodel => UI/Assistant/Viewmodel}/AccountLoginViewModel.swift (100%) rename Linphone/{ui/main/viewmodel/SharedMainViewModel.swift => UI/Main/Contacts/ContactsView.swift} (73%) create mode 100644 Linphone/UI/Main/ContentView.swift create mode 100644 Linphone/UI/Main/Fragments/PopupView.swift create mode 100644 Linphone/UI/Main/History/HistoryView.swift rename Linphone/{ui/main/ContentView.swift => UI/Main/Viewmodel/SharedMainViewModel.swift} (52%) create mode 100644 Linphone/UI/Welcome/Fragments/WelcomePage1Fragment.swift create mode 100644 Linphone/UI/Welcome/Fragments/WelcomePage2Fragment.swift create mode 100644 Linphone/UI/Welcome/Fragments/WelcomePage3Fragment.swift create mode 100644 Linphone/UI/Welcome/WelcomeView.swift rename Linphone/{utils => Utils}/ColorExtension.swift (100%) create mode 100644 Linphone/Utils/PermissionManager.swift rename Linphone/{utils => Utils}/TextExtension.swift (89%) delete mode 100644 Linphone/ui/.DS_Store diff --git a/Linphone.xcodeproj/project.pbxproj b/Linphone.xcodeproj/project.pbxproj index dea1d116b..f334eb911 100644 --- a/Linphone.xcodeproj/project.pbxproj +++ b/Linphone.xcodeproj/project.pbxproj @@ -18,6 +18,15 @@ 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 */; }; + 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 */; }; + D7702EF22AC7205000557C00 /* WelcomeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7702EF12AC7205000557C00 /* WelcomeView.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 */; }; D7A2EDD62AC18115005D90FC /* SharedMainViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7A2EDD52AC18115005D90FC /* SharedMainViewModel.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 */; }; @@ -42,6 +51,15 @@ 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 = ""; }; + 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 = ""; }; + D7702EF12AC7205000557C00 /* WelcomeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WelcomeView.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 = ""; }; D7A2EDD52AC18115005D90FC /* SharedMainViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SharedMainViewModel.swift; sourceTree = ""; }; D7A2EDDA2AC19EEC005D90FC /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; D7D24D0D2AC1B4E800C6F35B /* NotoSans-Medium.ttf */ = {isa = PBXFileReference; lastKnownFileType = file; path = "NotoSans-Medium.ttf"; sourceTree = ""; }; @@ -82,13 +100,14 @@ path = Pods; sourceTree = ""; }; - D717071C2AC591EF0037746F /* utils */ = { + D717071C2AC591EF0037746F /* Utils */ = { isa = PBXGroup; children = ( D717071D2AC5922E0037746F /* ColorExtension.swift */, D717071F2AC5989C0037746F /* TextExtension.swift */, + D74C9D002ACB098C0021626A /* PermissionManager.swift */, ); - path = utils; + path = Utils; sourceTree = ""; }; D719ABAA2ABC67BF00B41C10 = { @@ -112,10 +131,11 @@ D719ABB52ABC67BF00B41C10 /* Linphone */ = { isa = PBXGroup; children = ( - D719ABC72ABC6FB200B41C10 /* core */, - D719ABC52ABC6EE800B41C10 /* ui */, - D717071C2AC591EF0037746F /* utils */, + D7A03FC52ACC458A0081A588 /* SplashScreen.swift */, D719ABB62ABC67BF00B41C10 /* LinphoneApp.swift */, + D719ABC72ABC6FB200B41C10 /* Core */, + D719ABC52ABC6EE800B41C10 /* UI */, + D717071C2AC591EF0037746F /* Utils */, D719ABBA2ABC67BF00B41C10 /* Assets.xcassets */, D719ABBC2ABC67BF00B41C10 /* Linphone.entitlements */, D7A2EDDA2AC19EEC005D90FC /* Info.plist */, @@ -134,55 +154,102 @@ path = "Preview Content"; sourceTree = ""; }; - D719ABC52ABC6EE800B41C10 /* ui */ = { + D719ABC52ABC6EE800B41C10 /* UI */ = { isa = PBXGroup; children = ( - D719ABCA2ABC761800B41C10 /* assistant */, - D719ABC62ABC6F0200B41C10 /* main */, + D719ABCA2ABC761800B41C10 /* Assistant */, + D719ABC62ABC6F0200B41C10 /* Main */, + D7702EF02AC7200600557C00 /* Welcome */, ); - path = ui; + path = UI; sourceTree = ""; }; - D719ABC62ABC6F0200B41C10 /* main */ = { + D719ABC62ABC6F0200B41C10 /* Main */ = { isa = PBXGroup; children = ( - D7A2EDD42AC180FE005D90FC /* viewmodel */, + D7A03FBB2ACC2D850081A588 /* Contacts */, + D74C9CFD2ACAEC150021626A /* Fragments */, + D7A03FBE2ACC2E010081A588 /* History */, + D7A2EDD42AC180FE005D90FC /* Viewmodel */, D719ABB82ABC67BF00B41C10 /* ContentView.swift */, ); - path = main; + path = Main; sourceTree = ""; }; - D719ABC72ABC6FB200B41C10 /* core */ = { + D719ABC72ABC6FB200B41C10 /* Core */ = { isa = PBXGroup; children = ( D719ABC82ABC6FD700B41C10 /* CoreContext.swift */, ); - path = core; + path = Core; sourceTree = ""; }; - D719ABCA2ABC761800B41C10 /* assistant */ = { + D719ABCA2ABC761800B41C10 /* Assistant */ = { isa = PBXGroup; children = ( - D719ABCD2ABC777600B41C10 /* viewmodel */, + D719ABCD2ABC777600B41C10 /* Viewmodel */, D719ABCB2ABC769C00B41C10 /* AssistantView.swift */, ); - path = assistant; + path = Assistant; sourceTree = ""; }; - D719ABCD2ABC777600B41C10 /* viewmodel */ = { + D719ABCD2ABC777600B41C10 /* Viewmodel */ = { isa = PBXGroup; children = ( D719ABCE2ABC779A00B41C10 /* AccountLoginViewModel.swift */, ); - path = viewmodel; + path = Viewmodel; sourceTree = ""; }; - D7A2EDD42AC180FE005D90FC /* viewmodel */ = { + 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 */, + ); + path = Fragments; + sourceTree = ""; + }; + D7702EF02AC7200600557C00 /* Welcome */ = { + isa = PBXGroup; + children = ( + D74C9CF62ACACEB70021626A /* Fragments */, + D7702EF12AC7205000557C00 /* WelcomeView.swift */, + ); + path = Welcome; + sourceTree = ""; + }; + D7A03FBB2ACC2D850081A588 /* Contacts */ = { + isa = PBXGroup; + children = ( + D7A03FBC2ACC2DB60081A588 /* ContactsView.swift */, + ); + path = Contacts; + sourceTree = ""; + }; + D7A03FBE2ACC2E010081A588 /* History */ = { + isa = PBXGroup; + children = ( + D7A03FBF2ACC2E390081A588 /* HistoryView.swift */, + ); + path = History; + sourceTree = ""; + }; + D7A2EDD42AC180FE005D90FC /* Viewmodel */ = { isa = PBXGroup; children = ( D7A2EDD52AC18115005D90FC /* SharedMainViewModel.swift */, ); - path = viewmodel; + path = Viewmodel; sourceTree = ""; }; D7D24D0C2AC1B4C700C6F35B /* Fonts */ = { @@ -322,11 +389,20 @@ D71707202AC5989C0037746F /* TextExtension.swift in Sources */, D719ABB92ABC67BF00B41C10 /* ContentView.swift in Sources */, D719ABC92ABC6FD700B41C10 /* CoreContext.swift in Sources */, + D7A03FBD2ACC2DB60081A588 /* ContactsView.swift in Sources */, D719ABCF2ABC779A00B41C10 /* AccountLoginViewModel.swift in Sources */, + D74C9D012ACB098C0021626A /* PermissionManager.swift in Sources */, + D7702EF22AC7205000557C00 /* WelcomeView.swift in Sources */, D719ABB72ABC67BF00B41C10 /* LinphoneApp.swift in Sources */, D7A2EDD62AC18115005D90FC /* SharedMainViewModel.swift in Sources */, + D7A03FC62ACC458A0081A588 /* SplashScreen.swift in Sources */, + D7A03FC02ACC2E390081A588 /* HistoryView.swift in Sources */, + D74C9CF82ACACECE0021626A /* WelcomePage1Fragment.swift in Sources */, D717071E2AC5922E0037746F /* ColorExtension.swift in Sources */, + D74C9CFC2ACACF370021626A /* WelcomePage3Fragment.swift in Sources */, D719ABCC2ABC769C00B41C10 /* AssistantView.swift in Sources */, + D74C9CFA2ACACF2D0021626A /* WelcomePage2Fragment.swift in Sources */, + D74C9CFF2ACAEC5E0021626A /* PopupView.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -452,6 +528,7 @@ buildSettings = { 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 = 1; @@ -462,6 +539,7 @@ ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = Linphone/Info.plist; + INFOPLIST_KEY_NSPhotoLibraryUsageDescription = ""; "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES; "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES; "INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES; @@ -493,6 +571,7 @@ buildSettings = { 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 = 1; @@ -503,6 +582,7 @@ ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = Linphone/Info.plist; + INFOPLIST_KEY_NSPhotoLibraryUsageDescription = ""; "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES; "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES; "INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES; diff --git a/Linphone/.DS_Store b/Linphone/.DS_Store index ec9d1dc224bc482e05b77058533914b4c2cd94a1..ee98363d48998eeedc9d54f56ee7d99d34976794 100644 GIT binary patch delta 58 zcmZoMXffE}&%)?DIe + + + diff --git a/Linphone/Assets.xcassets/Mountain.imageset/Mountain.svg b/Linphone/Assets.xcassets/Mountain.imageset/Mountain.svg deleted file mode 100644 index e67609aff..000000000 --- a/Linphone/Assets.xcassets/Mountain.imageset/Mountain.svg +++ /dev/null @@ -1,101 +0,0 @@ - - - - - - - - - - - - - image/svg+xml - - - - - - - - - - - - - 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..9dc0b9ec9 --- /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/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/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/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/open-source.imageset/Contents.json b/Linphone/Assets.xcassets/open-source.imageset/Contents.json new file mode 100644 index 000000000..f63666669 --- /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..9bbb7658b --- /dev/null +++ b/Linphone/Assets.xcassets/open-source.imageset/open-source.svg @@ -0,0 +1,3 @@ + + + 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..6eb862926 --- /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/secure-image.imageset/Contents.json b/Linphone/Assets.xcassets/secure-image.imageset/Contents.json new file mode 100644 index 000000000..ade67196d --- /dev/null +++ b/Linphone/Assets.xcassets/secure-image.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "secure-image.svg", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Linphone/Assets.xcassets/secure-image.imageset/secure-image.svg b/Linphone/Assets.xcassets/secure-image.imageset/secure-image.svg new file mode 100644 index 000000000..7c8d6ee3a --- /dev/null +++ b/Linphone/Assets.xcassets/secure-image.imageset/secure-image.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/Linphone/core/CoreContext.swift b/Linphone/Core/CoreContext.swift similarity index 84% rename from Linphone/core/CoreContext.swift rename to Linphone/Core/CoreContext.swift index c08a9cf93..789384f73 100644 --- a/Linphone/core/CoreContext.swift +++ b/Linphone/Core/CoreContext.swift @@ -19,7 +19,7 @@ import linphonesw -class CoreContext : ObservableObject { +final class CoreContext : ObservableObject { static let shared = CoreContext() @@ -29,11 +29,14 @@ class CoreContext : ObservableObject { var coreVersion: String = Core.getVersion @Published var loggedIn : Bool = false - private init() { - + private init() {} + + func initialiseCore() async throws { LoggingService.Instance.logLevel = LogLevel.Debug - try? mCore = Factory.Instance.createCore(configPath: "", factoryConfigPath: "", systemContext: nil) + let factory = Factory.Instance + let configDir = factory.getConfigDir(context: nil) + try? mCore = Factory.Instance.createCore(configPath: "\(configDir)/MyConfig", factoryConfigPath: "", systemContext: nil) try? mCore.start() // Create a Core listener to listen for the callback we need diff --git a/Linphone/Info.plist b/Linphone/Info.plist index 0ec3efdd4..c3b52b04f 100644 --- a/Linphone/Info.plist +++ b/Linphone/Info.plist @@ -11,5 +11,10 @@ NotoSans-Bold.ttf NotoSans-ExtraBold.ttf + UILaunchScreen + + UIImageName + linphone + diff --git a/Linphone/LinphoneApp.swift b/Linphone/LinphoneApp.swift index 3aae5e76d..2b7f833fa 100644 --- a/Linphone/LinphoneApp.swift +++ b/Linphone/LinphoneApp.swift @@ -21,9 +21,16 @@ import SwiftUI @main struct LinphoneApp: App { + + @State private var isActive = false + var body: some Scene { WindowGroup { - ContentView(sharedMainViewModel: SharedMainViewModel()) + if isActive { + ContentView(sharedMainViewModel: SharedMainViewModel()) + }else { + SplashScreen(isActive: $isActive) + } } } } diff --git a/Linphone/Localizable.xcstrings b/Linphone/Localizable.xcstrings index 4b684b10d..825ac2ca4 100644 --- a/Linphone/Localizable.xcstrings +++ b/Linphone/Localizable.xcstrings @@ -6,6 +6,12 @@ }, " or " : { + }, + "[nos conditions d’utilisation](https://linphone.org/general-terms)" : { + + }, + "[notre politique de confidentialité](https://linphone.org/privacy-policy)" : { + }, "%lld Book (Example)" : { "extractionState" : "manual", @@ -59,6 +65,9 @@ } } } + }, + "Accept all" : { + }, "assistant_account_login" : { "extractionState" : "manual", @@ -77,14 +86,51 @@ } } }, + "Calls" : { + + }, + "Conditions de service" : { + + }, + "Contacts" : { + + }, + "Contacts View" : { + + }, + "Deny all" : { + + }, + "En continuant, vous acceptez ces conditions, %@ et %@." : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "En continuant, vous acceptez ces conditions, %1$@ et %2$@." + } + } + } + }, "Forgotten password?" : { + }, + "History View" : { + + }, + "Linphone" : { + }, "Log out" : { + }, + "Next" : { + }, "Not account yet?" : { + }, + "Open source" : { + }, "password" : { "extractionState" : "manual", @@ -108,12 +154,30 @@ }, "Scan QR code" : { + }, + "Sécurisé" : { + + }, + "Skip" : { + + }, + "Start" : { + }, "TCP" : { }, "TLS" : { + }, + "to Linphone" : { + + }, + "Une application de communication **sécurisée**, **open source** et **française**." : { + + }, + "Une application open source et un **service gratuit** depuis **2001**." : { + }, "Use SIP Account" : { @@ -134,6 +198,12 @@ } } } + }, + "Vos communications sont en sécurité grâce aux **Chiffrement de bout en bout**." : { + + }, + "Welcome" : { + } }, "version" : "1.0" diff --git a/Linphone/SplashScreen.swift b/Linphone/SplashScreen.swift new file mode 100644 index 000000000..b2ee812a6 --- /dev/null +++ b/Linphone/SplashScreen.swift @@ -0,0 +1,38 @@ +// +// SplashScreen.swift +// Linphone +// +// Created by Benoît Martins on 03/10/2023. +// + +import SwiftUI + +struct SplashScreen: View { + + @ObservedObject private var coreContext = CoreContext.shared + @Binding var isActive: Bool + + var body: some View { + GeometryReader { geometry in + VStack { + Spacer() + HStack { + Spacer() + Image("linphone") + Spacer() + } + Spacer() + } + + } + .ignoresSafeArea(.all) + .onAppear { + Task { + try await coreContext.initialiseCore() + withAnimation { + self.isActive = true + } + } + } + } +} diff --git a/Linphone/ui/assistant/AssistantView.swift b/Linphone/UI/Assistant/AssistantView.swift similarity index 97% rename from Linphone/ui/assistant/AssistantView.swift rename to Linphone/UI/Assistant/AssistantView.swift index aa219ef92..16552e0d1 100644 --- a/Linphone/ui/assistant/AssistantView.swift +++ b/Linphone/UI/Assistant/AssistantView.swift @@ -34,7 +34,7 @@ struct AssistantView: View { ScrollView(.vertical) { VStack { ZStack { - Image("Mountain") + Image("mountain") .resizable() .scaledToFill() .frame(width: geometry.size.width, height: 100) @@ -203,7 +203,7 @@ struct AssistantView: View { .padding(.horizontal, 10) Button(action: { - + }) { Text("Register") .default_text_style_orange_600(styleSize: 20) @@ -230,9 +230,6 @@ struct AssistantView: View { } } -struct AssistantView_Previews: PreviewProvider { - - static var previews: some View { - AssistantView(accountLoginViewModel: AccountLoginViewModel()) - } +#Preview { + AssistantView(accountLoginViewModel: AccountLoginViewModel()) } diff --git a/Linphone/ui/assistant/viewmodel/AccountLoginViewModel.swift b/Linphone/UI/Assistant/Viewmodel/AccountLoginViewModel.swift similarity index 100% rename from Linphone/ui/assistant/viewmodel/AccountLoginViewModel.swift rename to Linphone/UI/Assistant/Viewmodel/AccountLoginViewModel.swift diff --git a/Linphone/ui/main/viewmodel/SharedMainViewModel.swift b/Linphone/UI/Main/Contacts/ContactsView.swift similarity index 73% rename from Linphone/ui/main/viewmodel/SharedMainViewModel.swift rename to Linphone/UI/Main/Contacts/ContactsView.swift index ded17529c..32124a5b3 100644 --- a/Linphone/ui/main/viewmodel/SharedMainViewModel.swift +++ b/Linphone/UI/Main/Contacts/ContactsView.swift @@ -1,7 +1,7 @@ /* * Copyright (c) 2010-2023 Belledonne Communications SARL. * -* This file is part of Linphone +* 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 @@ -17,9 +17,20 @@ * along with this program. If not, see . */ -import linphonesw +import SwiftUI -class SharedMainViewModel : ObservableObject { - - init() {} +struct ContactsView: View { + var body: some View { + VStack { + Spacer() + Image("linphone") + .padding(.bottom, 20) + Text("Contacts View") + Spacer() + } + } +} + +#Preview { + ContactsView() } diff --git a/Linphone/UI/Main/ContentView.swift b/Linphone/UI/Main/ContentView.swift new file mode 100644 index 000000000..ef8b2e08d --- /dev/null +++ b/Linphone/UI/Main/ContentView.swift @@ -0,0 +1,50 @@ +/* + * 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 ContentView: View { + + @ObservedObject var sharedMainViewModel : SharedMainViewModel + @ObservedObject private var coreContext = CoreContext.shared + + var body: some View { + if UserDefaults.standard.bool(forKey: "general_terms") == false { + WelcomeView(sharedMainViewModel: sharedMainViewModel) + } else if coreContext.mCore.defaultAccount == nil { + AssistantView(accountLoginViewModel: AccountLoginViewModel()) + } else { + TabView { + ContactsView() + .tabItem { + Label("Contacts", image: "address-book") + } + + HistoryView() + .tabItem { + Label("Calls", image: "phone") + } + } + } + } +} + +#Preview { + ContentView(sharedMainViewModel: SharedMainViewModel()) +} diff --git a/Linphone/UI/Main/Fragments/PopupView.swift b/Linphone/UI/Main/Fragments/PopupView.swift new file mode 100644 index 000000000..531bb4146 --- /dev/null +++ b/Linphone/UI/Main/Fragments/PopupView.swift @@ -0,0 +1,81 @@ +/* + * 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 { + + var permissionManager = PermissionManager.shared + + @Binding var isShowPopup: Bool + + var body: some View { + GeometryReader { geometry in + VStack (alignment: .leading) { + Text("Conditions de service") + .default_text_style_800(styleSize: 16) + .frame(alignment: .leading) + .padding(.bottom, 2) + + Text("En continuant, vous acceptez ces conditions, \(Text("[notre politique de confidentialité](https://linphone.org/privacy-policy)").underline()) et \(Text("[nos conditions d’utilisation](https://linphone.org/general-terms)").underline()).") + .tint(Color.gray_main2_600) + .default_text_style(styleSize: 15) + .padding(.bottom, 20) + + Button(action: { + self.isShowPopup.toggle() + }) { + Text("Deny all") + .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.orange_main_500, lineWidth: 1) + ) + .padding(.bottom, 10) + Button(action: { + permissionManager.photoLibraryRequestPermission() + }) { + Text("Accept all") + .default_text_style_white_600(styleSize: 20) + .frame(height: 35) + .frame(maxWidth: .infinity) + } + .padding(.horizontal, 20) + .padding(.vertical, 10) + .background(Color.orange_main_500) + .cornerRadius(60) + } + .padding(.horizontal, 20) + .padding(.vertical, 20) + .background(.white) + .cornerRadius(20) + .padding(.horizontal) + .frame(maxHeight: .infinity) + .shadow(color: Color.orange_main_500, radius: 0, x: 0, y: 2) + } + } +} diff --git a/Linphone/UI/Main/History/HistoryView.swift b/Linphone/UI/Main/History/HistoryView.swift new file mode 100644 index 000000000..3e9cf4ba0 --- /dev/null +++ b/Linphone/UI/Main/History/HistoryView.swift @@ -0,0 +1,27 @@ +// +// HistoryView.swift +// Linphone +// +// Created by Benoît Martins on 03/10/2023. +// + +import SwiftUI + +struct HistoryView: View { + + @ObservedObject private var coreContext = CoreContext.shared + + var body: some View { + VStack { + Spacer() + Image("linphone") + .padding(.bottom, 20) + Text("History View") + Spacer() + } + } +} + +#Preview { + HistoryView() +} diff --git a/Linphone/ui/main/ContentView.swift b/Linphone/UI/Main/Viewmodel/SharedMainViewModel.swift similarity index 52% rename from Linphone/ui/main/ContentView.swift rename to Linphone/UI/Main/Viewmodel/SharedMainViewModel.swift index dd80aa860..31a93e888 100644 --- a/Linphone/ui/main/ContentView.swift +++ b/Linphone/UI/Main/Viewmodel/SharedMainViewModel.swift @@ -1,7 +1,7 @@ /* * Copyright (c) 2010-2023 Belledonne Communications SARL. * -* This file is part of linphone-iphone +* 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 @@ -17,20 +17,29 @@ * along with this program. If not, see . */ -import SwiftUI +import linphonesw -struct ContentView: View { +class SharedMainViewModel : ObservableObject { - @ObservedObject var sharedMainViewModel : SharedMainViewModel + @Published var generalTermsAccepted = false - var body: some View { - AssistantView(accountLoginViewModel: AccountLoginViewModel()) - } -} + init() { + let preferences = UserDefaults.standard -struct ContentView_Previews: PreviewProvider { + let generalTermsKey = "general_terms" + + if preferences.object(forKey: generalTermsKey) == nil { + preferences.set(generalTermsAccepted, forKey: generalTermsKey) + } else { + generalTermsAccepted = preferences.bool(forKey: generalTermsKey) + } + } - static var previews: some View { - AssistantView(accountLoginViewModel: AccountLoginViewModel()) - } + func changeGeneralTerms(){ + let preferences = UserDefaults.standard + + generalTermsAccepted = true + let generalTermsKey = "general_terms" + preferences.set(generalTermsAccepted, forKey: generalTermsKey) + } } diff --git a/Linphone/UI/Welcome/Fragments/WelcomePage1Fragment.swift b/Linphone/UI/Welcome/Fragments/WelcomePage1Fragment.swift new file mode 100644 index 000000000..69b58d13e --- /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.orange_main_500) + .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..fa309bcd9 --- /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("secure-image") + .renderingMode(.template) + .resizable() + .foregroundStyle(Color.orange_main_500) + .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..803af3968 --- /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.orange_main_500) + .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..f9befaeca --- /dev/null +++ b/Linphone/UI/Welcome/WelcomeView.swift @@ -0,0 +1,154 @@ +/* + * 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 var sharedMainViewModel : SharedMainViewModel + + var permissionManager = PermissionManager.shared + + @State private var index = 0 + @State private var isShowPopup = false + + var body: some View { + GeometryReader { geometry in + ZStack { + VStack { + ZStack { + Image("mountain") + .resizable() + .scaledToFill() + .frame(width: geometry.size.width, height: 100) + .clipped() + + VStack (alignment: .trailing) { + Text("Skip") + .underline() + .default_text_style_600(styleSize: 15) + .padding(.top, -35) + .padding(.trailing, 20) + .onTapGesture { + withAnimation { + self.index = 2 + self.isShowPopup.toggle() + } + } + 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) + + 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)) + .onAppear { + setupAppearance() + } + } + + Button(action: { + if index < 2 { + withAnimation { + index += 1 + } + } else if index == 2 { + withAnimation{ + self.isShowPopup.toggle() + } + } + }) { + Text(index == 2 ? "Start" : "Next") + .default_text_style_white_600(styleSize: 20) + .frame(height: 35) + .frame(maxWidth: .infinity) + } + .padding(.horizontal, 20) + .padding(.vertical, 10) + .background(Color.orange_main_500) + .cornerRadius(60) + .padding(.bottom) + .padding(.horizontal) + } + + if self.isShowPopup { + PopupView(isShowPopup: $isShowPopup) + .background(.black.opacity(0.65)) + .edgesIgnoringSafeArea(.all) + .onTapGesture { + self.isShowPopup.toggle() + } + } + } + } + .onReceive(permissionManager.$photoLibraryPermissionGranted, perform: { (granted) in + if granted { + withAnimation { + sharedMainViewModel.changeGeneralTerms() + } + } + }) + } + + func setupAppearance() { + UIPageControl.appearance().currentPageIndicatorTintColor = UIColor(Color.orange_main_500) + 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.gray_main2_200) + } +} + +#Preview { + WelcomeView(sharedMainViewModel: SharedMainViewModel()) +} diff --git a/Linphone/utils/ColorExtension.swift b/Linphone/Utils/ColorExtension.swift similarity index 100% rename from Linphone/utils/ColorExtension.swift rename to Linphone/Utils/ColorExtension.swift diff --git a/Linphone/Utils/PermissionManager.swift b/Linphone/Utils/PermissionManager.swift new file mode 100644 index 000000000..6a830e412 --- /dev/null +++ b/Linphone/Utils/PermissionManager.swift @@ -0,0 +1,47 @@ +/* + * 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 + +class PermissionManager : ObservableObject { + + static let shared = PermissionManager() + + @Published var photoLibraryPermissionGranted = false + @Published var cameraPermissionGranted = false + + private init() {} + + 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 + } + }) + } +} diff --git a/Linphone/utils/TextExtension.swift b/Linphone/Utils/TextExtension.swift similarity index 89% rename from Linphone/utils/TextExtension.swift rename to Linphone/Utils/TextExtension.swift index b48850251..74e85bb0c 100644 --- a/Linphone/utils/TextExtension.swift +++ b/Linphone/Utils/TextExtension.swift @@ -111,4 +111,19 @@ extension View { self.font(Font.custom("NotoSans-ExtraBold", size: styleSize)) .foregroundStyle(Color.orange_main_500) } + + 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.gray_main2_600) + } + + func welcome_text_style_gray(styleSize: CGFloat) -> some View { + self.font(Font.custom("NotoSans-Regular", size: styleSize)) + .foregroundStyle(Color.gray_main2_600) + } } diff --git a/Linphone/ui/.DS_Store b/Linphone/ui/.DS_Store deleted file mode 100644 index 9bbad94a3ae4f85c2f8481ca6cd960f65801c524..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 6148 zcmeHKO>fgc5S>i}wN)YIP^2DL>NSGUmP%DGZfFm^Vgv_3!6s3$aCfWNA%`fE&+U)t zm0!Z&DQ|YS6;Z;qL}4qWUbKB1}b-t zifYQpP<>u5^_GTpRDj1W#+X&O3i!kL*{^wNb7c9~IDx;obB1UoBcE1Rf$=lHDvPS_ z^?s?=M*GRr&1f^)iaw{`>@=;jdQ$bX@kg$`v$o9d<1Bw)jHZLm_8V*JtT3aoBNW3C zx_tUjn4z8a?ZgZ#$4z}AilcbY*_qAuULSS!{=wnAt7k`hy{5@;62NHXhLe PlZ}9rK?+geQ5E Date: Wed, 4 Oct 2023 10:51:53 +0200 Subject: [PATCH 015/486] Add Profile mode view and change popupview for reuse --- Linphone.xcodeproj/project.pbxproj | 16 ++ .../Linphone.imageset/Contents.json | 2 +- .../info.imageset/Contents.json | 21 ++ .../Assets.xcassets/info.imageset/info.svg | 1 + .../profile-mode.imageset/Contents.json | 21 ++ .../profile-mode.imageset/profile-mode.png | Bin 0 -> 156163 bytes .../radio-button-fill.imageset/Contents.json | 21 ++ .../radio-button-fill.svg | 10 + .../radio-button.imageset/Contents.json | 21 ++ .../radio-button.imageset/radio-button.svg | 10 + Linphone/Localizable.xcstrings | 27 ++ Linphone/SplashScreen.swift | 4 + Linphone/UI/Assistant/AssistantView.swift | 209 +--------------- .../Assistant/Fragments/LoginFragment.swift | 235 ++++++++++++++++++ .../Fragments/ProfileModeFragment.swift | 129 ++++++++++ Linphone/UI/Main/ContentView.swift | 2 +- Linphone/UI/Main/Fragments/PopupView.swift | 81 +++--- Linphone/UI/Welcome/WelcomeView.swift | 2 +- Linphone/Utils/TextExtension.swift | 10 + 19 files changed, 581 insertions(+), 241 deletions(-) create mode 100644 Linphone/Assets.xcassets/info.imageset/Contents.json create mode 100644 Linphone/Assets.xcassets/info.imageset/info.svg create mode 100644 Linphone/Assets.xcassets/profile-mode.imageset/Contents.json create mode 100644 Linphone/Assets.xcassets/profile-mode.imageset/profile-mode.png create mode 100644 Linphone/Assets.xcassets/radio-button-fill.imageset/Contents.json create mode 100644 Linphone/Assets.xcassets/radio-button-fill.imageset/radio-button-fill.svg create mode 100644 Linphone/Assets.xcassets/radio-button.imageset/Contents.json create mode 100644 Linphone/Assets.xcassets/radio-button.imageset/radio-button.svg create mode 100644 Linphone/UI/Assistant/Fragments/LoginFragment.swift create mode 100644 Linphone/UI/Assistant/Fragments/ProfileModeFragment.swift diff --git a/Linphone.xcodeproj/project.pbxproj b/Linphone.xcodeproj/project.pbxproj index f334eb911..fe845df79 100644 --- a/Linphone.xcodeproj/project.pbxproj +++ b/Linphone.xcodeproj/project.pbxproj @@ -34,6 +34,8 @@ 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 */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ @@ -68,6 +70,8 @@ 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 = ""; }; FBDE73581C1DC4F98CC3DF3A /* Pods-Linphone.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Linphone.debug.xcconfig"; path = "Target Support Files/Pods-Linphone/Pods-Linphone.debug.xcconfig"; sourceTree = ""; }; /* End PBXFileReference section */ @@ -187,6 +191,7 @@ D719ABCA2ABC761800B41C10 /* Assistant */ = { isa = PBXGroup; children = ( + D7DA67602ACCB2D700E95002 /* Fragments */, D719ABCD2ABC777600B41C10 /* Viewmodel */, D719ABCB2ABC769C00B41C10 /* AssistantView.swift */, ); @@ -265,6 +270,15 @@ path = Fonts; sourceTree = ""; }; + D7DA67602ACCB2D700E95002 /* Fragments */ = { + isa = PBXGroup; + children = ( + D7DA67612ACCB2FA00E95002 /* LoginFragment.swift */, + D7DA67632ACCB31700E95002 /* ProfileModeFragment.swift */, + ); + path = Fragments; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -399,10 +413,12 @@ D7A03FC02ACC2E390081A588 /* HistoryView.swift in Sources */, D74C9CF82ACACECE0021626A /* WelcomePage1Fragment.swift in Sources */, D717071E2AC5922E0037746F /* ColorExtension.swift in Sources */, + D7DA67642ACCB31700E95002 /* ProfileModeFragment.swift in Sources */, D74C9CFC2ACACF370021626A /* WelcomePage3Fragment.swift in Sources */, D719ABCC2ABC769C00B41C10 /* AssistantView.swift in Sources */, D74C9CFA2ACACF2D0021626A /* WelcomePage2Fragment.swift in Sources */, D74C9CFF2ACAEC5E0021626A /* PopupView.swift in Sources */, + D7DA67622ACCB2FA00E95002 /* LoginFragment.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/Linphone/Assets.xcassets/Linphone.imageset/Contents.json b/Linphone/Assets.xcassets/Linphone.imageset/Contents.json index ff043ddb2..e87351a00 100644 --- a/Linphone/Assets.xcassets/Linphone.imageset/Contents.json +++ b/Linphone/Assets.xcassets/Linphone.imageset/Contents.json @@ -1,7 +1,7 @@ { "images" : [ { - "filename" : "linphone.svg", + "filename" : "Linphone.svg", "idiom" : "universal", "scale" : "1x" }, 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..40cce74f7 --- /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/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 0000000000000000000000000000000000000000..740a909fbd254ace1477914020358525f2efd4ff GIT binary patch literal 156163 zcmce7^zz{(x8NtfWXoyrF1Uc-KEr0(%qd8@6Y#t zcz&3f*W5qdbI!TXb)9paiO^6}z{R4(0ssJ4Nm2Ga0HDAD0ECJGd0L6`lXZA{V7e&k zxdQ-)@_!d7Pa^5}(;~?Iy~1mtbcA~Q=>^qVMpXs?Dq^wkOwj-oQ@E0>jFuN@?;f*_ zX>2;sBfj4xcrDt!RCYH|h6+8m#BTWKLNwra*4-^RjyT6UT7W=ThJsuo^U@_ zR__KpYzAa5eA+F6(s_vmO3jjH)kNb%V_TKE>SYn*`|e_o!Y#+qeZ81uCcYJWAuSN( z*6|vVt6Fg{dU2<>5#9IWbAqJV%M-s5^5Bpj0>&DNex0Q=mf5=yC+Q)uNbB$59z`rX zM+5Ljfv#`sEf=#H&TCf>d9vWwnABFU0^+sH2&1tdqhX6X$0eq2o8{|hdDdKWfBfjK z8=U+cYyRpOIO3aXcLev;W}smB8Q^GG!-%qDQWR{{#%cMHEht2Eh!)7$p9&_QqOgt+E6N2NN-^eTlQ4XDI>vK!A`yZ zq8qz-A{p+TKOwFm#LXLhr~3_0(V!j+Kz!}thKs{J%Gy@jN}hQn125&&1<`$ zE$)bR)U=dQAb$(!p(U^X0c)UT%+PJ3&2R=)pltq{4!-4cX%p3SG33k4l$HswSrpuF zj3O*)-z2sWlsgkii+)M7%tp}`X7)H#cpQ20nw)AY*1RHdapA`)3eb#1HuelVblGek zSHL&qE6Oa3e;Me!Fb<%}#H~4eXZvkHzVK5te)o!w;fcKeRP^g7H(W&3XnxV65INzp z0r;6n`}pC2-AvE?qBgT;N(=O(b%?F+((NJ{!fAmLpH7lPlcV?U(*N*s0C9VHi0`@R zZC(A*#d-A(>U?V|x>2DvI|3`t$w0TlVY?80ygTiTp%H^*J)9NrOX~bsDm$8XTIvQ^ zSt&r-yLwQu*hUtn_J2~-_x-ERf^EK0I%u!(?z}zJig!jl7+CeBm?cl1FU&q%K}c~O zJ{{Tg@{ffR&qeOE|Mpb%YTFq`xGw!pZ}1+~=)uC?DO=CY{i)dP&77)N zk$1fCph1yTBZ+uICw%Vw5nQM5_u&TQ-1R|R$rNfAvz#m+Et98L+H?IbmPe}+;Fr=9 z%}sL8cs@;kp&?^C0B|}sc5so&MZC9 z6~q_E&7Bjyw{C4-OipM)|Gm3#5NmBAafP`3_>~|?vtj&yP_=JsHqNy7t>@iJRb*9i zOAib3n{wqlGMD-+ia9H=1B?bl(*7Pr1jWm25h18UlueLLNRPW|?!g=AC?GT`ox6nJ zdw^=aD=f$8Fbru(8>HMODvx3cjDa!~RMLg0 zqYYHv1G`{eBKBih>7+XSr;U9|R6xLN8{P&GD}smUcJmD_q7Hn9T=jB^21CuTTp~_a z>qIavx~2-SGwzucc2LR%f7DLjT-VX>ioFq5M0ZQLtbdIKuAEJhcO=P(z#On>d; zK5Em%673-S=D8RC+y8-oro-vIf*hU5r;CX>r6GHr+Ug8I0rro>oN_-0G0EUa8^|1( z$aL8j)&3dOq_>x2jSpHQFUEX>!LwNl3h%nZ6`m((IY}%7w$KJiv(nQAda6)En*xG3 zJ_O2>m)0yeQQ0fPO$-gcyMxO0_n0qw`P{S21AKd~LG-Swb!5OVh@xxBmH*u6cZU)t z28xhfsB+`9n`522N5FY)MN2`Ha|v`IB|ZPIuw7H)u`ieT_49L@BBx_lE+e?J^hK1b z^X>W*3M+tvjT@s(a*MN=n(eQL=cu`$kcI_#v`rh)u9_#G3-)I<*z6IP<=e%GCmVix*qW)6*?sT%!9UT=l8w-&p!W(QoH^uTvgWvUH-`hLG)b{&Xm5SG>?L% zmILJ;16-br?yvrN%9Ryl{titr!75~heY-H}A}y9IrJt@!Yb_ir3_Ml^Lk9MR7V9flpQ$hZr#+Qvo_);R7Ne@d!k+pM;Z zI=Xw)ZMPigCt z=d4x_GW<$|m8hsS@`&D9Ha5uo`j1L`M&?i^Nj8NWx>+8q2Y=V*zx2kmDSW*K4ZE1) z{|Gn(+JNass!;hC4^oHcQ=oEC69v!Nw4o(~>Ak;{;fHZK!faxC;!|kMeFa`Nan`8h ztpg|--h0f8=2C60Bl9vTC0i^X0=uu@zlr)+ZN6FuH}+mn1cS$W{+Nk@M>ouB*PfqA zD3Vk%GBL;%kDkCL1^6Z{zi{|kN1}3_#|kdzb>>2CQh`v?uqz=( z=sGWsLj&!_LeZSe7aR4&>$*P9S{sUO>^Gc?UHkoHLW}G1yn$->ZeS7AjOTbVT+6i4 zlKtAJuzd7OXZ!-CsWbYIOHx)zcCnD%Jjkqqb1qdtb%2JxN>jaqQcx74e*&Q|_H`TE z!7jVUmJspH5MwHlb)7xj9**J~ z247dZQ~7*)I+T_oTpOSdyoY+~jV3@sliC6J05_2F0A2d!0ohYx|L6bXac-5!oHNas z*rX)*-3Eo~2j=iW)JfX_XlV$jkl957dHdaZKRNcz@Bm>^Lp?h(i&4VD>fngY=JWLc zicrnRMVc7&N%BrMgYe(htoJ0@8b;{jYm5L#g|vdpI8a~-Hlw-tw_edOKQ;SuW(aY- zTX;KLxg*%wQ@3PZ^EIS0V?2?D7&5WI5HLFUyMDO0yLs+J#CW97S*O=(>IwW}XW0H} zGdweck$=`jYwfMKWWA>4? zVIk=F^3*xV{TJ@y#vlJ`1FaU>M7_vz(>EZEucO?b#v|JXP`u;;RTWUUs{>k9C0&-L znIuKv%G{tk`rwz;;DB^V@#$}@dDti0RQ)-KMgaQzk zOrx(LlNE{WRdGVmYB&p(@#^hSvmTYZncu$%tx(oVB^*_#I5?Pd^wEtm1hoM~ zj>b|6f-ez9-MEq+lKNx9OzlRXPzXUB+AlpXhFKq;rf;-{b0=6fOaWZ*=v335_d7S) zFp(M0cA=Zs$)_;UXIj=te@idf^}T}7H$BSkWA!Y5Hd8H(Qh{mIN&9P;YOMJG1Jkgo z<#?IFFFpP#;lhP`Gj7NpzK-Tk=r=&oJ+3D=WDOEJ6oT5x_YG5R1pPK~6FvKNI%6BN zbUJTPcn#_=)XwnMqOz9MHd+)dl;R)?)EV$@_&!Bn|1s!9$ayiH!lmi3<<>$JVVR_X zKFe!u)WW>R z2XW+-!C>sjhvB6=)sdwf74Z&|`vgC0m2vAICLyv!a8(}ol;?6w@T`{KyGiuKrH}y4 z!8x*{SW66B9$RT_YYpbf8I}I>=?5n9_@f5vREZ*BqfSNA$T%1m{q?|a_mJeA2~FPY zatSrMiCw#E2S;~LpVRk54|lo1u<*-^*$_#Gu;jiqdKeH7Erm_?0{z%w>xfuzZl&5K zr_}9g8gq&hizc`ND{5U`##$@C=0kg@0L(AfEo8AEPDD*tw6E6`63}WYnfv4BeQ&ND zB|Fi3%OlS(v&HR}|H09>jrI_#Zvy~jTW3U8tXFk$d{jf<7YiO%8uxlLig-<5oody; z#NYMA@GstDg8Lsrv1j95h7xgg1@d8N6vdX1z2zGr`S^ANh;VI?;z!Lv@iqo$-Qd>- zqfr(!m6poDw=OhUgV~I>S1X5kTYLMdyLn&iYik3Wi@*4X{)=kORP;ohUZmkd z3!OC5LX%r*jVY|ysrC|&!hKtYU{1{a3)L@FxWFZEZom*fsyxww3r~`~O`s!cTZkCd zDb6y+fj!W?(CDcE$QCbE+iH5O^$~(XfW#RxegXbh|79L|KKjXU~UG zD0fsG-|z8DvraAfo(@k~&$Nid1WUMXug=U&&mZ%+_k-abC3xQ)4!j%jg<^ehl1NY9 z0x5re!rXu0w@F;kitL6A7h$+_eV`EW%6RwKSfrI%eA@r|BwKvYaAWiNKIu*=DbC_Y^=#}~i4e-6U)#XmWdr1(krWes!5WSiaP(;-=g9) z3DZe$G^sD-%6Ganwz?gTq7pOI_pKRjq zE#k2U1&v|QZy%Bc2$vU*ue_?jZ7JvuU%5qI*7$Ke{HdI2-+7@Wy{fkFNuc|x63NNG zG{4R@`lI*(44DLe(HTNe+&^Oxk0Kf{vpOH8VMZQ^xiWt*PmQO@#$pU&ha& z6%A1WnB4|~lgJ_Lt{Y4Er>W7H$bcR}j)zkbTTDPI3igqNC40OQf=k{}9_?tbQ2WX` z%TWp*O@5CtXHpeSdORzgkKaI>%wv()rZT^v{30dW=iEPy6{&~Sh&)`UdUO@Svw3Ce zVWiCh{MA8L%@&rbf$;&-4OmD_~e?pH%O zkDxKswrWO(Ms7v&j^;{#qGci{_j&Hm#4y@H?E=mCNDg9}R@|GH4F1*rH+5@SmCQ|k z%(}$J?dwYYCZlH$YkFWFUWqSiD@rNdtMkj+7ZdfJAr%hBljFPEW;<06d;IwuujRqP zVKcN&-*4QaU`HI!ad4y2{duzbbp^|Gb!NkSH?o(e+AN=G&H2-QzB%vl{S++-t|!>8 zvCJGU_U~zSqc~poE~2ku6F0QvQMY_nThB{mHm+@P;+jS(C-YPoA*&|Z7(dkz0LtZB zxfRI(UA}V!mVog_Ph%H|w%u2+j~M!v`)iB`{xZbL2rS)~)I*}}!jY3UE2HP}S6kN- zQV3L%FR#Nu(X(I?eqjM=YN)ikGqTckiHtrz$W{msL$7rJ*(d6P<1syW?tnx@1LW5} zYbl`j@KL?RUK8MEj_8Gd$T##w}^9?%DcF}`E8eabCab2Q@f=FsG3D) zP8rZ?e{ZafPtwY?!UbAxXEdj{VF(Ps-sZzkMNbIdEDK%OF6PhzU3PFY>?<2eoki^$ zWX1vRhA3*!c(g3-iwA-$jN2tReGDyA?8KKd-^x*fzvDk=5g<~qwJOd;bGkjz7qUA) zAh5*lW`c1O8NX;myQn$Ff1+yuDraBXsNPJ=%t%_03Ux1C&MT9J-VF)!omyUB=(Z^Y21Cd@c1hQx)Q#j5ehd>CR7nf!6sI9UQ@(L&Tg@i2PO$WV!+RjK_b-yGi}-ZaN0N+7{*1&*fG3uWnu^BHu9yiB^( zv`Sn_FvrSi-L35)&aah5z>AWh&^5b~Q@Y3xpPD%30*c<|u(Z%P6}=$)l9b#CoNoMp zaXE?Ik#Q-0BK)alC%(S#GU~Ui_3oZ~9wkBM56w?AnWYyhsPJ{qujXr@ zLf7QXQb}!;SYh?(B8W~3jd65s*Trfz6RzTQv7g1I^o2&e$iPwZWlZ|5w z&e~`x48CjNLJ-7m;&SZhduD%$4OAOZpiW2z5@66(T-mI{El@R*U#>MwX@+enI&o-Ek3Tc`Ps}Cs$!UyIixvm^_Xg-W8so>ab$x8OwM|p8cUWR&uH`^_MvV z>tW`Fll^=7adMTk7vWT}xY|U&@}XSja@>3%8jH`8>Mvu0{TuVp-#xpsVwrDcm*+8F z#OB9Y^`2~2n$KcP7+xGS&Ipaq-)UdAraBb7R~onXuseyBBE9B88-|}f$L5PK4u($7 z%=5F&90aXn)cwKh`TWjr0y=Cl_2i%~@y`6>wQvfkhNvJr69$;>V~n-jJC zez6IEav$%fdM(lPJs_ET5=6-^65nRNkzgWlpkc-)s6!YbG)`njF1rrsaq%j`b$rzq z(J>WKu>K)>#HE?q7{Q%3mz`})E%+-hKnDOvy9S>k2@ksGLr*l7E43mA2yU81$irq5 zbBVTvTGv%UK_BeMgjU!tUEEJMJUWX0fET{X=w8){HhLe8F%YsXU_~6o0@rYeG+*Y2 zs`=uUH{`m;Z8bVI9m}h{`}p#tnd=r>lEu}E9TmXvO_B&D=tvMyBew5#4ri166@s_? z>)937WZ&Ayq^)&ctPRz8EF`W9zoyz9&W?H}AxRWE+}LSL%gHs1$zdI(i0@6}ju^vv zb#rE(Fz5JIRd&eW9UiIXb{-R3%0m16w6@2Xx+&gWB5d4Yp3k6E$5?Dd7htX!lJ_GA znTg`;?ui3A4f`0kqA|GIrVA(YEv{!vC6v3{eDnQ8Awyr*C}8faDcZyZ#as>d#*2ku zbnJ++(WzhgRN8Tch5G;Jo+p-EeDDUM zA{9!*e?3sjUuP=0_F3RLhd`-70Z0T&ZrjBV8o^pbN)WlPF2scbpq32Llf7eT%Pg@j z$8gyggkfQz!9Vk^V(QlWBJ5YsEAf`Uwb~OZ>{breS4af@qL@(zbkeDo$7a;st5(zD zpQgnFnOmq3aw`Oeo0*(7-tIq;=RtpKy0Y!O;NBR1=M31pua_J%1zzSnEr|{$J7YW>6_$Qe^ z(W{fnKF_jE-ge@2z{?wJN^Mh3)|Y&7!Yc?wE{X0_vG8#cZqH%;8@oQISBKm zmVkggX%%8FbJ3#^t~`NCn~>g)g6qsiLjQdTsvU~)%uF0;Cy7Y^yGF##i;7P;U!Q-! z#l8R|7{$pj3<+XB&~z*Jf`)of;xGa5M@ay-I*$bG2VO$(uNxozYgWE~MLL zC9?{=L{yC5-FRy*4cbKE-KYGaZX*7Kt$uWWiY=<#Nc>fqZ6z66ow^XCBGpA&L=emU0KLkR_^a$ZfY2*0$s+3Z;Y31OxVe+jG@b% z#}#YZE1*mceEMMr3b-)l-P}6&EhUDE{-ZsSWPmO3Vd5Pc3s!i%Y^QW-LlB1Y_#xi{1I=4Y`O!PBt0{u z!XNqAbr|S_p29ev{G=#@5ByC(xZ*EYw%(l#GJVcw_%0S%hs|{~nzadyf%Co6E+l4Ig5@op^u#J>c_?vzT5E=yM$h=#N zO@ma6SiBg-OFB8=G3(kM`maj&AnZ3r5l0C2yq%Z9NlfBy$1%XRU$z6e>=%ydKHJ1% z9tS#=O|we=1=^$Hrj6o#29f9_djtJ#vu0Wr^+B&&W53SDJECi9=%eU^lRZ>Ds6VPl zJ?SHa*?I-zU`J}=%ML&^A4AAfZ_{#Cnd5YJ!;0C)Iq006p1h8l#oVH`m3M|&N={2s zO>*tUoK`L8(ho|*CuEqMocE!9Qsg2qsws3R2?F0yuoS&>d@*M+m-O;DoRwVcvx@fm zj7ATECU!z42+>`UvtR2$vmB4S$nG8dFCi;URj7O*mnNAD0F^#Q#^QAD`|X_=)kEzWpAWA?4At=Cg4hT527!p@;F zPoV8VOHI59NsAbTiB)ie`&7ze@6J&eS4F~!*Aa#X;3V^}vdn?1UDjq4>Y%B(lq?DW z-6!f4Mte!OOyrjtqKopaOck$tlsJ)SKRU`gaR6+zxvd@|Jv$wN!WtMuJ>(FuN_jO~ZKE%0e z&|hr9eTBh?Qx&GR+(=E5p2{8L(uM|!-Ee8A1l#gc5JzhD=LRebim7e4S#<@slatv$ zLn8(4v+YyY`{#xT@i|_{YKP5nZ&5tpcSRW_hVx+(sz0bNm1yx^`{iQWPtS}TnOeU> z%ar#Wsw<#swC^ue)~4t|TjZew@`^>WUno#9dd zEsr0NkQ5c-4R*qL&m{V1eroDqCAFQyE>vWNUjfLb;6|T}aPTUR+1_A1?L#JTcHW@V zdP&duw@ASpLE?k^W6no{%MDoRqcNcAdjA5x{7EW=rbqRj4~PHcL5gMF?cN9S8->AF zVS1zu7r*&)69q=F{2X zHeEt0YAPJ`t4(eDYnBzWxii}*$17&7NQsKNx~95v?^Uz$UG5W6QuCsE&-Iy=X62%y z+ZVWMP1~CR7o^Q549k;kC9PI;r-5I9N0|9-d{!hXzS3F|oUbpJ1zP29U8ary~*tpLh0MLC#9e7Cp?NLr`l@sK zZk5(ECqrO9Ph;_=wU(>sY94%13y-WIZ#!&JgyS{b-t3<@S2S09`mG;LM7N$vm`oRY zf(o|CTyZb_T!-t_%6~$nhy7yCul%$Br5DCrKj;JxvBcO^?VC0`CjAM3dt-}!hlI2$hx7;xtWdzgnj(63mzihVn66ki?% z`^&gDyQK$656g$(z(|`xhm=P|gC;(H8EVubuc=06RPLtUTWtNYJep0nIX=b`73#k) zNJH<29m2+1uiF_Eq;4a){Wp@9uI`tl{>gePYwUIT4C+_jEDYYla@0MYe-5T6|6Uxl z$>Z67@S%C9;#cCX453dJ$2lN(ou$1t%a2L%G#fsSJbIUk-2kb(Gyi`JQZd9;->__Q$B zpZ(j>)~%>B2Yp?J^jI@?UL9H#=QiZ!nML;)CPgvMG1ry-+v)MQZrZRGPQRCsdWi2i z`%^jwZxN_wHlcjol8`AIdA~T#@a(etV6J38=T^$+ZezFA|Innxhj6!;^SmM(ID+@y zcV3<-DIy%7eFs^*-aI`S5Sb6h-tX?`AtF6Fy$c+hh4HPS>l zPPjFvru>-cI&`?P@%EDM%G?DbV*14!4rcJ{78dbQm0W6j4U$TnORPXXdm-5(j&kep z85xYH|-H6Cm#bpNkk=xZGG_-SC z;__Hsn>A-3@TUq;_%>F{h~lq`+vA;_3yZBtJEmOPMVu0mjtCPALDc!RIrMej&Q|W0hg;kX7J2T%WnxGI+_gmj;ln zjJDK6dh+Su<1j%b59j3f7!3cqsHffDbOhW49L~G+U$zK2^THkY;6s^)Jl=Dp*avGj zwI9@|>;3O%nrr6+7SfzdL*E;2U(sGk^0ZjTcjE$^G9ix+eFP)5F#JOZ?*wO_~3eg5}cj;6J)@{_bC3)`rz9 zNIJ`4YLcQ1LU2f3odH^GG*QA*mj~`^x5*P!a)zq(FBX2!hMY)!kIZjl$Mg58P=Dc( zf4=ZCN*H|xf|)-ji&&p&9hD{~`@Cx=+yu-9nx6=sH{LgUE$`Bk26T#@sWxu|-f zfpJFJ?9iOu?S+B(mk}nM10^q>x=sZSD=og9)GH+{qTCK_NiYmWBRgZZ*4e~rwg*TS zUihGisZ+A>nl4DiC9Bw+H<6jSv=^7Tdv#Fs<`8z1Kw4k}UVb{MtjxPi|<_f1r5B*91Xj|AJnNE9)d>wkE+lgZiLA|neEd0M1+BYEsfo- zsfI*Dje{T|)d`Gx>^@nAN&*4FcOf*rvH@%CXzd(nbhqB+5i=^>QfQs-pk&b-jL+(U z!_}2Ppw1zbLk85$LqPr$?78V7hBcejz292L-HwMXDB@GIW0I}M^2V^f23HikP`7}g zNToW9;{*a)C$Z_*sF_b=1pD%DU$0jVB^r`csYvo9rS5YMg1G!hMQD#6KkR2l_qO=2 zis`f*e~G=HiETM}7(R8UgQwdx>#Z0Ou=NiyK;JmocI^-6nxpXe`n+fn21|iraoF1R zt(ri9sm&S+^Jx#NDz`(vs`cJaT+?FG;=@B zNcmq6F5RULaxbl~Do7kbW-dSnpG+*pBK1x#XY7jDb68cX?sdpTSA?H>;3^rBhB&RQ z0g$NiKLl7*-}|(T$XS|arYN?XI3}@h@l#O$HxYfD&yjU_Dn2&kw^_>THucXssST_V z$O?extLBm{w(!O4KDTq?E=PD*s{%KfD>$^6djrPX3E+Ry%cX$mGX?A1|V4SIM6{C~y7dQ{{3Oe<}9N z*P|i`zu@=FS4ldB-$D@0y-a_6%9r z2pW}GcJDt@4zkhOs3u(gN!Pc@RRuSB9Kfx9TsDPt)1;<2VT671&FjS3Th6)v`8Y0h zJ+vulb2(X+B1!v<3zyw|y90O4UdL$TB3{=BS5k74d`I(-5Q|%4VBz`D!s#~MSfS4y zBaNz&F~fiLc6`YkVWS0>YeW(Ak79n^Jbj3a!*}$qutEdsT#SOR zh)S%`YYhn#4aA-k(8`tRz?*Byu4z>bLOpORO3ztVBr?F^(MBdLINx>SQgOH5Olwy)Y#ri??c|grH&<72J14kqCjI{8bYi#UGiYWJFDec?u1p;j! za5m~buCkp*scr?;bC|jZ#B@P#qE^G=sXLm~=Xp;%{z<|bQTRf-HBq4cCvOfLeJ^Jl zjfEw6&l6moI$uNe-U9g2(E=Vs8XRAj8K2)89MOdg`Nze3wegeU0HBApX%3<-{$vp% zH==~uTyIHh^FgZt>rLWSjJ7$Wg2f*UA67E`s+*GJ{URcV*6-e}3bQJ85?Z-(1H5?k$PR`jS>rCm`SiKn2_lcgGX>P4-=s{kj=nY?%mexvD z9gh9G^xxYxX&qgQ9U78bo8Lsa5!9*rE)uLgzs^?@I0Ajv6Smu7oqnt0PR9=Bk6?!i zUiV66StV|2l{P|Bp7i-OIC>I3J1%;e<)#kp>>B;M`4zI z@)r@&Is~2brSP8t!S*_{RqvAtPkmd4$VHXyj(@JhUoank_4HqfkKiBiGjg|@L}pIm ztgo87o*2G6&vv5An_37r%Y+Ty3&zlj7`+kdj>n0|mw|FMiK# zIKF#gC%oyUulUB(2t{P;by{oxzt`5;`Y0;c1#Lp~BE?z1v95gRx6$8haFumkP6Ma! zpgbk*cGn73g(Pug>Sa7hZsL1pb4i&cCv!=Z-`P*qP*|YN4vHjR!X!d-$Ms^icBiDESc(|zqdu;)^PI5lzKo@x zA-5MNFB`dQSzXe^r8$@n=ixRyfI~cQE>PssjBX`MT)O^vB9BtiR>IeR{bQ5~k9c}| z|0A0CWdqH%e|NTW5qnW}OV2dYQTgYDeu3(sGW#D2k}TWHhC`pnrH8)?uC0dh4T*&u z9|hUF`o`9)&9-N(<8fPgdc((JKeE0D?D*RKYd}WVq6iFOs zPg`UvJok#=Fh?QlbKm&>1=c-BB z-$jW1F=cjX>)j@Mnn8NfpbYRD_}=_fGiJWd?zUH36Zq@E)7aljrpjQ&Tm>`!=+BmN zj|!EmV<~eEiW`df)F;bL)Pnyrr~kmYZk@0dla+DUhRjNv`U1%Ib;7)Nm{|M6I6R`J z83M9yP1B@PQj90*qObnDmR#Ds%gi__Ay6sHn^qL?4Y=4j8Cd*VQ*+t0d->ShWvjyu zc@pcuD*Sg&l0sFKIkp@2sk{7yC2k3xiRL?$!S979Y0Z5kJH4J?Y)wnhd~tv3{%phE zopa)9=qotj5WI4VtU}F+@SN{%jqW2%NvCK73|Cw<-)JE*FiaFXZ7<$7<*Bc9{h2Ru zg<2crGehtQws$?u1Gt6o8c&1|y)@nZk)zLNzht7O;rY!??M%#;Xr~UNSJ%wuVeU*! z$tN>=FF3@^+y)DUB4pnyWwp~HFv`d-S?%T26p|aOA+OEcLg&OKUTc1+M&{;(D7^}m zO3gi=$z|yT1jKsr?|L#0n|GvgM$3oX%P@BqHhAg=$IBQt*HF?6zkuoe&B= zV9jg3`woU%J>xx(ThC+)*0!|3`K~{oSKk$#Qm8VROWSLh6d{f=CbTa%s{aXQYsH|2 zin_{9bN46UddUiL=&M^LT7jKixMa!GhhtiXuX{`%k*1E}1xK z@!3MeK61ZuyZaW{OK`hFOUC_`;bUQ%r>QjilB2OOOx&yzo96lC9Y= z!9e^q2xRpMxRHCif*CJP(#^SOhib}--cOu`6TE91`u>0bu*&v`ja}eGB}b`#;r2m; zF`sV~^djw~esWtgt<|SCD>so6_UfOLH9Z!YFEFUokGAB%v*jSb#BL<;DsGI#_V82L z?IPV{cffH$9UHD_xIS5i;WNf@^7S}N(-3g6u&c+t5mV9xc6Dc=B@6q_FQ%E#6d|^w z*^<5MpO)IADX8OqDi{7YKW(ylNl4>9dwfH{ZW15MLea)3PDG%jaPp{`Y#{!A9=|74<%`xJ43LjJsS( zF*y0k^KoilYA#)B6%#E9S%Cd%Bvg^C3tTN~@MEDwaE|(w#kkH*LOPMoVZaVmS`0VZ;TUr!W?fqP+cwhNRoH`Qmz2yCOd~ot7U` z9T(HB^r?e`)Tfc<;&Fp)$-6&bDmSa6*Q(N>N&4PhkvheLIiK7G?w?9KvXnF93 z@kMpITF5Ct&vWdX^OWhdpc#YD?k*0O&&tmOe)wKV$#@5X-)6Furn>r4Mql!boSDj7 z6?5I&Z@nluye)2CPQ2M$BPv?!t|C{x&#-(|iuKI{HFtDX@!s3wcJ|a3(_D*{Q`9$i zbtXPfZJU0_Lonp&C&t)>Zr*s#)fr_Yu3f)*rul^Ykf>20?Ij4a{#BjO^Eas3fqun? z^x^0bND9+Y_Pxv!%5)vmY2bFS@L=+Xv)9`Id?O$2P2n?7RH)>mu9NTa8G?nG!S_}+ znJdNUM`Qun_CyNP+^bH%jxLh(Wr^KqM9NF1hbh|b;7DLJ{(F`h4^0?>Qqba?A$ zJNNQu`yuXUFX$@-;|@zSs}<|hSmrAw=Z{8=XYT`a!hPMpwOG>A@>Zwy+NK`tIA=FT zs(vt;dE475AaT$m?SB4%^xr96x^5W_;=CAQf~vBoM2w*wh+)CN530)fV<)IXJ`w(X z0{r8T%ih5vK7WlKl4_eQVZjd=<112_$06zCMaJiBAUKRy22Md?noTrX&3E{vOyJ8$ z!I14@EUu!cJUzLS?%w(gQ~}-+6qTIJ<954<-3y`+^l$G#iEiRWR8GKTyf;iTA5y&D z`>K~1+?>Lf~2IAN|+e$RkC@_>8DkHaW*$` ztx}DgQsbh>ASWGMUs%#jYR$awf?EAHZC7WA4)3x>@wGxB(kzTMMi&|&x}xs-KrNtA zV)#~h?r>yDA{poE!&41^jp0%TyETq$v$3-YWKDZ-`*PE^hrS9EQM+`zEP=llaG3S5 z(0h<@n6Sq9Hv4z$yK5f3{Q3ukwlu&pQwM<;I{h0 zZ+hO<)lVTYpIwU*KM?;O z$ixt`&}-bFGC<^cP0h(nAj>Vca;=nmSqYrS`^Ptc4W7Sc88ZG1`8bul)oId_h&OB^ z&2HTso+NA{8(Z*fKM|jE;o5V0@lwHg0bL`aPS+y+G%#4J+PF=MZ+KWc#u}2ck#q*O z7fNHeM}yExvb4>poQbuOiHB3b2j`P>g@o(z|M3uP3mS0F=M;|KX#uHAQ?#m}RYea+ zK^@!;G1p`oW7=MF#sCL0dp*A7@E^RoUu+Fi5qSs9lW(62M^0J+`}!fGur$(Lr#o&s z-`+K?Q_6f6GDaCo+mtEN*weoK{|Bu=Qok6jFt|i=4qha%hF}T-4)Ib++@iP%(g+U8 zE_f~kj1_5g2+$=jVodJmq-~irF5!ldgd5QhN~1{9dx?ev?qx_C1(Pv~XrvP7E% z{L&JV^UBRIupiR$LMsEc8i?;q8|QgydUB45@wZv@N#>tO#dwT#Gh&%b9*+&l=T{kDfX{|X0+8zW^J`S2J;N2b;9VB z&CQ6m-sE7QZHaf+04B^!*1<&%h6x=%gTzum-9*z(-x5Fr;W!2^F~CKfCE_hn4F+8t z>RqExf}52=EVK{H>N^&T4PXgLB7n<__k8JX&6%z5HH9tXQ@oE+e zz)|~&{y_VR1O^e36$dH22}mYMFsD980OlY8Or^@W56fwC+g6&KUl2BOu(C!=4;)}( zg#jh{i9y(WfEZR%&|J*;{`J0}}1hBM9Nv284gAQ?TGY~DA zLkuQN+Z4b5%*;&TMcMJ3U}}I9{SUw+1~L|t34#7Aue?&S*zsJV@{f7U3}9A0yhOt? zlNK8CtOuCnu3;Q&`4HtE%WW*H3iDN0hnJVIq#hqjV+lPL3o~v+>fGa4W-*qf3=NKI_a6^xOT*R!Cr-=_X}IH97zwbnksf4qg3+*I&QaWQ{1D%K&Cs zSJvc=w1bFMfC=-G5~ho3EI3@{UXh@}XpTIS?)g3G0k6audltV2nzhyT$jy5NeBV~; z2!ah#L3LSX@2@5HSm{Re6JRdEEZGztKZ6S-;S|?je|>h;iE%`^t|#xsu4cWqmv=S{37O=MJH zQ;!y;;n30~*|@|o^x@JLPn1OSr;sajKOi}ZR32FR4%Jqk-Lj1u+qY5Pnvi7!CRcg~ zkI?aZ?xFUelU&~tUln9O94phz0Od>sWTI0N@JfjpmBdn^&e{sy^0hBB z-~rfyBqWb(B3uyFgCIF~~Xc@q?dgWMe-ZOwqeqKu@6PHOY$Uw1@ zBvV*8h!zbn@7TAcPx*E;n5A1B=5++Uc?Q$OajSZlUPv8DJ(zlZOP3)YoqIft$r6`! zu2M|!(nDrwIR5I_fB9Wm9aez<8pvPp%B4xSfNrpqXZa3*ajfNR@;e05*U) zBm?nh3EAXhNY=TINU;K^ZfHCWuCXo2K!r34vMrcaD?u5D@!Tfn4i^coOEIK^!$JUO zR)W?vLdNHyz;RHzWrB!jmcWz@h-L|Z(GVn)HbCk8nVb{Xh2tZ5yxSYR=Y5~}#CykO zn&TGXcO6&D+{|A7QzZUGKWMRM3_#P^OkeTGMA+?~j7wS@V|C(BZM8iLtAtzO9Ska} zZk{VLvFca6;uX2M#4VWz?>2_7X`vke~r$iDd>XEnEt*mH-zFGR6H3 zEkyv?I4o00#)i3N@uFY3`JMUH!aM0P+GQKo=#4v$)8+F^G&MUzVH5XX2fjt2vtA~7 zqS+lNuoTu3&Aq6Iq-t!W?K2F0TDQ%8TeqJ+i%UJwJTsT`yLZs!hRxDf;GhLJyPYFP z=hk=$5nLb?3dBv<_qbu+F<4QZ+z~jcmIZlFObhN zM@%N36Rfk}835A=4lDf$fQfJk^hHxsQv*A;^+8DPy87y?ku(OpQh*3EmpQgG2_qlM zVJtk@N}pM-pmRVD+kHC;i>Cs>JSiT{1gg!Y=;wXs4Lg-Eu7OJcOZ%a!6?#0ttY(R8 z2B4HD^Q>H$XXzef8Xqbsn_V?M)Mt+} z+TMHjkwdTji!XllUcA(nWv(LlGH*#grVyZ~j9wVm#biosjMq%XRGCeA+0lVu-r_Qi z&2-Ot^hrNejn^swty#?4YCCOsm&{XCW1!tGnRV8d*n@?5&yh*42^VG6Hvm%; zQfDBMA%RDMrR;QG8QSGy#dw>9SjUo&VMZ z$e{uoVjl}$j=0KBcRj`67iqW;P2(Lq&!lb7eGc{7yj?Hl8{`cL<@M>ny?g24fd|Dy zCqGP~ZGWcfA?BrG7F38ALZJ?{^DPR^v>tlOS)(E6YB56!Svwp*iJl}zRA!65D6#;+ zuAEq;wbeCR=YILn%r4r0#+7u>rYq<%8MB!uzWm`I*#GB0eXq~($J9jd0X89JWZC8R zEExF2GLtTXHx&vc@Svgkk9kbd7xB8n)NCM$#r;a*^|}$Zh1&NjVKm~|HLGg9Dt>zQ zFqodXtj1lI7Zyeo>PPf0Ema?8jew82gek}jUV_M6;*@|y@WSbKTF+c!3Xtg~iQf0) zuYX6cAFg5MaUr>L`L99Z>P1#J0-Q5dgzSN#eLgp#e4VVuek zC3&8-2=YYYo+c74NhIe;*kw%T0+`J+E#-(mxZ0IHOz!8DIWC-HNWivV*gY)wH4GiQ2A1?Dr9x+#b(rqBv8+;cNZd4}u{SgXm;!#&br*W@ z!oY19W}731H7UTyR8fdXSlX7#CT`B+LjUOK-*Z{B@`r>iM>w1-l`4A>?YB%vl>n4@fFMiM+b4?IW@eD!8( zcUDDXx!>zjzul1vaP4&zw^$dT))Rf@eoEpI;zK=NSqhOt6Ff21q)7&WYwd0#G{Ugo zhBvCy?MvT>Mj5hqpw&{6kT3+&7r_gaiit4kB1uD(@(rZ#O1jI9tjVtAX_}dx=3_P4 z`)3}!Tnc(Ln@M3LAm-CJ>{!V4gJ3z>$>?jFUQFMbzl0vW#l`;0v1`BZGtYg$mFTpx zop=@yR|`2K*;30)n!DT*fbgswKYqM)u_GQ62|w%W>w-ZrkL4!dZ!EgG%)J=9*|=f> z_-qMm7PCp;rbN;PeH+Y1Z#pj>uBy??`ZDO4l-f~qNL^SWb%l@Ia4#_dK)|de`Gg*f z#<R#IAzS5MH{K-M+$C~+!sT#0|oWj}Eb7b#h$%MC$; zQUcAONH1{_(r8m^ipPNhz!bOHr(zf{Deh}zo5wPyatL^X3ZJL%mr+(Sud1FoWJ1+_ zE|L}r-li=?pndpDSsiq2EnI#~;5E5k5*SX;MbIuH283=c>v|l4J zoD0py#I?WhhF9&rdEee&`-8u|VbMt!m1+Rcs~b}RNKUL;nKD4ZGlXXe=Jd*BKV!}9 z<#&lcViv|>NkGP`c=;mII2s?gT}v3%R@-Tr^aoDT4uI0Cp@y1WXT}tuG%=k`VLmfvdc?~Ic{@njHK&Al*vQLXx%e4+;p*4u%kwX!Z_b=g zkKDpSV|Md{$h!W`?4LwXVR3^BP!YN=LfJb%~ z5HOE0pu*Vsc*|j|CY~KIoB*#KO%@sm&@E&>Rr^Vy8HMkiDlGnng5_pV-Ah zV>uJMngH<7X`cR#%ACS7{VqXcJY(Wb?Q14xR<}!KDHG6?W#wH@>}w3eBq9NvQ6Z;- z(zKaM9c5G|JOvr~29Z%>rg-n(W5=%k(C7YsFGiEt)>i<+Z*B=dW|jzKpUqtkv&0|F z8;j}LOkY;An(Sj%t?EO@1&@Y>)ZntV+Q!&8FPL9%nYCE&%j;pC^%F1?RX4F~%i@KM z45nb0;K~F50E21Cf|#o2t|awDSDD9Gm-so&6ZDq(uhB&t z*gHlO0=~Lkc4Md%1nzlY?7p*;bgiahj$bA7XUt5LJBn=ph zf*_fk$WvS2ON?JLPoA+tgD?e7D(M3DTb4@;6_mhgNZ(`qX|PIZC6B9KuH8e z(Adj@kB@FZWb!D5c%ipxgFN8lHyYZ{A&#HXrjRJT*!1O`b6&LtP025OijbdKepKgnGf;D1pzgKLb9Se5Dy=y+>gG9c%ejcCn?4zNgwE0!fhU-W@8sBCZ6IIC7wlO zce1pO(#SEFms2A%)r~AKah}q*V@%uNs`hLLk}kDVu&THv(_}gBw2o8LyY=V>GeclmqNS z0WzHkdwE*ZE7NK}L8oQHk3O?y(vORU8435W*vv}&rH|9BL1t~Wjkhv(A04o|w#)a$ zvS}@5O{cQX*A|DNudp(Z*IjpAc8=;Hdqd}M9xWtoK#B5TbMW<6cQYnJW-kS8H1EEUdqLhhBGA@BjFO0wsMhL z?$lOkm>0#wD59W7KWd^*0*Tm0yklsWGXk}L45gUWd z_5HW~vrqlZrJn_UfGEC?%{0wSe70gBNCH9#g_W2Di>ZZL3iKaV2*i?(ExF9d4FFC& zx9B@86wq=2?727bbjT;n(E{i(q;}@rqGQK4NN8Ze+ zi911qq!GUvc)R5O?jTlEGE~w4r<>Hv%S`R$4j0D+zuW^J>5(v!c!MQ#T_k@}E%N00 z7=pmW_=RCCV@%gP2&zLFk_%dx)euTnc92C2XGz~XCU!8YG*D6gZsy1)Ic`4~!y1*< z3?%@K`a4IsWhl8#4CtCnQlO!9((ujD^=^6EMjhzoAZ55sB1(fwIHhIl-Tu%2(PuyV zdscnH3$vS?k(K*0%}ifn(#sdQGNS@C?NaTfH}fD9pR36~H0C|1EUQ6gZM9X*Si^C0 zcKAaKCYI@-FQ77t-ydMpDyhH#Fd?Rnm`l9p##|y>+)ELmt~9d5k??uJ1s4R9I%3d} zsXijYXfl%?LWVd%=taNurMI^hw*M+U3cF(ZF#Y3=w^O5ioO%o@mye&Il@rI3J<)2=)MQf%bRc9%fL6boyk|j$%(I~7g%d$y*dtX@i$Z#UL`ehlltdYeVSg>e zMkAABmvID?rEPvenb0=Or#Hl9t81-hh(|yl<106c+Dta{5 zTR!{?U-+5ly&r9~0ZN$Zo5EcVolHDGV7CDl@vPYM1dzFV_wIP(jW-fxugqQU3xEJ< z?%cVvFadV!b1YN~(@%ZsQ^s_faspL!GKZZbJ$Ze~w}Zh^UMiE5 zh)MLegMCv`SegV##W6{_#hCQNM5Z=V*&n#Z<(iXlE{+~zN|qUD)_JDSS}YuchJAv@ zMi%7aV^d?4KxhJta$E&>33x^oM5pozD>QbqPyn3@MI=Bu2sTW94wowb-HSW9uB1z=q)A-%V5Sx$qO4|+$5DIUY#-(-U zW&oL(qQmzgRG1?E5;~Uv0sxuNw}gwG*{PHIQ*OJ3y}at*?ftn&BSEw|TcsaseV%sr z?xn?phiQ?4%R1cPgRZcDmJ}+v0DQ$04N09zpo%+>&p~}EoI2*#j#HEh@ zo&R}o$BXIy^Zo&Krnk@|H7{j^!337URgNhz4`YCV-=6!HL8ZL3^gUL7+QisR4hveD z2-n~Nj4U^RY4TbY9%wlL<}K6H1yfaU??lP8(i3b?Ho%l~R$YPR#e|{0rI{O%21}j* zu=E<2FkdL%XN@CU+yhH;0F$I!*+?!| z0dJt^QH3X$;AXg_Q>BCSqLXGhC(TMQi~xQJmIh;KLgu&w@~{^gpQ&si&b`({Ol4mh zhe_UUf&uANNbdEVhA(A$ZIWxDGbprtV@ihUD|>1HO!5MYICR5iL)jdhKV-v5NWAEM z5nInvV>bM?NWCQHQCG%NlbEEP%7)28xHhnjvMh}<<$h_-y&WSe=*&FeEPdsp&yooA z5|KrO8cQ17w#I~JWo$r@K=A&c|Gp32aKq1oiSSG&Ok=B}ul(a#OilhdiOD=wW;v|N zUYbUxCAryvs`_5I^;BDJwI%a0m=>pRReE4Q@*_VYFOadAKD-J{Ci)8VaN!+hwlT8` zJph~N*wMj5NF59lrdC%Rpwg}btdwPngSqr13hG$Hb^7A}@Zj%e6H{-ZM`-7*-A3Pc z^uJU8#6jw`JCdN<GKkD%QL5W{?~iKnt1rMO7+Pq_fnLYU2gl^s@(SkQJ_WbY_>=|H^n1W^WB>-I}V zGnRdkMsUSc*^aWHKsdG6q3lNzyGkQuN04lHDM&(8#^n!^XWexfNns^qnZ|%7**y8F z)T0Mo>g9qxa!2l$-a*qLPBVzEez9BpH{T?hYNVJN90u{1nK7OUt|S(-7nw~Jc*&o&rXwlK7?n+QK&-GSUUha) zWI8Ig^*CmqhKW0}Z+eAAfL$7;^lPU^F8#>Dh!*;LOGBanQy+}fu#}_KAy7@8|EO;6 ztYkWkfgCuRy_8G*!F-Q&d7?Rr_0G99d2S0Soo!e@ZTuJ#jPB&D71N1~Ek zFwyiOSQGUU%@s&(d{iKqDx)RlktE3nuIW^OBenMf%S?@lxF{S_G^WtVgCOyknYiN% zX|CAAr^4pTp3q@-9DMVGilOl+AbCB7=q}i9>YR` zYp%x?S-(8p3#h;(93JSNvtLbjo%=)7YfjT6v7)p3XJ7w?3x5i~yWA56mzL;-cw7uZ zg44!CTqZw{!f;{$!_VG;KWFGeKx|mua*Gefb|5tHhyKK3F=1A>94>OJOYReYym`0z z6WO0UDYS^|9Lt!QJ)`x;!#KfETgEpos|Z?`$Fn30EA@qG)!pF%rnxx`SmGaK0lwwR z%NAaK-p=cLMZ9dFZs}piu8d-z1bih{GElqQ6AjFk%J4w2HYe2sOJ6)?*ikIqy-{BT z^=K=tF9p0LE@X`xj5c0Gep@pn`!L8|Xa$<)7Nr@|=u*VkdEd?;NfOD+71b3`g1i(+ zZD~~kLiMhcYCsl48r6lNjYnT4hAh6)e!5a4GiV9RJOS0jhVuDBQ8K0K2~Qb_A?P)x zxX*ME%3P|@x@8*L>C#~mAeSt5l_1KqA+wocFLhWemab;UbsgU|Ws5 zlnOnTIllXiKfB?q@BjI~e59G;bDg8~#&7*LHC7J_c!KAp1^0JO8iu8@iTxxnE;k{h z_<4&=Pcf8eFMT^`H!MmeIcdi;NDFB)X*qybg$^i?NStL-;*dlhB-=#NlEhlLBL*!& zocgcS9~44V^enMQuWV2lBbmw~3yYZQ;z;8*P(NJMc05;IW4rQUqNjc*O3dd#-Dncd zRXV|y0MTTkkSCY8?|Vh&PKh6UM|>~1{c)c5^j7-&=U+o>laI`09{atoKk(PrzVY6_ z{LMvkm&5qklW&Twa;r&?n&tqSAUEJOL%-v_XLu$}8`D7M%F0Txd1RQQ@)Y_Ts3D+0 z0JimVd0_GlM;YK$ZOl@_=!x`>JsAvU7?;@NlX~#kSLE^Rj>TYlX$hay18`~I!(?R8 zaKMeJi9Lw542Y$})B{Y*82{g2bjgO7Uvkd%-9VS&$!qglj4B4{LRGp4FLpn@AdQqU zB0{c^5d!5dB^vP{_XnoKIDAQz`X7$YLg@;}iw4jIS2*gH!?}Qp2+X0;ECb8=iHupx zTpE}`uTW5iq@X^vdqpV|k;(N)MXe-)t}gu)FPRz#QPm7#SeDW<#_hM#iaSypJ!EAK z^1h}6n!8%Rg8NN1ZiXzIoDvwNFw8)tA!vjZDKa&IY(0OvtmrQ(39^SFyIE$5Q&2a$ zccl!o8ddDcKZoh+f(a%K2MWCX&A0Bo@3pt=-w%*UzVZTIc6E`LAr|~B6_zxn2|T8? zD(A9zOsiFnk)?mWodUv0wI4s|@{DE`Y}W4b+Mcb3{sf6RmdM~^nvJo<-&^1M)=Vq% zNI6LBFA#TWVJ%l*eRYP~;_yUR|A78t=gyr{V;tLCTt1R?&>!=0asUIn7Jy5{UV7K~ zqXn0jocJ8QZ2y0uiNTsk;jObvITguJf+E_WJAD>rF=poAN%4wBVlfjzFbsoA5l5*_ z4jDXSxw^)aWS)dcVH1sIByG3N1~2}E+c%UubI~q;sj4GC*m`q*%gst&cLiEdSF%~oHW_SGMUrM7_&tC z%L~fKT<#VE?z-z;NfKJf3)(Qzpc%y5w2~TrE6?Rc>bCK6v)gv6K@To=q-<89t_!Kn zU8I0)G>Z}do$|^{R2U5OQF4!rWM4;fl{@!24HYj-*9OJX-7bJsT_v(i*<8uDFOqhp zA6N{?&N+0yi~Bqdfs}#C)Bq_^RToYgdL>YuYvVIyTN7hEh(lMoHC!It>T{`*Qe}MR z%^&*w=U#$!KJmw8mzQNFF@<4-`Ec==`s_w4C21^XH59~N{Bio2zI3Xtd%_xo+G=|$ zn=+WCJ6xF=d0(n}moRj=#_~Wrko%L|*1^MIB!s=wh`ZdkZ(mt(1MH>MltyBY)fBg5 zfVsrJRzeUA=F2{C;#1s+JR)#;<)M$zcOU#qRSV&EzFP>0L*l3?lc;M*MA2~46kcTFLDsIK%1?@AEVPMC@^`}oNW{;hJw)P5S2MS}T8R#^opF#v)+KLM zy?k1BZP>o5dPfS8irTNF`F=DSQI`et=%q$Fxy#L~r*e1ZE|>4DB_?CkYCy>|7j(PK zd96~jJ_M_F&JYY}>b=3?1}RTM7|q<2UV_B=@4w(5(7p@)-}FcfAoKdSw7z$Ke%?re zGRquU<%)|+1{2E>hiRGR0GLSpL3kEa3qWdM8%s0=g9+VMJD)3YDj-w)k&@}hpbYVu zesK0=#A7}w08>sBMiUpzEI=DpjjOT59v?#kfa$=c+*r6fOo|AGP9LXH=8{b35({la z3UCS6_&tCA?*Dk}-+k#Vhn9Nsg7$hzPcmh=8NrucTD+M6kj*gkStd6I15*B)sGfl3 zog#_^Q7hHXN+YQvNj~KATNV<8eWHW&L9cQ^%d1_Q~m@l&E{i9D%`{ z$`fOUN(sV`~pF?AHIB^?JkzA(^e2|5Ek|DdeeLt_CZDX1T49IH4|f*?()V3g^7 zmgg)~*m4>nYDd;(S~Un84T`}B|9@Zn!rQUj;xZk;1hAAH4P=^}1eSrt^mWU9W;vc$ ztG?jaOSzOir>G-q?}%lg*D;v2)kb9ureEeorqK_0Zx_tmhsX@q3MnEH zvD|8mvl}uqy+RlmOtb^0c^Oqa9j;G+O8hM>EW}HH?aoJ(&@<6rqZi-*A$rci|CMx= z`^m*DgYF`hRnPGHV?f<6Qax}a`#^mplbnDk011tPe5Rxq*=i)=b96DtCKuN=Z`BjR zaae{o7Vuj2#ihL}!TYLo!XCO7C8ZP6EONzxN-#JArbsdNfM$8H<0}xP1Of-@WwN-) zR8Ba>;;VZqtqZ1xl42>N>%UUj;-qj=s5B9Upvg%?k5x+Y$wvA;V>YcnFc=cTuz#>p z%9a=^|M9_&?S2K_fB8?);1R@SR%0^x`6()tp|3p|uPmtjSj6-2{=_tnmOV8kF zEX_%yP)q2sr?|=TzuTL~5-y#(T1_^ipST-d% zxkxVNf=me_jU1J!1FIMYspM4$@kd(=cA-P!wswI@EOCy>KzbIRgVFseGv7--+4pb=xbO%bx zmg#t@(kzuFF!K;fFgKFrtuJv-{@4j#8Z)UEBTjW3yP?~n$wL+)bU~5no)V6V5ddjN zJLPjq77Q;9)SG|!yRLdaz$GM%V0dn{dWT3`X=zbWCBVFo_eH~C^TuM@^J=cUO483s zamORq#@EE}MN!_T%Dt-4$*>C@7BhslmUb=C(o&HHSOUD{amXft2bOxCAazMdxjwB%zWR0i$QO`Tjx*;Hjo zW=Q-(MWNkED4E!X^bg6SW=bWo1bL~g&*24()=%&>QYvG~_aFj6%Xdv)#1z(rza-Bj zfE`IeF)^rpO-$_<$tww9U;asdk}CBfc?#qW{${`R2|DM~zeKIoLv*^9P0YOM(qFs% zca5#IXW0Ll0s#+Br27MliMr*oe<}z9hEw_*Fzo=DaF?Us0k}jzgXbD7J)U#C2lzu# z0lXJ@Ul4~`2}Klqts1aTC|U7@^j4NRF8_>SE-m)bxdl(k8i$$YCFbGI@UlcOAE<4H zPTwcwoiibpN3h5sO9kxOgNWM5{HuQ#nN& z?qpiMCzZxB^&Vm3;93`KHjt(QE{;~CAz9yoGkKpiW=I!%>1AJC>r#Us=D8-(1_qBC zCxgUY&I)=>4NN0Xz^%~?BA_mMnS6mr23qYQ;5(z$LDK5%s|y?1<^AYnh=Q?!`uo`L zWQ-rBc*UeeX2RwA+ff>QTE?J_Vr737Qjazgj>AMEX)0}WO7^>}i<~ST#xa#}?Q8N< zTIOBzDs2%gD_yTk|N0^Akqbkdvi$w?=AOxumQ_Dz>3MzcU)=PC_h1=HOX8V<+rqZ9 z1RqQKv3TEy(Z(dLjZFGHms#bAm$6GQ#ln=FTBAXh;ly*p+ZLv29rUgG$1BN#7t#|_urB;czaooI?@IluKG9k zzB8ZQ_;YlcHp}4h>TmoyO}7t8!zh(%BqKWMA47ozCkGhHv?`g!@Pk>*n1E<$`<B?E)0vvWu2#(&;-q+ z1_%rbfS3wNB1iRH;^DI@L{5&6=p;qj*%S*AUa&~Tu|Mvu(CBi)! zF7hXe!L*k*`i{N&16C_Azzkf%OhB~i@<2mjjfGZR5VyEB6fpc?R+$B#F)}rs%g_A8 zcmCvLbK;uy2li2a-<@>ExjQA6#j4ZD1DPjOh8HW*{%Op`RH(=X!J%&8GShb|#r363 z=LtR@+}wD)Q@JVzmR)8n8Cixb}cT5`sc#{Bp zp6YZfvzXXM9AA=O@M2~fLqk?y-2DuRmQ1v!Cfh{!!-qJek%!^81%+~eYj@h(8h0K_ z-$m~D$TO2NB_)$zfhx}2kb!SEN=z=(W)WAo@aD?E)>LKj?Mi>zM7SN538?Cwbk}4G6k2y zUi!*Acps1vYn`XXmH}Ksgc{ra;UE5C19C*xU!YUI`s%AoRS3Mp0G4>a0WKk;4i?G& zun%J9P^Aag0ht+ZaE)L2+jqYGk$}rN2AALW_4iO~<&czX>~<|VCTNMqP_~_^TH-*w zR>Jb}vKF`h4S=~+;X-0GL5y&>LTz@Zwc7*94M4b8w-==U0U(5%e2IbQk#>)_=@Bg=|3cOkhkmQMjY8ZFkBiXe0cppU4&fQ`G-Xp1ol>Q{l-BN<~8AAe13nsbI(; zDUWDEYVukstEpbMOcP;Ksbvj=!hMK3wfkb-PSgr|r2S1y{jflD+!y}hNSsA~&{qjS zOJK5lE!BM#fXj0IE=6Hl)QQx=_TuSWK164I;+LrT2$)O=>|eO&%WsD+ssT#G?pjGv z3}7q{6YJ6M7?X~HjY{N6U&F_U_8j9g5`U1mX38a%igIP*8_cwq6ww4FUy+*8W{}+{vb19ahI@99-rc409%jYahh*RZ(g6=2VSh-6pHcP`y_VQ;i7e|(^`(zhya1#eKX$WS` zMP4>46ox-?7;cYykc-lgXwk4?0v9#Kgt{is)y=!(S@JXR@<|}tQrYAN6c4ij-#(I zHq$Dc;N7$8C)QVB{`%{$x3a;aLSk{3FvP&Y(08B=B&zg)wKORr{9!%VO9lq?qF?#q zi<@)Xf9*8C1}$`t&=1}9TXaS^!RI{GwF`yo# zc+8a88A||VaqHq1=54#&ADvk5azDhZWxGomyTs>3z|2s{Q|a6wZaQ!H;d9+RS5Zz*xHz7W&3XAgeM0i zfi{lH7Fo=sqO{a6WJ#|Yc@jMUWlgH>tJbt7KM8T+j+NNaOuk^!_w2iST~K4?K|1T> zzxaqiW@CEudoK8ykG~k<6llAlnTo7(7&Ds@(pPxSkXerKEYnvu?<+z%Fen0;yycc# z1h?VyzJ2>LJl9yx??#SzWArtcDybou3cxZB6B;02zKhcj+o2;^!f{Tz3}Y$>+?M9Vsysft%=|c znx5Z8jSHVow}0uIbpL^6k%x!|Ori?dMr4&k(0d@I8vCO8lh)^|Q5O4YpT5wZ$#v1@ zLCP_9XK!NH_rbM3?U#5sTpc8CbI1v`(LnTvuIG_o8I2M}=OU+J#$J(J(GpZ$V5__we)z2B zsK4J(;MPWevZ9nWN{LNH>CP_CrEZhb9isner7(G+%}oxI`=R@m*^q{7H7Hyv69uaZ z5lhw;pud^rqt71ENim@Eytq-gfZP;3x_J1+u^0dOXFhWuq$BV=SvfDr7h%71oP?2c znU}X7@`D+n8D1w676EEQ5g2f#9w z3`X7YT;mf{7}HR?P2v!Vgx$nIdkxAcNDSihb=G=JY!GdpY%nPir4rB-_baFnghsX7 zO^G@u+QpE164|2U9YU9v5-hVaQ70rOLDfZwMwIW&eQp3QrZUDx4HVW=Yn*@7Fs-O@ zs6?ipoI3_WZ!N2&Id(B;*P8E=37Ww%?SxJkzM;-GFBJA*> zLCt`l!@~4hEldkVKkWDxo~3MNhKfJtB1e@UJuW##A4>o#uY>4eoqUhT8uU1u&l zqt7Miz&`IuHJVE|q3>`3Dq$LTIU;23nIag<_;JUdp@X;j;s63#3R0fbcU`$-0pOBf zfL(HaaXbvDiI-=E-5$4niO#$9Vp?0hn+_c7i%X%<7#1lr1@{1ka(J;RmRZXw#wCcP zLoQi!MOlOOL?1Di1KKu~(|wEUbc7oe1WM2H`Z=C%pU&va4VkzlCMTvO%Uq5KvGs0` zG6tZNjTZHo!7LcG;(cf~l3P6F2~M>JNpLs0QZfl_29p|9zf*n_O7V*nt+s4Xw{g%o zOa;=U&Vwu~YqyepPd8AA8mJvik5Wrbihic?ig*n2tQ5a34~IVyS4+#J%$Na7QVE+x45d*nX^28d0pKvibBZniGA>K)ODeyMHF)-n`bLE`PKRIYg{=!4|LpbO zan)--`n4NnxXCUwGy>5oL4hIAOAwH$CeO5w$HX$YFT4nDReT-qfOnhwdk#=(4Uz`# zjJP&xuvyzP&HVFivP5`in5jX@yr}V&(u&}{0SSRb2ku~HTYc{&%l4+5ZVE4Y(Tj?& zeB~?BIfDg4&1bx`ypMS(Bx@WjCU^G4`#~o&n${)ObBCTo*Kqgi|IZDN1YB-!tkOSk z|37rFeS}GYLi-SN;0(PeqJ$AsSdRf8X|GU;jFR?p4{m4fTj04e<_+x}fGQF_C6Sbm zvB<7+#Q(1&9E%%QkQ|#P*yGhmvUl*VH5l^Ev2Pql=}d6{!r+sRbfwrwXONO7rBw|n zTnRY=k}nxigPTfAYAx$UQQ~ zJXlK4excJ0jz02oM5LWE8V%1_qLF|b!{mr;KRbqI^=5|X&6H&l<`SRZaPgVfgo2q% zt#ShkaY%^|R(M2l$IW!<S@xDnVe8R)EyIc;LM_}s!IHD;#yZzfUm>>8h(n$+@L1x-v& z$jd+?a+~59;YvYG+9o$L(|nG(nF$7|3`koI$@GIzEEv}b9-&U^-^4_Nrdlmo)-2;N z^W>6FK`tq_QC!<;WG$KFA*gFS8@kwKn#sr-jhe)LHga*#=O&O#Nt8a9I_yD8o#ng@ zf~E0NH?ri;x6ybbm|kebU2fwSXQzOqma@ds0DQ5x08wzp?ok)*SQVX zjG3*n?fUFqe)id0t|1!F_TxE2RypP|(6L?Z*)^S3JimC3Ee;b?JBC+O@L*FxrEL?V zV_8eKwFZ{8JwvT3M@0HwfxQRNgqzQMy)Z?;f&K$7L-ZGLmm_1`z$KWS^Uga@+>6k> zM7AwhON+Bc$8O0zU_KEa2Y05$&T%I$o&VXd`I&!p+Tb!CqxYP918pu&Bvp}c(Ki46wZ4sdD82LPV{oC+?3Nf#M*!aISaH_X4{6|czf zuK0Hf;1g5pYiNkcA2)lvV=#l7d@yn7s#)f^H%of3&(5V->I#ApGBoa!dJuD|KyyUz z5=lKk^WXTHSHFWn&^r=sp1yJ#*MC>L& z=0r19BLMhIpp&P~OaLhw4O}bS?>L8Kx@QdqjfvETJ6_i3DPbr9&P+>Hreq)ii2B0k z8p~;lNN9|(O|ILBZX0AScx9^+@0lqm4I1d30IEqGMP)eC?^E04Lnz>+2F$b>odU3UQ$oyE0Q8QCYi;8KxZZaTY6wBn?bo z{5#)zTbP`E`{{g->0*_BYVRJ}dgyC3yJfQ^YBo%(G7JGw^75FsGr49VlOx;)FPTJT z8LdQyWS^!oiAcC9i+J$-@pfMlN!J))uC4TFfww!8!Q*xYlUrwU+CIYsLpGpU29pa6 zHlelLYz!#ntfoFn`iZO|K+KYlKqe!}M@mS7WAp?4kU*di3GlE1Am&zShsqd!YwvZZ z#MYLcKpMKOD!}n1$P-4BPOVQMiX#b9q}*q4`=uT~vB3@1*zB+e5kS)bW|e5LIDYGI zs&V_-(u1_^)9?-oK$@LA=)%uDdN_9u^fl26sGH|__sPIR1W9Lx@G zj3q{peqm@{B94Y>VM*#?*M_l1<#B!7rP+Ud^UHQmH=EbUdeHCD9+s2>9xysfON7xZ z)|V;YcPm|S{w!T`@ebNH*Pyvc6G%_`n9!UAC7}Sw!y&=xR(&iJLsf z3qR4Kg5Um}S;)z02AT|ZGlY;f6WByD4*+0`0cUHXNfWIm19G0nH3~=sQ<=tYEe6h0 z3}%sqJ}ugsNGO_+k!-7x0BR$ok!mC4l+-Ii$x=2290mcPGRkc{RwaQCVjZAqaG4RpEGdsX3Usz=_ycR7Q~m zP)zYYgDFD)0g!2R#@U5FuuNZOmjIrM3;yXJ?#idPoOZ_en;!g6bXNObTD|qlG|!~P zCT{N~BUxO!nb!H{eraHBTab!57$`&1U4o86(uvGPA}>i1f#pPdjik?BFBR?pia>S0 zIEKOdVFJ`T6nyj`Y* zL{kR#eQt*ft<9YPV^fktlV>cE5kW$>=swYVjP*JLmj3pq5PU;)* zU-S6gwE0W_H=V9E_!+w3pZvb`CwRsXlc~p(gF_z^gCHIdl_$gVY78bAc>Dn?fsw~E zjz8ce45lo}2kSMv+NXI@&){C76dH7#R7)2SdN}DEhi;G)DPZEJ6bDr z$qSxKTW1+G1gMPcI>%I3BZ+Of#)>!RPG(8$Ypz>G(dc!Dr|ATPPq@p$#6s?(HQ5xGIe;a=D8MJcDfUNZV>3_e zq`55`S!zt08ZRn3omp~)CpUO50qSV9npt8d%fAp3o0%oHSB_o&DNCwu7Xif>t&@rH z7E<&w*Z5bHhYITQNST)Dca`e|qf6>;u~3Mhb=rsS<&mc>l+Qsn;(pQ93)?nq`ZMl` zG7Gyf7E@XC5>Ohm4klA$T|~M_UFM|#yFTw@pY^Ilk(nPo?-^G3somtYJ=08_OqMaO z-mT2^FHOS_og?BgQ3ev9!Cr!xK-}dGH{1{`2^20##9N|+9%tyckkEs78Oat{W>dH_ z@yBk;e9Mb|;KRJ}!f9MTz47pWrmL3zmY`VD@2yk!j<3);m+qtuQ;@MEAuAvhK7qE7 z`kEA32#7-;HN|az5tGCYNy1_Rr0d;o0;tep=E-^lxa@H|)T8^B+LDR0fw$YnebQMQ z8g$mCjJ7aQvUz?|GS|@tPjWvnJ2gpDn#Dc=k@k>OO7e)<|Aw%fa}&9c5GnL2)6l9k z-_pJyH%YvPhDS+To?D`%{FDYNb6sYkRv4QpUcx%i-n@Da=hgUC0=ZLs>hEyDZ(Q4s4EqQ2y;^zV!*( zc*}=Q=h4u3%pd#{3%$Sq24;*V503C0mVguGB5e?)+vRw!ue$0g`HXq1DhI=}Z{Z>c z0WmgH^_9sIf3#46%!A~h$C<%=Tn^@fvGwO0cFew`Ht}d**2|9JwJvdEG(fw&PIEhg z8LHM8A19=aWuN}>=f7*J(YyvD!G^{50l+j1k@JB!SYkJ%(m*Cz-$`5yE(~b_D4&|9 z^)0*T#PKfOch6xu!W1RG5?+u&Kgo!b#BgMExSh&9wlqHDo}~^QVfQ$I-YmbQXHBtt z4|nXOVtL7H6e^m_2XObB;BDW& zt2P8F{w9>kGDS=>8Ik~GE{{kW-=gB`nplKDYtd9g+|VFx-`9A_q(4>+vjC@A@eDU#*(?2=zJ3`?=$Rd|PIXCxb?4PJj+3NG#E$nMY%$w-_i|7AgROkN?cCoycVk9p zE;MM{mI)>fn&M&0RH+0YF!J#wyi04DuXfnAzRpu7MW9Nu+(x2gkool@wb5b<8pXAZ zY;{U(tSPS~A@rdtiS#Ei#q(PnJdxuhRfBR+41a&i6vutB{SyaCaz6$|QVgMJeN9Li z>P8%uPV%&!^%IsPl&FcMSRy4XiKuU(3Y1h~8ouERpO3;dQX^Rz}hl{*b-^$|aVs z!tX+%Q>*eL?~dtf3|#tSn9PLN2t;fQ=w8x> z#@voxf6#6$eh4%e1}vqqOb{~S91;VY3z?#7yXFr%!8Le(BeG+z|0O zc;6^OX^kW^lsCk6k}-3bVn*5R(VXDFCcDuaD$w5KYXM8y5;ykD%rs9YXnJ~@!OsL= z#-wDeLw|FMKO^Q7+qT5rJ}G}#Hi7+H6XG6`GL`&!l9$i0OCQH+3PXzc(7coil2}Z* z{@th-Ag}31tCRxA(+JpUa|RWImj*>diDXopnHwaj@uuG^P#JqEuJVuu7pc^kHYA9* z<=;&JdogsE_>pPl883lotI>Mb)i1dGGJs2c-sFj~n)k+(z+H||4%KKS6Jgh9x-@n1 zjUDJ&7>SQ%Rz0&)sz^uBkL#$jw%Wd3R>@=yCZ2BK67yhO)O8LZ8LSi}4CI8>8wV+2 zeFXXqE55ON_imKq3t)t__Oon<@D_WQEG8~!wUtYM@=tf2F1Xy>J4FA8UE>K7CD(7z z(5&8}2Waxh-Si#bxr=66p#(q}K&Jw30DVyaR4Tt4rTs!{x8CU{br9_pm-@-sJI_0Q z^F){0%rGEIRP>c63p#sKgSKws_L{v^4J3-fGuBAwKvg(|UZE|HQl%zK?fw+I(Px_6 z7v$WYXA(w*+yf+zPH8w5^r7MPlDq_jTfxPytYHY|vC?+aN*9Ns42K$tog`%&kzwA1 z$1Nxunlj?0|Hu=AnyGHHC8tWeo~b4@;!-KGk!9JBnErCISkI{Cj@5M@hb(4EJZMu2 z@&j?1Y4q+ilF7nEM?GBr`Q5~>^3zTFY0hr^xpRN&zg-GVRXl4vK+56N$gv$6}>Z9(721{g_H!(p&D4Vgz&tZ6{pwvk+`A!YAbaIkc8Jy6e<$cc{DCVL;LsjmzcLfs~kp>wIzo%qyQuDkJ3V zE*bC$EQnTFV3!Iq5|Mb6XBKQN+-!Ow1k7oyk9^mDVnq8Q$s-Myl)4R3gk@0)0@ZvZ z`!7Z*M=5tFN%&}HdmM2&Gb5l0P-&#t??`SvyY`bVrj-~=(oX#pf21WPi)h*1%7(g3 zL}`Svu1HL#Fr8(>)^J2z=&05{OWY5B7L;HMu`Fc?D@UnCEJvT@zDqUC!|vN`qkr>z z|Jz4iV$~OrB%oEJWIF&&>)31xZA=W1c$8XbW_h%F<>Q!4n7$X%p6#iWj`W~cm(?t0 zZO;gcW6DAzsV5|~C%_ZTJ(7E@L>@N8JOjqe5*`q9iT4kKJ`fc5-FKhGSY{SNh0o}~ zF%Oc1x08<75-y;5iO&B!erxZSxq;pFaMwOPSfy*e`Oj!u?{F$l2vF6GsjX=w??@BN zHg2ZHnX~Bczj!yT_qD=|V4&0o^?OMRISqWNth$Ca5R!E#`YF+8j+cFFu}z2AQwFy? zVlJP zw+ips01PHc|08Ul#%VAZ>PHGbxo_HUW5mygbuHCi`h!+eH=?shBVX z(R-Gby-3O}@>|5hBF9AYxvxqfWS*{Q?5PT=>#+*$U(%`7H&nD{=wYAte(&qI{QS8uhx`>DaSU$oT!NA0K~MoD z18jHq-FFuzbBBQu*i7_2klV87d}?Y+*m}6j0Wz<!0YLKp*&E*QhJj%zB`-<| zFqpc|OKr`^+OIrj22)3P!lYOSXpD;iOHEji?bXj;UbJ(KN3GgZHZI&D%k{bHDA7uM zAz0hCv!otNzWCffdEvYGW!{xuPG~45w{Fzhx?$|xdp8WIp^Y-M(F zW+R2!EetScQc@2yh>?L_TwCUcnE@EHo+pm2(J^*+v>8Bl`0p6ID%#u_3>X=&@e2n) zyU5Fqtt2+HFK!F~+a5Ovc)=S|4FPdF-9ZZcW`13k4b(+~FWqF;F~~?6jAM)OE;E*> z9@J>cA6QPv8iA<gN<7? zmXg;MWt@!aYN`-!aLdcTEOVnqYoJ2;L}jES)sT{ea?)~=V}??QwSjJ^0n2g8J^GBR z+aJ}P_LHP2KQou@dvo{`{5mehrpN8gA5TmUGx{o9=CeW5U2K`RCWx! z!^j%P_ED>4p?|yd-M9SQ>3~bv>kfR7Hg^vyD;YIsWvG6EkJ1J&AEph*@1hr9aTc9( z#yrh1Fhra`n8Jn;0~(i(;vQ`Vl|5z~P{rq1F9FqgZro5A?id8y0~AU!+R6;%#b-{? zd6%3?Gut;(bK540GxIcn_h@cTm`cDhB#lOt8Aj|rZUHw{`2-kA{^LfjIiXeG8Y%H} z3bFcl8=CA!l0mlA_H%I>szJ&Fr0<=rc!!aLKU3`vw06JN&7L8kdF%$T4gaY zHIQlUJionXF)f8&8x2|RH7~uOx|a6}1}-gN+cYoHX#C+1Ub_48t<#_5muR71BpCvU z$rI26fDlqpGcvHD$r!W-_aIPo58Lp0N)kXJrfN4#T8m>gUho3iztpBdd!6EHo9?*x z1RY!Iu`9cvL(D!NU+)Vu*5Kjlrpb&h+crfneC{-DeZhBA*tR?Qakh0rqKQ(M-);fcb4mrFC-8+tiqEC@ayvRKr2iVjm$j&*(7EjaWT=@NRh74 zcp1co@?XE3Y6jVh+1I!sXGzA*AnZUnS(ZgV6@qwk)7k~8T z+xMd5QmGRe?BH{i!L$HuxZ|w44~*GNxF({Pk}#lYamuE)px-mP*vs;}o7GHaZBKVY z)gDZZU9oq-)?4)+B=!J+Vwoj|!aazrTI*<`6^M`(*!ucC;K<`R-$%p#SZ@-=UbRZlr_*B|w!oq9!gTaqmj2}hJ?$fUM6{J3ROSh6(`yQsn)i@{a3+l>|HB;TJ9Dif3%%l?l0KU zpckIEnWoR!$S0m;LSuo7&N}xO43butC|l#c1x#aGDs(Vt?TKd$KnmehE8KUjt_^6N zL2Pf3V*F8myoWe`Ql?NCMr9+#?VJx;LP~M>=qp9SPrYC0kI;@vJY+#-MI9@e5mJC% z>9GouF|SqMH2F!w-6#%2-cV65>b;5T0gFRV*g%0~FS>Y5V=n3BKUhIl5iSuieN6&} z>%1AVk$xd6QIpXqd+9Ue554?dw0icH^l-+?OXkYRhI8lHv+|Jf@0q zkBPyIqcxF7SWLU&Rd>0XtORSLlyAwK8@TkfV*xA;T$KSj1D|-lsE)cts7`|%b66II)@5ZHee6~gw%mc znMadzXHj>42gM!d(HWOqLL1LNpBiVM%Ut*_nmPL%nugnT>kew2v6G60t+X_`fezCw zoycZrIiIBmyE)y*0QdI8t909;6}t1t3f*&jjqW+RO!qHx2V7O`WHejp#{Pv zf?34~I#^xWjUWJsRU0n6WPdiDz;u~ccurE zs)m@%z6ctp*iWx}X2-?+WME)D=%gY=6c%>R&s~erBn;0Xxp+(%MwIB_Fpo~rN}Sr@ z$)0(<_~!Zma5VWMm=|8xLItFsN507888=f~ZBMpYUlLXSQbnOrY=ltjmx~nU2?}x3*Yhm|LSzW<>u}odhLV% znNrXeLWj`W1er+XvOFuRjD^g3Nl;N{-#2LY%qm^<9cR(=&YG2eBWMzhIApQ}ZU%gy zKL2fK1!;>HxgT2Xr39cUCK0ww2Re8A3{CGmlj1A`%uU>%Ow2%UNmNG~>d70dciB_c zktEgbKzI)R%V~L~CkdkB6+`k6z*`W-^#m%VwM|7g5SKsEO7Ztl9cfKYihQ5Q<24cE zpNfUx58kfnsYziVClR|31(Uo{Dw0fSoWK16=%$#=hA^riP8uzh;RCZ7vQpioA@|0# zniHETahRs991@$^koZimJ!)Nnc`YsNCn=TW8oQZk_W}Ukr<3~yvuaqCt>60j|8QFI zn8+&M_QE&3n*n9i!3~&83Dv-7Fqn9z@ob{f55Og=$^l%$gNCX;{&>JnaFL_0!5=co zZ4idO1xzNuBoceD4E+Z_8_*r=w~aP#TA;Jf*ud}i zcG|$ea%#gI<;^L1$p!UjoK46k#YIs}x2Tt`Q#TBx-H==!hsQR?t>Cno0hq}zH_PTAJU#FwT+x)Ucn&B6Bn%y9? z{K8Ll8$92XdnCcqc}lPh#jyH9hpz!lX0!xyZeqY*;!Z#_Ov*4e@&qnZ?wNqIwg}K` zRfjwZpd7?h=8QBv0i)x>Mx?walAZdJf!>smgra05qew{!gPIqb4WC4biRn(xi+MaHP z{=za|@!42Pi^W7$5`4z{W~GEpnn?SVh|-XF=P@uG? zP8**099lSQ8|}UOK{|eHO_)3s^jJmc6?@0#;kxFX*4VprfZgN5uA%gBqoA$R4cdCv z21c9nlugc2!CtG30a*b7aKyq73JbEA!9Mgek;xt#B+04`38QL=>pV93d!Ua&!h*P% z3r&Ct8uDaJV?(~Dd`AX{y&`$Q&?mISNjH#Awc8trrwZ5G9Hizgd8-s~D;tHL2hnP$$C5y@K>%sr zV|f7Rs}c*!eMHF1SpBpOjSa67xx3B^ZDEjk;Q#e*dRWU^uY1nF{MbwH`t9%g96~j~ zUYa2elkW$rPZiL{M6Ls-0Gj+)@bfQhJhIBMJy^^;@4Pc-pebyosVso?=jY$vEqPyD z*+{{0+?NczOx|PZR~|LMtj2q(bV?$+Ll~=fiH@=wYi0&;iADmzk`Z?)NrZLC#07&1 z(-EoB#BwP}@In0L8B?tfghC(Oj7kCZgpcge$y5)kNMNVPv>x}6re{QxBo(63`Kfk! zQ2Ji7wmWUwI6<^~*9O`+wV5t>!6p0Ejd>zkyVIR zCYZwh`U-W}b+LA0g7rhQm%xq+!+2%%~OKoN( zyTw4Rv(=oW4IAdE+1i|{4)OXPyU|xymU+~_NQ-=Lm)F;%GQ`4EOSI0K+=xKiv#D-! zDX)zYT$Ut}Vv&Nn(Fmc=tzjEX+9+X$#(+`{TuJcZ?~CgZ*LOciwFwNfMTP=#E?G+? zB?gq(%^<_dOu_;rHYN*YV<10CVe?5kiHei~E-WeJKR@?+m^-Dmnl(wK__21A*Y@-?&%!g9jHWzd&p+k? zE;SjnWM80tiFXMVO|UJdSZ>|}82SjrVM4;lpA(%jO7g)JTngaCyF7X3`M*o2VOJgh zxVXprAtjIqDS;L0H4`sd3QaGWvaBR63T;n#DU$@W_bxhPa*ED){&Q)mKTUUj{Vsao z;5vJC`m|-XCCNDeh7YbsYWFh%RnRrw#_Z+ucFohorp-hf&Y;+uO2i60J&5x!m@MEn z5w2}yo3DY4=tf#x<+d>&uq(AG0CEtMOB_sQls1qOV5F#3?ee6&qE{Wu0vM^EMEi=0 zLetYzDIrOY$t`_jASEtCOfG-q1|U%tx4_5^F=YYaRYSaKSL$OjSu{wlc_DX-Se7(S zbz{EANoj9ao7bN`StwI7}sthC}u8GRm@&eYdKw9?hmE&Q@LGvfT(MV=w06 zTWRh7&(Y%9FQ$jH>21$@Co|_i2rRT~*Dkonp^*s&Q{3dJICbR65i&&tC?Vh^jDHG$ z9P~X(#)%oj57r;m4f*{dEl^N6=}761^gWbtVf{Xjx=(p545nBmleI*>J~PQo8;Y_H zGu(@ayEN)nv&Lmx1D6IY%`E=X5_`}Pz*T;(p0ufN1~9pDUzsb0xXndgqDubCFh?@+b_O?TC=mnJ{5L9tfwZ! zShYqPSs@T4sD4htuIRNTroWGzpaTa_FvGY`hnH69(6Qs(FicQ~8OZPX{;O%@ndeYr zVp6ih?G3p9v!4*w_MD5pGi7%(E4ap>@!-Ap&?i3f7xda6`UzS-dYq0QK0u3W$7zN~ zwHr6i(V1s#5pcG?y3SyBomQ7mPW?j)kYiX68X?9sa)+kE+&mb|5y_Btv z`-8HJ7(qgt6Pe|Rv*{OoZt(izYKBh;?r*qRBHZ30b2Lp8)3Y+7&X{?XQ7L{Z-cLrV z8ef@)oTM?II!e}Cnc0X@UG)}Y12CByvjpt(GN?NWKpyb*_PSlEZn4zvP!F} zwcnluBo~qx)lbZ&oU5-R_L8|QUG3a(z3go-e!-i6^)LVGPc^PfF48hoRp}rqnJ^m=BItSH=0-R`76z%Z5tAhn zTR9k%?681mcmuOs+H+~|wNJK45`d!)bOXE{f9SWFGY1G1TDmpLKS)`Xsm2d z`>Hh43JoQks+Hxa47HMKcA-np`ND70E!)3?2F-`gDi4i`mwfkc-Tse0|FaiKiLr@^3fTS1R88Dfued&uCpm0H< zRkB8I(4z&Jj}>5+Zof+QU#tqMYaETa#-o*lv~otKdx=ih$Ecm7vP708rS-(trOV5OC3^>a8^jsRe9(W(XT*K=yZ+g3qw$o#+y-+doXSqXm+#^ zXv@dw?C*FH(UxtXw|NM>m0k1PY;c3nJARD9D*u@;1jp#F^T@G7haNmeU%zcX9bHd0Tz(i=7MJL2H-3ulzT+NR`odS(&B5;XnJJoI*hCB55I%6%?R5UR z=OnF63OWL8((39W&28RJm%RKX0tE2_byipD_`MI%!8>lFg9q-TIUWIT+_-@no56l= zr!F^SJ$9Y++pEmT0&K3Q&_^lpl>jE{m8DTE+N++hnMlCtisVo~1#k;4gyd5zWk5Ed zqd8429!0k%rqaj~_l`%=EA2H}Sz4v`+L}}^f}6k5n&#~nWZTw6iqQ!YGn5Me?Q}bI z;`oE?61ZJ_8m5CLk{`_}vUsJ2#CIm9I@Qco7fRlu*7O8T@{hU!0GS)!4l}#UJQ!H- zw#D5aqm`LV&yO@zyj^1gQc{Dm1F(Tf?q8xjyM|xh4^joxIpOuFY>F{40vf$s5e)Cd zIJqJ%i5HzWAO(pH2Y1ncW*oW}Y9_O`r=!UWXq84VkM{xZ2A*&0Gt2-qk=TQN#wUcL zuej=}tH`tj;ZcBF5%Ml@ErJz-pf&mqbl`0GCvZnyKwP~!zwNX%FJFE1FKH`-%zmbA zhsoAt1}sTDG)W6Gl1pnTM}ZZrliuk8w{MF_>CkFM`}ZBBiO`TRq$LJ@$Jis)13=4C z26u~r+`<(4i8+dMnUOX;HNM*7DKJX$;(h=?tx>H-(%a$`c%D`mf$DK{Q%y-wf)}d8`{WF+CP5gm|GSm<#cd6jG0F=|Vy5(snGgmx&P6gU zzGz!S-9?pBVmA>3+E+=vVMcaxj|ayDi}%bz)x@J1v>1ny6u6t2C6AIy;-x+xptT(ob~FX(&p_ z-tT-XJ)AY?&%m?yAykjTG48(mZi2fUm8F7KON5&n117NV7&P&7T;O?!s~oogo-@1; zD=RCc2|d#1;CTnwK!JmzLlIH>7QM+t0rCEeq3)_sfcCuwTYRlKm|r=IjO#*@qz7 z7rEM^SZr2zLjUW%{Z^aXn9_rEa-&GN;r ztgi8WIzfl|LAv$JH`0ZdJzreo>4jtGJA<}^5ALJQXPiS-Jagbl8LhF||CGiG;EoI}U z2(`dbrQ7=xcpa~rqN z^v2D+&qkVL=67OxTC~`byb+bs6T&>BY#JYD@13{PhyUQ;3NtKKeNqa0s^}0T=0v11 zAoz|FW|?=pSXLqSEe7(Jc)npQP?sA&xcb)^boRBeo8VFUEVA>9)l#S}X33?Kn>L!d zrn&Ka`+Gm~w;#ldZ&~F|8#C(j{E%7YUK7)@%9X_|v&ii|!ZJz&yOG2ntMX&c1gfpJ zr-3O!QEFbI|IjQ~d4^5%vbrAzXJBPee_3mdOR)pIXCNiO>|i^@UV@A;St4YR!(+f5 zbOt#Wbl|uKu$Zs8?%}^?yiAJiv}sgb~-QNbo?dBuc@Frk25l z_GF6w8Bj*UDrC~-XvNtS)5mi&vvg164Ep2^cS(XK5@#0IxgY89KzVY4w$9JcS=$z9 z$Ih*E*3Qi|w|z5Z^P4EK0bOp?I!qjN#jDolf%N)XPx_%AUud_BYH&SC^h8J#Jke+$ zJA57hO?YsYmOHf0q(htgsZ~C27m1%mU;3O$kt5=EpIz$^T9?0xR6;n@m_$+DKq<&P z6W6{#*J23h(B9%wI}(#6H-L$Y1p2@{!ke_t-aavaOiJNa#wMc8j*?^=gbb1FbvaBS zHN}l+h?->%1r}Tr+}yE<<$W&mDhwnl>W@0x0XUYp+2W*UU^3*AtYm|I@9u|U&18Q~ z1{n=4o%!`w{d-#7crHDh^%nR4)EEBMMSq4^V1#Y(U?@fYai4;OpY+V64h%7vNaEq` z1{R(L_c**}=w|>lF*R1w!V%HmeCku5lH-o^INKo4D*rqh!s4+2%#ykEabZNIi(EeV z7)%rR)XuYdEYcVN?;a26lq)WeU@@20VBGZfD{tkWZLU**wW3W{E2z=znBg)KXz;!bmFMgS&U%=g}Y3%&4rUM1BR zpf8#6i;P&sLkI7tuYd6i^y(k{;Z#Nv?q7DjFP>PUPki*xxIJl$3p!}(ykylWqbH*B z`W>gZp?bmj7t;1K&JdOnBeUM}BDMK?v!W~2e&F1R0E_t3U}+=`b-+NbEb{kRlNTJ8 zHzsJynP=0~+*VNh;!1(M(Ln~f-{J-z;=PBDAEy7$PyZTC&TULdHvpfrsni~)pHtI%h(FYbMnMM~tKHiiYenWkj`vG2hHGBTd#-(xNV1Kv&}XFTJLP|6sU z2}a_Xk9*&L3d}fb1IxiL_nrb1Vx1rNT}d5`i}F@KVM><`Q6Ew-4Tx$I#I|I-Ezw<(v$;WB6A$`D5-~j z1B?&ePxKim#fN&&c5bjms0R&cqRW5tzOS5)d;F*Fe-B-;^m!HOHua2DoC1s*+MP+{ zVdhDBBWe34?^sA#Co-rm!*ybE(@K#EN{J=$IhWC&|F3V-Y@DJCE_x1a-m#fBY}!bz z*=cF#Q}`5VfG5C>Iu*eJa@$#SQk#t!e}pqZEu_7&CIGY7jZ(;?-Ayt4T~QZFahCWV zT}cK4gDI8LCAR=FbZgxHudcE;j@dB)*%o(tGZRhP#GbaPMv{D!>OSx&6{%>3fCXqX zg9Xjerwk-{Nf=8dZK8U^dV^FhxWm`gQ=kc=1O1Ayq$0SU36TL%nMznD_cpS6g<1;9 z3zgAO1_`nGg~j9#Oxg%wGlgdrk^mJ;^1=+h!nOd@lM_U7T+@Tjg^U@UW%P~jeJ?$%#bWQ*es3{r!H|J;!qE@TX?BbGz%t!%AGRZR22hteI z#EK{PdZBZc;ig|qCKjF|GNu*Eq1?J<8_hCvjA|CgmzHV6{03%= zm-${l$c<*E`euDn2{sxDvr%`L`?%{TzU$JrT>FuOA2h*#d1kmXGoWdMB?FmoS@`%$ zya%QQ3)h`xlaGnREbEc$`&L%^aiZ&WR(WktC(~Ct^&R@W2d#}9%uR0b`s(77@RCn{ z@{_{Q%(6G+vdblzuFU=I6|DJE&L7nCay5Q0)Q#;QD$kOr(-TIZ!@xXfnO;63y+Ug=TTLrsj zx6(PgXN2Hb;r?K4aasDSqrA@)yM$+&+-A1=!d^DC%wrJX+!rlS#Ken?oecOB1BpCP zk;;<_Zv)ZLlo)^R<9e#Gi4uIx*0kg$0Hop`Ld@M_Dix7vzoB}`#b8KN5-G8eyjfT# z$)L(sh9bpQMoDb#3*bap1(;w|RuY%I|2--7IUU{>go=R7EPY?*4docHRQw}d)%@XG zX#UVGw6yIqdg!+y+jZeDeDOz@fBvFBvqT?DU@{T`F~t1r=G>Q{!Vg)6DlnQTA&F2B z9Et%d24LcOL{bmfOY@Xjbsua8QV`(M>6c4i1AzHx`;tcuFe{m>rAs^}6Xa={N?2cI zWQ@Ud+2fK`>tu<{od}hBi@lU}#$2Kko}ZeyMxbIS^rbYQnE+v~4<3T6F^5b!JVutt zgKaX+PD`U5mk}0KLWj}|pXhVd!_@yIc~=xUlGc`(G)j0ZcU#-vQ*ju5Jg;2OmayGgqA?^ewz+0u28<^Oxn7yUkp zJka{WXZ~8V?oxQd!rZLJ$|l!P7J{SHhlyI^HR1bP^a~B{gp z1_>z(+6s*~BNh|SB?dxT06?By43N%I*&;KrE?V6uxY1>~-p68A<1hgzO|HloOec;j zS4Xvrytb#0$+OBGTxKOwEh$v1`2<}ieFrm*7=UFr+;9V3am5vqISwWUUM~xyf~U(g zX_0-4_XPtyyqCt9qT{x6gS|Z6*vlU{^k1}X%~X$0`!$ zLQ&V0g`^8?F@{n$a^(nJc=`8Hw*3rb6Hu7j&K)ARg=;Gmj~_}69^AnNigvJfiTjiN z_Z_A$-*OktZhH<*E$pJk?(=CQ_b=b^yOljqZJL;xr0r*(MRUxG9X)(M zop;eiy!{*>b525!rr5py`5Qk$FMi{jXx9Z7Qg3aY7LOjG#k~*Ep}X$jD_&tzW0uZj zaD!_AquFQj12O%B?y3OQNp43`zdS1wL<{l80l1-EM}OBX!&_|(f?Q&ncvT~djSN5mm z&qe}**iMp>pn(jKZt<}K_?x1gIWe0Po)9EF!KJ_2g$y8{yVosQYZ)|Yhmk~_&NO$5`0jubY*2ll1JGJ^&A%O5Nu*yn##iP0E|7F&QJ@lu{U8EFH}x@ic=zvv?2O%MHMD znY~=4tlJ(QCvQ=~u8ig5i{eIDKe3j|Pc;%dhB8?F^>ymxZOMc~a44>+!4K)TxDhyb z=&+35z`|~vPhf2G)@^kB*bzF)tmP(dWN}Ts_2a3E56WyI29aN!j)e)a$Fx=1UMg- zEiWIXc54&mJ;eDi5atG_&F+~-KEa@Ow!DLDyd2-Z)tV^5T4D@`_gmUd#O3gHC~+!QuM+!yXGJV&h*-IE## zQs(-kqub?*%lu{k=!)}S_UB*A{!X*W%T27)Gc)NeVf)bA;-G8xvbLwA;W_tlUzFI(pal)2?=Y#MZ+zn$!p<;=%ot=27-Y&e7JrH42gE|- zT?Dv9f)5xT%ZP=$QTrBQJD>YApS=3C*vsRepiSL_(!a_meR`0NA3DhWPMh80Gj#EJ&!uyA?W7GGH&Gwu zESZU1J9dDX&BK!69$Lu*)&>9sGeUC|fWNvPTW;br*K5tThXwIYu zJbg?2eUG0wPD^~wnc1^wVe5Hx_8B{9ZqsIo<8LuhkhfYAPSy|+_LO^(7~V+x?zoNq z@(=z^VmkSrSb4{Okt!HX^1hP{PFosZukl8bIMho?#S$KexYOy@LP~DsYwqwpYC=p z0j7=oe8!Y6FC|m7wq;r7PFc*T!s;csBz=u6{>#mXbD-}pai z`Xz7R7in7D^cp2 z{VcnG?mNtl2%jr4xhYPlUnFPFA}`<8Vc^S5D`IcJqGruB!d~a=C{UYrYC$y7*4Mgp z_{561<0aU;pM-ExMhrF7A}B#vxqO z_BVOHl^GNvcxGb}OO&LEsB}`KY&{1sD|}rf6VB%sb8(3_%+B$>Zlp}fKr%&~X208% zAoP3^vJ<(uRiG2f<7{`V)ac~pk#)H|3KmikX|^@>E`Gs3gXbLXIK+x6lL?TC&ago8 z8b*{DNLfa?lPj`BA2P`z3o9|n7XKX$R61~(e&*UmUfa{aVx`tG`U|T+P6{q9=?#Og zcfRwT1vE0X1C&FouCB^@)15VzQsrH&;}yM2b}5Dl_b~+7v-H{Jb zp#%0zODE=(ris1OqF&}Ak5R#BP#GN>g2NOPq|h?$8EFVOky9`@a9@%*k`jzPiAB$+ z=fqu3A}z$OYVKP;`8Qu>aMToFg}DFq)#Ka+O;VHlg+90MT?TSlWMY6xf;icd`2HF#gdPJi@oe>DMSfVMobku79MGZ;i%;w?N- zUS;<3Qf4{fN@t`-t+<6IxczK^lWo9v$6g_383*u$@!bMx&|n6#P_lvqKjbvaZS;nX zo4F5~k-oXj?Bm}1?xlMVJV2`qE}#3I-$gHZ%|8^TxW`_?V~6(B{rBF>?e{u+^7f18 zug$Lg9y7K*Fuem1Eq!WoUo5nakO#m614+(W`r75;11{Sj|n%(DqGR=)V0Ah?^W>J}-vV|B`n+VTL5CFaA^7aq9;k7GT0Ax1RrV z{EXhf#~94c&SrR~`8_D0fPhgF!aES=!Oyl_6$Vs@IcE2{(r&|Y#YkoYi-~w|CZ=vd(FsnfkgbgtH6bJDh*5;qjU`jCU@!L|T8!l|*1}2~4tsDN zYg!!M`Vy_*`S&z={tM)VMhr}Bh!~hO5O>^uA01y_rR{Jv=a~dCi>w94Ix%I_$+`K3 z=nRr10x}OL0rv6~ERL9z#q~a&SbKmDA2}o=D}b+dn_r~Wb!BdsiS`{*HaC^Lnx8q$ z*V&{A2B7J;qv42||5=6mU}=@6`8ua35X-|a5?^|MX^9Tq{eZakSJ#sUW)OEdgV1Rn zNyBBqpc|QTaC>y%5@9#?GP~#7NA}U`i6aPKko($Vz&h2M;*sYZ&CSelgV<$eb6w&j z`+RL^I6Nol9p$zHqsR1H51U|QU21&%=<})nW}4|uDoJs4@dTa4uBO$sH35vj3?tJf? zUqoa?WJXS8Cdc3;sz`|}RZ0S?>0D8!YwU0&SHX2#+q?)Tj}C>HIZu7<79P%#^?wW$IhPEFFbk5dpz&8IkQ6A`!JIz z`Zxdkf5L%sDM7L9Q@p=TImpji%tEn)*_cmg&S)E)tugO|?M0885Lf4bn;mok79br5 zXBGK|n5_5k@)jM)p8zt2wZF`wH$ zJDMA78|*|DmJ+4*1OUa8*Pfy-gE~hXoaC*pXh`1y!p^oM7aOtAcMd0i^VG(@8x-@U zf9Ick>{x=Q+DX%ADuvV=bXSIlwoFuCt$ne9VZvf4mJqI?v^IIWK3A z5w!U_xQ`#$Ahr}i1=#fU{eXXLTX&)e)a z#TlD`9^fdk#rMtFxttYR-K=@XO7nxh*k%;%kT;hhB3D#}Lt`@s^{f-P(QKKpc~--B zCbxt~$R|EQukrbmP7SES6PcbMMM9|P!)7w#_nRCs%DC1OQS>D{!v}A@&5q}x z98as0OE&{Vg!nFF-jI5iFHw_`O=yk{XWC6@!bs@don1jlv1#qeqV>u>K9_fQ zAOU=v>6Ol)FZ76ZO0rEi#M%)?u?>FCQqj_*iE4HB+AZxWL=OODK%2iKm+I$LV72}9 zj5PCW#qnmO7PJQ(O&oLB2}l*y}E)PvkEG7)WZ{%@|Bx3=rz8`M(!2- ziG*v# zl6Yj!s}t$`>{7du1xDNAe22nx#CzgQK@4_;?!ERp4VeMUmH-_V;_#skTnb5-+GHYU zTY&bGpuO+kxkY>IoFR`9J=kEQwk~he@!`ItI-$Dz_+UhbyAMR+1kBn(_EeP$s=*nu zqg6NUjr$Minavg2zH(J;TPet8VU#g!7E{gFi0xY@m2Zp=^WMWW4gz7 zsI&bvLC13`?Uf7Ke{heHxLw)bJ8cxfQsp>Get+e{)xHo4iP+Fp9ena&1-ld}rF|%P z)&kov20!NWd$i4HD)Kq~+oFD8g%Qpsj=$fb%U7SF-Mw9QIJ;6XB#3P+tH2`|wOzAG z`s?HOz52?3LPtOM@6cJJvGu|i`05oDqCxF|S+|1iD*`7;{{Wha;0Z-N@Rd;D^Tr!* z&4t%kN)x$!3x-$RdXlyeP~@P;n;CsxYK}9FbLe2-z@Gf#LOGPmQD#(Lsc@>71_FD})Ml~Q>7czB#To!6ZY2!6jyjG%77xgXu z445<3=0$Ps%4PcG&5y8y`F(or^;=3QoAaF8qAWUBsMG66{!Vjcg}Q?t4d2-1W0_Nr zoxgs&Ppx)G@?*wKYaARN(*aWuh(6aguL$CMG#rb=I-X7FaCj`gK_fHNH&$L)mAW9C zD1Tnr>e0bysn{P5UD|z_5H6k%nG4C4EmXIR@0+nBj><=DI5T%JPtDJe%+r|64Hwr1 z?SV>0%n^~$2oM@(Cz&ZFRJped0rp10Cj%*^JJQb#F;tWmMl)pp`k%XY?N@*2?YArb z%>tzAjh-=$SC=ZnI`D=AcuNn${57d`(J6_dv@L5)D*i4I%!~1!9)@7b9QC|nL*w1P z#Ft)rNzh9KT^4+iSB~R#pZ)A-1xfH+{imOPI zntAzmh@NM(YdV&qQf;Z*px8rx~9VWbbf)UvxB& zQN^C{`S@M{A<&I(dvl#~c1(jfm_Ztz%$SBox>RJQD6lOYGeb7v-(b0%i9R&atMU$$ z>d;$e=kDluj~4Aq!p;@UjEzP^sqV)6qB|Tc{FK2Coa1&|TMWyVow@(7H%b8%{G5A% zuKESbN^z!6r6Q_RtH2W8-`$}bSFfp_rajtYU}Sx3o$kExrW6&Fot#C6mn>AWfz*kW%J-lh73L8_u7N&qX`SvK z9MY60^9(sRh>U?oqI#WdqZ8-YkU|oajNZKcHvND8fB%6vhtV9$rc82eV}*0qVI**k zH-t?-zNh}ePg8mDHXVQGTh!{U(nh~26hO1tl1+55V4AzrV~4a)_jn_jFxr<7)-+m& zc3NQx>f-+9a*i9B3JaIjwy88T`k#O3`RCcOUC?*F`-dD6YL8p3Cgksdzn9trU3!nB zN3Qi@YvkHJL3GO~KIBHZ+>|@pcBh^jY2U>WahR#RN6>uJ(QuCnrk{?7`}|uK)%>WY zcF=ASbtGEWQdW*~*Ppfa)YkgVuiw2Z#Xg3*r-eQ;g|PV3UdvbqW=JWGFw>E0U|Ozo z=|S8N+HK)tT#O&`5IX_d8^enX&@b6FFQ0qvIY_EV&v7`HKl`&kTkB z-M+OWsZzb%KnfIK&VmHZ1%m=(Mkps>nD_&x(KIDV^Fjd->1Gkk-Nn030#PUh{m zq9|@?!wJ~NH&i8q8MQt;uQ#q=rXfL^C#xJYZ|l21fuQ?{x;=KX8>-C-3BTpR5$!%0 z(mXK31%QC<)A8_77%qHCIK~huoG{9Y{^JMC+|A1)DrR?SeYMX>>_oIBJN$h?tIx{> z=}iFum58AfFBGBoc}?ldD}$z{h463`?x|3Y)b1SY({)B=$J2?(3EsPZSDKT+=E0V> zyFE!E=!j&}Jbfp{cat10HxtnS*6mmS_L&YQzkcodXaBb!VF&ZK8L32pPie;p87>t0 z1Z(agg6nt<2UsYKD`9h9dF2&30O(@E*+g0i)*CtmA?*b6T~MI)a#$oz)(# zeDEhZg1p2O>n>$`J0z;L(w(4T$uwGo0!p~a2LoyxXg##Mx=clb(wLybkkH6MT$WrG zKU$ZwBgD&NquZfRJb9VE&nWlndn3vV?U%hQ=>%%#j#lF8%LXA7PEeGvum>CFh{LH~ z;l%-2_3igRPx;lSY5LvY=jf50<@SJK|Bj;6(Q=CT*t(32ZZNWms=1x`b0%XH zbFz%uz7cyK6hhe1MKHg{c4dM*O@0h1P8hdfZvmV@5IrXL5ou>ZC4|aZLG6vJ6ef3= zuAhMS6T9X6JUZ_j4=03{<(q>RgD6a|?;p?urflDOa74qUim3xY!Dlp>TJBxCEc(!> zGDqGe^cwegPcFvCboZ@Was<%ql%^n%?2Aq6^SK65*g!e}gB)+~4aMdJQ%Layr_7vr zXS3jFccj-zt`26b<)q9ks&kpC^IS@&ys1^D=R$+uy8Ra2-?_v0wdmQ_8Vzx*?09xF zrTn8C1-A`UYooed3x=Nd?|h5$`H+g%;IZ?s{lxqJuYt26>!i|qJLOJEd zj&=xEsDd7`MN74`%Cg#Ikn4ito`ywN=Z*NABMK z5ih2uIlub0MT|*tORPg9B3odFuq7If;y?#&wFs27&EM(D)h$`%D<614p11P{f5g#o zL#nF*^u|}?{7s}N-t%STLYOU{6wf~M}Oa@ z&ku<_%SVK_W&|p6Jz$NvCZ?$_ zM5ieb$N}N8c9G=w`?{YJ$o#;hA)&V70OGsHC zAO6yR_;hG?&U7$eoP67PP?M`8+fO`nr4zlXgQxlEU-~*YXqoO=Ii&?Y3UUZySwsUi zbjdo>iS4F<#xk^(;aRaG7MkpEcM}bP{B-kA-u2 z=@0)l-M#rQ(c?zQ8lT(x*zf5UasJ{WH}Tgmdhf@FL&dB3j?n%5#ne(Zz?Pv(HRuf=noH zt!``a;%oCnh3bEwqcRk$0BVI~1oSJ>%XNisKgG1bQ!8D`oB2n#9?rQ)S&SS*cxsn3o0K=#`Wc%eW2{%2|Hxu=O&I2Cu_rpf*ewIR`fP0m__ zVQ7mkcld&Q2ON8}JV=bpda@EFh%wcNSKn7hjHO+Gu|l-Hik1>Y6jM33fCA?b8_!JS zppy3wl>Ew?V1Ls0)Ysy-6S|vJt^(8s5-lLqTP#rpSlT&!xM0p{lk{`azhgK_hrIa2 z_Fr|06r_0oS-}PQ#TW>x{9=+TxE8^6EOyt4#iGX=GC_5Dzf^Qx&J3S7r^u~HBDyeb zJY9`pM@*edFYrl+JD0F8a4eyHX)X5zJ?+2mGiT;rV*Ic6{xjJ@rNwY!HDd6V#07%_ zL37CED5^+R6;1RnWz$OC1En|=IednCRjF8ow4N2)le3NJU%o=U%_k@aEXb&t1pQKB z2zt38(s(_j{TKj(zUSSBO7bjCR}Yith- zTHV~HCoW&6C!ctNt}&}~q+>_Vn3fs;Ssb$bRHt5?0ER$9jz#ajB@b<^h zL`Is`hxvDXmIL=DA8(((c(0X9ffMQzq_duM?ODh#hCb% zeSd%&t%g;pXLyg6uwA=%zE5{3cZW*138#y4LiUJas~R5K5muc-Wt*=!s?6TB;X`^rjbTvd0sTp%W?E{Bphsrs3J3cXRt6g2qO;FnA^#x#wOGhyq)`Q)BE3Ji!A~WI7K~yTO#LN&N z$_-_OVOLRRNF2vde~h7__4hjM$7Ol=B2asCGLcJ$xm_7WAof`FxR@4Z^u%ifVn8l| zWP%0o9A+3@%e_1i(D`v+~_7mS(f z?cw%IFTIqd1)(V*F&d4~-Ay`_xY7vj_tucG7arIlFU|EZ;annL4Tc#A5FE@#f8$>` z>o|d4ZZbu#%{zGil8rYQFqr}zwjZbK&CoUs-6M22R>sUD<6yQY6NFJ(C}MBvHfiNU zpQ6?apQYxd_fxaED%3a}J>;k>yMzW5QTFxevXs|#*q(Hnuqi)$A!*%I935z*v@RPZwOEU$srtDZn^k2KPvPONNZ*9>;hZAcX%YN zejl(6e(U}{dVA*q-9;mk{XN>@_uSciK)d+8837%k+aACk$m&TO$A|IDx)Au}96-Lm zIJayEA=QMGz43U+wlUI}5!VKtViZrzgP2^UPN$CU-uI>0=d6*p27ig?aETmZRFs1z zN0%QtKAZ1sOQc;SkbC4J!-qnO;jLS@5S(QI?_4?(X(sN2{pzp&D(Ozx@!{)Y7Gtp&$ z>8U5LQe$h2!s-TlR}-3i|BskQDG4i?Y z#u1Ac>jC*@{`O8;Bvg5D`6AyWFsyB)T%Xq za57z<2m`s3Xi@+h0VcrN01T|HmtTI_wUVpPu%6@5jj)er{^I{-?L69-KYjF7S>(Zr zKpm8jEuNy;6xE~DEu#JyXEtCiDm`TZG%~e4Mq4YBQ%d?$85N1_KJhf=@B2xj?I$Q( z+n~@MFdE1~FbBr6PZx#3qPjQFp->_KOAzN0jv_$MX`yLRfCnK{*M?o-&mT@^ba>44 zZo}9fvvsjPdF0h7!O&63;}12Xhs#p-|0u1HZ`@#;((O|J11}P>Bf9h2ZE7?-V)M|* z1K`MVwxD96jXFAf>}&m=6eE$)`Q%l*Rf?7Uwwf-GTxQHTy?*BwedmwAFX(LrNJIx6 z^tTZv4)py}88Ynkb4#;Ifr;8ym?wM6MyVW;Y{N@ z?Z&IOzD8#a%+>Xu`B&Ewm?3`{a~o1cpvFCjLMKGz9^?5yG2uf2#e{!_A|KojRFWN3 zxqYgTG{<@a9S-~AHpWs|D$2|f=Ok8+qgE_yQG_lO{O!?O zEOkNYLmp}Rl(a)fA5&iF;Qw-sEfnK8L zYDDgeF48>LE&-r6nxs%Ws2IsdVR*mAPwSyDh-nXKG$Kzh7f0H%c&aEeSR@&2Gl(YB zpwx~?{@9s-BlgVuuQJ2Z=5UfJgSWm<)7^dPv{1&}7L&M#{Dw`D#K`leP|V1&!GJ^8 zBF!7cd1|sVid@D=If8lO1h&vIh_9Q`FX|kN9-6J~%s2FSVjG}z{^2`_V{I3~28;$u zbg_QwqQOlfM8~pjE$(i%in9G)1~!RM?~(8>#9D_9m;gn8`Pr%yXUWNe9;3W zRKsE;z}yC%=W`t+)x=Di5QMaqmlyr`I8BjzE~ed159rYccQO9yMxcN1ogTVyG9N!u z=Mr<+D^{H@$9+&#@aLe|2YHt$QUK%Sftjx#X#crl0|scq^z)#9d3bmj9rXbMbmeFM z%ID}D7wKg2k92=CIy59X+v22JKDlH_mgaz#26$i}MBCq$ zO^UN)-lKU>-M&oO>YB7wgj$L0O>rr?{OIzA*EjjP)6AvX8!Zj{-HzHGq`ZkB;?u8j@MFsKNx* z7p>2}UL?oVI=fYMy0NxF{%jzb;>d@}M^f^yEPd#(vwf}+6Gp>B@fB(;6?EuAuqlcn3=()O6iIfm4_z8|;V(|LO zzzicW!}@Xm3w_ApWSS&i@#QanIeO6@SZK?=l=TQ^!G4c(+mmxSnCTmyMo3b9Bu6sc z$xJ$sq$o`FeDjhkVRdX4=3Kf#KJi&=o%}DwijE`|Oc#$TBNtoMwVxXj=}|*8=#eTJ zqS9w9-V5U69`(dnQRIqf26FD)&jO;Uh+-b}N#(`gU?dOdd$D_;!rCeUDlBGW4S>co z+I(VLrIHZsG&Ai%&gkY0eNHh!K{yB1(+uovqRI}!wnKEq;XjbaiHHe-E@C8`9+EZ_oq@^JT6bigN9^K;l&9 zqCLht1!e4vg8s+nVKZtsGZFK}yJI6kn=RM7Jn_%-jADJe70kJ$P;CHy9n7&rQBs+r za26zHr8=#S*!nM>Sy2-<_KfP^SlsSlZf;S^`9_f$x+e+s#lOcBy$;>rX!@D8K3(q* zsFyb?q1;%1d`;oU6N}{Z-Xim{6&jzHPq%zxPy4{%ZX|J?=3xTG^v*sQo{#Cbt?NS7 zB@64#6dZ!*M`JA}xtHgTI4{Qe$M62`H?jHst#^9-!pVH}@ZugahtiD~9>V>mI8p^u za5N!Ty%~~^{ESUlw(H;&a{;kdzy3zzT0`VXG zCeeTUYectRV^6FrX;t){=~mh=d+;6eVa6nogvJm&!tssiEeaaUudyxM ze&KoQKK~5i;D&$jGTR3@xowfHBgxT6MY_7G;<}YV?7)C+2WV%UBd}(<6zSH(&o3pQ zgYNl{_I0ZLI-I;O8t9V1MD=GF5Yh-I-6Gf(>0lI;mY+(ok^XKFv3F@cKhBfK98SF# zRrW7sm-5+AbTQ8wd3W`5j>@}(iPR5Nfjg4vDI&=GcR;VzYDpzI)(-xL?SSYy01Y^o zkShX+fwc!ZoY!Fv78>6HYttkrOJ~c@JI>``y4RYX5PXpT?M~)9e6rAbFpStJK$Ryg zA_3i2@Uf?>Rxos-YEE}|YOl5ba zrC3EJ^W#qpy3*5jet(CG?|z$(Uq_{1E_8zAQzGhQXEL-KB5@%y8<1JTaq)AIV2y~g ziOrS0Yb?A5zpmHm2#Q$R=84WnMbKly1)mMaSEW8q`8`pQOHnrszI}wM^_Nm3id^O) z)ik9Ol0tRfWin5qI>1iVLn{uIe*<V#Evg!3ccgWV zO0Q`eR{ea@a>U*`n^C;8L&yK%A5wAa4c;HoK%2kMa7?1iJRghDc%S#_7V;=_fDsIM z0Avx+C917LKW?^1O)G2+aY5Jmko#epI%wBP%doFH5tOn%}+0Uk}82F0{P}!%HEQ=2_xeY#P$I_&W&L0pOw8=I_)x zk!l4XqDfhWVCHVuD zw7s|Q|!K|Y|EZE-lIN}VIjFrABY$~-sqR(iBHXoF3ce8w_pMR^9@w4fDp5(SgW z8x-uw?jb*h-(xsmXpT;y$f}C6TY^Av3d1(JpiU+dl1s~*jP@KS6cBZygy-2OwrIo@ z$@swz9sK_HXu`)C`S>$l*wBZJR?rg`e72U>2F;yf2*yjhN|m|DqtYN)eEn`u3YL6S z$ff1gfm(?-HizLiH z^>N{3{$RsU6bXV*P=LQv4NS~w@0SeZG3}#_0egYs1q@Wof)a$VI@TA{;U%a-rV=yH z8FOk8?{)M4{NLU@kGMLz_h?b6Yu;)uR;Xg&K%ScwEWupS7OFst(u;TucN1;#ShG9o zbevS6(88Kh7^Kh?_lHK`{T`J^57hP?A1kHHHez#ql{)MRwV7e+G@!S&i(7C&ORDUJUzcD8{3JxNb@`7O~# znLpvlexIHm4Cpf35@`YwQp50=k$en}Z}nW&|Bh{p2r!uo^aV|Zu0zHnv z%kwjtN{3!-m`o<}S|~Bxzkk03I{*X|HpYb3<#DV{mkx7{^8{=>7jki4iae&1|8&QY z6ixBso`fEk2-G{1I5OkaOFqolo@fmT1QXXdIhVTaoALy6#59^rq6A(2cD!U!qDnoe z;JGh!h2;gw^qE`6N~BdMn7m6>5TtDitpXZJ&5YtLCnGAlZALI#vPeLG^G+awxTy5w zUz(t!2&tmp+6uKgZCYW}upob>{guoh@jckF(!t(kkz) zgFr$N?TqJi`)EuD5DPAos5hL1%+uB76iGN`B>c&noq?d3Xy0Y?nv$+QGSVgkxa~B# z=f{8nNRXV699OVoII*4*xu`%7d%hRrPk3B7neTqUkb!pdx$BZBJ`9vV$B_&u%}@XI zPs`(j!Juk`hvEef+_1h99n2F|=uulj`dSI?%g=rItm6YU{^abBt3XvbQc@=cJ5i?X z0Hc*odODJAkK%ZF0gqDPrD_=KLCB@BlrK@mDWc`(X>V>x!-8T-vng^#!lZTj3{73- z+Y@C@MpIgUVw?BPoCe(v2hr*fVlP}sDo`elljuo83J9tn2(TF+7&C}bps+@eJ5R4N zJ7RW5TImYwl?yA@V|&-md4JC|mtBR;b9PkQboufcEqC`R+fl|N-uwfb7 z1wu>5T-h+}?^-C;5Zj6K+awi&$MFw%ug*-zvM|)woy}m5py#^!MSmyF<=3LASQRc2 zr3_eNM;yQ)m8T;sUaEXr`%o};p$t+J0qy6!REmF<0j6!X?OS~Aul5JD2A$7jyA2Ld znSWcQ_&s92wTAcPa@H6;_j&XtC!fz`bJ~$h1XdVG$3meGkV`n5d=Zw|)2%S9uvBD; zfLh`{YmyTz6-$D^M2hlHhax|HeY%1ipX$zKO4DJH;{rnodfbtjv|83$L&|x=e~Iyp zD7i;Z*-oqR#k8W!sY;z(p~|apN?fV$%oezze?Tm)QcpcsomMurr02DMK{`R3mws)L zs#Jh4W|Z+F2<)UFZ!!ZsiRcAR7c|G%35`o_eBVP$vSq9)qJh9-ugF8Fzek=`trD^5 zaE)9{XoD@q>5SF!;P|xsCUIyIG#77}o18);Q|Gtc>QFatNSB7ITPy4c_M~U+@@PcM z=~7Tr023PtBNv%MT{7gKK-26Bj zOWGB0u+pwKhl9q*9@AyAM~fT9Nae<*P2O`m;EOAO0gfd$N!T>xWa1MfuaObI19mLI zjLl<`3TA0dl{R!V)smoXhl@g695<|x6M2$wKt)Z2pR>j`0Id=a-?~dj-+Y;d`wYOq z5tX7!11-FWCktYqV4MpEIm(6Z&n+ORxjxqKbR|{Ew=UHURW0M(00}T$!5!$RKTmGX z@_^Z@cGi^dAm78#Pe(3=pR;&DK!}R{s(lLgXhnX{oZSV3J{yb_U+;HmtBJhROlWjC z($LoIm>sVeFLT1hccq7I!|Bn#_bobWWbMvJan1IkbeW>HwYA`LpOMPyxd^_P-jBEd z?RU|2(B!FxEc`Oq7C4mbSR&WbZB6NNMF<3u!V>G^t1~^#cU|Oj>i!c+UZ^_qmXMBK z`mh#}vRUBTnyFWhvr{SF8wz{85CVTgV10Xg8zm+;1J(MKX3ELy*ko>?8m|!b16|yu zNi=t0Sur$XhUEQY0PtK&FQW zB$a+Bjv1_qe&(Rxt#U9yKZt+iN#b^aMi+7~rPznB(d|cscY%6}_gs)y*pZ=@xjSIV z`;b*mLmsKm$G5`hDRO6?y?%*iQ|nALJYZ*ef!Rtf3*mW>>d2|Etw>z(^Tg?lrA5U0 zD_}z?<|8d^IG5foGFca%QX&YZSCaeisM1B($xNUd>QjN?`NPhO@dJ+w zC-dx);J1n>TNY{v|5M zy?7J@)#L1Nf~NiGS;zUpz!$=fEzrFtKvplO z7oXggj&CKiWy81L=Es8QJ;NrFXj>}GP_koN&I@bcp};`2&kvb`8yHRgzTHl@LMMF` z+D}vM8h{fxyP2j}wNTt@g{}d`vr+RqodHv)vaUVXqENCtQ2(SF49U&mM&ps_pUcO$ z$%y582WeG&jO^5p`1{;B9MJ=2zMu@_c3DTLoYs2i_YUD<_p5*R>_VTAcRmM7oYBcV zSi9AuqoWg@K^)0P+6Wi`IG4y*hEs{lZ~Vq@oJi4vUzRGAxI~`+{cFGWYsk%nX07;q zf1(t*4>$UrcWFpTbr4o*FM{j^!`ia4CS(kt7rT4G$gug+d zZb7I&nssCmJUpv3D;155g36WB6(N&F6pgn4!dTdXR+KXnX&x$bn+Z;;X14eZ;DR==o zhup}9IB10=VK3OpgrN9@ouTQXP_!?W=J06#WrJaR^b60EiHhpO21mF}w9rBU3)))p z8~3po_`XYP1KQr)kjnXhjzhdbA^qD6ZzRIZPvsbj98+FL#<6bKNJ4D~Ng{{fEe;Iw z7@*2!K}V|eJ3}&ua-+j@lRV1I(afdiE2%~$+Gc_(7o90SUOu<2%!|Bo(GATKG|>~g zy#OjR%}%5$^m=_h$4!|alleqQEga8`H@c(AR9b)GV>-NHAy)}q z6^rNH6fVX;-MDZvA3r>QpNAMN{B5#!vUnh!f@??;uIG?Y)@`?zpL+T5t zB>)K+QqY0^8GK*vRN-^aJtu`dxL|Dk)W5bGvizyDjuY}kzP`wY8I3)i66#7DBbp=xg9n}e7L90~h&@oG z*N~T(>HZ8x4Gyko{^FCA?x~87koq&{&zSSEqAhXhC~6jJX3S4QaJ3Sp%tdktP8d6t z?M7SV^N@B0&;q-0!RYAXctWC#Y`_HUA0&$8duJj@M3n4H;^6Z05Y(5Gp`3{p+nm+D zF=3%DP?e0*GS)0up)44t6YPf5`#@RSqCeeMr_*gh43(p)-QMCO*ss#+Bh(3n?SMT( zdR5L8c^mqdq(x98{$BUk7dYbYxUhN-pDdn}d$OaC%_3vGP9vJAN}`&xAVHKIOJ4AoexKQQJ^8D(a%bXqAuF zW6wO-G6*W#wtSUf(Hgw!k|}%?-vF_c)|8o6puhj=Yy8}nICA}L$&M9oL{`|NZOlsR zqbiJxEF!Y+E`gcR8@PBxk83Qt>13om&e`!@Ss73Y_38J@rDtp#X$2uzC1G8C=jT_E zQ{6PA`p{5F+0YT;TIFDFuT9^>=cuMjmD>G*|5)GH;0<6QNbrQwO60yGKXo`AOVvJ{ zNp#!L%Tqhm3ho@$p@GpP!<>~G1&!l-7d9n`PT&B~-+QNOk}8p-rH-MRGraVB|~rKADPm(kiXMoSniD`qSN&AZIaCLuRJ zGs;?3N-|nuyD{Kny1Kc-l=c9YfQrLi4&cFvH7iC;3tFA2(+tG|Naj+DQT;nBQ^vpo z+b$f^pg&L{pFp z8hE9rPEcVNmsUTl-f7QxZHeEG8YS15EtdZ7b#STC-NiS-0q!$Dl`=_hy|Kn$!q1b5Cr!^dNq zJh+?~{^rNB)3rE{8s~N}eS&&^W!k;Rt6DLzNw{N~h^(jdcywe21Nmp>T%I`7X~peE zul0FPq7Hs-QI$lDm>?xtD@T&WQhMtb6|_`nz0^(wOG7Wcsyrrw>z1)s9gd5vqjFd~ zR4al9Wc1)*ml=x;LTl2sC*aP-RFJ$W3TS|4V&XFb+3fXb8cn>L-x5`FK*~fMmuU?_ z=?syrotVtrUYAlbhbnsK2ae+AQ=Zsu>hxCB@iRvfxt6#fpV6s;?Uf!~y|g8t)$TNC zHk(VQ2U*w4g^0aJixwf?E76_F3qe%Yo!Dr!PH73zK!K6pLyYJyq&rSdn|w|>?T+SQ zW}1J44Gh|oGjyV;sQj$j!0?&5ZANmoW2x2QM%B2Znb`&*G8dUTx|IwP&Oxw5bxRp& zvO~SOzClaqUh;-KW@KQ(zX=>m~AmE>5&pvb+WkIQrUaEcsj#0%kEb-^piP02y-OFL$T)Ti-Qpu$Sw2Rj9_L!Zqm>27Q~Qs!9`xP z`Q(}ML{H9MH-^T9&t?Br1#XwEQ`=+dOVOo=tYL?M6=XEIb&`?$8?{!AxiU`R#^~MV zv`_gIXyANEfgP<9`&^fUb~MI-UNJjphxgx>J+++9Y3;@}0SbUDb~{b5(&UXNAQS|; z90=TeuFTj{>IgIyOTU*oWFpDaH2rdRj3KJMC=NK6KEXL(ALH;JabKuM6g(S zIa5Z=_T}Jho6W9?L{USf??hiYQVnSE^SkYw@tC19VQeF@FV{?%oheiH(ET1PSmu1A ze&$7ENl(iAURyDGa#578ylH7IN-JDN+obu<=XQxy>;YSb7(DFos5r%il1ZUPTW5OI!oBaRci#IR;=|TrhaTNCmmm4|+s(wYS3U zdOId)cUjRD+L~)om`qR^Em%WUn9>{)iBhOvFB8!dxk(baHEN9&4L=%AX)!q>+St;L zKIpQ5d`5oHo#99-;v|uyM0+oe$ewzVT1=^9;UJm+?7_M)~Wb(UIj*3Vl3qN-N2+IXH@$BK$}tv4Xg2F~iBU zAkR_FicPTV;O8eP_Nk~1x)^_^Kt$%pmA>rsdn6zO;7~brIkq;-E#5 zHEIcK6=-v%PnWi~WUuWt3mP6T#inRFnq8vhIvQOFG*H?LV-Ihr;~LG@X3+0M0WCqd zxJB!$YeZ=tWl#pKiE~l0VEIW$jcT1_M^BY)QyD+lOs|qp8L-N(1kFFi^~zvHK#P(c z)G-ic9wP=QP>+DeQ5Vo@H>7hC(!Ca*#&!~OFh;Vzyu*5s>)tu?Fsae|2&0hFlN`t; zuNy(?G1sAo`Rc2$5|B*To0ngHIeKFc>*ynI0X6_@3WDnxur&ZcTz476bECaoq_>bX zIk7Vli}O~Iht$hXX>uR<(6p$>Hwi?cIABLF{fqhY*traLEhoSWd_a5Pf)g!@*QB=_|L{k&71%WG7&PZ}QC{(&w!3;XhnMC z1npDaY#6F%T9g8AdK5{a5|GWvT52JR<&CDr3lgZIcJ!&M@8YP4KQxi+WJs|6jI4DO_6igGs64aYVGzc?k-5Lzaut?kP~#|Zig z87Vl!9Oc8&N1hJo6F8SRe=Xj?J4`h#lEiniV+}s9PNz|nHaDx~mD>SPxPUN!DC5G(eCL6A z3)Is8hLHo3`CGsBTP5aw+EN&H1Hl-IdhoZSl|U|W4aXA3mK~51NKTq_9FqRY>|vgr ze>wRBwFQ+;QEIe8+{6ey%$UVenK1z@B30~D5vdYfFUnfWI9AT79GN;nL{*#LW2V7N zAaYw9ntsJL2055Bc5vn>mdO-3Y;zF3Xm!P=toGXKT=E780K?fI)AKVK9aCO0BsExW zyZXx|EoKu{9^t^;8KkB#V?mruIDmi-KEQSb`*f?@;@2!VC|3z|NG#!Gc<*l6mH}8m zbCbb}CaU8@Ca9{mWiHLCu{~t_G#U-%JZ-G4Xm>bs1o2ste=YuyMW1nTC)4FrQln@GDc2s2 z;)|w~hZ|ql48-8c|4}X0sB>7N(-YCODNEBFE9=&Rx*2#76+~VKEf^PNja=%5q^caL z(-lH#pYF(%pmtd;honO886EB)(|rFn1x6t=b5iksO@Y^2N_w|ZJ;w`{QNza78?--D zrwSG2OF9NiC4gD=594Nze0!=(=TDy{Vk& z+>;==Wbf&l#0l&$kT|&8+gE5d8uRbZ&hKoh2yv$6L3kgjK*TviZV`m@`AbcfGpcfF zLV`+$YE9TBjYuEfH4=@?8~7Wl2rgFyX%$?9vOpISmtFC7lJtv}_eu(Vs&!~`MRK3D z_LBQkh9SuzeTUfjV*F{33n%l$sFpGUA{Z>pW7rHBH_Y*uUU~`b9;KZfjG*@)#~OfO zG;+zH4uw)2 zaWVl~uoKlZHIg9ZJ6hoK{PibzZ{1K0OFkd7@j{!9NCBtH;z9!DDBd(H{Y1TybNX@|nLYp~aS%VXKRu`~puBOuK zj%?P{xZSp5AvngHTieQdF6NR-)?)hxNhqWSz^UxP!IaKJZTWqQSVK|*i)y%mV3v1Xz#y9XAWx;fC3-ChGGIAvlwQ!Bh`9flrx7v#SW+F#xfl)g6r_t+z<0~ zEVDIId5g6Mh5)x+-N7kxE7UvF(L66jE=DY6*i6~IqUTO^?;)>7!Ua*O>0buVzr+Ej z1r09Q0_qU3E-pZO{_em2b03vDvFfwyYQ1O42xPm^p@SXk~QoXf&1gRKRm zJD?hfT4W*bpVga5nH2hvO&C(CBXmpHd2pAHc_uVDG%WG?t32_f7c3Sdqm_{4X)=0t z{r%6D9vy_*$0uThyrpG1N*iZ*0_h5EgLv}FXn4yqj$c)9sM z$OUOK?TaYuBQL&>p1*NT@D`KIPQVl!w69p2K#>ifU7E0g4IEL@Sx zP((J*jYiKD%@lbK{*K6JjMK4v9xBZ@R@P`^eM9ID^i-F~Hw25_vDgZuB}nCnplXqY zDxb1p6py2tX})E8`%(8{Q@eEKNuj@saw+}cL3JQkYRtdekP(2T2>}DS%q&WuEM{`4 z(<6MT7Z zF&9|~||31mq~pxIC%4qM`YgzF~bHahMAU10-Rx*^0sA95H=tQoKXKr&%3 zcwNbJm@)^tDSeu^wzgn+(LPd+;ZuL(zdO^p>=q+>a{30P6~VHi%d)ZuODpPeK*Rk8 z0XJL@j~&cq5k#`5s6Y_4BUJ~S7!&j|Ixwoa|Hj(w-n+|t>LG_G;b)D~n8o(7ak zI<2<8&YY(AJ$02n`2J_qc=87RoOt=JQ_8Ok_Sb40$f zP|pq2#!e-{Ykg&%k<@NYi#ym7wFM@w#i=E&70B~u^?foIkEc4tssbXy$&|B@uwSq@ zYnzwl9F%OgXVbC#IH$P0~i-Q2KKKU;xa(ZD*39(BSfsurr=^1s@K2 zv@SX6oqf=!-2A^Y{m1iC;^6ItLhddSgj9x+R z`IkU@7^PdUBp#xpky=>ck%8w;`roC|3vec*MT(vSQ*8p_WX_Af)~nIoMbPByO!~-r z4|DSOFgIz)=-A|V*JeE=9GZ0R_*e>f)Je&y^~5?&hhyr@$MnKy*vY=~ek$7=)Z=Iq zmCM)+dTq_2Y*=q|L{ON(NVrBGVXxa4L=Jf>5@BVTQ5Tue;0@2AjOc_gpig|{!*rFY zl?gkQi`k5Ns~N4Wd4^MoyqUHg4@DJ;2P*Jjd@P!AMskyH{`$1~I-Ua$ zbV)7zx4GK+DCRW&GA+z>$g>c10liE+U3o_ztS96(<35vh@uHwgs>p}aG#gT*1a=#U2n-i;F!499kL=4|{<1vAj%JB<0OM!$cw|Q-e&;*i5$#I^W~hus z4koBTpfv1^W-q?@qI5HX_%x%P8S>OHr1MF8b?vNU-CW++mW37Sq>iN62cfuw>0IJb zLmNr+DxDcy2$N-OSmBg;&9qf^DQ1<;DmxuH(}P)8&_fX~hm>8nEhr`$V&G>Gb_~jTjUazRlBdCswb{;eJADQ(dVJ|h zfsN$yn_~reJvgS%T)#>m`M~=mmvqjb+hkjkw{qRn4Ge%(p0HkiQsq!fMNF+T5e_1T zD$|u8(`N(q?eg&~Cqs5xmvSDiU%AHb*%tXBZ;dPpCt~9lHSL|LSi67|xiW#8|DV-q zRGxNqDz&NtFmO&8lz8f?XJ|TR$1*M?FH{<=FgN>LF2i-{`Pgf!(L zoB|}mBN(rGY4~I3>FBfi>?c2hIUP(36Zys*R7G|!h3SIeIs!2GUa&U~Uf{k|0~7LJ zX)zuAHItf@u0HVdm?lrZM42}Fc-%NIMJ^kJ=XRw9KS^;x^?W=pP1JVu1qddLC!*05 z!9)%kC2qXuUt)Aacsd%5El4Gz#-gkiWi0(#<#m>&c1{pEBCG0?MNh7`6)eJ3X(Op9 zCvyaS>IRcQBK_Hm#IYjV5GdBu0|jb3JNJ2!k3}dKi@V?Iid0c3tXB|2>b1)3 zd2~oOp1(xbKJ$~b`rJop_4+f^>a2<|?Z(D{RtHRT^!jq?utV8{lgSglg=#h4Fj~3N z6-|jY8(ILAY0~nbyF$-D{S4jw!26}c!tnTz<{Sxj@`8GN7FI8Bik=$ITV^PsbcRrk8VmP%~|OI}}UZVVQ0F&)xye@Aj2=j_;RGexz%vB`_F zCun9K>I!iAC&(#Dev77jnkJi9`~{^t8s5f|vI^bQ;vjXn8z#Tj>aEg?AN@GZhe&)M zbA&yXtXiD|8vU>FW{THnZcHhg-5zf!`;$>MzkFJjNjRF);xW=A9k|pH^}(lj&2wNp z6$Hc4OY5>A=p~G*_k{Ifts+HZ)`K~$HBV1T*P(0G%EG!xy?jrNA4exsrSqzZcw)hL zA95@5iOBi-3no*<=wk+&{1cz}ga8t#BnR6CnjEi1p$`yDtSdI6844VLfCAA3WtbY^ z+qZAWyti@VtYh7r->=f7q5&GEF}keQz@^{YuY`Mpv3!Qws*@pdD2!@IZtN;5;2x^a*K}CI!7fr|L|*= z)2{Cd#2(Hi0xd5l71W*qiqC%bv(eM9yx<4jd>qBReED+a3z|Hbq;et1v-fcsG5Pzv zd6*44-w?;rDu-kkl3AUWQyy)wjy_cfA0JMU!ysB-jaQFXeaJz7AVNif4@X{4MT-Qz zIb>#tQW?ndU$if%I0BwP9TmM%D!o3mGU`N4a@pvK<5}{B!KR1+60JvO}qmc)hBS(g*azyFtjE463bI~UqT_CWHHNuis zm=llVLmIP-+1Y5)%Ek)qKNu>-ZHrsZp>^?rLyh~(9JGB~Qxa6o%_VUti z?`|u!kdvwfL*$#hdHh+Esg(XEJ@>(nP?x{g-TS;yiv@2KWBaU3@?B%qpf*%_t$U+m zx;H+eML31}MV%XUaAlk1BR7nCQ#n1aAXSv!i%1fAm_TB%5nDC6BxqX@CTqvge|Zz@ z>La<%DNXL$muaz2b@-NldEscjcLtoyPkrj+m>K`_qaHUWlfM3Y-~3nTpMl{{9&WpK zvKuLgt-HRfSFvJ_<1x_VK<#}^tgfy?w@;Wa*oZrK?$FCGzgz(l7}hFM=y-@xqg5o< znlyN5ch4;F*_=I42a&v+i!LP`Y$%=eQm~?MR1^C{;YuX>{JhlQw8->+t*v&sI}BGO z2{i*pHPeO-#u2{xY(a;6_h@V5vZgM<@hi32gy>$baIicpX|a1i+iR<|`q@v>!P`u= zvQ0f=@MF3-qRsUcIvOvSoiXJUcar4?$^{?@Cpye%okN3dz&gM7$!k}rv6#|`0g^UH z@|U&%ezd5(v_ZpzaaG|BphUXPk?ze|LmM_CRX`|m?CkTo$pgCXvVAV3g`kg#eILqq zhitdNe64LtanVy(uhIPn_i08GEv`*AVFf7^GlX2cN(?Hn87Z81BzaR9d85g_FL^-08>GCf%T>fWaFBz8vCMNd zv9>^!qm?RjXMu#;7ZD#0NTyYir%gT1JjV0pV9Gevf-ao?fCNdYdMBqHeA4!i?m5G` z^qc@WApY`RB`7&Hj9?a4t*6y>RH2ixs?^Jcf`GWK(4vC{|**v4Xv+>SHdq`M>FSq@Ukv&}yftXeviQrHF$U`UTKc=lVKz);aRy z#WT8fo2IwlqA)$AOH8AG@Fzb?AOHMk=+mG7Df%QMoHsx533}l}AEamB|2#eO-1AHc zJwwkw`#in);)m#CANv@6^5Y++k8)J`)aDv>c+xM~sar0`{Ba9G4;enBF;D){gri;S zLNnz}Msh_N{VZ%_6R8L31KZTR9^@o)M2m~$W|9fJOQf0W_Y_Y(Bkf}2k*PfoQ67-Z z86$|peWp5Q(%p!+BYOXhXX)u1?_H}DR8fXZwS?+$?Bja^x6ODP=W>@@j$GMkBW zIWKEXIQ>569G+SW(Ybu`b?B+$;i3E*uM}lR`Ds-4A^jskGEeL@Y4ZW=M=pl6gA+*AaK0r{1K4(Mq{LZ7t06y~-Jb zmLI4-7)lsU*Omo}30gd@7H2dE&x5#n^|_(hIn%-1Slky+pvt+7(LfBf0jg_>XJ%Gd zN=X-=n&GGlOv@AJL60NZ7u3dBN1s?15JeAagQB8`2M5dyjipcqx$fcwFG?x=iR_d$ zwpOWqwNH!TA%*=rw7ET?XMf@*z4$XfLoa^%Gqio>X}WazvgGf#p_+lckM~<#%O2aP z3tzgO9&K%1qUYJ>-2Bi7=*HzsRE&<;QDsDLIb$Y`ZO!2UjfW%ZFh$?s{gl9_HJVbC z3ZyXub>7e-9-+ngJ#%y=f*mp{S*3%}6a1Z(H&)vJN;7l}v&Ygk(lU&l2>)?r(4jo09P z5CFpe^K>j%DLLyHAB$inn}S%;q#Cul&r8?64>|4wg@=Q}L3#%tY!u-6LHn7Iv>Yr9 zDx6Hq#gw*RX*7!Shg=p!AJTTw48C_ha0IKm&m5+-Ll1`MRjM>jkjO)w&a9?8iB9xn ztz^8sh{!+CHV_IP2jkcyD*o_IW=m?Vc4(EI&*}b< zCetNN50;{P1_-_^P3)bI4K#PBheYFCH#b8Imor}A>=eyM3q@+#p$w%jOpXHQ6VWYO zaLB#nh!V08O-NbHA>eEMUK_4?8B`mL5!G@D=Z?|W3=UX_B3hLpqFT%-OlldPXf-TC zlglD4tO}hs%el5Mle`!FcIAv6%_uD|m(2xl6iXVkIVyhoS#~^2>83McY8q68b`mm& zJdn}np*rv4C54taxjCHE-U>B9^Q>=+o+YB(-8cV;rn8Ym&cofe`8Sxz`Na7{`?h9= zzU&=&KC0=Lj1=!pN4!ZaD2Jay47GJ5N-R5AkBzS!}% zi>)JX8EXjLd{W6GUt_p!ft?=R$RSF9@pb|6_X(6Uk;xa|B6&cnJ=y6n&1I0uRCh1(4Vjs}upbSIZt64vv!kIak zYqR^3R%IKIk)tMcXN*{3Is1xkW|pIwRo&#UvI_$rAe)o>rzb#$6X>q#yp+8?I5)00 zgH@ID?FB^A8N_I>ZA*1H9H<%Zn~jyOR2%^`z#iVezDBd1BO1PaM+;Y(x?UT!X#K;_ zOHt!|R`CAFHfOpJUEsw+TQABU)d-agTx9170LgO7=o~vxOSUm+l?YVt7!|Y3n6;VZ zT1*RR*a7gtCHyig;*oR0$3=~fuuC|`lAjEx5;h_*k_pI%tngECj7;qxij?83Lc6l$ z{dpz~+dxvNt}+#Td25^Y7)?Do*k_Uo`o^V5{#AuG0-@IR0$xPaB$Y-YOV4Wc8Jtw zgw7WSBTf&OjmqakI(szMfM8-RAhiVdfFg%;i6S@TU}6n5o6QJeb$ksVnZ9<6IV+7d z)49)#fxkNdjbHkuUn*&v+T*aC~_Dr%fEbxX7irNR%sNHO4eXi z8rdXXWIb)k;SNf1$e ze>^FTkS=O1D$@K+N*vibLUAz2LR939KqqTB=BVyiq#{C>9V}ju5MIaTBO44h0c%Tz z?h8@U0~nsp(JMP+ntg;O4dV0+;f(S1Lf)J2TND+Fir+xsc>YY#380K6ZGat0q#PjP zLgkz1s{nySmU(kqutQns@*nk7I=?LB#8oV3dD}7&>c;c4|1JK zkI9t6&NU?R%#Mf&HJ8>+@Tm5F5_aADM3eOD`tQeIlV5B-dAy8d#Zy*VO z@QBgL4kL?$C$?#Eyf2bP;tjXNpz&*X&t<#Q?zh zu_qDDM}QTCHWh430`Kz?dp5L)kHZPnQu7djG_uXJoXx5AS%)sPqyZFDc6g!ku@p2D zg-2J`*6Hf@WznBRvF&s?RK*Zc0VyjNPCWv3d|RmeMuAS35#PZobs3~U$LHzM0o}Rx zy0DDX<6SBy`%2+U(UvNV^jd9^v;#gHvK6rvZtyrbYdg zD8SNrv@rP;tqq665-2^RxCM&Zc&?vVVAiZgd@7_NA{b*7lWyI*CC`KZ<;6eFDg(%X zV+w?l13m{bg3D0Cxx~6X4_NUy2eYEs>;#mQxZJ1pFwL+<@0X1HNuxO`irm^n$|eBr zAI8&rG5h=Ai+KSADsK5h*E1|mP3Wk!FtM;LK&hNer?iQvq8Cm;(DHvS=q4)Xw4GEY zg*lazDlgFrK7)VMnT?KCI(@DYklZfyJfZR!6T30d+(zX30j=xsq+YP&nRB!O`nlvN zlx7Q!V<9DhA|{Sbn>=~T`AEcJIW8391cH(|zT`rCaAUzkWLH{VB3DJw+NGeDWaj~n zU5^(IqQ55eIpr%si*qTIutbj#hV3ww0U=&UCGqo+^ind~m(L4v?#i4|%h(pKM?{9R zleveb6;^4LH#@?)MvCZ-?!`21tKLIPttD?Pk_?W>m4DL(1cUio&Vi1OKvU#G(PzBC zk!+6R0$StFemU&&zL_;v{m zKG+emh4}Tae?7BSk)ZbATxKR~Ct}iUY+-2OP@h;1U>z)U5T+ji75@SAh>L<`6nXR*3uu~4Fff3p4Yy>l8&8Og8Vo!&2iK4d4 zmoGz60iCRnV~GNw3_cbRe$=>NeIO`H*)s%g>HC^J97~N=24w8pt2xGm*bhVV=TrK&B5Uad?78rctaUY`95@RVCM4k4vr#GgNADt^Qp1f#yZ7z?Qs zA4(^oXrG-+B&-Hu&Yv~qO>@B$x|Z-V(@^S{XKsFsZdN*g(sdJ5~b@fgBmo8!(1!Smi$ajh0_9mRK_g z)UeL}&fobvl7_<9vgWXs{(`oUtn_EG8Q>aDCZ3BmdqI!Cx5u43cmJ!a*PeKmf2EJV z!&5$f{n``HynW~He@#DP!vG1{J^%jS|NBy<3XMyft0n%1@%r57J_j-9@TD((N#u!c z+_(X>ML-G255Zo*$wZ)r$1tQ>LbEA_Ja`|Y48w%?3#>7`59E#T9#8${zwwJ~${vei ze*WIyrY=xSvb7&ApOQRBk}8Fgf?Uy8CCDk%!o`k2W?87Z=}4ZSp`y~EM>i3(*d$1& zAeq4^W}}+5l^B1|4%#Pf@`ll&-JQE~Oe@Ge#p`nuP)D0$sDQw^6`jfkEk+Yb83Guw z0IMeb%-ObZa1L+-`SBA*0B6`^CsWa&T=IP-J5XSxu&1wXTw=7ZFR4WUN(Sww>SH3e z5yInxE;W06jZQ+m$HLw%`Mu_|Qf)P;Z+7Sk-oF>T@1jC`%JxON5-l``G1tc7_*KPr z$ob>%)N8jz16p%53+XEk2T-~Op|?8!J>~aC!^D1$l>-FI^RtYPuA|X5BGDQhR zexi!pMb?4P@Z4EvSJR4e{Kujb=whYONNGN1s3`5yHwyh($TK0NIaL)^mQ?F#*3VRm zJyhr=`DgehBL9UdSvltg7eGm~qqDKT#y_@2Tv!aKD(6WFaaeJ%rV9~g4T2mN+C7II zyfzAi`0o)%p+FYlY~?|VjUfLZI*yD)mO*78AU7dJGN8oaC<39g7GDM0Hu7a-dzDem zfkboxU3IW$!7+@Qel>n#v%=T5iL~$wcq^{w$&AY>r14Sx1oNg1DX!bGpld3*qA0E6Gg@GhL_EZCk@q z`R6r2bTQu3O^%#S&HVQk1GeKxP0@DbAzWd&t(!wHh3b zbe^g8QyY`1BMXKNd8fgok4kAxN<(JdW`#P%A)&ZW;FdN&{MU245>Bp(Q?oCm@9_9x za~X3R=p~Ms9ZFP=1G$usL_RWfV!f^f03I$#F9Bl)y8||W*AS3QQKZ2D(+sB+x4q$q zDL=@>GIs3DO7h219Z^$*_k6Kn6|pCZdyMXo;s%UF8&E3A zRlb1MN~*<5W$~3%Igh@Ovnua0OMOmjyH%=WfUo|blPy$Fj1n=H{=T7hO2`&_uqm0) zjf!{@nMR_^A!JCRBna#?Dkbg0<{TAvR(i;=HSyla(b7nBTVkv<93v=Uh`92^Zg=|- z8Dc7ZDGnj_i-y5*QUT6RB?R0N5$8->WL8O+OE)2v-jU5{qJCR{V^wlbW|NsXvt`7> zp31_)DQa_c1{BpPT_AwSgABPk#|0nPoFhOi>=8R==yZczojfR7NSW%MWtL}(92`j$ zFO`}AlG<8XlV0iL7EsWoIGN+gP^fYsu{e(lY?N7UGDZ45a;q}*R$m?f%-BH(l@A== zsd~w4vfCPfsNk`R9!KeF(P^muCTNinP*ao1NLRIII;Vk*ODcsbH%gz8#2b$o^>ag_ zgNaSR=eu|GNm4~TV3R+rl3ZSw*xL((W5uta84oP7R8h(~0U->72&<-9a{N+sicV^U01^dKqb3uvC1Z9vCv#=OnmNvsYIKSO zpX}GUYFTEPXh8SSp@wb|59JW=D)d9 zA$tddVe{r(IhZ_`F{d?f(mC$eE>nc>0?ONRF=gH0ck~K!d?K#lOrlT-Xg{yN61E9o z2GmO|?J*`PEvJt5oyU2r$RFa{6RDyK(S(unA|6ubvU-LnFsVqXiY(T`xAGkmO-*@4@JN!# zkx!?}t8-FO_oX7D$aPfCo{l&QA08c3v)?7e#4;&)fi;%U6DWmFAaN8aIQc{Ztpn zOfgSpQVb&-LK&rHWXaKJXLTSslCoHl*U1YDk+JkJU!dx6A{)X8)$2<`{iNWi(CYl4 z9TZ|IS>)Edp_DE{-jS$f0>6k{QNg$}@5}qn_?%7n?`FP~!X6bZjuP<}c8aU6LQ=V& z8Co6X*mRDTB4GtOM@FU*A|iB6r6_ol=*R{#Wasi=h8&ur&bJIIj$9)WS@oN{CTA?G zT0sk9$_gDfFOKL`(ssQHV^uv45nC*LLoX#425TYhuwZM&*N;dQrH>_I>(xrYHU5S- zi3pRZGVbRJir;I4oy&jMoXd-m3^ruBuf{{zURE*~n#{ zB7(tlyu@1h>Q}#-IVgb(z-)4ln9A65kqk01AS2Wo73l&G$-X2e5N|F0eixd{ z&h}uAHil^4(QQl1MDbI3FU=sLE}BCqfLu_{`^SK7#E9(;S~s@(Edc}aIe&K66uXi& z6tn=87a)pWDk~@5+NA4F5M;5KH&iN)Y4O<+qltXMUcQ7RUr`8{&|hDb97?FF;2a|5 zYEi0wGpgYyj9?C#5gSftl5&Mpk)yRH-vDB&l0c<=ryR8se0*>!H!rOUNYP`vx61F& zegsYD1Dde2IiBfz0gXipSu~(w=?s$r6v)qq1Bzo69MdRqILRnh6<|x|KxxHY1$FrO z=(smwP-YL*K7bhq9s(uKBvn*5rMbD}P{T<-%I8VVn)x%&H$ZzEI$tRzApa5$ChQDo zas*)xY+wNO$A`EEIoL1+ZeRP_*W__Hmb@NI(B$5>)if~0*0^#Iox{O=S2~#Tj^flW^U2BK1II8_3*{cvdopLFT53NNT8NE;ME^Qy}M^&ve?JK*Cs-+3EOSLaGs*%L!pg(?Tud6DH6IvC>V$RW3 z2IP|8cQl;R?h!j=M`LaCg`J3Ho zymg1Z^ZHx#oi}dN>v!+c-h&-ljK{J`%tu3^$U$Yykk`WZBe!LO%?$Jg96-=2MWL5c zb71gYu#sqvYp_T#O2u~zmFws}#OJ2VXz75z!|KYq2(NFgZ_*Yc4wpAKX>)Cb?;Fr6 zN7}t^M;1JCL$y!`)q^ux_zOsA6zYshRXr-|`5U9FLfRrx&{zJ=5ru+0@9{Z;=H(&i z@r540dE?dmE>F`IvHqRyPy6kYC!BLw(SpoxP!$NQJT(NvRTaF;)uV>xJG(4cwFbS1 ztdG;w{f}gUPoQ6*i}C0Y>HkDOIz#53OP#=}w4wp=vYZ8j_8tN$h&^MT|N5{0x)h0~ zg$PeQ^_18QHlpI%u`>xVX@Q(T4FbJn!z#~54{{(gPLZGcK#dwxy=B$Glva<{4swCO z7q-SLvSX<9GWEGsNf}8oX{nulupeTdQlWDrYR$RO&ObFRA05yjssu{_B!spyR>Om1 z+CShxdGA>4i`bKcLk1{OA>9&i1WF|6A}5R(dUT83=!$Gzt2?0X>KZ?nuaUFQ`*FKJ z&AU3!&Y+S;ZkwlC48jdivstF*@UwBKoqqbjLe_-93-_#NDSM3ME=4sQS^Jf%*5 zm%#Q;E9`rGerF71?6X}Q0(2?V5ya$GfU5E;>#OLFXRKxJl8&hrAo0$#@a(y&Eh0~# zmniOmz&dUt;9`dosaREaAJ>mXF)0e>zVL-FJd}=Qj%5!24RS#oFfM>}R zRqix4g6Q0M9fd|~NhePRnrS(h!FpLr5~#QRvOG*taPT}c%L9G+A6CJ^a_q?en8l^F;{V7M^lW8oeP-MlK}ZI@n>zd~`%R5BBNx2S@bQ;fN{X zr4+ru;k~+kpFa1Y2|cl~CV4KXE}ZdYD%IYR9W>q`(Dt!d$_1N)R3D;(57H00wit^@ z#y0d93mBb-WPxEp^PYjtO{Im{0R^=K`oB4+&H~565h!m8bEa*^NT7xU6Mv7Hq#Q)a z)rpx_v@fG-nFTA%k}@N6EI|P*BsvXM5P=GRbe~wo=LZ!Oo+Cv-^DU^Z2hXaxLMAp6 z9ubEHcRDE;B~r(o+DEwFV5;2RQ;{iX2rP8Wf7idn#TL6f*%<-Ly9}x9myfiwf}@$f zUQ??4qKf=t{1C^9$Twh*;BV0BKsZtC z2DAoj3bQq$(f}0Gs%cRuSF)|iZ=T~o)!DOKWmd@#sRJ46lqj;e)E9g33UjhlCUo(s zZBb^3XX!10ERQRZ+@uqA1qQBY%VO2Tl=tVoekpnEyt79}2B(Z@j^~u2U~_dv3NzWm zpyhNf_6@qfKp1fw%_XELh4<~Lsl-DMc`1S$S(S1I)G}lp*~giOHVRqO!b;w=CzFDX z?mnP{2lwfZ@9fhb?+xj}1O+>#RK{Q3xJQ5f!%x#Dg9WfBGp5HUY{(WIKTqLo;=(oo z1wLbFaTd_LWIF(ci{HP;h-_C-Tg?^K^n_AUwWR+y9AvgR3r01Yc}waHI*lpy_#7Zd za)LCjnf4E#n==W=HCT$3i`nOrtM7X=a`>BCHz8v1ieBRh+w62_!XlQEdnvzY;#^ck zp;cIL(q?#9Bl#UI=(k^3L9bX=K?PBLebzwGghm2riHiN5?TJh$K4)?OurFTIg0w8$ zck9+Ic`Z`1z?!}J=9?veKeUegjo^wf%CKRW=QHz-b@y)3hLlA(+uI=Ndh#Cy1a^Hc^VG zQOLsNXr%wRT^#L{3nItT{*GkYlF_;yhWbLa?1a(KMcn`_D#Db~L6gD}{QQSLN(s(jwRF&ae_E zzkfi7NW`Acl-4Gr(MkCv@sopQ)*@#VxvD5;LidQ3-l`M>tpVZe^`#%|fKlOow<{I$ zUD3d7X-l$(_JeI?TI?hdWv)mU1U0eUZvbUvd0I4*3cBw5X(@n2|3j6WK@7j7`ygMv{MUt+~0-3rocOXTtM!GHos z@GC8qLoTU8ki|o)Rm`*)5*d!=GRaxT0P81h7*&ZQZ#Aq|Fj)AYpF+{*W}fB}814R(m897&=24Kh5)qXc~{eOUqfuQa7+ z>tZz1+z{krE~nx^^0XHyKay6MlN7V?W&!Q4xsvxHIe{Z7Yy+{4Pu@Rz{th-7MKX`3 zCDXzrbo%C6PM3fKX!F%Z>L!0)XVBvfVkVlN6W*vG(J;mH*vUNP4FVU) z{y>aK@-Bm(2SlNV(31=;R6ccrJ2!CJXJcK4)is&Koik$Hxn; zV5}=NE%GeufJWqkEn)lgn$X-U%`;7(lWr1=G5pl0K8}*ef0-_de5wKG^7nr4n?F*ysfK<8&(}92 zC36&r2&z?~eTn=^2(0sMDLVM-SHD^rLVqupDng+T+X~6W*Zl=SNQniQkX zQ1rvEX*baOooVV0+n*8J47MLf6GklgaY<7`p%2?ca9q7SH(R19UMRqUGrCyhN{#2j z$B7oFsE3di96@HU7!9Szd864e1~16-V2>MYM_Wwu10=!oA=%RcgU0)Rqn#^2B{`S+ zdzR+FB5;^4RAe0p^qAkL%T6XVlMB;@*565!8iIjdbjoJ4mpMvl={x01S>M5KcyTGkYw4^rIh@_ko<`H@@)=pqNl# zkZHH1@ea!M<%U`}kQ3;%>= zrpiK-1J}`x%$V_N@nbXExxYi3>uE>8yvoidR!kXh5JGAVW zt@RqTxwS@vwN;uu*X{eWp2>-V#jmYOz=|&E1iCCStsqBAhUADcp-Pkr*^e=|HL_tc!rU!Whsfw>4|1O^KfVH(7Hscv-_2x zy5v_DWOdxm1&iCt-FTV2&(1mNjNx; zH+ir40QPAV-0)tq&nV(m-k;(4fhB`|S(MtsHv{sAP`N4fRBPhpP<;W_jlR38=4*G<%Qj z&7gZgM{E=BGiY%bz=BhghTPeDuc_vCOND07B4_QC-wEzo-LRHiPzyWP#P0^KZ3TjEF>|(mwditry zpiGNzjHL!)QT=q3{7zaNddf#QZEGiXNlEHb4L?m*NtIGT3HO5)Dw4|@^)%mU#}ZOQJP#)OcSkbI>lO8w-l!|QzutM zS(RcKv(xnY>#xyt?|^@-NOupWbeAcB1;omEB7@!qjk^Su9uzS|l{G5CCDs&qh#*o9 zA`s3IlZd^WgI3~i&=+}A zQ!FbR8ywL!r0rINDSaj!*eQmPdLczQU~cCHtNzylwvR^ zv!c|iW8>q*I(2EW|A2NxirLGDDroD9uyXE4HVN0d@$c}jp77DE@2-e zNCSEUIuMVuA$61n`Dt?B+S(e{4(b(-&NxJ|3&>5EBGN+BktCI>bbF&zc;U7qT44{_ z^Fplbkf54nt!-Jv^f7W{R@)l8F74u#=9S1hKX?ClMBjh?bviy6Gcvejr*TfB-F?l%AiHtY)iH-U130FL#X$r6uGuVMh}|MQ+5z{nEu`!0==Az9f2$z^5KDeuJi9A zR5sJtU4A~;rG>C+%0{j9kQUWc3>Ny)MWMx=%bD1f5Y%o6*pnH!Qd-t(P;>)(f3$Xv z$^-COMS&p(bptA-)`w*RcFs~W46p9=+15Tv}UMMO>qYfbakn*yW5)oSm z!Vi06RpfHN)yjA!xiHbl$Mk_<(A!1+@~Li)j~eGfFfs6Er`pcBL5og#IBaUwtuTn9 zqm&f*h|oF?+^Ils6$t~2O~OtLHjp4PMf__?b)888VIAN{Tgbu;m4OfTi+VJv@pM~zg{JUi3Ete&SHMyUMEM29~FssM&SdB4To4RYlu@{exa;@b@Gqkkz1 zeHi^)+1wC01*sqh3_qDs;M<*6qmoJDnJviPoGJBYOAC0883xDVX|%f1=QSE?SW1*y z7q?KI-b$q z;goiUGudQ&>=*-OZJOXMCXP6^SPZ-&w0L2Pjwco)Q!ahv!*n~=Nyx+gDBUCQout4? z1c!mVl57IOMcXCux|HuFEh`}_fgDzLQailajQQNaX-0y0TFsB_5}*aGtRd0%oPUt< zLN^IA8U!0Ex)2Fi9V%d2e@n7bpLET~SXZSJJxq3o8sl`?CMr{@qxx0+{^DR-^|(AC ztswV&WMAw~eWS9^+pVSurXRU0H`w!oPEl2tt|+z;&W*fYj33VUXWhBP04;99|6Vi- zdOUMr!c|AC*hjRJfoA^f&;D%u%2&PuQRv`$v2Zf6S3qbT$R*n08EuYhK`?<{qD>?m zOpNmsxlOV@^(Zsnue5yqXc9zJHFQ(^U=CpEU<6gphiwJ+XGvAVj$p5~0?}Z>YcTY% zdNEPWq9jEmuzUCR4`fYmFe=yYD8-Gow@Af;eSs_yw%y3KrGWxTM&XLdP@OWEmGDBz04Ve04fS_pv|B|-peF^QVL|*c0fs_*KA3Hkt|n0LW-RL%5*Z- zv_u&zyngSDWUL~P&9Ert_iEu7*)AgApP=CkV8{05MyAE2njWXM6k`Ar%cTKTQ3`t| z3oV*bf?mNK-S^dD)O4s!TZ$$~r9l&F3mljtszmuqO8r&6Rh$RGEX%`GiVJ8ay1(Hz zQ{p9Zk>NujAJe;Dao{DNiVi(&Yf$(`s3HfF3Dyi~CG=;%{N*o8A9DXJlf3i5P?pkp zgx1fEV0w`cet0;WxDA7rN~E|CK$%?(C3yCO5xkcEzXo}yf6o(nHn9zC^tN6Nb6cYiQ zIhWN!PoqhHog~5rA`v0VfrYAr}p64HdRd0bI`eSM@3X>Q*S?pI=pBHYA4CVBoxTHGwQW=u6K9I!8q*yy4{+<9BhP)q3f)K)1Qn`58QE^kCLG+ip-|G0ymfpQiao(7$##bOW?-ryFTYgR320z} ze&12MU;^l9YhL{bl0%Ga-rm`x+xK_rt$X|Q$9MMV7TdYE_7Ca);gAld$lphYyB_ri zt87cwX?yz;b@?`0Fdi}Bam0w~5%e(8W|$Gs9@E$;%mI*rR+PvO6wo0Aq4Yi7s$gaf zn$lU!RgRC`kzb0Iv(V|tlSi>^uG*SId_Q5lqO_-koe<)t8SD($063QzV9EggA;_~%T9uZ$ zb>5QKT1TJsu-|c|I(2lIUb#g_sv1g zVHR{@7v>Zer5Eye+JvDqq0VfmJO4Q7Y$#NMU@Amy(hMdiQ6sd;!Gts#NpmQ4!US_l zYP6H2Vj0flyvT%ZUgPf~#VW(0Xqgp^qDf{A@=byQ<)F%^sN!bFv(e(`@}iT?Xi;dq zguG33S}>|Sw6sH!M!l-DDLI$cBOOIVkXC@8`ViH4eBbWTSmcF7+6Rg0{QfhcgqGs8 zbs_fN#MwgC9HVFLAW7PwUPer>4B2sB2vtx>l#6H+5hfx;(BCcT!lV7>r6;-RMo9&r zIK@g~pd}_8&nBa?szXMa0#q(dsGWq;37H-XUf)1(c7D4dZW0L{;iPmRbKAxFGvrTEQR*D9i{+dFs2qK#&`7v2F4>IdYA#zq(-?!3L}^9afyry*1F5A0ENN&u^!}+QOpdLKAN?OLf1+Ns=JIV7d<@F*r zmr-GRLgpGpS*Y?cy}(pSIrG8Oh9AzTNREC%J$Cq3*9N>dv-1>qA1_)GAxAitj2J>@ zq!`Vp#q86VpM&(P$#^QCiJ~9UY%1B$l#13oLv-=MW{x5vY2TY0V|ioDuyZsO z#gHyLhGTxuIUhSf40KED@^Lj5$iq})i|#-G1mN(Y(miAQc7#+h211T_PhB7M_;pSB z#z}60XrV(0J;xR}7 z0>}WHwp5v*A)}NNq^HUL&bES|*JV%zxvuEL4m4JB{Bf?bmRgGtDjz|AmrUASdWzH% zMOf?u%)?yHJte-1zTA{zn#u~weJkhDY%PxH);q(Urx58ZnIfC!HC5=Sh+jKu zX&)>V=I&f3=%rsv0fH4U$bE@njn>VlGJ-;x^M-)4vvk39qAG_{jCOSU9_m+?q1H1^ z%<2i@+Nec2UI-Ku38TVLt6<4@6=GQ?e+Lh~UD7C4kKr=}1x0kk=-l?^I^Ukt;qkaC zFhZ}`>5H(uc<*q1MTxrCi?~lmMbM$#S;DXKC7C)rLV& zo_r1#GMpz~cC^o%?C$YVB|r3lZ{IoOXpr3tbDjgf3amLg|Vfi%Jyzv|1_>QGldSN*(=&uWBwk;_M*bEUJ zL-MH0RDQqTr9NaV?9%V!vMP~ue*ock2*R(@irm)|WdKxf!s+&wU)nWEKu&R@5ydu{ zx@z~C`suf*GiXtJ&^+|066mEnml%%VV_i5ko=Oy*9%-I)N>kwW>E!Dq5Ay=Se2W3kCq!uscO&SHsVNuZec8we&0;CH_B9r+tJ1GWKz(fAwmIE*KB zFxjwXurH~C35+gs)ldw<4#Jr^nB%-JMV3W$n~fXbq>EVVRZ7g zSET!M%F2d0eBt)YZA?~mr>j!Dct3;_cg!31-P`w>4xY&#i`J1xN5||?b|kfZqiYVB?4oceGwa}E2Zrw^9dj5F9Wnrd03S@(9whIU3lorMW{erA zo#O*`8ponC(zHe)s233Gm^a6c(BInB1Wq3E9w8Yw;eG#rSw0|{$dN~S(*fH+II#df zY6nw$fxDx(SZW`0{k!lVNx_O3t;<}LS_O!vyWbFDD@;LU;a%gR0u$0g(o|_sB(-2$ z)JEeE$Q?1G$ALcEgB9r{gxf28J?Qa;?ZfKI25mA*yU9%68eil2a7@vOt<4u8$Y3KI zEiiv=8m#stI9OZf_hj3@w$5lfvw@ARXU>|YKxq#VI3W#0W(s@Qj$T^DcmzF;wSm9U zn1F$e=*}gWV_Z;33g;4F2G$xVar?#w*Uu!89NwgjzBoCVG4F zl%!Rp9Ef)rF&PypPQV2exfS|&Awwnhi3^Y$JZ6PH@)Cr-|_&7)(;Li z(qzcIv(8cEQ1Vtf99@c_t7v&OX~v6u3P*D#TlcMG7z-a2i z(gtEM3IXEfDtwzEuarL<)#X4UkC<*Jt6qn2Zm^tHX%of^8XFrnF>Go;IkoSry8qq_ zHW~#aBD;{I;O9Xm2@)cxhQwyjZ+3VynuxQD93DI$=MB`0bkPW*MiVeU_`9`vVK*&L zB33=fV--b4p9SJAlJu`_=2p#*-=SQH5PTLE^4{p3j&m;?c>{fPwWXze2m5UA$va;x z-$2?&+QkPSg+&jkBG7*xcv++DFt+?!aK&C$(x3%54k&24tAYbxKZ~i?16KBpoap+hB}sm?uHcoY&`jdv{zj6 zo*CIWoK2WrTItb{X>2%=NHappRE|zH*o)awYh-POV31FV97@R7@q0jLb2c5blhtRG zbRzrz!Dz@}#aO@(G!mJ`sW{K7%cNmF+8cuc?=dnrV`_TP?nt`R(pp8LS{yX>vRo)) z2=yv&q1>_lXHx|>n-W;o=GP^{aNu|O}YK{+I2c}l*>0UuYq8$t*uF} zF&W|P9pl)p$iaFAc?xO(0K^*c?uuXd!WX1ylu=AkE5YOD!x3^aQIwb17)?MuW1OqV z=TQX$r`tuED6~iuxlWRO$><#!ydu_$e1bcdKySP!vpE$Kkz&jj<(o~2#Fnbtq1wz?|^mUPjJCS#4-UQI?VXVhNp)6wp+2*$TKI#t;O zh=l7xl3uSInTlTos-;w?5}OGe!ZAmX^20L9CUiFL<3)7$;7AttjHAQZA`>J}+;c>$ zwjlu8A9D!Tj97$PY1qfYLKMdv0{XCmqC!s31Oo58Ag6ej2`K26oHgWJqOeBtE}4|a zu_AwlEa#C5(E$*BHk>J-wNj`Abh6OIaxG#iwYyOuseGhNJ2=6i6FDU!K{MH<)GHUWH}f-&b%q>F124Q7#tZl= z3zH&^MD=xy$z4vHQWY>`FA~dapolC;i-U{knwK?Ta$>p%c;V4~a zn~6RD=y*tjjWs%Cb_y&Es;;wUmuAN^DY$I0qqksZRMb2SbdelK(mie;wGV6u@aPP! zjhWhoHuKKtfpqE_PQhFaIk+#SZKF8eXx;(cVt#!)ZwVVVVnnjvX^FZBI>UuQTfTAsQeobowv({c2NRM*K!$%$|a2hC`NQJl2Ib#Q>kr= z3#qL@)w);GOG{%2xAWePMj&u10Zst;fPN>?(mcyd6klyhhrdHtY9i_u(`2k!3|iQV z*P!bjz=*a1D6n5})SI(~fGXW~TXjVFSekOlts#gpd0A%Ng4njLJV9rTaz45R^b*B7 zKpxQ|6%MCsVd6O^_b9(FD#zb=;|;8v(rRC1Y5~E7KjuQ~C_shSI)DvEE#YVeIG1i? zfMlLmSNU8HroE9@rc#1t%Ht-qE^Q;-@Hw)AGv^e!IhS$`M+@Gwx5$H|$q{Mh6}Axg z#A~LD;;7ryE-mi#rC%&QbVEL}zD|-U?Q2v~qP}0W6d0ShQQKjqu6gux5NbNKOdhNE z6R$S5kd-IUaEwavAaMWHOWT4V?j0UT75o6J@OnzE%ZT@3rb{}`OeCl5If0`dqNd?G%Zj#+t4!u z(2Gbl*l*j))SNU>tvlrusH-6llOB=FG(*mC)Ad-3fcmM9+%~9CH0aa!d$o2UKjnOKW)9}g;8~F(s#*()PE&e)@9gf= z3Okj1JA1-ZHP9k=b&U@8_Su{yPN zi2|*QJ+xSigp!}4C}K3xG^s*c@JayB00XFH!FU?#Sb)(I(1V$>wv>(sY3$KLH#L5K z4+tXLMNsr1(;{BveWEjvDvA)Jn0%W*PbBleS~1-$$RIm}s{^L3A#EfDz}D?2ps1-7 zf=|vh_VZkIl5zYk$PpPc7DVDNlh&0$E&+^)6do$&9W_Q_4~m2`m2GnEy22by;SdeP zK#pmrmCI)cn`r4_P>IR&F59yXp`HZAx)7Alg(br?b9q1UAMA4{?M*ss%tuFW@|>0y zsSI2c=w1aU6La6!1ky(!QH19scNzCFni)Kg*@x5QV42yutnPP48n-R2MOwR>Ejbgg zIJZm@oq|I>2NN3voJPyV6ypZLTo^V>ITz6zb;yBi?r>Zqpg+wJAM4FfB8-hL?MKfg; z*TS_KK=c(rSr(;eWuK8mP_tOjL*!)gLRnks3nf21I^u=YWCyNM<>mx-WOz04+F-|u z7b+^%fc@?4@6vd>q#|$95l4AIJW=f?uPeryv&gSM7>-3#40$fOA(hgrJ6bLf7ByP< zU?Ct%Y-PN$jD#LRdq#=m_*KYPgm`)I1wJCkiwF&$8>|Hk2x7;Du}isn3aUORsn<9yV z4n=}idS$rN;#ve$+nOxYK!~Q8mlC{s2Oz0=tlq&#tXPy2jwV^rhn_rjZcBGnNf;kE zUMP@R&NAAak0tPhs>dNCnS()x#_SO8-g=wQ$r?5L9UAT)${vN>LVyEr-@Pjxct*Te zkC^sG(aj;i3J93P+2g;l{RS(f1z>^vO3CpTEn}wW*-m(_J!}Txmu+_JJN){6wo^#| zLL1vV33|`2Az_CO#1Ea=?*PRsC0m$$oQVeIP zt(sgIh@`Ol9mlz4ihOwfOe(gHgYyr z@)M`+bans9sI!!ai8lbgzfm^DK>}S3DtgM4^N5i}h_BE1SV3pZB>$2lQjY4z$0L=O z0E)?z{r=9rh{mHB=l;FB9Qh4tcQm56?mwXW2Zxd*y*oyd^puX_Q1T(5>U&YiIuzQ4 zz#=UYDmN2~0>`<+4sRcMIUFq_K@$-tB#BUED0Makz5}Y`7`fx;wX(dbA>q5bWE31n z>DnL2Yz+gB$S1xFTAgUFkV5J<_omF-n@X8k!$#$EKR38U5e?Yd-JSgn>YG2 z>^xD)6shz`Z7LPTtbvWn9>iK-Iel`)y(G+7NZw0|A4^Zaij{MDVzDOo`#`c0_Ing8 z?hg+&Pm=fZgS`impEw>f$|%lLra%Snu}BdDN(ca9e0)qT-mBiaw?p^%Jl#DS)84F* zVi@e-QrI(_3e`R2z5IB}4&8K0UT_2Su+N{j)@9@lXe#f2YrOw$4OVC!g_Qg`msi*6 z()y+pNMes{@xF<4s%uxCpi3*Ow8nO)gMAht2GXqT3*vLZUKK`ZKVVzAkK!eEaCaFr z*LMF(i(BcY4G^D7C45zPFXCE`~2|cPiy!(;?KSC$(n|c~`inMI)5R4xL)qF5X_t zQP_JV&SpBw6U$$36NF zucX}v_oc%8m>0zE;XYIBNA%X-i1v8l&rn6j&ewU&bzAW-20 zrcwr7)se(z)MqMwz|rM4L+b+D<9PY>tPa+g0`5|mkw&1akR#e!-{1|T$D2w++GmB* z1ZQpYgJAA-Hjx%wbD`(i=|y!pZvsPJ#fK9(mJ_~A+4)4p`BXNF;T#o+GdWi2|4S-{ zzOYI`&@iCCNBkPl;>h#FqK5N~C|eTE>Y zN~2AriFnUtlPIcMM1m3U=OOXhE6pwcvI_7VrS$oEx}f4>{BXvFb6F4TXI#2|tIHL^ zUZ8Ru_5w~O?qiBviWgu6KmF-Xi@kuY@UCxONp3|xm63%+3kFb3+y}bcXmSRyiZit^ zAFMoMI=0z~9Liv{sG82K1|#qycOau8lCmEJZ@U;yI*8HUBZgXk*B^?fq|W^QQ-32^ zhI0wD?8cQ#Qmis%$7)`v%`4)9c6JU$A_rZ5prV1t1Miz{rjHFy?L1~UA{t_=my6QDfE+(lZd?+wgom55dnZr0Sy94uKiF7 zQ|Am^%q1ObkAaxoqhkhHj_A(bk%+t>PgJ4ArSYaS?dFDJRXB&JLy(Gb++HeFC61z& zp3Cn6X*=-b%~sQXiy$f{f@X)jvK3O)lWU3;5Lpp=srXTKud-18uJ}w9pC@rHB^}Ga zj27s4Ag=k@&Sfl?uV6kyrU(Pg2;gKgnh8{2?t?^;$#(@_M^$AK))0UUM=zNb5PRd@ z;t80V(Q6S`Tko+SU$uA>+G;BPPTF(a84Dahld zgBh%PT>4?+@l?hID354iF6keJJUs0Q&XopPuwolh$FdG2AMxG&u2B7V@_P{_xtH#Z zh9qY#Qk~BkPDYp^)Q=@&zEojY>9vYk#*1P;DW%_PgCn`|bR-+hoSiHP%HsVRB3#aN z^zlUK6|~j@?Xi2vkvI~%c~T!v3vs&O)GU^IBnw_Nkj+^vwKzua)qG4iJpFb@4!GY| z6c7ui2ilt78+w=nv{6JVLbop(Ukyeto9tjha27h3a7NdeW|>Te{C*HHpHYMi24s|s zMwbe8ID3^7S4zcULkf+?vLI39gN^!VlB?5)MLs*=+w91;8}fN_szjHwp)?Ht1I_Q5 zOhTQAG!r}S^Qm+n!oO}alL-xn5*wl>dTU+{M0SG}bd?r`N(PnX!DL2_gBH>JOWiD_ z4Ow(rBTAGjmHjdgOZTl+D#?rns4>-Vv+N7pUW7L=m!)|CBQKU`?E0@Vd61Lra|dXD z+Pw#nq*vHt14xSM==6g3VhUTU7vAL$d0aS`Cx>+fkvZ%30tG&pvj8UWH}Wr2EoB5< zC^Ue*Fq>NW+sMx3L<0V~wFQ=nA%0Dh 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 JOw|QI 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 qFGvPgtaq7r@Bf>g|FellT~ceZ^xBmV~~o0e|? literal 0 HcmV?d00001 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/Localizable.xcstrings b/Linphone/Localizable.xcstrings index 825ac2ca4..24e0bd2b5 100644 --- a/Linphone/Localizable.xcstrings +++ b/Linphone/Localizable.xcstrings @@ -88,6 +88,15 @@ }, "Calls" : { + }, + "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." : { + + }, + "Close" : { + }, "Conditions de service" : { @@ -97,6 +106,12 @@ }, "Contacts View" : { + }, + "Default" : { + + }, + "Default mode" : { + }, "Deny all" : { @@ -116,6 +131,12 @@ }, "History View" : { + }, + "Interoperable" : { + + }, + "Interoperable mode" : { + }, "Linphone" : { @@ -166,6 +187,12 @@ }, "TCP" : { + }, + "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." : { + }, "TLS" : { diff --git a/Linphone/SplashScreen.swift b/Linphone/SplashScreen.swift index b2ee812a6..cbcc3298b 100644 --- a/Linphone/SplashScreen.swift +++ b/Linphone/SplashScreen.swift @@ -36,3 +36,7 @@ struct SplashScreen: View { } } } + +#Preview { + SplashScreen(isActive: .constant(true)) +} diff --git a/Linphone/UI/Assistant/AssistantView.swift b/Linphone/UI/Assistant/AssistantView.swift index 16552e0d1..8eb653895 100644 --- a/Linphone/UI/Assistant/AssistantView.swift +++ b/Linphone/UI/Assistant/AssistantView.swift @@ -21,215 +21,12 @@ import SwiftUI struct AssistantView: View { - @ObservedObject private var coreContext = CoreContext.shared - @ObservedObject var accountLoginViewModel : AccountLoginViewModel - - @State private var isSecured: Bool = true - - @FocusState var isNameFocused:Bool - @FocusState var isPasswordFocused:Bool - var body: some View { - GeometryReader { geometry in - ScrollView(.vertical) { - VStack { - ZStack { - Image("mountain") - .resizable() - .scaledToFill() - .frame(width: geometry.size.width, height: 100) - .clipped() - 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) - .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.orange_main_500 : Color.gray_200, 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) - .frame(height: 25) - .focused($isPasswordFocused) - } - } - Button(action: { - isSecured.toggle() - }) { - Image(self.isSecured ? "eye-slash" : "eye") - .renderingMode(.template) - .resizable() - .foregroundStyle(Color.gray_main2_500) - .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.orange_main_500 : Color.gray_200, lineWidth: 1) - ) - .padding(.bottom) - - Button(action: { - if (self.coreContext.loggedIn){ - self.accountLoginViewModel.unregister() - self.accountLoginViewModel.delete() - } else { - self.accountLoginViewModel.login() - } - }) { - 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.orange_main_100 : Color.orange_main_500) - .cornerRadius(60) - .disabled(accountLoginViewModel.username.isEmpty || accountLoginViewModel.passwd.isEmpty) - .padding(.bottom) - - Button(action: { - - }) { - Text("Forgotten password?") - .underline() - .default_text_style_600(styleSize: 15) - .foregroundStyle(Color.gray_main2_500) - } - .frame(maxWidth: .infinity) - .padding(.bottom, 30) - - HStack { - VStack{ - Divider() - } - Text(" or ") - .default_text_style(styleSize: 15) - .foregroundStyle(Color.gray_main2_500) - VStack{ - Divider() - } - } - .padding(.bottom, 10) - - Button(action: { - - }) { - HStack { - Image("qr-code") - .renderingMode(.template) - .resizable() - .foregroundStyle(Color.orange_main_500) - .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.orange_main_500, lineWidth: 1) - ) - .padding(.bottom) - - Button(action: { - - }) { - Text("Use SIP 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.orange_main_500, lineWidth: 1) - ) - .padding(.bottom) - - HStack(alignment: .center) { - - Spacer() - - Text("Not account yet?") - .default_text_style(styleSize: 15) - .foregroundStyle(Color.gray_main2_700) - .padding(.horizontal, 10) - - Button(action: { - - }) { - Text("Register") - .default_text_style_orange_600(styleSize: 20) - .frame(height: 35) - } - .padding(.horizontal, 20) - .padding(.vertical, 10) - .cornerRadius(60) - .overlay( - RoundedRectangle(cornerRadius: 60) - .inset(by: 0.5) - .stroke(Color.orange_main_500, lineWidth: 1) - ) - .padding(.horizontal, 10) - - Spacer() - } - .padding(.bottom) - } - .padding(.horizontal, 20) - } - } - } + //LoginFragment(accountLoginViewModel: AccountLoginViewModel()) + ProfileModeFragment() } } #Preview { - AssistantView(accountLoginViewModel: AccountLoginViewModel()) + AssistantView() } diff --git a/Linphone/UI/Assistant/Fragments/LoginFragment.swift b/Linphone/UI/Assistant/Fragments/LoginFragment.swift new file mode 100644 index 000000000..ad9c536f6 --- /dev/null +++ b/Linphone/UI/Assistant/Fragments/LoginFragment.swift @@ -0,0 +1,235 @@ +/* + * 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 var accountLoginViewModel : AccountLoginViewModel + + @State private var isSecured: Bool = true + + @FocusState var isNameFocused:Bool + @FocusState var isPasswordFocused:Bool + + var body: some View { + GeometryReader { geometry in + ScrollView(.vertical) { + VStack { + ZStack { + Image("mountain") + .resizable() + .scaledToFill() + .frame(width: geometry.size.width, height: 100) + .clipped() + 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) + .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.orange_main_500 : Color.gray_200, 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) + .frame(height: 25) + .focused($isPasswordFocused) + } + } + Button(action: { + isSecured.toggle() + }) { + Image(self.isSecured ? "eye-slash" : "eye") + .renderingMode(.template) + .resizable() + .foregroundStyle(Color.gray_main2_500) + .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.orange_main_500 : Color.gray_200, lineWidth: 1) + ) + .padding(.bottom) + + Button(action: { + if (self.coreContext.loggedIn){ + self.accountLoginViewModel.unregister() + self.accountLoginViewModel.delete() + } else { + self.accountLoginViewModel.login() + } + }) { + 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.orange_main_100 : Color.orange_main_500) + .cornerRadius(60) + .disabled(accountLoginViewModel.username.isEmpty || accountLoginViewModel.passwd.isEmpty) + .padding(.bottom) + + Button(action: { + + }) { + Text("Forgotten password?") + .underline() + .default_text_style_600(styleSize: 15) + .foregroundStyle(Color.gray_main2_500) + } + .frame(maxWidth: .infinity) + .padding(.bottom, 30) + + HStack { + VStack{ + Divider() + } + Text(" or ") + .default_text_style(styleSize: 15) + .foregroundStyle(Color.gray_main2_500) + VStack{ + Divider() + } + } + .padding(.bottom, 10) + + Button(action: { + + }) { + HStack { + Image("qr-code") + .renderingMode(.template) + .resizable() + .foregroundStyle(Color.orange_main_500) + .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.orange_main_500, lineWidth: 1) + ) + .padding(.bottom) + + Button(action: { + + }) { + Text("Use SIP 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.orange_main_500, lineWidth: 1) + ) + .padding(.bottom) + + HStack(alignment: .center) { + + Spacer() + + Text("Not account yet?") + .default_text_style(styleSize: 15) + .foregroundStyle(Color.gray_main2_700) + .padding(.horizontal, 10) + + Button(action: { + + }) { + Text("Register") + .default_text_style_orange_600(styleSize: 20) + .frame(height: 35) + } + .padding(.horizontal, 20) + .padding(.vertical, 10) + .cornerRadius(60) + .overlay( + RoundedRectangle(cornerRadius: 60) + .inset(by: 0.5) + .stroke(Color.orange_main_500, lineWidth: 1) + ) + .padding(.horizontal, 10) + + Spacer() + } + .padding(.bottom) + } + .padding(.horizontal, 20) + } + } + } + } +} + +#Preview { + LoginFragment(accountLoginViewModel: AccountLoginViewModel()) +} diff --git a/Linphone/UI/Assistant/Fragments/ProfileModeFragment.swift b/Linphone/UI/Assistant/Fragments/ProfileModeFragment.swift new file mode 100644 index 000000000..44c0c66ff --- /dev/null +++ b/Linphone/UI/Assistant/Fragments/ProfileModeFragment.swift @@ -0,0 +1,129 @@ +/* + * 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 { + + @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("assistant_account_login") + .default_text_style_white_800(styleSize: 20) + .padding(.top, 20) + } + .padding(.top, 35) + .padding(.bottom, 10) + + VStack (spacing: 10) { + Button(action: { + options = 1 + }) { + 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) + .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.gray_100) + .cornerRadius(15) + .padding(.bottom, 5) + + Image("profile-mode") + .resizable() + .frame(width: 150, height: 60) + .padding() + + Button(action: { + options = 2 + }) { + 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) + .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.gray_100) + .cornerRadius(15) + } + .padding() + } + } + 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)) + .edgesIgnoringSafeArea(.all) + .onTapGesture { + self.isShowPopup.toggle() + } + } + } + } +} + +#Preview { + ProfileModeFragment() +} diff --git a/Linphone/UI/Main/ContentView.swift b/Linphone/UI/Main/ContentView.swift index ef8b2e08d..a30a20a4c 100644 --- a/Linphone/UI/Main/ContentView.swift +++ b/Linphone/UI/Main/ContentView.swift @@ -28,7 +28,7 @@ struct ContentView: View { if UserDefaults.standard.bool(forKey: "general_terms") == false { WelcomeView(sharedMainViewModel: sharedMainViewModel) } else if coreContext.mCore.defaultAccount == nil { - AssistantView(accountLoginViewModel: AccountLoginViewModel()) + AssistantView() } else { TabView { ContactsView() diff --git a/Linphone/UI/Main/Fragments/PopupView.swift b/Linphone/UI/Main/Fragments/PopupView.swift index 531bb4146..178e817ab 100644 --- a/Linphone/UI/Main/Fragments/PopupView.swift +++ b/Linphone/UI/Main/Fragments/PopupView.swift @@ -25,49 +25,62 @@ struct PopupView: View { var permissionManager = PermissionManager.shared @Binding var isShowPopup: Bool + var title: Text + var content: Text + + var titleFirstButton: Text? + var actionFirstButton: () -> () + + var titleSecondButton: Text? + var actionSecondButton: () -> () var body: some View { GeometryReader { geometry in VStack (alignment: .leading) { - Text("Conditions de service") + title .default_text_style_800(styleSize: 16) .frame(alignment: .leading) .padding(.bottom, 2) - Text("En continuant, vous acceptez ces conditions, \(Text("[notre politique de confidentialité](https://linphone.org/privacy-policy)").underline()) et \(Text("[nos conditions d’utilisation](https://linphone.org/general-terms)").underline()).") - .tint(Color.gray_main2_600) + content + .tint(Color.gray_main2_600) .default_text_style(styleSize: 15) .padding(.bottom, 20) - Button(action: { - self.isShowPopup.toggle() - }) { - Text("Deny all") - .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.orange_main_500, lineWidth: 1) - ) - .padding(.bottom, 10) - Button(action: { - permissionManager.photoLibraryRequestPermission() - }) { - Text("Accept all") - .default_text_style_white_600(styleSize: 20) - .frame(height: 35) - .frame(maxWidth: .infinity) - } - .padding(.horizontal, 20) - .padding(.vertical, 10) - .background(Color.orange_main_500) - .cornerRadius(60) + if titleFirstButton != nil { + Button(action: { + actionFirstButton() + }) { + 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.orange_main_500, lineWidth: 1) + ) + .padding(.bottom, 10) + } + + if titleSecondButton != nil { + Button(action: { + actionSecondButton() + }) { + titleSecondButton + .default_text_style_white_600(styleSize: 20) + .frame(height: 35) + .frame(maxWidth: .infinity) + } + .padding(.horizontal, 20) + .padding(.vertical, 10) + .background(Color.orange_main_500) + .cornerRadius(60) + } } .padding(.horizontal, 20) .padding(.vertical, 20) @@ -79,3 +92,7 @@ struct PopupView: View { } } } + +#Preview { + PopupView(isShowPopup: .constant(true), title: Text("Title"), content: Text("Content"), titleFirstButton: Text("Deny all"), actionFirstButton: {}, titleSecondButton: Text("Accept all"), actionSecondButton: {}) +} diff --git a/Linphone/UI/Welcome/WelcomeView.swift b/Linphone/UI/Welcome/WelcomeView.swift index f9befaeca..03710d291 100644 --- a/Linphone/UI/Welcome/WelcomeView.swift +++ b/Linphone/UI/Welcome/WelcomeView.swift @@ -112,7 +112,7 @@ struct WelcomeView: View{ } if self.isShowPopup { - PopupView(isShowPopup: $isShowPopup) + PopupView(isShowPopup: $isShowPopup, title: Text("Conditions de service"), content: Text("En continuant, vous acceptez ces conditions, \(Text("[notre politique de confidentialité](https://linphone.org/privacy-policy)").underline()) et \(Text("[nos conditions d’utilisation](https://linphone.org/general-terms)").underline())."), titleFirstButton: Text("Deny all"), actionFirstButton: {self.isShowPopup.toggle()}, titleSecondButton: Text("Accept all"), actionSecondButton: {permissionManager.photoLibraryRequestPermission()}) .background(.black.opacity(0.65)) .edgesIgnoringSafeArea(.all) .onTapGesture { diff --git a/Linphone/Utils/TextExtension.swift b/Linphone/Utils/TextExtension.swift index 74e85bb0c..ac514e2a2 100644 --- a/Linphone/Utils/TextExtension.swift +++ b/Linphone/Utils/TextExtension.swift @@ -126,4 +126,14 @@ extension View { self.font(Font.custom("NotoSans-Regular", size: styleSize)) .foregroundStyle(Color.gray_main2_600) } + + func profile_mode_text_style_gray_800(styleSize: CGFloat) -> some View { + self.font(Font.custom("NotoSans-ExtraBold", size: styleSize)) + .foregroundStyle(Color.gray_900) + } + + func profile_mode_text_style_gray(styleSize: CGFloat) -> some View { + self.font(Font.custom("NotoSans-Regular", size: styleSize)) + .foregroundStyle(Color.gray_main2_600) + } } From 55d7bf8de78e035e6f804ff597726ede53cf2635 Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Wed, 4 Oct 2023 22:30:58 +0200 Subject: [PATCH 016/486] Add third party sip account views --- Linphone.xcodeproj/project.pbxproj | 8 + .../Conversation.imageset/Contents.json | 21 ++ .../Conversation.imageset/Conversation.svg | 4 + .../Linphone.imageset/Contents.json | 2 +- .../caret-down.imageset/Contents.json | 21 ++ .../caret-down.imageset/caret-down.svg | 1 + .../caret-left.imageset/Contents.json | 21 ++ .../caret-left.imageset/caret-left.svg | 1 + .../video-call.imageset/Contents.json | 21 ++ .../video-call.imageset/VideoCall.svg | 3 + Linphone/Localizable.xcstrings | 43 ++- Linphone/UI/Assistant/AssistantView.swift | 11 +- .../Assistant/Fragments/LoginFragment.swift | 328 +++++++++--------- .../Fragments/ProfileModeFragment.swift | 30 +- .../ThirdPartySipAccountLoginFragment.swift | 237 +++++++++++++ .../ThirdPartySipAccountWarningFragment.swift | 184 ++++++++++ .../Viewmodel/AccountLoginViewModel.swift | 1 + Linphone/UI/Main/ContentView.swift | 8 +- .../Main/Viewmodel/SharedMainViewModel.swift | 4 +- Linphone/UI/Welcome/WelcomeView.swift | 26 +- 20 files changed, 787 insertions(+), 188 deletions(-) create mode 100644 Linphone/Assets.xcassets/Conversation.imageset/Contents.json create mode 100644 Linphone/Assets.xcassets/Conversation.imageset/Conversation.svg create mode 100644 Linphone/Assets.xcassets/caret-down.imageset/Contents.json create mode 100644 Linphone/Assets.xcassets/caret-down.imageset/caret-down.svg create mode 100644 Linphone/Assets.xcassets/caret-left.imageset/Contents.json create mode 100644 Linphone/Assets.xcassets/caret-left.imageset/caret-left.svg create mode 100644 Linphone/Assets.xcassets/video-call.imageset/Contents.json create mode 100644 Linphone/Assets.xcassets/video-call.imageset/VideoCall.svg create mode 100644 Linphone/UI/Assistant/Fragments/ThirdPartySipAccountLoginFragment.swift create mode 100644 Linphone/UI/Assistant/Fragments/ThirdPartySipAccountWarningFragment.swift diff --git a/Linphone.xcodeproj/project.pbxproj b/Linphone.xcodeproj/project.pbxproj index fe845df79..f4fe4d61b 100644 --- a/Linphone.xcodeproj/project.pbxproj +++ b/Linphone.xcodeproj/project.pbxproj @@ -18,6 +18,8 @@ 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 */; }; + 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 */; }; @@ -53,6 +55,8 @@ 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 = ""; }; + 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 = ""; }; @@ -275,6 +279,8 @@ children = ( D7DA67612ACCB2FA00E95002 /* LoginFragment.swift */, D7DA67632ACCB31700E95002 /* ProfileModeFragment.swift */, + D748BF2B2ACD82D2004844EB /* ThirdPartySipAccountLoginFragment.swift */, + D748BF2D2ACD82E7004844EB /* ThirdPartySipAccountWarningFragment.swift */, ); path = Fragments; sourceTree = ""; @@ -411,6 +417,8 @@ D7A2EDD62AC18115005D90FC /* SharedMainViewModel.swift in Sources */, D7A03FC62ACC458A0081A588 /* SplashScreen.swift in Sources */, D7A03FC02ACC2E390081A588 /* HistoryView.swift in Sources */, + D748BF2E2ACD82E7004844EB /* ThirdPartySipAccountWarningFragment.swift in Sources */, + D748BF2C2ACD82D2004844EB /* ThirdPartySipAccountLoginFragment.swift in Sources */, D74C9CF82ACACECE0021626A /* WelcomePage1Fragment.swift in Sources */, D717071E2AC5922E0037746F /* ColorExtension.swift in Sources */, D7DA67642ACCB31700E95002 /* ProfileModeFragment.swift in Sources */, diff --git a/Linphone/Assets.xcassets/Conversation.imageset/Contents.json b/Linphone/Assets.xcassets/Conversation.imageset/Contents.json new file mode 100644 index 000000000..ac4b400d4 --- /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..27d2bfb1b --- /dev/null +++ b/Linphone/Assets.xcassets/Conversation.imageset/Conversation.svg @@ -0,0 +1,4 @@ + + + + diff --git a/Linphone/Assets.xcassets/Linphone.imageset/Contents.json b/Linphone/Assets.xcassets/Linphone.imageset/Contents.json index e87351a00..ff043ddb2 100644 --- a/Linphone/Assets.xcassets/Linphone.imageset/Contents.json +++ b/Linphone/Assets.xcassets/Linphone.imageset/Contents.json @@ -1,7 +1,7 @@ { "images" : [ { - "filename" : "Linphone.svg", + "filename" : "linphone.svg", "idiom" : "universal", "scale" : "1x" }, 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..42f37b716 --- /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..a3a1e39a6 --- /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/video-call.imageset/Contents.json b/Linphone/Assets.xcassets/video-call.imageset/Contents.json new file mode 100644 index 000000000..0617b9467 --- /dev/null +++ b/Linphone/Assets.xcassets/video-call.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "VideoCall.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-call.imageset/VideoCall.svg b/Linphone/Assets.xcassets/video-call.imageset/VideoCall.svg new file mode 100644 index 000000000..5bfd91cd2 --- /dev/null +++ b/Linphone/Assets.xcassets/video-call.imageset/VideoCall.svg @@ -0,0 +1,3 @@ + + + diff --git a/Linphone/Localizable.xcstrings b/Linphone/Localizable.xcstrings index 24e0bd2b5..9fbfb1302 100644 --- a/Linphone/Localizable.xcstrings +++ b/Linphone/Localizable.xcstrings @@ -6,6 +6,12 @@ }, " or " : { + }, + "[Forgotten password?](https://subscribe.linphone.org/)" : { + + }, + "[linphone.org/contact](https://linphone.org/contact)" : { + }, "[nos conditions d’utilisation](https://linphone.org/general-terms)" : { @@ -106,6 +112,9 @@ }, "Contacts View" : { + }, + "Continue" : { + }, "Default" : { @@ -115,6 +124,12 @@ }, "Deny all" : { + }, + "Display Name" : { + + }, + "Domain" : { + }, "En continuant, vous acceptez ces conditions, %@ et %@." : { "localizations" : { @@ -126,10 +141,13 @@ } } }, - "Forgotten password?" : { + "History View" : { }, - "History View" : { + "I prefere create an account" : { + + }, + "I understand" : { }, "Interoperable" : { @@ -169,6 +187,9 @@ } } } + }, + "Personnalize your profil mode" : { + }, "Register" : { @@ -178,9 +199,15 @@ }, "Sécurisé" : { + }, + "sip.linphone.org" : { + }, "Skip" : { + }, + "Some features require a Linphone account, such as group messaging, video conferences...\n\nThese features are hidden when you register with a third party SIP account.\n\nTo enable it in a commercial projet, please contact us. " : { + }, "Start" : { @@ -199,12 +226,21 @@ }, "to Linphone" : { + }, + "Transport" : { + + }, + "UDP" : { + }, "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" : { @@ -231,6 +267,9 @@ }, "Welcome" : { + }, + "You will change this mode later" : { + } }, "version" : "1.0" diff --git a/Linphone/UI/Assistant/AssistantView.swift b/Linphone/UI/Assistant/AssistantView.swift index 8eb653895..a974cce31 100644 --- a/Linphone/UI/Assistant/AssistantView.swift +++ b/Linphone/UI/Assistant/AssistantView.swift @@ -21,12 +21,17 @@ import SwiftUI struct AssistantView: View { + @ObservedObject var sharedMainViewModel : SharedMainViewModel + var body: some View { - //LoginFragment(accountLoginViewModel: AccountLoginViewModel()) - ProfileModeFragment() + if sharedMainViewModel.displayProfileMode != true { + LoginFragment(accountLoginViewModel: AccountLoginViewModel(), sharedMainViewModel: sharedMainViewModel) + } else { + ProfileModeFragment(sharedMainViewModel: sharedMainViewModel) + } } } #Preview { - AssistantView() + AssistantView(sharedMainViewModel: SharedMainViewModel()) } diff --git a/Linphone/UI/Assistant/Fragments/LoginFragment.swift b/Linphone/UI/Assistant/Fragments/LoginFragment.swift index ad9c536f6..b1ba24389 100644 --- a/Linphone/UI/Assistant/Fragments/LoginFragment.swift +++ b/Linphone/UI/Assistant/Fragments/LoginFragment.swift @@ -23,6 +23,7 @@ struct LoginFragment: View { @ObservedObject private var coreContext = CoreContext.shared @ObservedObject var accountLoginViewModel : AccountLoginViewModel + @ObservedObject var sharedMainViewModel : SharedMainViewModel @State private var isSecured: Bool = true @@ -30,184 +31,141 @@ struct LoginFragment: View { @FocusState var isPasswordFocused:Bool var body: some View { - GeometryReader { geometry in - ScrollView(.vertical) { - VStack { - ZStack { - Image("mountain") - .resizable() - .scaledToFill() - .frame(width: geometry.size.width, height: 100) - .clipped() - 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) + NavigationView { + GeometryReader { geometry in + ScrollView(.vertical) { + VStack { + ZStack { + Image("mountain") + .resizable() + .scaledToFill() + .frame(width: geometry.size.width, height: 100) + .clipped() + Text("assistant_account_login") + .default_text_style_white_800(styleSize: 20) + .padding(.top, 20) + } + .padding(.top, 35) + .padding(.bottom, 10) - TextField("username", text : $accountLoginViewModel.username) - .default_text_style(styleSize: 15) + 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) + .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.orange_main_500 : Color.gray_200, 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) + .frame(height: 25) + .focused($isPasswordFocused) + } + } + Button(action: { + isSecured.toggle() + }) { + Image(self.isSecured ? "eye-slash" : "eye") + .renderingMode(.template) + .resizable() + .foregroundStyle(Color.gray_main2_500) + .frame(width: 20, height: 20) + } + } .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.orange_main_500 : Color.gray_200, lineWidth: 1) + .stroke(isPasswordFocused ? Color.orange_main_500 : Color.gray_200, 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) + + Button(action: { + sharedMainViewModel.displayProfileMode = true + if (self.coreContext.loggedIn){ + self.accountLoginViewModel.unregister() + self.accountLoginViewModel.delete() } else { - TextField("password", text: $accountLoginViewModel.passwd) - .default_text_style(styleSize: 15) - .frame(height: 25) - .focused($isPasswordFocused) + self.accountLoginViewModel.login() } - } - Button(action: { - isSecured.toggle() }) { - Image(self.isSecured ? "eye-slash" : "eye") - .renderingMode(.template) - .resizable() - .foregroundStyle(Color.gray_main2_500) - .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.orange_main_500 : Color.gray_200, lineWidth: 1) - ) - .padding(.bottom) - - Button(action: { - if (self.coreContext.loggedIn){ - self.accountLoginViewModel.unregister() - self.accountLoginViewModel.delete() - } else { - self.accountLoginViewModel.login() - } - }) { - 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.orange_main_100 : Color.orange_main_500) - .cornerRadius(60) - .disabled(accountLoginViewModel.username.isEmpty || accountLoginViewModel.passwd.isEmpty) - .padding(.bottom) - - Button(action: { - - }) { - Text("Forgotten password?") - .underline() - .default_text_style_600(styleSize: 15) - .foregroundStyle(Color.gray_main2_500) - } - .frame(maxWidth: .infinity) - .padding(.bottom, 30) - - HStack { - VStack{ - Divider() - } - Text(" or ") - .default_text_style(styleSize: 15) - .foregroundStyle(Color.gray_main2_500) - VStack{ - Divider() - } - } - .padding(.bottom, 10) - - Button(action: { - - }) { - HStack { - Image("qr-code") - .renderingMode(.template) - .resizable() - .foregroundStyle(Color.orange_main_500) - .frame(width: 20, height: 20) - - Text("Scan QR code") - .default_text_style_orange_600(styleSize: 20) + 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.orange_main_100 : Color.orange_main_500) + .cornerRadius(60) + .disabled(accountLoginViewModel.username.isEmpty || accountLoginViewModel.passwd.isEmpty) + .padding(.bottom) + + HStack { + Text("[Forgotten password?](https://subscribe.linphone.org/)") + .underline() + .tint(Color.gray_main2_600) + .default_text_style_600(styleSize: 15) + .foregroundStyle(Color.gray_main2_500) } .frame(maxWidth: .infinity) - } - .padding(.horizontal, 20) - .padding(.vertical, 10) - .cornerRadius(60) - .overlay( - RoundedRectangle(cornerRadius: 60) - .inset(by: 0.5) - .stroke(Color.orange_main_500, lineWidth: 1) - ) - .padding(.bottom) - - Button(action: { + .padding(.bottom, 30) - }) { - Text("Use SIP 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.orange_main_500, lineWidth: 1) - ) - .padding(.bottom) - - HStack(alignment: .center) { - - Spacer() - - Text("Not account yet?") - .default_text_style(styleSize: 15) - .foregroundStyle(Color.gray_main2_700) - .padding(.horizontal, 10) + HStack { + VStack{ + Divider() + } + Text(" or ") + .default_text_style(styleSize: 15) + .foregroundStyle(Color.gray_main2_500) + VStack{ + Divider() + } + } + .padding(.bottom, 10) Button(action: { }) { - Text("Register") - .default_text_style_orange_600(styleSize: 20) - .frame(height: 35) + HStack { + Image("qr-code") + .renderingMode(.template) + .resizable() + .foregroundStyle(Color.orange_main_500) + .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) @@ -217,13 +175,59 @@ struct LoginFragment: View { .inset(by: 0.5) .stroke(Color.orange_main_500, lineWidth: 1) ) - .padding(.horizontal, 10) + .padding(.bottom) - Spacer() + NavigationLink(destination: { + ThirdPartySipAccountWarningFragment(accountLoginViewModel: accountLoginViewModel) + }, label: { + Text("Use SIP 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.orange_main_500, lineWidth: 1) + ) + .padding(.bottom) + + HStack(alignment: .center) { + + Spacer() + + Text("Not account yet?") + .default_text_style(styleSize: 15) + .foregroundStyle(Color.gray_main2_700) + .padding(.horizontal, 10) + + Button(action: { + + }) { + Text("Register") + .default_text_style_orange_600(styleSize: 20) + .frame(height: 35) + } + .padding(.horizontal, 20) + .padding(.vertical, 10) + .cornerRadius(60) + .overlay( + RoundedRectangle(cornerRadius: 60) + .inset(by: 0.5) + .stroke(Color.orange_main_500, lineWidth: 1) + ) + .padding(.horizontal, 10) + + Spacer() + } + .padding(.bottom) } - .padding(.bottom) + .padding(.horizontal, 20) } - .padding(.horizontal, 20) } } } @@ -231,5 +235,5 @@ struct LoginFragment: View { } #Preview { - LoginFragment(accountLoginViewModel: AccountLoginViewModel()) + LoginFragment(accountLoginViewModel: AccountLoginViewModel(), sharedMainViewModel: SharedMainViewModel()) } diff --git a/Linphone/UI/Assistant/Fragments/ProfileModeFragment.swift b/Linphone/UI/Assistant/Fragments/ProfileModeFragment.swift index 44c0c66ff..447fae4e8 100644 --- a/Linphone/UI/Assistant/Fragments/ProfileModeFragment.swift +++ b/Linphone/UI/Assistant/Fragments/ProfileModeFragment.swift @@ -21,6 +21,8 @@ import SwiftUI struct ProfileModeFragment: View { + @ObservedObject var sharedMainViewModel : SharedMainViewModel + @State var options: Int = 1 @State private var isShowPopup = false @State private var isShowPopupForDefault = true @@ -35,9 +37,12 @@ struct ProfileModeFragment: View { .scaledToFill() .frame(width: geometry.size.width, height: 100) .clipped() - Text("assistant_account_login") + Text("Personnalize your profil mode") .default_text_style_white_800(styleSize: 20) - .padding(.top, 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) @@ -110,12 +115,29 @@ struct ProfileModeFragment: View { .cornerRadius(15) } .padding() + + Spacer() + + Button(action: { + sharedMainViewModel.displayProfileMode = false + }) { + Text("Continue") + .default_text_style_white_600(styleSize: 20) + .frame(height: 35) + .frame(maxWidth: .infinity) + } + .padding(.horizontal, 20) + .padding(.vertical, 10) + .background(Color.orange_main_500) + .cornerRadius(60) + .padding(.horizontal) } + .frame(minHeight: geometry.size.height) } + 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)) - .edgesIgnoringSafeArea(.all) .onTapGesture { self.isShowPopup.toggle() } @@ -125,5 +147,5 @@ struct ProfileModeFragment: View { } #Preview { - ProfileModeFragment() + ProfileModeFragment(sharedMainViewModel: SharedMainViewModel()) } diff --git a/Linphone/UI/Assistant/Fragments/ThirdPartySipAccountLoginFragment.swift b/Linphone/UI/Assistant/Fragments/ThirdPartySipAccountLoginFragment.swift new file mode 100644 index 000000000..f072ff1f5 --- /dev/null +++ b/Linphone/UI/Assistant/Fragments/ThirdPartySipAccountLoginFragment.swift @@ -0,0 +1,237 @@ +/* + * 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 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.gray_main2_500) + .frame(width: 20, height: 20, alignment: .leading) + .padding(.top, -65) + .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) + .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.orange_main_500 : Color.gray_200, 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) + .frame(height: 25) + .focused($isPasswordFocused) + } + } + Button(action: { + isSecured.toggle() + }) { + Image(self.isSecured ? "eye-slash" : "eye") + .renderingMode(.template) + .resizable() + .foregroundStyle(Color.gray_main2_500) + .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.orange_main_500 : Color.gray_200, 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) + .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.orange_main_500 : Color.gray_200, 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) + .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.orange_main_500 : Color.gray_200, 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.gray_main2_500) + .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.gray_200, lineWidth: 1) + ) + .padding(.bottom) + + Spacer() + + Button(action: { + if (self.coreContext.loggedIn){ + self.accountLoginViewModel.unregister() + self.accountLoginViewModel.delete() + } else { + self.accountLoginViewModel.login() + } + + accountLoginViewModel.domain = "sip.linphone.org" + accountLoginViewModel.transportType = "TLS" + }) { + 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.orange_main_100 : Color.orange_main_500) + .cornerRadius(60) + .disabled(accountLoginViewModel.username.isEmpty || accountLoginViewModel.passwd.isEmpty || accountLoginViewModel.domain.isEmpty) + } + .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..386f98b57 --- /dev/null +++ b/Linphone/UI/Assistant/Fragments/ThirdPartySipAccountWarningFragment.swift @@ -0,0 +1,184 @@ +/* + * 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 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.gray_main2_500) + .frame(width: 20, height: 20, alignment: .leading) + .padding(.top, -65) + .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("conversation") + .renderingMode(.template) + .resizable() + .foregroundStyle(Color.gray_main2_500) + .frame(width: 20, height: 20, alignment: .leading) + .onTapGesture { + withAnimation { + dismiss() + } + } + } + .padding(16) + .background(Color.gray_main2_200) + .cornerRadius(40) + .padding(.horizontal) + + HStack(alignment: .center) { + Image("video-call") + .renderingMode(.template) + .resizable() + .foregroundStyle(Color.gray_main2_500) + .frame(width: 20, height: 20, alignment: .leading) + .onTapGesture { + withAnimation { + dismiss() + } + } + } + .padding(16) + .background(Color.gray_main2_200) + .cornerRadius(40) + .padding(.horizontal) + + Spacer() + } + .padding(.bottom, 40) + + Text("Some features require a Linphone account, such as group messaging, video conferences...\n\nThese features are hidden when you register with a third party SIP account.\n\nTo 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.orange_main_500) + .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.orange_main_500, lineWidth: 1) + ) + + Spacer() + } + .padding(.vertical) + } + .padding(.horizontal, 20) + + Spacer() + + Button(action: { + dismiss() + }) { + 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.orange_main_500, lineWidth: 1) + ) + .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.orange_main_500) + .cornerRadius(60) + .padding(.horizontal) + } + .frame(minHeight: geometry.size.height) + } + } + } + .navigationBarHidden(true) + } +} + +#Preview { + ThirdPartySipAccountWarningFragment(accountLoginViewModel: AccountLoginViewModel()) +} diff --git a/Linphone/UI/Assistant/Viewmodel/AccountLoginViewModel.swift b/Linphone/UI/Assistant/Viewmodel/AccountLoginViewModel.swift index 3920bce13..7a6da1d2d 100644 --- a/Linphone/UI/Assistant/Viewmodel/AccountLoginViewModel.swift +++ b/Linphone/UI/Assistant/Viewmodel/AccountLoginViewModel.swift @@ -27,6 +27,7 @@ class AccountLoginViewModel : ObservableObject { @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() {} diff --git a/Linphone/UI/Main/ContentView.swift b/Linphone/UI/Main/ContentView.swift index a30a20a4c..c0404cd2f 100644 --- a/Linphone/UI/Main/ContentView.swift +++ b/Linphone/UI/Main/ContentView.swift @@ -27,9 +27,9 @@ struct ContentView: View { var body: some View { if UserDefaults.standard.bool(forKey: "general_terms") == false { WelcomeView(sharedMainViewModel: sharedMainViewModel) - } else if coreContext.mCore.defaultAccount == nil { - AssistantView() - } else { + } else if coreContext.mCore.defaultAccount == nil || sharedMainViewModel.displayProfileMode { + AssistantView(sharedMainViewModel: sharedMainViewModel) + } else { TabView { ContactsView() .tabItem { @@ -46,5 +46,5 @@ struct ContentView: View { } #Preview { - ContentView(sharedMainViewModel: SharedMainViewModel()) + ContentView(sharedMainViewModel: SharedMainViewModel()) } diff --git a/Linphone/UI/Main/Viewmodel/SharedMainViewModel.swift b/Linphone/UI/Main/Viewmodel/SharedMainViewModel.swift index 31a93e888..ac654f468 100644 --- a/Linphone/UI/Main/Viewmodel/SharedMainViewModel.swift +++ b/Linphone/UI/Main/Viewmodel/SharedMainViewModel.swift @@ -20,7 +20,9 @@ import linphonesw class SharedMainViewModel : ObservableObject { - + + @Published var displayProfileMode : Bool = false + @Published var generalTermsAccepted = false init() { diff --git a/Linphone/UI/Welcome/WelcomeView.swift b/Linphone/UI/Welcome/WelcomeView.swift index 03710d291..ce4052e11 100644 --- a/Linphone/UI/Welcome/WelcomeView.swift +++ b/Linphone/UI/Welcome/WelcomeView.swift @@ -30,7 +30,7 @@ struct WelcomeView: View{ var body: some View { GeometryReader { geometry in - ZStack { + ScrollView { VStack { ZStack { Image("mountain") @@ -67,6 +67,8 @@ struct WelcomeView: View{ .padding(.top, 35) .padding(.bottom, 10) + Spacer() + VStack{ TabView(selection: $index) { ForEach((0..<3), id: \.self) { index in @@ -82,11 +84,14 @@ struct WelcomeView: View{ } } .tabViewStyle(PageTabViewStyle(indexDisplayMode: .always)) + .frame(minHeight: 300) .onAppear { setupAppearance() } } + Spacer() + Button(action: { if index < 2 { withAnimation { @@ -107,18 +112,17 @@ struct WelcomeView: View{ .padding(.vertical, 10) .background(Color.orange_main_500) .cornerRadius(60) - .padding(.bottom) .padding(.horizontal) } - - if self.isShowPopup { - PopupView(isShowPopup: $isShowPopup, title: Text("Conditions de service"), content: Text("En continuant, vous acceptez ces conditions, \(Text("[notre politique de confidentialité](https://linphone.org/privacy-policy)").underline()) et \(Text("[nos conditions d’utilisation](https://linphone.org/general-terms)").underline())."), titleFirstButton: Text("Deny all"), actionFirstButton: {self.isShowPopup.toggle()}, titleSecondButton: Text("Accept all"), actionSecondButton: {permissionManager.photoLibraryRequestPermission()}) - .background(.black.opacity(0.65)) - .edgesIgnoringSafeArea(.all) - .onTapGesture { - self.isShowPopup.toggle() - } - } + .frame(minHeight: geometry.size.height) + } + + if self.isShowPopup { + PopupView(isShowPopup: $isShowPopup, title: Text("Conditions de service"), content: Text("En continuant, vous acceptez ces conditions, \(Text("[notre politique de confidentialité](https://linphone.org/privacy-policy)").underline()) et \(Text("[nos conditions d’utilisation](https://linphone.org/general-terms)").underline())."), titleFirstButton: Text("Deny all"), actionFirstButton: {self.isShowPopup.toggle()}, titleSecondButton: Text("Accept all"), actionSecondButton: {permissionManager.photoLibraryRequestPermission()}) + .background(.black.opacity(0.65)) + .onTapGesture { + self.isShowPopup.toggle() + } } } .onReceive(permissionManager.$photoLibraryPermissionGranted, perform: { (granted) in From b967b67598720b7c89914f16b15d458432af098f Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Thu, 5 Oct 2023 17:01:05 +0200 Subject: [PATCH 017/486] Add QRCode Scanner View --- Linphone.xcodeproj/project.pbxproj | 18 +++++ .../danger.imageset/Contents.json | 21 +++++ .../danger.imageset/danger.svg | 3 + .../success.imageset/Contents.json | 21 +++++ .../success.imageset/success.svg | 3 + Linphone/Core/CoreContext.swift | 69 ++++++++++------- Linphone/LinphoneApp.swift | 2 + Linphone/Localizable.xcstrings | 9 +++ .../Assistant/Fragments/LoginFragment.swift | 54 ++++++------- .../Fragments/QrCodeScannerFragment.swift | 57 ++++++++++++++ .../ThirdPartySipAccountLoginFragment.swift | 2 +- .../ThirdPartySipAccountWarningFragment.swift | 3 +- .../UI/Assistant/Viewmodel/QRScanner.swift | 77 +++++++++++++++++++ Linphone/UI/Main/Fragments/ToastView.swift | 70 +++++++++++++++++ Linphone/UI/Welcome/WelcomeView.swift | 4 +- Linphone/Utils/QRScannerController.swift | 64 +++++++++++++++ 16 files changed, 420 insertions(+), 57 deletions(-) create mode 100644 Linphone/Assets.xcassets/danger.imageset/Contents.json create mode 100644 Linphone/Assets.xcassets/danger.imageset/danger.svg create mode 100644 Linphone/Assets.xcassets/success.imageset/Contents.json create mode 100644 Linphone/Assets.xcassets/success.imageset/success.svg create mode 100644 Linphone/UI/Assistant/Fragments/QrCodeScannerFragment.swift create mode 100644 Linphone/UI/Assistant/Viewmodel/QRScanner.swift create mode 100644 Linphone/UI/Main/Fragments/ToastView.swift create mode 100644 Linphone/Utils/QRScannerController.swift diff --git a/Linphone.xcodeproj/project.pbxproj b/Linphone.xcodeproj/project.pbxproj index f4fe4d61b..f086c50b4 100644 --- a/Linphone.xcodeproj/project.pbxproj +++ b/Linphone.xcodeproj/project.pbxproj @@ -18,6 +18,10 @@ 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 */; }; + 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 */; }; 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 */; }; @@ -55,6 +59,10 @@ 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 = ""; }; + 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 = ""; }; 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 = ""; }; @@ -114,6 +122,7 @@ D717071D2AC5922E0037746F /* ColorExtension.swift */, D717071F2AC5989C0037746F /* TextExtension.swift */, D74C9D002ACB098C0021626A /* PermissionManager.swift */, + D72343312ACEFF58009AA24E /* QRScannerController.swift */, ); path = Utils; sourceTree = ""; @@ -206,6 +215,7 @@ isa = PBXGroup; children = ( D719ABCE2ABC779A00B41C10 /* AccountLoginViewModel.swift */, + D72343332ACEFFC3009AA24E /* QRScanner.swift */, ); path = Viewmodel; sourceTree = ""; @@ -224,6 +234,7 @@ isa = PBXGroup; children = ( D74C9CFE2ACAEC5E0021626A /* PopupView.swift */, + D72343352AD037AF009AA24E /* ToastView.swift */, ); path = Fragments; sourceTree = ""; @@ -281,6 +292,7 @@ D7DA67632ACCB31700E95002 /* ProfileModeFragment.swift */, D748BF2B2ACD82D2004844EB /* ThirdPartySipAccountLoginFragment.swift */, D748BF2D2ACD82E7004844EB /* ThirdPartySipAccountWarningFragment.swift */, + D723432F2ACEFEF8009AA24E /* QrCodeScannerFragment.swift */, ); path = Fragments; sourceTree = ""; @@ -420,6 +432,10 @@ D748BF2E2ACD82E7004844EB /* ThirdPartySipAccountWarningFragment.swift in Sources */, D748BF2C2ACD82D2004844EB /* ThirdPartySipAccountLoginFragment.swift in Sources */, D74C9CF82ACACECE0021626A /* WelcomePage1Fragment.swift in Sources */, + D72343362AD037AF009AA24E /* ToastView.swift in Sources */, + D72343322ACEFF58009AA24E /* QRScannerController.swift in Sources */, + D72343342ACEFFC3009AA24E /* QRScanner.swift in Sources */, + D72343302ACEFEF8009AA24E /* QrCodeScannerFragment.swift in Sources */, D717071E2AC5922E0037746F /* ColorExtension.swift in Sources */, D7DA67642ACCB31700E95002 /* ProfileModeFragment.swift in Sources */, D74C9CFC2ACACF370021626A /* WelcomePage3Fragment.swift in Sources */, @@ -563,6 +579,7 @@ ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = Linphone/Info.plist; + INFOPLIST_KEY_NSCameraUsageDescription = "Share photos with your friends and customize avatars"; INFOPLIST_KEY_NSPhotoLibraryUsageDescription = ""; "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES; "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES; @@ -606,6 +623,7 @@ ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = Linphone/Info.plist; + INFOPLIST_KEY_NSCameraUsageDescription = "Share photos with your friends and customize avatars"; INFOPLIST_KEY_NSPhotoLibraryUsageDescription = ""; "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES; "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES; diff --git a/Linphone/Assets.xcassets/danger.imageset/Contents.json b/Linphone/Assets.xcassets/danger.imageset/Contents.json new file mode 100644 index 000000000..63a1157a7 --- /dev/null +++ b/Linphone/Assets.xcassets/danger.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "danger.svg", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Linphone/Assets.xcassets/danger.imageset/danger.svg b/Linphone/Assets.xcassets/danger.imageset/danger.svg new file mode 100644 index 000000000..7f47d7312 --- /dev/null +++ b/Linphone/Assets.xcassets/danger.imageset/danger.svg @@ -0,0 +1,3 @@ + + + diff --git a/Linphone/Assets.xcassets/success.imageset/Contents.json b/Linphone/Assets.xcassets/success.imageset/Contents.json new file mode 100644 index 000000000..a4aa98205 --- /dev/null +++ b/Linphone/Assets.xcassets/success.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "success.svg", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Linphone/Assets.xcassets/success.imageset/success.svg b/Linphone/Assets.xcassets/success.imageset/success.svg new file mode 100644 index 000000000..633f0687c --- /dev/null +++ b/Linphone/Assets.xcassets/success.imageset/success.svg @@ -0,0 +1,3 @@ + + + diff --git a/Linphone/Core/CoreContext.swift b/Linphone/Core/CoreContext.swift index 789384f73..444526dac 100644 --- a/Linphone/Core/CoreContext.swift +++ b/Linphone/Core/CoreContext.swift @@ -1,21 +1,21 @@ /* -* 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 . -*/ + * 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 @@ -25,9 +25,11 @@ final class CoreContext : ObservableObject { var mCore: Core! var mRegistrationDelegate : CoreDelegate! + var mConfigurationDelegate : CoreDelegate! var coreVersion: String = Core.getVersion - @Published var loggedIn : Bool = false + @Published var loggedIn : Bool = false + @Published var configuringSuccessful : String = "" private init() {} @@ -41,17 +43,30 @@ final class CoreContext : ObservableObject { // Create a Core listener to listen for the callback we need // In this case, we want to know about the account registration status - mRegistrationDelegate = CoreDelegateStub(onAccountRegistrationStateChanged: { (core: Core, account: Account, state: RegistrationState, message: String) in + + mRegistrationDelegate = + CoreDelegateStub( + onConfiguringStatus: { (core: Core, state: Config.ConfiguringState, message: String) in + NSLog("New configuration state is \(state) = \(message)\n") + if (state == .Successful) { + self.configuringSuccessful = "Successful" + } else { + self.configuringSuccessful = "Failed" + } + }, - // If account has been configured correctly, we will go through Progress and Ok states - // Otherwise, we will be Failed. - NSLog("New registration state is \(state) for user id \( String(describing: account.params?.identityAddress?.asString()))\n") - if (state == .Ok) { - self.loggedIn = true - } else if (state == .Cleared) { - self.loggedIn = false + 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. + NSLog("New registration state is \(state) for user id \( String(describing: account.params?.identityAddress?.asString()))\n") + if (state == .Ok) { + self.loggedIn = true + } else if (state == .Cleared) { + self.loggedIn = false + } } - }) + ) + mCore.addDelegate(delegate: mRegistrationDelegate) } } diff --git a/Linphone/LinphoneApp.swift b/Linphone/LinphoneApp.swift index 2b7f833fa..e63f62ae4 100644 --- a/Linphone/LinphoneApp.swift +++ b/Linphone/LinphoneApp.swift @@ -22,12 +22,14 @@ import SwiftUI @main struct LinphoneApp: App { + @ObservedObject private var coreContext = CoreContext.shared @State private var isActive = false var body: some Scene { WindowGroup { if isActive { ContentView(sharedMainViewModel: SharedMainViewModel()) + .toast(isShowing: $coreContext.configuringSuccessful) }else { SplashScreen(isActive: $isActive) } diff --git a/Linphone/Localizable.xcstrings b/Linphone/Localizable.xcstrings index 9fbfb1302..de219faee 100644 --- a/Linphone/Localizable.xcstrings +++ b/Linphone/Localizable.xcstrings @@ -155,6 +155,12 @@ }, "Interoperable mode" : { + }, + "Invalid QR code!" : { + + }, + "Invalide URI" : { + }, "Linphone" : { @@ -190,6 +196,9 @@ }, "Personnalize your profil mode" : { + }, + "QR code validated!" : { + }, "Register" : { diff --git a/Linphone/UI/Assistant/Fragments/LoginFragment.swift b/Linphone/UI/Assistant/Fragments/LoginFragment.swift index b1ba24389..10c3c9201 100644 --- a/Linphone/UI/Assistant/Fragments/LoginFragment.swift +++ b/Linphone/UI/Assistant/Fragments/LoginFragment.swift @@ -150,32 +150,33 @@ struct LoginFragment: View { } } .padding(.bottom, 10) - - Button(action: { - - }) { - HStack { - Image("qr-code") - .renderingMode(.template) - .resizable() - .foregroundStyle(Color.orange_main_500) - .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.orange_main_500, lineWidth: 1) - ) - .padding(.bottom) + + NavigationLink(destination: { + QrCodeScannerFragment() + }, label: { + HStack { + Image("qr-code") + .renderingMode(.template) + .resizable() + .foregroundStyle(Color.orange_main_500) + .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.orange_main_500, lineWidth: 1) + ) + .padding(.bottom) NavigationLink(destination: { ThirdPartySipAccountWarningFragment(accountLoginViewModel: accountLoginViewModel) @@ -231,6 +232,7 @@ struct LoginFragment: View { } } } + .navigationViewStyle(StackNavigationViewStyle()) } } diff --git a/Linphone/UI/Assistant/Fragments/QrCodeScannerFragment.swift b/Linphone/UI/Assistant/Fragments/QrCodeScannerFragment.swift new file mode 100644 index 000000000..03f9fe288 --- /dev/null +++ b/Linphone/UI/Assistant/Fragments/QrCodeScannerFragment.swift @@ -0,0 +1,57 @@ +// +// QrCodeScannerFragment.swift +// Linphone +// +// Created by Benoît Martins on 05/10/2023. +// + +import SwiftUI + +struct QrCodeScannerFragment: View { + + @ObservedObject private var coreContext = CoreContext.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() + .padding(.top, 50) + + Spacer() + } + } + .edgesIgnoringSafeArea(.all) + .navigationBarHidden(true) + + if coreContext.configuringSuccessful == "Successful" { + ZStack{ + + }.onAppear { + dismiss() + } + } + } +} + +#Preview { + QrCodeScannerFragment() +} diff --git a/Linphone/UI/Assistant/Fragments/ThirdPartySipAccountLoginFragment.swift b/Linphone/UI/Assistant/Fragments/ThirdPartySipAccountLoginFragment.swift index f072ff1f5..e2a80eff9 100644 --- a/Linphone/UI/Assistant/Fragments/ThirdPartySipAccountLoginFragment.swift +++ b/Linphone/UI/Assistant/Fragments/ThirdPartySipAccountLoginFragment.swift @@ -50,7 +50,7 @@ struct ThirdPartySipAccountLoginFragment: View { .renderingMode(.template) .resizable() .foregroundStyle(Color.gray_main2_500) - .frame(width: 20, height: 20, alignment: .leading) + .frame(width: 25, height: 25, alignment: .leading) .padding(.top, -65) .onTapGesture { withAnimation { diff --git a/Linphone/UI/Assistant/Fragments/ThirdPartySipAccountWarningFragment.swift b/Linphone/UI/Assistant/Fragments/ThirdPartySipAccountWarningFragment.swift index 386f98b57..98b8c6478 100644 --- a/Linphone/UI/Assistant/Fragments/ThirdPartySipAccountWarningFragment.swift +++ b/Linphone/UI/Assistant/Fragments/ThirdPartySipAccountWarningFragment.swift @@ -44,7 +44,7 @@ struct ThirdPartySipAccountWarningFragment: View { .renderingMode(.template) .resizable() .foregroundStyle(Color.gray_main2_500) - .frame(width: 20, height: 20, alignment: .leading) + .frame(width: 25, height: 25, alignment: .leading) .padding(.top, -65) .onTapGesture { withAnimation { @@ -175,6 +175,7 @@ struct ThirdPartySipAccountWarningFragment: View { } } } + .navigationViewStyle(StackNavigationViewStyle()) .navigationBarHidden(true) } } diff --git a/Linphone/UI/Assistant/Viewmodel/QRScanner.swift b/Linphone/UI/Assistant/Viewmodel/QRScanner.swift new file mode 100644 index 000000000..3e9b46419 --- /dev/null +++ b/Linphone/UI/Assistant/Viewmodel/QRScanner.swift @@ -0,0 +1,77 @@ +// +// QRScanner.swift +// Linphone +// +// Created by Benoît Martins on 05/10/2023. +// + +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 + + @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.count == 0 { + scanResult = "Scan a QR code" + return + } + + // Get the metadata object. + let metadataObj = metadataObjects[0] as! AVMetadataMachineReadableCodeObject + + 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 + //scanResult = result + do { + try coreContext.mCore.setProvisioninguri(newValue: result) + coreContext.mCore.stop() + try coreContext.mCore.start() + }catch { + + } + + } else { + coreContext.configuringSuccessful = "Invalide URI" + } + } else { + coreContext.configuringSuccessful = "Invalide URI" + } + } + } + } +} diff --git a/Linphone/UI/Main/Fragments/ToastView.swift b/Linphone/UI/Main/Fragments/ToastView.swift new file mode 100644 index 000000000..fff782c09 --- /dev/null +++ b/Linphone/UI/Main/Fragments/ToastView.swift @@ -0,0 +1,70 @@ +// +// ToastView.swift +// Linphone +// +// Created by Benoît Martins on 06/10/2023. +// + +import SwiftUI + +struct ToastView: ViewModifier { + + @Binding var isShowing: String + + func body(content: Content) -> some View { + ZStack { + content + toastView + } + } + + private var toastView: some View { + VStack { + if !isShowing.isEmpty { + HStack { + Image(isShowing == "Successful" ? "success" : "danger") + .resizable() + .frame(width: 25, height: 25, alignment: .leading) + + Text(isShowing == "Successful" ? "QR code validated!" : (isShowing == "Failed" ? "Invalid QR code!" : "Invalide URI")) + .multilineTextAlignment(.center) + .foregroundStyle(isShowing == "Successful" ? Color.green_success_500 : Color.red_danger_500) + .default_text_style(styleSize: 15) + .padding(8) + } + .frame(maxWidth: .infinity) + .frame(height: 40) + .background(.white) + .cornerRadius(50) + .overlay( + RoundedRectangle(cornerRadius: 50) + .inset(by: 0.5) + .stroke(isShowing == "Successful" ? Color.green_success_500 : Color.red_danger_500, lineWidth: 1) + ) + .onTapGesture { + isShowing = "" + } + .onAppear { + DispatchQueue.main.asyncAfter(deadline: .now() + 2) { + isShowing = "" + } + } + } + Spacer() + } + .padding(.horizontal, 16) + .padding(.bottom, 18) + .animation(.linear(duration: 0.3), value: isShowing) + .transition(.opacity) + } +} + +extension View { + func toast(isShowing: Binding) -> some View { + self.modifier(ToastView(isShowing: isShowing)) + } +} + +//#Preview { +//ToastView() +//} diff --git a/Linphone/UI/Welcome/WelcomeView.swift b/Linphone/UI/Welcome/WelcomeView.swift index ce4052e11..7ec1e7ae9 100644 --- a/Linphone/UI/Welcome/WelcomeView.swift +++ b/Linphone/UI/Welcome/WelcomeView.swift @@ -118,14 +118,14 @@ struct WelcomeView: View{ } if self.isShowPopup { - PopupView(isShowPopup: $isShowPopup, title: Text("Conditions de service"), content: Text("En continuant, vous acceptez ces conditions, \(Text("[notre politique de confidentialité](https://linphone.org/privacy-policy)").underline()) et \(Text("[nos conditions d’utilisation](https://linphone.org/general-terms)").underline())."), titleFirstButton: Text("Deny all"), actionFirstButton: {self.isShowPopup.toggle()}, titleSecondButton: Text("Accept all"), actionSecondButton: {permissionManager.photoLibraryRequestPermission()}) + PopupView(isShowPopup: $isShowPopup, title: Text("Conditions de service"), content: Text("En continuant, vous acceptez ces conditions, \(Text("[notre politique de confidentialité](https://linphone.org/privacy-policy)").underline()) et \(Text("[nos conditions d’utilisation](https://linphone.org/general-terms)").underline())."), titleFirstButton: Text("Deny all"), actionFirstButton: {self.isShowPopup.toggle()}, titleSecondButton: Text("Accept all"), actionSecondButton: {permissionManager.cameraRequestPermission()}) .background(.black.opacity(0.65)) .onTapGesture { self.isShowPopup.toggle() } } } - .onReceive(permissionManager.$photoLibraryPermissionGranted, perform: { (granted) in + .onReceive(permissionManager.$cameraPermissionGranted, perform: { (granted) in if granted { withAnimation { sharedMainViewModel.changeGeneralTerms() diff --git a/Linphone/Utils/QRScannerController.swift b/Linphone/Utils/QRScannerController.swift new file mode 100644 index 000000000..7d37f2cb5 --- /dev/null +++ b/Linphone/Utils/QRScannerController.swift @@ -0,0 +1,64 @@ +// +// QRScannerController.swift +// Linphone +// +// Created by Benoît Martins on 05/10/2023. +// + +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() + } + + } + +} From 3669674eae49e34d86d169b24249d6bed7fe5c2c Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Mon, 9 Oct 2023 14:47:04 +0200 Subject: [PATCH 018/486] Several fixes Fix iPad and landscape views --- Linphone.xcodeproj/project.pbxproj | 8 + Linphone/Core/CoreContext.swift | 14 +- Linphone/LinphoneApp.swift | 2 +- Linphone/Localizable.xcstrings | 9 + Linphone/SplashScreen.swift | 24 +- Linphone/UI/Assistant/AssistantView.swift | 9 +- .../Assistant/Fragments/LoginFragment.swift | 499 ++++++++++-------- .../Fragments/ProfileModeFragment.swift | 10 +- .../Fragments/QrCodeScannerFragment.swift | 26 +- .../Fragments/RegisterFragment.swift | 64 +++ .../ThirdPartySipAccountLoginFragment.swift | 7 +- .../ThirdPartySipAccountWarningFragment.swift | 11 +- .../UI/Assistant/Viewmodel/QRScanner.swift | 28 +- Linphone/UI/Main/ContentView.swift | 2 +- .../UI/Main/Fragments/PopupLoadingView.swift | 59 +++ Linphone/UI/Main/Fragments/PopupView.swift | 20 +- Linphone/UI/Main/Fragments/ToastView.swift | 75 ++- Linphone/UI/Main/History/HistoryView.swift | 24 +- .../Main/Viewmodel/SharedMainViewModel.swift | 48 +- Linphone/UI/Welcome/WelcomeView.swift | 19 +- Linphone/Utils/QRScannerController.swift | 24 +- 21 files changed, 684 insertions(+), 298 deletions(-) create mode 100644 Linphone/UI/Assistant/Fragments/RegisterFragment.swift create mode 100644 Linphone/UI/Main/Fragments/PopupLoadingView.swift diff --git a/Linphone.xcodeproj/project.pbxproj b/Linphone.xcodeproj/project.pbxproj index f086c50b4..47c1b6328 100644 --- a/Linphone.xcodeproj/project.pbxproj +++ b/Linphone.xcodeproj/project.pbxproj @@ -29,6 +29,7 @@ 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 */; }; + D750D3392AD3E6EE00EC99C5 /* PopupLoadingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D750D3382AD3E6EE00EC99C5 /* PopupLoadingView.swift */; }; D7702EF22AC7205000557C00 /* WelcomeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7702EF12AC7205000557C00 /* WelcomeView.swift */; }; D7A03FBD2ACC2DB60081A588 /* ContactsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7A03FBC2ACC2DB60081A588 /* ContactsView.swift */; }; D7A03FC02ACC2E390081A588 /* HistoryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7A03FBF2ACC2E390081A588 /* HistoryView.swift */; }; @@ -42,6 +43,7 @@ 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 */; }; + D7FB55112AD447FD00A5AB15 /* RegisterFragment.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7FB55102AD447FD00A5AB15 /* RegisterFragment.swift */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ @@ -70,6 +72,7 @@ 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 = ""; }; + D750D3382AD3E6EE00EC99C5 /* PopupLoadingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PopupLoadingView.swift; sourceTree = ""; }; D7702EF12AC7205000557C00 /* WelcomeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WelcomeView.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 = ""; }; @@ -84,6 +87,7 @@ 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 = ""; }; + D7FB55102AD447FD00A5AB15 /* RegisterFragment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RegisterFragment.swift; sourceTree = ""; }; FBDE73581C1DC4F98CC3DF3A /* Pods-Linphone.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Linphone.debug.xcconfig"; path = "Target Support Files/Pods-Linphone/Pods-Linphone.debug.xcconfig"; sourceTree = ""; }; /* End PBXFileReference section */ @@ -235,6 +239,7 @@ children = ( D74C9CFE2ACAEC5E0021626A /* PopupView.swift */, D72343352AD037AF009AA24E /* ToastView.swift */, + D750D3382AD3E6EE00EC99C5 /* PopupLoadingView.swift */, ); path = Fragments; sourceTree = ""; @@ -293,6 +298,7 @@ D748BF2B2ACD82D2004844EB /* ThirdPartySipAccountLoginFragment.swift */, D748BF2D2ACD82E7004844EB /* ThirdPartySipAccountWarningFragment.swift */, D723432F2ACEFEF8009AA24E /* QrCodeScannerFragment.swift */, + D7FB55102AD447FD00A5AB15 /* RegisterFragment.swift */, ); path = Fragments; sourceTree = ""; @@ -420,6 +426,7 @@ files = ( D71707202AC5989C0037746F /* TextExtension.swift in Sources */, D719ABB92ABC67BF00B41C10 /* ContentView.swift in Sources */, + D750D3392AD3E6EE00EC99C5 /* PopupLoadingView.swift in Sources */, D719ABC92ABC6FD700B41C10 /* CoreContext.swift in Sources */, D7A03FBD2ACC2DB60081A588 /* ContactsView.swift in Sources */, D719ABCF2ABC779A00B41C10 /* AccountLoginViewModel.swift in Sources */, @@ -433,6 +440,7 @@ D748BF2C2ACD82D2004844EB /* ThirdPartySipAccountLoginFragment.swift in Sources */, D74C9CF82ACACECE0021626A /* WelcomePage1Fragment.swift in Sources */, D72343362AD037AF009AA24E /* ToastView.swift in Sources */, + D7FB55112AD447FD00A5AB15 /* RegisterFragment.swift in Sources */, D72343322ACEFF58009AA24E /* QRScannerController.swift in Sources */, D72343342ACEFFC3009AA24E /* QRScanner.swift in Sources */, D72343302ACEFEF8009AA24E /* QrCodeScannerFragment.swift in Sources */, diff --git a/Linphone/Core/CoreContext.swift b/Linphone/Core/CoreContext.swift index 444526dac..cfa03dd68 100644 --- a/Linphone/Core/CoreContext.swift +++ b/Linphone/Core/CoreContext.swift @@ -29,7 +29,8 @@ final class CoreContext : ObservableObject { var coreVersion: String = Core.getVersion @Published var loggedIn : Bool = false - @Published var configuringSuccessful : String = "" + @Published var loggingInProgress : Bool = false + @Published var toastMessage : String = "" private init() {} @@ -49,9 +50,9 @@ final class CoreContext : ObservableObject { onConfiguringStatus: { (core: Core, state: Config.ConfiguringState, message: String) in NSLog("New configuration state is \(state) = \(message)\n") if (state == .Successful) { - self.configuringSuccessful = "Successful" + self.toastMessage = "Successful" } else { - self.configuringSuccessful = "Failed" + self.toastMessage = "Failed" } }, @@ -60,8 +61,13 @@ final class CoreContext : ObservableObject { // Otherwise, we will be Failed. NSLog("New registration state is \(state) for user id \( String(describing: account.params?.identityAddress?.asString()))\n") if (state == .Ok) { + self.loggingInProgress = false self.loggedIn = true - } else if (state == .Cleared) { + } else if (state == .Progress) { + self.loggingInProgress = true + } else { + self.toastMessage = "Registration failed" + self.loggingInProgress = false self.loggedIn = false } } diff --git a/Linphone/LinphoneApp.swift b/Linphone/LinphoneApp.swift index e63f62ae4..56b0c0ad3 100644 --- a/Linphone/LinphoneApp.swift +++ b/Linphone/LinphoneApp.swift @@ -29,7 +29,7 @@ struct LinphoneApp: App { WindowGroup { if isActive { ContentView(sharedMainViewModel: SharedMainViewModel()) - .toast(isShowing: $coreContext.configuringSuccessful) + .toast(isShowing: $coreContext.toastMessage) }else { SplashScreen(isActive: $isActive) } diff --git a/Linphone/Localizable.xcstrings b/Linphone/Localizable.xcstrings index de219faee..c46a0bdf1 100644 --- a/Linphone/Localizable.xcstrings +++ b/Linphone/Localizable.xcstrings @@ -140,6 +140,9 @@ } } } + }, + "Error" : { + }, "History View" : { @@ -176,6 +179,9 @@ }, "Open source" : { + }, + "Opération en cours..." : { + }, "password" : { "extractionState" : "manual", @@ -229,6 +235,9 @@ }, "Texte explicatif du interoperable mode : lorem ipsum dolor sit amet, consectetur adipiscing elit. Etiam velit sapien, egestas sit amet dictum eget, condimentum a ligula." : { + }, + "The user name or password is incorrects" : { + }, "TLS" : { diff --git a/Linphone/SplashScreen.swift b/Linphone/SplashScreen.swift index cbcc3298b..4f537a833 100644 --- a/Linphone/SplashScreen.swift +++ b/Linphone/SplashScreen.swift @@ -1,9 +1,21 @@ -// -// SplashScreen.swift -// Linphone -// -// Created by Benoît Martins on 03/10/2023. -// +/* +* 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 diff --git a/Linphone/UI/Assistant/AssistantView.swift b/Linphone/UI/Assistant/AssistantView.swift index a974cce31..57da277ae 100644 --- a/Linphone/UI/Assistant/AssistantView.swift +++ b/Linphone/UI/Assistant/AssistantView.swift @@ -22,13 +22,14 @@ import SwiftUI struct AssistantView: View { @ObservedObject var sharedMainViewModel : SharedMainViewModel + @ObservedObject private var coreContext = CoreContext.shared var body: some View { - if sharedMainViewModel.displayProfileMode != true { - LoginFragment(accountLoginViewModel: AccountLoginViewModel(), sharedMainViewModel: sharedMainViewModel) - } else { + if (sharedMainViewModel.displayProfileMode && coreContext.loggedIn){ ProfileModeFragment(sharedMainViewModel: sharedMainViewModel) - } + } else { + LoginFragment(accountLoginViewModel: AccountLoginViewModel(), sharedMainViewModel: sharedMainViewModel) + } } } diff --git a/Linphone/UI/Assistant/Fragments/LoginFragment.swift b/Linphone/UI/Assistant/Fragments/LoginFragment.swift index 10c3c9201..d11ff1b79 100644 --- a/Linphone/UI/Assistant/Fragments/LoginFragment.swift +++ b/Linphone/UI/Assistant/Fragments/LoginFragment.swift @@ -20,222 +20,305 @@ import SwiftUI struct LoginFragment: View { - - @ObservedObject private var coreContext = CoreContext.shared - @ObservedObject var accountLoginViewModel : AccountLoginViewModel - @ObservedObject var sharedMainViewModel : SharedMainViewModel - - @State private var isSecured: Bool = true - - @FocusState var isNameFocused:Bool - @FocusState var isPasswordFocused:Bool - - var body: some View { - NavigationView { - GeometryReader { geometry in - ScrollView(.vertical) { - VStack { - ZStack { - Image("mountain") - .resizable() - .scaledToFill() - .frame(width: geometry.size.width, height: 100) - .clipped() - 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) - .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.orange_main_500 : Color.gray_200, 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) - .frame(height: 25) - .focused($isPasswordFocused) - } - } - Button(action: { - isSecured.toggle() - }) { - Image(self.isSecured ? "eye-slash" : "eye") - .renderingMode(.template) - .resizable() - .foregroundStyle(Color.gray_main2_500) - .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.orange_main_500 : Color.gray_200, lineWidth: 1) - ) - .padding(.bottom) - - Button(action: { - sharedMainViewModel.displayProfileMode = true - if (self.coreContext.loggedIn){ - self.accountLoginViewModel.unregister() - self.accountLoginViewModel.delete() - } else { - self.accountLoginViewModel.login() - } - }) { - 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.orange_main_100 : Color.orange_main_500) - .cornerRadius(60) - .disabled(accountLoginViewModel.username.isEmpty || accountLoginViewModel.passwd.isEmpty) - .padding(.bottom) - - HStack { - Text("[Forgotten password?](https://subscribe.linphone.org/)") - .underline() - .tint(Color.gray_main2_600) - .default_text_style_600(styleSize: 15) - .foregroundStyle(Color.gray_main2_500) - } - .frame(maxWidth: .infinity) - .padding(.bottom, 30) - - HStack { - VStack{ - Divider() - } - Text(" or ") - .default_text_style(styleSize: 15) - .foregroundStyle(Color.gray_main2_500) - VStack{ - Divider() - } - } - .padding(.bottom, 10) + + @ObservedObject private var coreContext = CoreContext.shared + @ObservedObject var accountLoginViewModel : AccountLoginViewModel + @ObservedObject var sharedMainViewModel : SharedMainViewModel + + @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 isLinkQRActive = false + @State private var isLinkSIPActive = false + @State private var isLinkREGActive = false + + 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() + Text("assistant_account_login") + .default_text_style_white_800(styleSize: 20) + .padding(.top, 20) + } + .padding(.top, 35) + .padding(.bottom, 10) - NavigationLink(destination: { - QrCodeScannerFragment() - }, label: { - HStack { - Image("qr-code") - .renderingMode(.template) - .resizable() - .foregroundStyle(Color.orange_main_500) - .frame(width: 20, height: 20) - - Text("Scan QR code") - .default_text_style_orange_600(styleSize: 20) + 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) + .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.orange_main_500 : Color.gray_200, 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) + .frame(height: 25) + .focused($isPasswordFocused) + } + } + Button(action: { + isSecured.toggle() + }) { + Image(self.isSecured ? "eye-slash" : "eye") + .renderingMode(.template) + .resizable() + .foregroundStyle(Color.gray_main2_500) + .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.orange_main_500 : Color.gray_200, lineWidth: 1) + ) + .padding(.bottom) + + Button(action: { + if (self.coreContext.loggedIn){ + self.accountLoginViewModel.unregister() + self.accountLoginViewModel.delete() + } else { + self.accountLoginViewModel.login() + } + }) { + 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.orange_main_100 : Color.orange_main_500) + .cornerRadius(60) + .disabled(accountLoginViewModel.username.isEmpty || accountLoginViewModel.passwd.isEmpty) + .padding(.bottom) + + HStack { + Text("[Forgotten password?](https://subscribe.linphone.org/)") + .underline() + .tint(Color.gray_main2_600) + .default_text_style_600(styleSize: 15) + .foregroundStyle(Color.gray_main2_500) } .frame(maxWidth: .infinity) + .padding(.bottom, 30) - }) + HStack { + VStack{ + Divider() + } + Text(" or ") + .default_text_style(styleSize: 15) + .foregroundStyle(Color.gray_main2_500) + VStack{ + Divider() + } + } + .padding(.bottom, 10) + + NavigationLink(isActive: $isLinkQRActive, destination: { + QrCodeScannerFragment() + }, label: { + HStack { + Image("qr-code") + .renderingMode(.template) + .resizable() + .foregroundStyle(Color.orange_main_500) + .frame(width: 20, height: 20) + + Text("Scan QR code") + .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.orange_main_500, lineWidth: 1) + ) + .padding(.bottom) + .simultaneousGesture(TapGesture().onEnded{ + self.linkActive = "QR" + if !sharedMainViewModel.generalTermsAccepted { + withAnimation { + self.isShowPopup.toggle() + } + } else { + self.isLinkQRActive = true + } + }) + + NavigationLink(isActive: $isLinkSIPActive, destination: { + ThirdPartySipAccountWarningFragment(sharedMainViewModel: sharedMainViewModel, 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.orange_main_500, 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.gray_main2_700) + .padding(.horizontal, 10) + + NavigationLink(destination: RegisterFragment(), isActive: $isLinkREGActive, label: {Text("Register") + .default_text_style_orange_600(styleSize: 20) + .frame(height: 35) + }) + .disabled(!sharedMainViewModel.generalTermsAccepted) + .padding(.horizontal, 20) + .padding(.vertical, 10) + .cornerRadius(60) + .overlay( + RoundedRectangle(cornerRadius: 60) + .inset(by: 0.5) + .stroke(Color.orange_main_500, lineWidth: 1) + ) + .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) - .padding(.vertical, 10) - .cornerRadius(60) - .overlay( - RoundedRectangle(cornerRadius: 60) - .inset(by: 0.5) - .stroke(Color.orange_main_500, lineWidth: 1) - ) - .padding(.bottom) - - NavigationLink(destination: { - ThirdPartySipAccountWarningFragment(accountLoginViewModel: accountLoginViewModel) - }, label: { - Text("Use SIP 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.orange_main_500, lineWidth: 1) - ) - .padding(.bottom) - - HStack(alignment: .center) { - - Spacer() - - Text("Not account yet?") - .default_text_style(styleSize: 15) - .foregroundStyle(Color.gray_main2_700) - .padding(.horizontal, 10) - - Button(action: { - - }) { - Text("Register") - .default_text_style_orange_600(styleSize: 20) - .frame(height: 35) - } - .padding(.horizontal, 20) - .padding(.vertical, 10) - .cornerRadius(60) - .overlay( - RoundedRectangle(cornerRadius: 60) - .inset(by: 0.5) - .stroke(Color.orange_main_500, lineWidth: 1) - ) - .padding(.horizontal, 10) - - Spacer() - } - .padding(.bottom) - } - .padding(.horizontal, 20) - } - } - } - } + } + .frame(minHeight: geometry.size.height) + } + + if self.isShowPopup { + PopupView(sharedMainViewModel: sharedMainViewModel, isShowPopup: $isShowPopup, title: Text("Conditions de service"), content: Text("En continuant, vous acceptez ces conditions, \(Text("[notre politique de confidentialité](https://linphone.org/privacy-policy)").underline()) et \(Text("[nos conditions d’utilisation](https://linphone.org/general-terms)").underline())."), titleFirstButton: Text("Deny all"), actionFirstButton: {self.isShowPopup.toggle()}, titleSecondButton: Text("Accept all"), actionSecondButton: {acceptGeneralTerms()}) + .background(.black.opacity(0.65)) + .onTapGesture { + self.isShowPopup.toggle() + } + } + } + .onAppear{ + sharedMainViewModel.changeDisplayProfileMode() + } + + if coreContext.loggingInProgress { + PopupLoadingView(sharedMainViewModel: sharedMainViewModel) + .background(.black.opacity(0.65)) + } + + if !coreContext.loggingInProgress && !coreContext.loggedIn { + ZStack{ + + }.onAppear { + self.accountLoginViewModel.unregister() + self.accountLoginViewModel.delete() + } + } + } + } .navigationViewStyle(StackNavigationViewStyle()) - } + } + + func acceptGeneralTerms(){ + sharedMainViewModel.changeGeneralTerms() + self.isShowPopup.toggle() + switch linkActive { + case "QR": + self.isLinkQRActive = true + case "SIP": + self.isLinkSIPActive = true + case "REG": + self.isLinkREGActive = true + default: + print("Link Not Active") + } + } } #Preview { - LoginFragment(accountLoginViewModel: AccountLoginViewModel(), sharedMainViewModel: SharedMainViewModel()) + LoginFragment(accountLoginViewModel: AccountLoginViewModel(), sharedMainViewModel: SharedMainViewModel()) } diff --git a/Linphone/UI/Assistant/Fragments/ProfileModeFragment.swift b/Linphone/UI/Assistant/Fragments/ProfileModeFragment.swift index 447fae4e8..8f07c6c28 100644 --- a/Linphone/UI/Assistant/Fragments/ProfileModeFragment.swift +++ b/Linphone/UI/Assistant/Fragments/ProfileModeFragment.swift @@ -114,12 +114,13 @@ struct ProfileModeFragment: View { .background(Color.gray_100) .cornerRadius(15) } + .frame(maxWidth: sharedMainViewModel.maxWidth) .padding() Spacer() Button(action: { - sharedMainViewModel.displayProfileMode = false + sharedMainViewModel.changeHideProfileMode() }) { Text("Continue") .default_text_style_white_600(styleSize: 20) @@ -131,12 +132,17 @@ struct ProfileModeFragment: View { .background(Color.orange_main_500) .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") + } 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()}) + PopupView(sharedMainViewModel: sharedMainViewModel, 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() diff --git a/Linphone/UI/Assistant/Fragments/QrCodeScannerFragment.swift b/Linphone/UI/Assistant/Fragments/QrCodeScannerFragment.swift index 03f9fe288..08bde763e 100644 --- a/Linphone/UI/Assistant/Fragments/QrCodeScannerFragment.swift +++ b/Linphone/UI/Assistant/Fragments/QrCodeScannerFragment.swift @@ -1,9 +1,21 @@ -// -// QrCodeScannerFragment.swift -// Linphone -// -// Created by Benoît Martins on 05/10/2023. -// +/* +* 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 @@ -42,7 +54,7 @@ struct QrCodeScannerFragment: View { .edgesIgnoringSafeArea(.all) .navigationBarHidden(true) - if coreContext.configuringSuccessful == "Successful" { + if coreContext.toastMessage == "Successful" { ZStack{ }.onAppear { diff --git a/Linphone/UI/Assistant/Fragments/RegisterFragment.swift b/Linphone/UI/Assistant/Fragments/RegisterFragment.swift new file mode 100644 index 000000000..ac7146ce7 --- /dev/null +++ b/Linphone/UI/Assistant/Fragments/RegisterFragment.swift @@ -0,0 +1,64 @@ +// +// RegisterFragment.swift +// Linphone +// +// Created by Benoît Martins on 09/10/2023. +// + +import SwiftUI + +struct RegisterFragment: View { + + @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.gray_main2_500) + .frame(width: 25, height: 25, alignment: .leading) + .padding(.top, -65) + .onTapGesture { + withAnimation { + dismiss() + } + } + + Spacer() + } + .padding(.leading) + } + .frame(width: geometry.size.width) + + Text("Register") + .default_text_style_white_800(styleSize: 20) + .padding(.top, 20) + } + .padding(.top, 35) + .padding(.bottom, 10) + + } + } + } + } + .navigationViewStyle(StackNavigationViewStyle()) + .navigationBarHidden(true) + } +} + +#Preview { + RegisterFragment() +} diff --git a/Linphone/UI/Assistant/Fragments/ThirdPartySipAccountLoginFragment.swift b/Linphone/UI/Assistant/Fragments/ThirdPartySipAccountLoginFragment.swift index e2a80eff9..77aeacdf6 100644 --- a/Linphone/UI/Assistant/Fragments/ThirdPartySipAccountLoginFragment.swift +++ b/Linphone/UI/Assistant/Fragments/ThirdPartySipAccountLoginFragment.swift @@ -20,7 +20,8 @@ import SwiftUI struct ThirdPartySipAccountLoginFragment: View { - + + @ObservedObject var sharedMainViewModel : SharedMainViewModel @ObservedObject private var coreContext = CoreContext.shared @ObservedObject var accountLoginViewModel : AccountLoginViewModel @@ -222,7 +223,9 @@ struct ThirdPartySipAccountLoginFragment: View { .background((accountLoginViewModel.username.isEmpty || accountLoginViewModel.passwd.isEmpty || accountLoginViewModel.domain.isEmpty) ? Color.orange_main_100 : Color.orange_main_500) .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) @@ -233,5 +236,5 @@ struct ThirdPartySipAccountLoginFragment: View { } #Preview { - ThirdPartySipAccountLoginFragment(accountLoginViewModel: AccountLoginViewModel()) + ThirdPartySipAccountLoginFragment(sharedMainViewModel: SharedMainViewModel(), accountLoginViewModel: AccountLoginViewModel()) } diff --git a/Linphone/UI/Assistant/Fragments/ThirdPartySipAccountWarningFragment.swift b/Linphone/UI/Assistant/Fragments/ThirdPartySipAccountWarningFragment.swift index 98b8c6478..63fe6d911 100644 --- a/Linphone/UI/Assistant/Fragments/ThirdPartySipAccountWarningFragment.swift +++ b/Linphone/UI/Assistant/Fragments/ThirdPartySipAccountWarningFragment.swift @@ -20,7 +20,8 @@ import SwiftUI struct ThirdPartySipAccountWarningFragment: View { - + + @ObservedObject var sharedMainViewModel : SharedMainViewModel @ObservedObject private var coreContext = CoreContext.shared @ObservedObject var accountLoginViewModel : AccountLoginViewModel @@ -134,6 +135,7 @@ struct ThirdPartySipAccountWarningFragment: View { } .padding(.vertical) } + .frame(maxWidth: sharedMainViewModel.maxWidth) .padding(.horizontal, 20) Spacer() @@ -154,10 +156,11 @@ struct ThirdPartySipAccountWarningFragment: View { .inset(by: 0.5) .stroke(Color.orange_main_500, lineWidth: 1) ) + .frame(maxWidth: sharedMainViewModel.maxWidth) .padding(.horizontal) NavigationLink(destination: { - ThirdPartySipAccountLoginFragment(accountLoginViewModel: accountLoginViewModel) + ThirdPartySipAccountLoginFragment(sharedMainViewModel: sharedMainViewModel, accountLoginViewModel: accountLoginViewModel) }, label: { Text("I understand") .default_text_style_white_600(styleSize: 20) @@ -169,7 +172,9 @@ struct ThirdPartySipAccountWarningFragment: View { .padding(.vertical, 10) .background(Color.orange_main_500) .cornerRadius(60) + .frame(maxWidth: sharedMainViewModel.maxWidth) .padding(.horizontal) + .padding(.bottom, geometry.safeAreaInsets.bottom.isEqual(to: 0.0) ? 20 : 0) } .frame(minHeight: geometry.size.height) } @@ -181,5 +186,5 @@ struct ThirdPartySipAccountWarningFragment: View { } #Preview { - ThirdPartySipAccountWarningFragment(accountLoginViewModel: AccountLoginViewModel()) + ThirdPartySipAccountWarningFragment(sharedMainViewModel: SharedMainViewModel(), accountLoginViewModel: AccountLoginViewModel()) } diff --git a/Linphone/UI/Assistant/Viewmodel/QRScanner.swift b/Linphone/UI/Assistant/Viewmodel/QRScanner.swift index 3e9b46419..2d754e89d 100644 --- a/Linphone/UI/Assistant/Viewmodel/QRScanner.swift +++ b/Linphone/UI/Assistant/Viewmodel/QRScanner.swift @@ -1,9 +1,21 @@ -// -// QRScanner.swift -// Linphone -// -// Created by Benoît Martins on 05/10/2023. -// +/* +* 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 @@ -66,10 +78,10 @@ class Coordinator: NSObject, AVCaptureMetadataOutputObjectsDelegate { } } else { - coreContext.configuringSuccessful = "Invalide URI" + coreContext.toastMessage = "Invalide URI" } } else { - coreContext.configuringSuccessful = "Invalide URI" + coreContext.toastMessage = "Invalide URI" } } } diff --git a/Linphone/UI/Main/ContentView.swift b/Linphone/UI/Main/ContentView.swift index c0404cd2f..4290116a5 100644 --- a/Linphone/UI/Main/ContentView.swift +++ b/Linphone/UI/Main/ContentView.swift @@ -25,7 +25,7 @@ struct ContentView: View { @ObservedObject private var coreContext = CoreContext.shared var body: some View { - if UserDefaults.standard.bool(forKey: "general_terms") == false { + if !sharedMainViewModel.welcomeViewDisplayed { WelcomeView(sharedMainViewModel: sharedMainViewModel) } else if coreContext.mCore.defaultAccount == nil || sharedMainViewModel.displayProfileMode { AssistantView(sharedMainViewModel: sharedMainViewModel) diff --git a/Linphone/UI/Main/Fragments/PopupLoadingView.swift b/Linphone/UI/Main/Fragments/PopupLoadingView.swift new file mode 100644 index 000000000..60c921245 --- /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 var sharedMainViewModel : SharedMainViewModel + + var body: some View { + GeometryReader { geometry in + VStack (alignment: .leading){ + + ProgressView() + .controlSize(.large) + .progressViewStyle(CircularProgressViewStyle(tint: Color.orange_main_500)) + .frame(maxWidth: .infinity) + .padding(.top) + .padding(.bottom) + + Text("Opération en cours...") + .tint(Color.gray_main2_600) + .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.orange_main_500, radius: 0, x: 0, y: 2) + .frame(maxWidth: sharedMainViewModel.maxWidth) + .position(x: geometry.size.width / 2, y: geometry.size.height / 2) + } + } +} + +#Preview { + PopupLoadingView(sharedMainViewModel: SharedMainViewModel()) + .background(.black.opacity(0.65)) +} diff --git a/Linphone/UI/Main/Fragments/PopupView.swift b/Linphone/UI/Main/Fragments/PopupView.swift index 178e817ab..b3a521285 100644 --- a/Linphone/UI/Main/Fragments/PopupView.swift +++ b/Linphone/UI/Main/Fragments/PopupView.swift @@ -22,11 +22,13 @@ import Photos struct PopupView: View { + @ObservedObject var sharedMainViewModel : SharedMainViewModel + var permissionManager = PermissionManager.shared @Binding var isShowPopup: Bool var title: Text - var content: Text + var content: Text? var titleFirstButton: Text? var actionFirstButton: () -> () @@ -42,10 +44,13 @@ struct PopupView: View { .frame(alignment: .leading) .padding(.bottom, 2) - content - .tint(Color.gray_main2_600) - .default_text_style(styleSize: 15) - .padding(.bottom, 20) + + if content != nil { + content + .tint(Color.gray_main2_600) + .default_text_style(styleSize: 15) + .padding(.bottom, 20) + } if titleFirstButton != nil { Button(action: { @@ -89,10 +94,13 @@ struct PopupView: View { .padding(.horizontal) .frame(maxHeight: .infinity) .shadow(color: Color.orange_main_500, 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: {}) + PopupView(sharedMainViewModel: SharedMainViewModel(), 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/ToastView.swift b/Linphone/UI/Main/Fragments/ToastView.swift index fff782c09..16b4c9bbd 100644 --- a/Linphone/UI/Main/Fragments/ToastView.swift +++ b/Linphone/UI/Main/Fragments/ToastView.swift @@ -1,14 +1,28 @@ -// -// ToastView.swift -// Linphone -// -// Created by Benoît Martins on 06/10/2023. -// +/* +* 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: ViewModifier { + @ObservedObject var sharedMainViewModel : SharedMainViewModel + @Binding var isShowing: String func body(content: Content) -> some View { @@ -26,14 +40,44 @@ struct ToastView: ViewModifier { .resizable() .frame(width: 25, height: 25, alignment: .leading) - Text(isShowing == "Successful" ? "QR code validated!" : (isShowing == "Failed" ? "Invalid QR code!" : "Invalide URI")) - .multilineTextAlignment(.center) - .foregroundStyle(isShowing == "Successful" ? Color.green_success_500 : Color.red_danger_500) - .default_text_style(styleSize: 15) - .padding(8) + switch isShowing { + case "Successful": + Text("QR code validated!") + .multilineTextAlignment(.center) + .foregroundStyle(Color.green_success_500) + .default_text_style(styleSize: 15) + .padding(8) + + case "Failed": + Text("Invalid QR code!") + .multilineTextAlignment(.center) + .foregroundStyle(Color.red_danger_500) + .default_text_style(styleSize: 15) + .padding(8) + + case "Invalide URI": + Text("Invalide URI") + .multilineTextAlignment(.center) + .foregroundStyle(Color.red_danger_500) + .default_text_style(styleSize: 15) + .padding(8) + + case "Registration failed": + Text("The user name or password is incorrects") + .multilineTextAlignment(.center) + .foregroundStyle(Color.red_danger_500) + .default_text_style(styleSize: 15) + .padding(8) + + default: + Text("Error") + .multilineTextAlignment(.center) + .foregroundStyle(Color.red_danger_500) + .default_text_style(styleSize: 15) + .padding(8) + } } .frame(maxWidth: .infinity) - .frame(height: 40) .background(.white) .cornerRadius(50) .overlay( @@ -52,6 +96,7 @@ struct ToastView: ViewModifier { } Spacer() } + .frame(maxWidth: sharedMainViewModel.maxWidth) .padding(.horizontal, 16) .padding(.bottom, 18) .animation(.linear(duration: 0.3), value: isShowing) @@ -61,10 +106,6 @@ struct ToastView: ViewModifier { extension View { func toast(isShowing: Binding) -> some View { - self.modifier(ToastView(isShowing: isShowing)) + self.modifier(ToastView(sharedMainViewModel: SharedMainViewModel(), isShowing: isShowing)) } } - -//#Preview { -//ToastView() -//} diff --git a/Linphone/UI/Main/History/HistoryView.swift b/Linphone/UI/Main/History/HistoryView.swift index 3e9cf4ba0..a42cc26a2 100644 --- a/Linphone/UI/Main/History/HistoryView.swift +++ b/Linphone/UI/Main/History/HistoryView.swift @@ -1,9 +1,21 @@ -// -// HistoryView.swift -// Linphone -// -// Created by Benoît Martins on 03/10/2023. -// +/* +* 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 diff --git a/Linphone/UI/Main/Viewmodel/SharedMainViewModel.swift b/Linphone/UI/Main/Viewmodel/SharedMainViewModel.swift index ac654f468..ffde7651c 100644 --- a/Linphone/UI/Main/Viewmodel/SharedMainViewModel.swift +++ b/Linphone/UI/Main/Viewmodel/SharedMainViewModel.swift @@ -20,14 +20,24 @@ import linphonesw class SharedMainViewModel : ObservableObject { - - @Published var displayProfileMode : Bool = false - + + @Published var welcomeViewDisplayed = false @Published var generalTermsAccepted = false + @Published var displayProfileMode = false + + var maxWidth = 400.0 init() { let preferences = UserDefaults.standard + let welcomeViewKey = "welcome_view" + + if preferences.object(forKey: welcomeViewKey) == nil { + preferences.set(welcomeViewDisplayed, forKey: welcomeViewKey) + } else { + welcomeViewDisplayed = preferences.bool(forKey: welcomeViewKey) + } + let generalTermsKey = "general_terms" if preferences.object(forKey: generalTermsKey) == nil { @@ -35,6 +45,22 @@ class SharedMainViewModel : ObservableObject { } else { generalTermsAccepted = preferences.bool(forKey: generalTermsKey) } + + let displayProfileModeKey = "display_profile_mode" + + 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 + let welcomeViewKey = "welcome_view" + preferences.set(welcomeViewDisplayed, forKey: welcomeViewKey) } func changeGeneralTerms(){ @@ -44,4 +70,20 @@ class SharedMainViewModel : ObservableObject { let generalTermsKey = "general_terms" preferences.set(generalTermsAccepted, forKey: generalTermsKey) } + + func changeDisplayProfileMode(){ + let preferences = UserDefaults.standard + + displayProfileMode = true + let displayProfileModeKey = "display_profile_mode" + preferences.set(displayProfileMode, forKey: displayProfileModeKey) + } + + func changeHideProfileMode(){ + let preferences = UserDefaults.standard + + displayProfileMode = false + let displayProfileModeKey = "display_profile_mode" + preferences.set(displayProfileMode, forKey: displayProfileModeKey) + } } diff --git a/Linphone/UI/Welcome/WelcomeView.swift b/Linphone/UI/Welcome/WelcomeView.swift index 7ec1e7ae9..7797f187e 100644 --- a/Linphone/UI/Welcome/WelcomeView.swift +++ b/Linphone/UI/Welcome/WelcomeView.swift @@ -26,7 +26,6 @@ struct WelcomeView: View{ var permissionManager = PermissionManager.shared @State private var index = 0 - @State private var isShowPopup = false var body: some View { GeometryReader { geometry in @@ -48,7 +47,7 @@ struct WelcomeView: View{ .onTapGesture { withAnimation { self.index = 2 - self.isShowPopup.toggle() + permissionManager.cameraRequestPermission() } } Text("Welcome") @@ -98,9 +97,7 @@ struct WelcomeView: View{ index += 1 } } else if index == 2 { - withAnimation{ - self.isShowPopup.toggle() - } + permissionManager.cameraRequestPermission() } }) { Text(index == 2 ? "Start" : "Next") @@ -113,22 +110,16 @@ struct WelcomeView: View{ .background(Color.orange_main_500) .cornerRadius(60) .padding(.horizontal) + .padding(.bottom, geometry.safeAreaInsets.bottom.isEqual(to: 0.0) ? 20 : 0) + .frame(maxWidth: sharedMainViewModel.maxWidth) } .frame(minHeight: geometry.size.height) } - - if self.isShowPopup { - PopupView(isShowPopup: $isShowPopup, title: Text("Conditions de service"), content: Text("En continuant, vous acceptez ces conditions, \(Text("[notre politique de confidentialité](https://linphone.org/privacy-policy)").underline()) et \(Text("[nos conditions d’utilisation](https://linphone.org/general-terms)").underline())."), titleFirstButton: Text("Deny all"), actionFirstButton: {self.isShowPopup.toggle()}, titleSecondButton: Text("Accept all"), actionSecondButton: {permissionManager.cameraRequestPermission()}) - .background(.black.opacity(0.65)) - .onTapGesture { - self.isShowPopup.toggle() - } - } } .onReceive(permissionManager.$cameraPermissionGranted, perform: { (granted) in if granted { withAnimation { - sharedMainViewModel.changeGeneralTerms() + sharedMainViewModel.changeWelcomeView() } } }) diff --git a/Linphone/Utils/QRScannerController.swift b/Linphone/Utils/QRScannerController.swift index 7d37f2cb5..9bf9f3e7e 100644 --- a/Linphone/Utils/QRScannerController.swift +++ b/Linphone/Utils/QRScannerController.swift @@ -1,9 +1,21 @@ -// -// QRScannerController.swift -// Linphone -// -// Created by Benoît Martins on 05/10/2023. -// +/* +* 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 From adc8b00d6d77bc661daaa7755e41ff51b5c47036 Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Tue, 10 Oct 2023 11:58:07 +0200 Subject: [PATCH 019/486] Use SwiftLint --- .swiftlint.yml | 32 +++ Linphone.xcodeproj/project.pbxproj | 86 ++++--- Linphone/Core/CoreContext.swift | 24 +- Linphone/LinphoneApp.swift | 44 ++-- Linphone/Localizable.xcstrings | 26 +- Linphone/SplashScreen.swift | 38 +-- Linphone/UI/Assistant/AssistantView.swift | 16 +- .../Assistant/Fragments/LoginFragment.swift | 139 +++++----- .../Fragments/ProfileModeFragment.swift | 238 +++++++++--------- .../Fragments/QrCodeScannerFragment.swift | 46 ++-- .../Fragments/RegisterFragment.swift | 6 +- .../ThirdPartySipAccountLoginFragment.swift | 95 ++++--- .../ThirdPartySipAccountWarningFragment.swift | 188 +++++++------- .../Viewmodel/AccountLoginViewModel.swift | 58 ++--- .../UI/Assistant/Viewmodel/QRScanner.swift | 43 ++-- .../Viewmodel}/QRScannerController.swift | 60 ++--- Linphone/UI/Main/Contacts/ContactsView.swift | 40 +-- Linphone/UI/Main/ContentView.swift | 10 +- .../UI/Main/Fragments/PopupLoadingView.swift | 50 ++-- Linphone/UI/Main/Fragments/PopupView.swift | 105 ++++---- Linphone/UI/Main/Fragments/ToastView.swift | 56 ++--- Linphone/UI/Main/History/HistoryView.swift | 40 +-- .../Main/Viewmodel/SharedMainViewModel.swift | 56 ++--- .../Fragments/WelcomePage1Fragment.swift | 8 +- .../Fragments/WelcomePage2Fragment.swift | 6 +- .../Fragments/WelcomePage3Fragment.swift | 6 +- Linphone/UI/Welcome/WelcomeView.swift | 20 +- Linphone/Utils/ColorExtension.swift | 132 +++++----- Linphone/Utils/PermissionManager.swift | 4 +- Linphone/Utils/TextExtension.swift | 204 +++++++-------- Podfile | 8 + 31 files changed, 984 insertions(+), 900 deletions(-) create mode 100644 .swiftlint.yml rename Linphone/{Utils => UI/Assistant/Viewmodel}/QRScannerController.swift (70%) 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/Linphone.xcodeproj/project.pbxproj b/Linphone.xcodeproj/project.pbxproj index 47c1b6328..461240c2c 100644 --- a/Linphone.xcodeproj/project.pbxproj +++ b/Linphone.xcodeproj/project.pbxproj @@ -7,7 +7,7 @@ objects = { /* Begin PBXBuildFile section */ - 2B416B2E7C90375B792A28AE /* Pods_Linphone.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 2C08FB4788AD667D35BAE64D /* Pods_Linphone.framework */; }; + 0DF2F35F000C9BBAE8FCB4A0 /* Pods_Linphone.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 7C92C314A5427A62F953EB70 /* Pods_Linphone.framework */; }; D70C93DE2AC2D0F60063CA3B /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = D70C93DD2AC2D0F60063CA3B /* Localizable.xcstrings */; }; D717071E2AC5922E0037746F /* ColorExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = D717071D2AC5922E0037746F /* ColorExtension.swift */; }; D71707202AC5989C0037746F /* TextExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = D717071F2AC5989C0037746F /* TextExtension.swift */; }; @@ -47,8 +47,9 @@ /* End PBXBuildFile section */ /* Begin PBXFileReference section */ - 2C08FB4788AD667D35BAE64D /* Pods_Linphone.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Linphone.framework; sourceTree = BUILT_PRODUCTS_DIR; }; - BC39A28B26EDB00C91AB7756 /* Pods-Linphone.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Linphone.release.xcconfig"; path = "Target Support Files/Pods-Linphone/Pods-Linphone.release.xcconfig"; sourceTree = ""; }; + 3A132937ACADB95696E2F906 /* Pods-Linphone.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Linphone.debug.xcconfig"; path = "Target Support Files/Pods-Linphone/Pods-Linphone.debug.xcconfig"; sourceTree = ""; }; + 3E2758142B8F42856C3A34DF /* Pods-Linphone.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Linphone.release.xcconfig"; path = "Target Support Files/Pods-Linphone/Pods-Linphone.release.xcconfig"; sourceTree = ""; }; + 7C92C314A5427A62F953EB70 /* Pods_Linphone.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Linphone.framework; sourceTree = BUILT_PRODUCTS_DIR; }; D70C93DD2AC2D0F60063CA3B /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; 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 = ""; }; @@ -88,7 +89,6 @@ D7DA67612ACCB2FA00E95002 /* LoginFragment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginFragment.swift; sourceTree = ""; }; D7DA67632ACCB31700E95002 /* ProfileModeFragment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileModeFragment.swift; sourceTree = ""; }; D7FB55102AD447FD00A5AB15 /* RegisterFragment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RegisterFragment.swift; sourceTree = ""; }; - FBDE73581C1DC4F98CC3DF3A /* Pods-Linphone.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Linphone.debug.xcconfig"; path = "Target Support Files/Pods-Linphone/Pods-Linphone.debug.xcconfig"; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -96,17 +96,17 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - 2B416B2E7C90375B792A28AE /* Pods_Linphone.framework in Frameworks */, + 0DF2F35F000C9BBAE8FCB4A0 /* Pods_Linphone.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ - 9FFD5E6302DF1093E1CB7311 /* Frameworks */ = { + 1110B8CBF3C1BFD76F8BBFB2 /* Frameworks */ = { isa = PBXGroup; children = ( - 2C08FB4788AD667D35BAE64D /* Pods_Linphone.framework */, + 7C92C314A5427A62F953EB70 /* Pods_Linphone.framework */, ); name = Frameworks; sourceTree = ""; @@ -114,8 +114,8 @@ A31AF2AB8C6A3D7B7EA3B424 /* Pods */ = { isa = PBXGroup; children = ( - FBDE73581C1DC4F98CC3DF3A /* Pods-Linphone.debug.xcconfig */, - BC39A28B26EDB00C91AB7756 /* Pods-Linphone.release.xcconfig */, + 3A132937ACADB95696E2F906 /* Pods-Linphone.debug.xcconfig */, + 3E2758142B8F42856C3A34DF /* Pods-Linphone.release.xcconfig */, ); path = Pods; sourceTree = ""; @@ -126,7 +126,6 @@ D717071D2AC5922E0037746F /* ColorExtension.swift */, D717071F2AC5989C0037746F /* TextExtension.swift */, D74C9D002ACB098C0021626A /* PermissionManager.swift */, - D72343312ACEFF58009AA24E /* QRScannerController.swift */, ); path = Utils; sourceTree = ""; @@ -137,7 +136,7 @@ D719ABB52ABC67BF00B41C10 /* Linphone */, D719ABB42ABC67BF00B41C10 /* Products */, A31AF2AB8C6A3D7B7EA3B424 /* Pods */, - 9FFD5E6302DF1093E1CB7311 /* Frameworks */, + 1110B8CBF3C1BFD76F8BBFB2 /* Frameworks */, ); sourceTree = ""; }; @@ -220,6 +219,7 @@ children = ( D719ABCE2ABC779A00B41C10 /* AccountLoginViewModel.swift */, D72343332ACEFFC3009AA24E /* QRScanner.swift */, + D72343312ACEFF58009AA24E /* QRScannerController.swift */, ); path = Viewmodel; sourceTree = ""; @@ -310,11 +310,12 @@ isa = PBXNativeTarget; buildConfigurationList = D719ABC22ABC67BF00B41C10 /* Build configuration list for PBXNativeTarget "Linphone" */; buildPhases = ( - 5387C360B232ECCFFE549C7A /* [CP] Check Pods Manifest.lock */, + EAE6BD221624F991B659AC2E /* [CP] Check Pods Manifest.lock */, D719ABAF2ABC67BF00B41C10 /* Sources */, D719ABB02ABC67BF00B41C10 /* Frameworks */, D719ABB12ABC67BF00B41C10 /* Resources */, - C6890D9F62DE340F66542619 /* [CP] Embed Pods Frameworks */, + D7FB55122AD53FE200A5AB15 /* Run Script */, + A35AE8ABA69001024776F16C /* [CP] Embed Pods Frameworks */, ); buildRules = ( ); @@ -378,7 +379,43 @@ /* End PBXResourcesBuildPhase section */ /* Begin PBXShellScriptBuildPhase section */ - 5387C360B232ECCFFE549C7A /* [CP] Check Pods Manifest.lock */ = { + A35AE8ABA69001024776F16C /* [CP] Embed Pods Frameworks */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Linphone/Pods-Linphone-frameworks-${CONFIGURATION}-input-files.xcfilelist", + ); + name = "[CP] Embed Pods Frameworks"; + outputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Linphone/Pods-Linphone-frameworks-${CONFIGURATION}-output-files.xcfilelist", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Linphone/Pods-Linphone-frameworks.sh\"\n"; + showEnvVarsInLog = 0; + }; + 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"; + }; + EAE6BD221624F991B659AC2E /* [CP] Check Pods Manifest.lock */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; files = ( @@ -400,23 +437,6 @@ shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; showEnvVarsInLog = 0; }; - C6890D9F62DE340F66542619 /* [CP] Embed Pods Frameworks */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - inputFileListPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-Linphone/Pods-Linphone-frameworks-${CONFIGURATION}-input-files.xcfilelist", - ); - name = "[CP] Embed Pods Frameworks"; - outputFileListPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-Linphone/Pods-Linphone-frameworks-${CONFIGURATION}-output-files.xcfilelist", - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Linphone/Pods-Linphone-frameworks.sh\"\n"; - showEnvVarsInLog = 0; - }; /* End PBXShellScriptBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ @@ -572,7 +592,7 @@ }; D719ABC32ABC67BF00B41C10 /* Debug */ = { isa = XCBuildConfiguration; - baseConfigurationReference = FBDE73581C1DC4F98CC3DF3A /* Pods-Linphone.debug.xcconfig */; + baseConfigurationReference = 3A132937ACADB95696E2F906 /* Pods-Linphone.debug.xcconfig */; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; @@ -616,7 +636,7 @@ }; D719ABC42ABC67BF00B41C10 /* Release */ = { isa = XCBuildConfiguration; - baseConfigurationReference = BC39A28B26EDB00C91AB7756 /* Pods-Linphone.release.xcconfig */; + baseConfigurationReference = 3E2758142B8F42856C3A34DF /* Pods-Linphone.release.xcconfig */; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; diff --git a/Linphone/Core/CoreContext.swift b/Linphone/Core/CoreContext.swift index cfa03dd68..f20316b7f 100644 --- a/Linphone/Core/CoreContext.swift +++ b/Linphone/Core/CoreContext.swift @@ -19,18 +19,18 @@ import linphonesw -final class CoreContext : ObservableObject { +final class CoreContext: ObservableObject { static let shared = CoreContext() var mCore: Core! - var mRegistrationDelegate : CoreDelegate! - var mConfigurationDelegate : CoreDelegate! + var mRegistrationDelegate: CoreDelegate! + var mConfigurationDelegate: CoreDelegate! var coreVersion: String = Core.getVersion - @Published var loggedIn : Bool = false - @Published var loggingInProgress : Bool = false - @Published var toastMessage : String = "" + @Published var loggedIn: Bool = false + @Published var loggingInProgress: Bool = false + @Published var toastMessage: String = "" private init() {} @@ -47,23 +47,23 @@ final class CoreContext : ObservableObject { mRegistrationDelegate = CoreDelegateStub( - onConfiguringStatus: { (core: Core, state: Config.ConfiguringState, message: String) in + onConfiguringStatus: {(_: Core, state: Config.ConfiguringState, message: String) in NSLog("New configuration state is \(state) = \(message)\n") - if (state == .Successful) { + if state == .Successful { self.toastMessage = "Successful" } else { self.toastMessage = "Failed" } }, - onAccountRegistrationStateChanged: { (core: Core, account: Account, state: RegistrationState, message: String) in + onAccountRegistrationStateChanged: {(_: 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. - NSLog("New registration state is \(state) for user id \( String(describing: account.params?.identityAddress?.asString()))\n") - if (state == .Ok) { + NSLog("New registration state is \(state) for user id \( String(describing: account.params?.identityAddress?.asString())) = \(message)\n") + if state == .Ok { self.loggingInProgress = false self.loggedIn = true - } else if (state == .Progress) { + } else if state == .Progress { self.loggingInProgress = true } else { self.toastMessage = "Registration failed" diff --git a/Linphone/LinphoneApp.swift b/Linphone/LinphoneApp.swift index 56b0c0ad3..87b0a4007 100644 --- a/Linphone/LinphoneApp.swift +++ b/Linphone/LinphoneApp.swift @@ -1,21 +1,21 @@ /* -* 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 . -*/ + * 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 @@ -25,14 +25,14 @@ struct LinphoneApp: App { @ObservedObject private var coreContext = CoreContext.shared @State private var isActive = false - var body: some Scene { - WindowGroup { + var body: some Scene { + WindowGroup { if isActive { ContentView(sharedMainViewModel: SharedMainViewModel()) .toast(isShowing: $coreContext.toastMessage) - }else { + } else { SplashScreen(isActive: $isActive) } - } - } + } + } } diff --git a/Linphone/Localizable.xcstrings b/Linphone/Localizable.xcstrings index c46a0bdf1..6490fa139 100644 --- a/Linphone/Localizable.xcstrings +++ b/Linphone/Localizable.xcstrings @@ -3,9 +3,15 @@ "strings" : { "" : { + }, + " et " : { + }, " or " : { + }, + "." : { + }, "[Forgotten password?](https://subscribe.linphone.org/)" : { @@ -131,15 +137,8 @@ "Domain" : { }, - "En continuant, vous acceptez ces conditions, %@ et %@." : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "En continuant, vous acceptez ces conditions, %1$@ et %2$@." - } - } - } + "En continuant, vous acceptez ces conditions, " : { + }, "Error" : { @@ -220,21 +219,12 @@ }, "Skip" : { - }, - "Some features require a Linphone account, such as group messaging, video conferences...\n\nThese features are hidden when you register with a third party SIP account.\n\nTo enable it in a commercial projet, please contact us. " : { - }, "Start" : { }, "TCP" : { - }, - "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." : { - }, "The user name or password is incorrects" : { diff --git a/Linphone/SplashScreen.swift b/Linphone/SplashScreen.swift index 4f537a833..64d59e258 100644 --- a/Linphone/SplashScreen.swift +++ b/Linphone/SplashScreen.swift @@ -1,21 +1,21 @@ /* -* 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 . -*/ + * 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 @@ -25,7 +25,7 @@ struct SplashScreen: View { @Binding var isActive: Bool var body: some View { - GeometryReader { geometry in + GeometryReader { _ in VStack { Spacer() HStack { @@ -50,5 +50,5 @@ struct SplashScreen: View { } #Preview { - SplashScreen(isActive: .constant(true)) + SplashScreen(isActive: .constant(true)) } diff --git a/Linphone/UI/Assistant/AssistantView.swift b/Linphone/UI/Assistant/AssistantView.swift index 57da277ae..c3cc5e2bd 100644 --- a/Linphone/UI/Assistant/AssistantView.swift +++ b/Linphone/UI/Assistant/AssistantView.swift @@ -20,19 +20,19 @@ import SwiftUI struct AssistantView: View { - - @ObservedObject var sharedMainViewModel : SharedMainViewModel + + @ObservedObject var sharedMainViewModel: SharedMainViewModel @ObservedObject private var coreContext = CoreContext.shared - - var body: some View { - if (sharedMainViewModel.displayProfileMode && coreContext.loggedIn){ - ProfileModeFragment(sharedMainViewModel: sharedMainViewModel) + + var body: some View { + if sharedMainViewModel.displayProfileMode && coreContext.loggedIn { + ProfileModeFragment(sharedMainViewModel: sharedMainViewModel) } else { LoginFragment(accountLoginViewModel: AccountLoginViewModel(), sharedMainViewModel: sharedMainViewModel) } - } + } } #Preview { - AssistantView(sharedMainViewModel: SharedMainViewModel()) + AssistantView(sharedMainViewModel: SharedMainViewModel()) } diff --git a/Linphone/UI/Assistant/Fragments/LoginFragment.swift b/Linphone/UI/Assistant/Fragments/LoginFragment.swift index d11ff1b79..b340a8ad4 100644 --- a/Linphone/UI/Assistant/Fragments/LoginFragment.swift +++ b/Linphone/UI/Assistant/Fragments/LoginFragment.swift @@ -22,13 +22,13 @@ import SwiftUI struct LoginFragment: View { @ObservedObject private var coreContext = CoreContext.shared - @ObservedObject var accountLoginViewModel : AccountLoginViewModel - @ObservedObject var sharedMainViewModel : SharedMainViewModel + @ObservedObject var accountLoginViewModel: AccountLoginViewModel + @ObservedObject var sharedMainViewModel: SharedMainViewModel @State private var isSecured: Bool = true - @FocusState var isNameFocused:Bool - @FocusState var isPasswordFocused:Bool + @FocusState var isNameFocused: Bool + @FocusState var isPasswordFocused: Bool @State private var isShowPopup = false @@ -62,7 +62,7 @@ struct LoginFragment: View { .default_text_style_700(styleSize: 15) .padding(.bottom, -5) - TextField("username", text : $accountLoginViewModel.username) + TextField("username", text: $accountLoginViewModel.username) .default_text_style(styleSize: 15) .disabled(coreContext.loggedIn) .frame(height: 25) @@ -72,7 +72,7 @@ struct LoginFragment: View { .overlay( RoundedRectangle(cornerRadius: 60) .inset(by: 0.5) - .stroke(isNameFocused ? Color.orange_main_500 : Color.gray_200, lineWidth: 1) + .stroke(isNameFocused ? Color.orangeMain500 : Color.gray200, lineWidth: 1) ) .padding(.bottom) .focused($isNameFocused) @@ -95,15 +95,16 @@ struct LoginFragment: View { .focused($isPasswordFocused) } } + Button(action: { isSecured.toggle() - }) { + }, label: { Image(self.isSecured ? "eye-slash" : "eye") .renderingMode(.template) .resizable() - .foregroundStyle(Color.gray_main2_500) + .foregroundStyle(Color.grayMain2c500) .frame(width: 20, height: 20) - } + }) } .disabled(coreContext.loggedIn) .padding(.horizontal, 20) @@ -112,26 +113,21 @@ struct LoginFragment: View { .overlay( RoundedRectangle(cornerRadius: 60) .inset(by: 0.5) - .stroke(isPasswordFocused ? Color.orange_main_500 : Color.gray_200, lineWidth: 1) + .stroke(isPasswordFocused ? Color.orangeMain500 : Color.gray200, lineWidth: 1) ) .padding(.bottom) - Button(action: { - if (self.coreContext.loggedIn){ - self.accountLoginViewModel.unregister() - self.accountLoginViewModel.delete() - } else { - self.accountLoginViewModel.login() - } - }) { + 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) ? Color.orange_main_100 : Color.orange_main_500) + .background((accountLoginViewModel.username.isEmpty || accountLoginViewModel.passwd.isEmpty) ? Color.orangeMain100 : Color.orangeMain500) .cornerRadius(60) .disabled(accountLoginViewModel.username.isEmpty || accountLoginViewModel.passwd.isEmpty) .padding(.bottom) @@ -139,21 +135,21 @@ struct LoginFragment: View { HStack { Text("[Forgotten password?](https://subscribe.linphone.org/)") .underline() - .tint(Color.gray_main2_600) + .tint(Color.grayMain2c600) .default_text_style_600(styleSize: 15) - .foregroundStyle(Color.gray_main2_500) + .foregroundStyle(Color.grayMain2c500) } .frame(maxWidth: .infinity) .padding(.bottom, 30) HStack { - VStack{ + VStack { Divider() } Text(" or ") .default_text_style(styleSize: 15) - .foregroundStyle(Color.gray_main2_500) - VStack{ + .foregroundStyle(Color.grayMain2c500) + VStack { Divider() } } @@ -166,7 +162,7 @@ struct LoginFragment: View { Image("qr-code") .renderingMode(.template) .resizable() - .foregroundStyle(Color.orange_main_500) + .foregroundStyle(Color.orangeMain500) .frame(width: 20, height: 20) Text("Scan QR code") @@ -183,19 +179,21 @@ struct LoginFragment: View { .overlay( RoundedRectangle(cornerRadius: 60) .inset(by: 0.5) - .stroke(Color.orange_main_500, lineWidth: 1) + .stroke(Color.orangeMain500, lineWidth: 1) ) .padding(.bottom) - .simultaneousGesture(TapGesture().onEnded{ - self.linkActive = "QR" - if !sharedMainViewModel.generalTermsAccepted { - withAnimation { - self.isShowPopup.toggle() + .simultaneousGesture( + TapGesture().onEnded { + self.linkActive = "QR" + if !sharedMainViewModel.generalTermsAccepted { + withAnimation { + self.isShowPopup.toggle() + } + } else { + self.isLinkQRActive = true } - } else { - self.isLinkQRActive = true } - }) + ) NavigationLink(isActive: $isLinkSIPActive, destination: { ThirdPartySipAccountWarningFragment(sharedMainViewModel: sharedMainViewModel, accountLoginViewModel: accountLoginViewModel) @@ -213,19 +211,21 @@ struct LoginFragment: View { .overlay( RoundedRectangle(cornerRadius: 60) .inset(by: 0.5) - .stroke(Color.orange_main_500, lineWidth: 1) + .stroke(Color.orangeMain500, lineWidth: 1) ) .padding(.bottom) - .simultaneousGesture(TapGesture().onEnded{ - self.linkActive = "SIP" - if !sharedMainViewModel.generalTermsAccepted { - withAnimation { - self.isShowPopup.toggle() + .simultaneousGesture( + TapGesture().onEnded { + self.linkActive = "SIP" + if !sharedMainViewModel.generalTermsAccepted { + withAnimation { + self.isShowPopup.toggle() + } + } else { + self.isLinkSIPActive = true } - } else { - self.isLinkSIPActive = true } - }) + ) Spacer() @@ -235,7 +235,7 @@ struct LoginFragment: View { Text("Not account yet?") .default_text_style(styleSize: 15) - .foregroundStyle(Color.gray_main2_700) + .foregroundStyle(Color.grayMain2c700) .padding(.horizontal, 10) NavigationLink(destination: RegisterFragment(), isActive: $isLinkREGActive, label: {Text("Register") @@ -249,19 +249,21 @@ struct LoginFragment: View { .overlay( RoundedRectangle(cornerRadius: 60) .inset(by: 0.5) - .stroke(Color.orange_main_500, lineWidth: 1) + .stroke(Color.orangeMain500, lineWidth: 1) ) .padding(.horizontal, 10) - .simultaneousGesture(TapGesture().onEnded{ - self.linkActive = "REG" - if !sharedMainViewModel.generalTermsAccepted { - withAnimation { - self.isShowPopup.toggle() + .simultaneousGesture( + TapGesture().onEnded { + self.linkActive = "REG" + if !sharedMainViewModel.generalTermsAccepted { + withAnimation { + self.isShowPopup.toggle() + } + } else { + self.isLinkREGActive = true } - } else { - self.isLinkREGActive = true - } - }) + } + ) Spacer() } @@ -274,14 +276,25 @@ struct LoginFragment: View { } if self.isShowPopup { - PopupView(sharedMainViewModel: sharedMainViewModel, isShowPopup: $isShowPopup, title: Text("Conditions de service"), content: Text("En continuant, vous acceptez ces conditions, \(Text("[notre politique de confidentialité](https://linphone.org/privacy-policy)").underline()) et \(Text("[nos conditions d’utilisation](https://linphone.org/general-terms)").underline())."), titleFirstButton: Text("Deny all"), actionFirstButton: {self.isShowPopup.toggle()}, titleSecondButton: Text("Accept all"), actionSecondButton: {acceptGeneralTerms()}) - .background(.black.opacity(0.65)) - .onTapGesture { - self.isShowPopup.toggle() - } + 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(sharedMainViewModel: sharedMainViewModel, 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() + } } } - .onAppear{ + .onAppear { sharedMainViewModel.changeDisplayProfileMode() } @@ -291,7 +304,7 @@ struct LoginFragment: View { } if !coreContext.loggingInProgress && !coreContext.loggedIn { - ZStack{ + ZStack { }.onAppear { self.accountLoginViewModel.unregister() @@ -303,7 +316,7 @@ struct LoginFragment: View { .navigationViewStyle(StackNavigationViewStyle()) } - func acceptGeneralTerms(){ + func acceptGeneralTerms() { sharedMainViewModel.changeGeneralTerms() self.isShowPopup.toggle() switch linkActive { diff --git a/Linphone/UI/Assistant/Fragments/ProfileModeFragment.swift b/Linphone/UI/Assistant/Fragments/ProfileModeFragment.swift index 8f07c6c28..0eec8a5e0 100644 --- a/Linphone/UI/Assistant/Fragments/ProfileModeFragment.swift +++ b/Linphone/UI/Assistant/Fragments/ProfileModeFragment.swift @@ -20,138 +20,152 @@ import SwiftUI struct ProfileModeFragment: View { - - @ObservedObject var sharedMainViewModel : SharedMainViewModel - - @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 - }) { - 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) - .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.gray_100) - .cornerRadius(15) - .padding(.bottom, 5) - - Image("profile-mode") - .resizable() - .frame(width: 150, height: 60) - .padding() - - Button(action: { - options = 2 - }) { - 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) - .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.gray_100) - .cornerRadius(15) - } + + @ObservedObject var sharedMainViewModel: SharedMainViewModel + + @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) + .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) + .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() + .padding() Spacer() - Button(action: { + 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.orange_main_500) + .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") } - - if self.isShowPopup { - PopupView(sharedMainViewModel: sharedMainViewModel, 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() - } - } - } - } + + if self.isShowPopup { + PopupView(sharedMainViewModel: sharedMainViewModel, 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(sharedMainViewModel: SharedMainViewModel()) + ProfileModeFragment(sharedMainViewModel: SharedMainViewModel()) } diff --git a/Linphone/UI/Assistant/Fragments/QrCodeScannerFragment.swift b/Linphone/UI/Assistant/Fragments/QrCodeScannerFragment.swift index 08bde763e..d5c9ad36e 100644 --- a/Linphone/UI/Assistant/Fragments/QrCodeScannerFragment.swift +++ b/Linphone/UI/Assistant/Fragments/QrCodeScannerFragment.swift @@ -1,21 +1,21 @@ /* -* 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 . -*/ + * 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 @@ -26,8 +26,8 @@ struct QrCodeScannerFragment: View { @Environment(\.dismiss) var dismiss @State var scanResult = "Scan a QR code" - - var body: some View { + + var body: some View { ZStack(alignment: .top) { QRScanner(result: $scanResult) @@ -35,7 +35,7 @@ struct QrCodeScannerFragment: View { .default_text_style_white_800(styleSize: 20) .padding(.top, 175) - HStack{ + HStack { Button { dismiss() } label: { @@ -55,15 +55,15 @@ struct QrCodeScannerFragment: View { .navigationBarHidden(true) if coreContext.toastMessage == "Successful" { - ZStack{ + ZStack { }.onAppear { dismiss() } } - } + } } #Preview { - QrCodeScannerFragment() + QrCodeScannerFragment() } diff --git a/Linphone/UI/Assistant/Fragments/RegisterFragment.swift b/Linphone/UI/Assistant/Fragments/RegisterFragment.swift index ac7146ce7..8b80a1101 100644 --- a/Linphone/UI/Assistant/Fragments/RegisterFragment.swift +++ b/Linphone/UI/Assistant/Fragments/RegisterFragment.swift @@ -23,12 +23,12 @@ struct RegisterFragment: View { .frame(width: geometry.size.width, height: 100) .clipped() - VStack (alignment: .leading) { + VStack(alignment: .leading) { HStack { Image("caret-left") .renderingMode(.template) .resizable() - .foregroundStyle(Color.gray_main2_500) + .foregroundStyle(Color.grayMain2c500) .frame(width: 25, height: 25, alignment: .leading) .padding(.top, -65) .onTapGesture { @@ -60,5 +60,5 @@ struct RegisterFragment: View { } #Preview { - RegisterFragment() + RegisterFragment() } diff --git a/Linphone/UI/Assistant/Fragments/ThirdPartySipAccountLoginFragment.swift b/Linphone/UI/Assistant/Fragments/ThirdPartySipAccountLoginFragment.swift index 77aeacdf6..b8be41481 100644 --- a/Linphone/UI/Assistant/Fragments/ThirdPartySipAccountLoginFragment.swift +++ b/Linphone/UI/Assistant/Fragments/ThirdPartySipAccountLoginFragment.swift @@ -21,23 +21,23 @@ import SwiftUI struct ThirdPartySipAccountLoginFragment: View { - @ObservedObject var sharedMainViewModel : SharedMainViewModel - @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 { + @ObservedObject var sharedMainViewModel: SharedMainViewModel + @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() @@ -45,12 +45,12 @@ struct ThirdPartySipAccountLoginFragment: View { .frame(width: geometry.size.width, height: 100) .clipped() - VStack (alignment: .leading) { + VStack(alignment: .leading) { HStack { Image("caret-left") .renderingMode(.template) .resizable() - .foregroundStyle(Color.gray_main2_500) + .foregroundStyle(Color.grayMain2c500) .frame(width: 25, height: 25, alignment: .leading) .padding(.top, -65) .onTapGesture { @@ -73,13 +73,13 @@ struct ThirdPartySipAccountLoginFragment: View { } .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) + TextField("username", text: $accountLoginViewModel.username) .default_text_style(styleSize: 15) .disabled(coreContext.loggedIn) .frame(height: 25) @@ -89,7 +89,7 @@ struct ThirdPartySipAccountLoginFragment: View { .overlay( RoundedRectangle(cornerRadius: 60) .inset(by: 0.5) - .stroke(isNameFocused ? Color.orange_main_500 : Color.gray_200, lineWidth: 1) + .stroke(isNameFocused ? Color.orangeMain500 : Color.gray200, lineWidth: 1) ) .padding(.bottom) .focused($isNameFocused) @@ -114,13 +114,13 @@ struct ThirdPartySipAccountLoginFragment: View { } Button(action: { isSecured.toggle() - }) { + }, label: { Image(self.isSecured ? "eye-slash" : "eye") .renderingMode(.template) .resizable() - .foregroundStyle(Color.gray_main2_500) + .foregroundStyle(Color.grayMain2c500) .frame(width: 20, height: 20) - } + }) } .disabled(coreContext.loggedIn) .padding(.horizontal, 20) @@ -129,7 +129,7 @@ struct ThirdPartySipAccountLoginFragment: View { .overlay( RoundedRectangle(cornerRadius: 60) .inset(by: 0.5) - .stroke(isPasswordFocused ? Color.orange_main_500 : Color.gray_200, lineWidth: 1) + .stroke(isPasswordFocused ? Color.orangeMain500 : Color.gray200, lineWidth: 1) ) .padding(.bottom) @@ -137,7 +137,7 @@ struct ThirdPartySipAccountLoginFragment: View { .default_text_style_700(styleSize: 15) .padding(.bottom, -5) - TextField("sip.linphone.org", text : $accountLoginViewModel.domain) + TextField("sip.linphone.org", text: $accountLoginViewModel.domain) .default_text_style(styleSize: 15) .disabled(coreContext.loggedIn) .frame(height: 25) @@ -147,7 +147,7 @@ struct ThirdPartySipAccountLoginFragment: View { .overlay( RoundedRectangle(cornerRadius: 60) .inset(by: 0.5) - .stroke(isDomainFocused ? Color.orange_main_500 : Color.gray_200, lineWidth: 1) + .stroke(isDomainFocused ? Color.orangeMain500 : Color.gray200, lineWidth: 1) ) .padding(.bottom) .focused($isDomainFocused) @@ -156,7 +156,7 @@ struct ThirdPartySipAccountLoginFragment: View { .default_text_style_700(styleSize: 15) .padding(.bottom, -5) - TextField("Display Name", text : $accountLoginViewModel.displayName) + TextField("Display Name", text: $accountLoginViewModel.displayName) .default_text_style(styleSize: 15) .disabled(coreContext.loggedIn) .frame(height: 25) @@ -166,7 +166,7 @@ struct ThirdPartySipAccountLoginFragment: View { .overlay( RoundedRectangle(cornerRadius: 60) .inset(by: 0.5) - .stroke(isDisplayNameFocused ? Color.orange_main_500 : Color.gray_200, lineWidth: 1) + .stroke(isDisplayNameFocused ? Color.orangeMain500 : Color.gray200, lineWidth: 1) ) .padding(.bottom) .focused($isDisplayNameFocused) @@ -186,7 +186,7 @@ struct ThirdPartySipAccountLoginFragment: View { Image("caret-down") .renderingMode(.template) .resizable() - .foregroundStyle(Color.gray_main2_500) + .foregroundStyle(Color.grayMain2c500) .frame(width: 20, height: 20) } .frame(height: 25) @@ -196,43 +196,40 @@ struct ThirdPartySipAccountLoginFragment: View { .overlay( RoundedRectangle(cornerRadius: 60) .inset(by: 0.5) - .stroke(Color.gray_200, lineWidth: 1) + .stroke(Color.gray200, lineWidth: 1) ) .padding(.bottom) Spacer() - Button(action: { - if (self.coreContext.loggedIn){ - self.accountLoginViewModel.unregister() - self.accountLoginViewModel.delete() - } else { - self.accountLoginViewModel.login() - } - + Button(action: { + self.accountLoginViewModel.login() accountLoginViewModel.domain = "sip.linphone.org" accountLoginViewModel.transportType = "TLS" - }) { + }, 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.orange_main_100 : Color.orange_main_500) + .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) - } + .padding(.horizontal, 20) + } .frame(minHeight: geometry.size.height) - } - } - .navigationBarHidden(true) - } + } + } + .navigationBarHidden(true) + } } #Preview { diff --git a/Linphone/UI/Assistant/Fragments/ThirdPartySipAccountWarningFragment.swift b/Linphone/UI/Assistant/Fragments/ThirdPartySipAccountWarningFragment.swift index 63fe6d911..4ce290e27 100644 --- a/Linphone/UI/Assistant/Fragments/ThirdPartySipAccountWarningFragment.swift +++ b/Linphone/UI/Assistant/Fragments/ThirdPartySipAccountWarningFragment.swift @@ -21,17 +21,17 @@ import SwiftUI struct ThirdPartySipAccountWarningFragment: View { - @ObservedObject var sharedMainViewModel : SharedMainViewModel - @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 { + @ObservedObject var sharedMainViewModel: SharedMainViewModel + @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() @@ -39,12 +39,12 @@ struct ThirdPartySipAccountWarningFragment: View { .frame(width: geometry.size.width, height: 100) .clipped() - VStack (alignment: .leading) { + VStack(alignment: .leading) { HStack { Image("caret-left") .renderingMode(.template) .resizable() - .foregroundStyle(Color.gray_main2_500) + .foregroundStyle(Color.grayMain2c500) .frame(width: 25, height: 25, alignment: .leading) .padding(.top, -65) .onTapGesture { @@ -67,94 +67,96 @@ struct ThirdPartySipAccountWarningFragment: View { .padding(.bottom, 10) Spacer() - - VStack(alignment: .leading) { - HStack { - Spacer() - HStack(alignment: .center) { - Image("conversation") - .renderingMode(.template) - .resizable() - .foregroundStyle(Color.gray_main2_500) - .frame(width: 20, height: 20, alignment: .leading) - .onTapGesture { - withAnimation { - dismiss() - } - } - } - .padding(16) - .background(Color.gray_main2_200) - .cornerRadius(40) - .padding(.horizontal) - - HStack(alignment: .center) { - Image("video-call") - .renderingMode(.template) - .resizable() - .foregroundStyle(Color.gray_main2_500) - .frame(width: 20, height: 20, alignment: .leading) - .onTapGesture { - withAnimation { - dismiss() - } - } - } - .padding(16) - .background(Color.gray_main2_200) - .cornerRadius(40) - .padding(.horizontal) - - Spacer() - } - .padding(.bottom, 40) - - Text("Some features require a Linphone account, such as group messaging, video conferences...\n\nThese features are hidden when you register with a third party SIP account.\n\nTo 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.orange_main_500) - .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.orange_main_500, lineWidth: 1) - ) - - Spacer() - } - .padding(.vertical) - } + + VStack(alignment: .leading) { + HStack { + Spacer() + HStack(alignment: .center) { + Image("conversation") + .renderingMode(.template) + .resizable() + .foregroundStyle(Color.grayMain2c500) + .frame(width: 20, height: 20, alignment: .leading) + .onTapGesture { + withAnimation { + dismiss() + } + } + } + .padding(16) + .background(Color.grayMain2c200) + .cornerRadius(40) + .padding(.horizontal) + + HStack(alignment: .center) { + Image("video-call") + .renderingMode(.template) + .resizable() + .foregroundStyle(Color.grayMain2c500) + .frame(width: 20, height: 20, alignment: .leading) + .onTapGesture { + withAnimation { + dismiss() + } + } + } + .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) + .padding(.horizontal, 20) Spacer() - Button(action: { + 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.orange_main_500, lineWidth: 1) + .stroke(Color.orangeMain500, lineWidth: 1) ) .frame(maxWidth: sharedMainViewModel.maxWidth) .padding(.horizontal) @@ -170,19 +172,19 @@ struct ThirdPartySipAccountWarningFragment: View { }) .padding(.horizontal, 20) .padding(.vertical, 10) - .background(Color.orange_main_500) + .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) - } + .navigationBarHidden(true) + } } #Preview { diff --git a/Linphone/UI/Assistant/Viewmodel/AccountLoginViewModel.swift b/Linphone/UI/Assistant/Viewmodel/AccountLoginViewModel.swift index 7a6da1d2d..6f778145a 100644 --- a/Linphone/UI/Assistant/Viewmodel/AccountLoginViewModel.swift +++ b/Linphone/UI/Assistant/Viewmodel/AccountLoginViewModel.swift @@ -1,34 +1,34 @@ /* -* 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 . -*/ + * 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 { +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" + @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() {} @@ -37,10 +37,12 @@ class AccountLoginViewModel : ObservableObject { // Get the transport protocol to use. // TLS is strongly recommended // Only use UDP if you don't have the choice - var transport : TransportType - if (transportType == "TLS") { transport = TransportType.Tls } - else if (transportType == "TCP") { transport = TransportType.Tcp } - else { transport = TransportType.Udp } + var transport: TransportType + if transportType == "TLS" { + transport = TransportType.Tls + } else if 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 @@ -58,7 +60,7 @@ class AccountLoginViewModel : ObservableObject { // 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:" + username + "@" + domain)) - try! accountParams.setIdentityaddress(newValue: identity) + 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:" + domain)) diff --git a/Linphone/UI/Assistant/Viewmodel/QRScanner.swift b/Linphone/UI/Assistant/Viewmodel/QRScanner.swift index 2d754e89d..d5dd78159 100644 --- a/Linphone/UI/Assistant/Viewmodel/QRScanner.swift +++ b/Linphone/UI/Assistant/Viewmodel/QRScanner.swift @@ -1,21 +1,21 @@ /* -* 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 . -*/ + * 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 @@ -54,13 +54,15 @@ class Coordinator: NSObject, AVCaptureMetadataOutputObjectsDelegate { 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.count == 0 { + if metadataObjects.isEmpty { scanResult = "Scan a QR code" return } // Get the metadata object. - let metadataObj = metadataObjects[0] as! AVMetadataMachineReadableCodeObject + guard let metadataObj = metadataObjects[0] as? AVMetadataMachineReadableCodeObject else { + return + } if metadataObj.type == AVMetadataObject.ObjectType.qr, let result = metadataObj.stringValue { @@ -68,12 +70,11 @@ class Coordinator: NSObject, AVCaptureMetadataOutputObjectsDelegate { if let url = NSURL(string: result) { if UIApplication.shared.canOpenURL(url as URL) { lastResult = result - //scanResult = result do { try coreContext.mCore.setProvisioninguri(newValue: result) coreContext.mCore.stop() try coreContext.mCore.start() - }catch { + } catch { } diff --git a/Linphone/Utils/QRScannerController.swift b/Linphone/UI/Assistant/Viewmodel/QRScannerController.swift similarity index 70% rename from Linphone/Utils/QRScannerController.swift rename to Linphone/UI/Assistant/Viewmodel/QRScannerController.swift index 9bf9f3e7e..1a4042241 100644 --- a/Linphone/Utils/QRScannerController.swift +++ b/Linphone/UI/Assistant/Viewmodel/QRScannerController.swift @@ -1,21 +1,21 @@ /* -* 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 . -*/ + * 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 @@ -25,52 +25,52 @@ 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/Main/Contacts/ContactsView.swift b/Linphone/UI/Main/Contacts/ContactsView.swift index 32124a5b3..1c5d3495a 100644 --- a/Linphone/UI/Main/Contacts/ContactsView.swift +++ b/Linphone/UI/Main/Contacts/ContactsView.swift @@ -1,26 +1,26 @@ /* -* 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 . -*/ + * 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 { - var body: some View { + var body: some View { VStack { Spacer() Image("linphone") @@ -28,9 +28,9 @@ struct ContactsView: View { Text("Contacts View") Spacer() } - } + } } #Preview { - ContactsView() + ContactsView() } diff --git a/Linphone/UI/Main/ContentView.swift b/Linphone/UI/Main/ContentView.swift index 4290116a5..ccb8e9a67 100644 --- a/Linphone/UI/Main/ContentView.swift +++ b/Linphone/UI/Main/ContentView.swift @@ -21,15 +21,15 @@ import SwiftUI struct ContentView: View { - @ObservedObject var sharedMainViewModel : SharedMainViewModel + @ObservedObject var sharedMainViewModel: SharedMainViewModel @ObservedObject private var coreContext = CoreContext.shared var body: some View { if !sharedMainViewModel.welcomeViewDisplayed { WelcomeView(sharedMainViewModel: sharedMainViewModel) - } else if coreContext.mCore.defaultAccount == nil || sharedMainViewModel.displayProfileMode { - AssistantView(sharedMainViewModel: sharedMainViewModel) - } else { + } else if coreContext.mCore.defaultAccount == nil || sharedMainViewModel.displayProfileMode { + AssistantView(sharedMainViewModel: sharedMainViewModel) + } else { TabView { ContactsView() .tabItem { @@ -46,5 +46,5 @@ struct ContentView: View { } #Preview { - ContentView(sharedMainViewModel: SharedMainViewModel()) + ContentView(sharedMainViewModel: SharedMainViewModel()) } diff --git a/Linphone/UI/Main/Fragments/PopupLoadingView.swift b/Linphone/UI/Main/Fragments/PopupLoadingView.swift index 60c921245..99f4f063e 100644 --- a/Linphone/UI/Main/Fragments/PopupLoadingView.swift +++ b/Linphone/UI/Main/Fragments/PopupLoadingView.swift @@ -1,41 +1,41 @@ /* -* 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 . -*/ + * 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 var sharedMainViewModel : SharedMainViewModel + @ObservedObject var sharedMainViewModel: SharedMainViewModel - var body: some View { + var body: some View { GeometryReader { geometry in - VStack (alignment: .leading){ - + VStack(alignment: .leading) { + ProgressView() .controlSize(.large) - .progressViewStyle(CircularProgressViewStyle(tint: Color.orange_main_500)) + .progressViewStyle(CircularProgressViewStyle(tint: Color.orangeMain500)) .frame(maxWidth: .infinity) .padding(.top) .padding(.bottom) Text("Opération en cours...") - .tint(Color.gray_main2_600) + .tint(Color.grayMain2c600) .default_text_style(styleSize: 15) .frame(maxWidth: .infinity) } @@ -46,11 +46,11 @@ struct PopupLoadingView: View { .padding(.horizontal) .frame(maxHeight: .infinity) .frame(maxWidth: .infinity) - .shadow(color: Color.orange_main_500, radius: 0, x: 0, y: 2) + .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 { diff --git a/Linphone/UI/Main/Fragments/PopupView.swift b/Linphone/UI/Main/Fragments/PopupView.swift index b3a521285..dbfd53afa 100644 --- a/Linphone/UI/Main/Fragments/PopupView.swift +++ b/Linphone/UI/Main/Fragments/PopupView.swift @@ -22,70 +22,69 @@ import Photos struct PopupView: View { - @ObservedObject var sharedMainViewModel : SharedMainViewModel + @ObservedObject var sharedMainViewModel: SharedMainViewModel var permissionManager = PermissionManager.shared @Binding var isShowPopup: Bool - var title: Text - var content: Text? - - var titleFirstButton: Text? - var actionFirstButton: () -> () - - var titleSecondButton: Text? - var actionSecondButton: () -> () + 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 + VStack(alignment: .leading) { + title .default_text_style_800(styleSize: 16) .frame(alignment: .leading) .padding(.bottom, 2) - if content != nil { content - .tint(Color.gray_main2_600) + .tint(Color.grayMain2c600) .default_text_style(styleSize: 15) .padding(.bottom, 20) } - if titleFirstButton != nil { - Button(action: { - actionFirstButton() - }) { - 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.orange_main_500, lineWidth: 1) - ) - .padding(.bottom, 10) - } - - if titleSecondButton != nil { - Button(action: { - actionSecondButton() - }) { - titleSecondButton - .default_text_style_white_600(styleSize: 20) - .frame(height: 35) - .frame(maxWidth: .infinity) - } - .padding(.horizontal, 20) - .padding(.vertical, 10) - .background(Color.orange_main_500) - .cornerRadius(60) - } + 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) @@ -93,7 +92,7 @@ struct PopupView: View { .cornerRadius(20) .padding(.horizontal) .frame(maxHeight: .infinity) - .shadow(color: Color.orange_main_500, radius: 0, x: 0, y: 2) + .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) } @@ -101,6 +100,12 @@ struct PopupView: View { } #Preview { - PopupView(sharedMainViewModel: SharedMainViewModel(), isShowPopup: .constant(true), title: Text("Title"), content: Text("Content"), titleFirstButton: Text("Deny all"), actionFirstButton: {}, titleSecondButton: Text("Accept all"), actionSecondButton: {}) - .background(.black.opacity(0.65)) + PopupView(sharedMainViewModel: SharedMainViewModel(), 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/ToastView.swift b/Linphone/UI/Main/Fragments/ToastView.swift index 16b4c9bbd..ab092763d 100644 --- a/Linphone/UI/Main/Fragments/ToastView.swift +++ b/Linphone/UI/Main/Fragments/ToastView.swift @@ -1,27 +1,27 @@ /* -* 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 . -*/ + * 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: ViewModifier { - @ObservedObject var sharedMainViewModel : SharedMainViewModel + @ObservedObject var sharedMainViewModel: SharedMainViewModel @Binding var isShowing: String @@ -44,35 +44,35 @@ struct ToastView: ViewModifier { case "Successful": Text("QR code validated!") .multilineTextAlignment(.center) - .foregroundStyle(Color.green_success_500) + .foregroundStyle(Color.greenSuccess500) .default_text_style(styleSize: 15) .padding(8) - + case "Failed": Text("Invalid QR code!") .multilineTextAlignment(.center) - .foregroundStyle(Color.red_danger_500) + .foregroundStyle(Color.redDanger500) .default_text_style(styleSize: 15) .padding(8) - + case "Invalide URI": Text("Invalide URI") .multilineTextAlignment(.center) - .foregroundStyle(Color.red_danger_500) + .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.red_danger_500) + .foregroundStyle(Color.redDanger500) .default_text_style(styleSize: 15) .padding(8) - + default: Text("Error") .multilineTextAlignment(.center) - .foregroundStyle(Color.red_danger_500) + .foregroundStyle(Color.redDanger500) .default_text_style(styleSize: 15) .padding(8) } @@ -83,10 +83,10 @@ struct ToastView: ViewModifier { .overlay( RoundedRectangle(cornerRadius: 50) .inset(by: 0.5) - .stroke(isShowing == "Successful" ? Color.green_success_500 : Color.red_danger_500, lineWidth: 1) + .stroke(isShowing == "Successful" ? Color.greenSuccess500 : Color.redDanger500, lineWidth: 1) ) .onTapGesture { - isShowing = "" + isShowing = "" } .onAppear { DispatchQueue.main.asyncAfter(deadline: .now() + 2) { diff --git a/Linphone/UI/Main/History/HistoryView.swift b/Linphone/UI/Main/History/HistoryView.swift index a42cc26a2..de3a16833 100644 --- a/Linphone/UI/Main/History/HistoryView.swift +++ b/Linphone/UI/Main/History/HistoryView.swift @@ -1,21 +1,21 @@ /* -* 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 . -*/ + * 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 @@ -23,7 +23,7 @@ struct HistoryView: View { @ObservedObject private var coreContext = CoreContext.shared - var body: some View { + var body: some View { VStack { Spacer() Image("linphone") @@ -31,9 +31,9 @@ struct HistoryView: View { Text("History View") Spacer() } - } + } } #Preview { - HistoryView() + HistoryView() } diff --git a/Linphone/UI/Main/Viewmodel/SharedMainViewModel.swift b/Linphone/UI/Main/Viewmodel/SharedMainViewModel.swift index ffde7651c..5e8f6d39a 100644 --- a/Linphone/UI/Main/Viewmodel/SharedMainViewModel.swift +++ b/Linphone/UI/Main/Viewmodel/SharedMainViewModel.swift @@ -1,25 +1,25 @@ /* -* 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 . -*/ + * 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 { +class SharedMainViewModel: ObservableObject { @Published var welcomeViewDisplayed = false @Published var generalTermsAccepted = false @@ -29,7 +29,7 @@ class SharedMainViewModel : ObservableObject { init() { let preferences = UserDefaults.standard - + let welcomeViewKey = "welcome_view" if preferences.object(forKey: welcomeViewKey) == nil { @@ -37,7 +37,7 @@ class SharedMainViewModel : ObservableObject { } else { welcomeViewDisplayed = preferences.bool(forKey: welcomeViewKey) } - + let generalTermsKey = "general_terms" if preferences.object(forKey: generalTermsKey) == nil { @@ -55,33 +55,33 @@ class SharedMainViewModel : ObservableObject { } } - func changeWelcomeView(){ + func changeWelcomeView() { let preferences = UserDefaults.standard - + welcomeViewDisplayed = true let welcomeViewKey = "welcome_view" preferences.set(welcomeViewDisplayed, forKey: welcomeViewKey) } - func changeGeneralTerms(){ + func changeGeneralTerms() { let preferences = UserDefaults.standard - + generalTermsAccepted = true let generalTermsKey = "general_terms" preferences.set(generalTermsAccepted, forKey: generalTermsKey) } - func changeDisplayProfileMode(){ + func changeDisplayProfileMode() { let preferences = UserDefaults.standard - + displayProfileMode = true let displayProfileModeKey = "display_profile_mode" preferences.set(displayProfileMode, forKey: displayProfileModeKey) } - func changeHideProfileMode(){ + func changeHideProfileMode() { let preferences = UserDefaults.standard - + displayProfileMode = false let displayProfileModeKey = "display_profile_mode" preferences.set(displayProfileMode, forKey: displayProfileModeKey) diff --git a/Linphone/UI/Welcome/Fragments/WelcomePage1Fragment.swift b/Linphone/UI/Welcome/Fragments/WelcomePage1Fragment.swift index 69b58d13e..aabdc6503 100644 --- a/Linphone/UI/Welcome/Fragments/WelcomePage1Fragment.swift +++ b/Linphone/UI/Welcome/Fragments/WelcomePage1Fragment.swift @@ -20,20 +20,20 @@ import Foundation import SwiftUI -struct WelcomePage1Fragment: View{ +struct WelcomePage1Fragment: View { - var body: some View{ + var body: some View { VStack { Spacer() VStack { Image("linphone") .renderingMode(.template) .resizable() - .foregroundStyle(Color.orange_main_500) + .foregroundStyle(Color.orangeMain500) .frame(width: 100, height: 100) Text("Linphone") .welcome_text_style_gray_800(styleSize: 30) - .padding(.bottom, 20) + .padding(.bottom, 20) Text("Une application de communication **sécurisée**, **open source** et **française**.") .welcome_text_style_gray(styleSize: 15) .multilineTextAlignment(.center) diff --git a/Linphone/UI/Welcome/Fragments/WelcomePage2Fragment.swift b/Linphone/UI/Welcome/Fragments/WelcomePage2Fragment.swift index fa309bcd9..45f1e8fa2 100644 --- a/Linphone/UI/Welcome/Fragments/WelcomePage2Fragment.swift +++ b/Linphone/UI/Welcome/Fragments/WelcomePage2Fragment.swift @@ -22,18 +22,18 @@ import SwiftUI struct WelcomePage2Fragment: View { - var body: some View{ + var body: some View { VStack { Spacer() VStack { Image("secure-image") .renderingMode(.template) .resizable() - .foregroundStyle(Color.orange_main_500) + .foregroundStyle(Color.orangeMain500) .frame(width: 70, height: 100) Text("Sécurisé") .welcome_text_style_gray_800(styleSize: 30) - .padding(.bottom, 20) + .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) diff --git a/Linphone/UI/Welcome/Fragments/WelcomePage3Fragment.swift b/Linphone/UI/Welcome/Fragments/WelcomePage3Fragment.swift index 803af3968..1f9256037 100644 --- a/Linphone/UI/Welcome/Fragments/WelcomePage3Fragment.swift +++ b/Linphone/UI/Welcome/Fragments/WelcomePage3Fragment.swift @@ -22,18 +22,18 @@ import SwiftUI struct WelcomePage3Fragment: View { - var body: some View{ + var body: some View { VStack { Spacer() VStack { Image("open-source") .renderingMode(.template) .resizable() - .foregroundStyle(Color.orange_main_500) + .foregroundStyle(Color.orangeMain500) .frame(width: 100, height: 100) Text("Open source") .welcome_text_style_gray_800(styleSize: 30) - .padding(.bottom, 20) + .padding(.bottom, 20) Text("Une application open source et un **service gratuit** depuis **2001**.") .welcome_text_style_gray(styleSize: 15) .multilineTextAlignment(.center) diff --git a/Linphone/UI/Welcome/WelcomeView.swift b/Linphone/UI/Welcome/WelcomeView.swift index 7797f187e..b568f88f7 100644 --- a/Linphone/UI/Welcome/WelcomeView.swift +++ b/Linphone/UI/Welcome/WelcomeView.swift @@ -19,9 +19,9 @@ import SwiftUI -struct WelcomeView: View{ +struct WelcomeView: View { - @ObservedObject var sharedMainViewModel : SharedMainViewModel + @ObservedObject var sharedMainViewModel: SharedMainViewModel var permissionManager = PermissionManager.shared @@ -38,7 +38,7 @@ struct WelcomeView: View{ .frame(width: geometry.size.width, height: 100) .clipped() - VStack (alignment: .trailing) { + VStack(alignment: .trailing) { Text("Skip") .underline() .default_text_style_600(styleSize: 15) @@ -68,7 +68,7 @@ struct WelcomeView: View{ Spacer() - VStack{ + VStack { TabView(selection: $index) { ForEach((0..<3), id: \.self) { index in if index == 0 { @@ -91,7 +91,7 @@ struct WelcomeView: View{ Spacer() - Button(action: { + Button(action: { if index < 2 { withAnimation { index += 1 @@ -99,15 +99,15 @@ struct WelcomeView: View{ } else if index == 2 { permissionManager.cameraRequestPermission() } - }) { + }, label: { Text(index == 2 ? "Start" : "Next") .default_text_style_white_600(styleSize: 20) .frame(height: 35) .frame(maxWidth: .infinity) - } + }) .padding(.horizontal, 20) .padding(.vertical, 10) - .background(Color.orange_main_500) + .background(Color.orangeMain500) .cornerRadius(60) .padding(.horizontal) .padding(.bottom, geometry.safeAreaInsets.bottom.isEqual(to: 0.0) ? 20 : 0) @@ -126,7 +126,7 @@ struct WelcomeView: View{ } func setupAppearance() { - UIPageControl.appearance().currentPageIndicatorTintColor = UIColor(Color.orange_main_500) + UIPageControl.appearance().currentPageIndicatorTintColor = UIColor(Color.orangeMain500) if #available(iOS 16.0, *) { let dotCurrentImage = UIImage(named: "current-dot") @@ -140,7 +140,7 @@ struct WelcomeView: View{ UIPageControl.appearance().setIndicatorImage(dotImage, forPage: 1) UIPageControl.appearance().setIndicatorImage(dotImage, forPage: 2) } - UIPageControl.appearance().pageIndicatorTintColor = UIColor(Color.gray_main2_200) + UIPageControl.appearance().pageIndicatorTintColor = UIColor(Color.grayMain2c200) } } diff --git a/Linphone/Utils/ColorExtension.swift b/Linphone/Utils/ColorExtension.swift index 4846fbfb1..19fc96c2e 100644 --- a/Linphone/Utils/ColorExtension.swift +++ b/Linphone/Utils/ColorExtension.swift @@ -1,91 +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 . -*/ + * 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 transparent_color = Color(hex: "#00000000") + static let transparentColor = Color(hex: "#00000000") static let black = Color(hex: "#000000") static let white = Color(hex: "#FFFFFF") - - static let orange_main_700 = Color(hex: "#B72D00") - static let orange_main_500 = Color(hex: "#FF5E00") - static let orange_main_300 = Color(hex: "#FFB266") - static let orange_main_100 = Color(hex: "#FFEACB") - static let orange_main_100_alpha_50 = Color(hex: "#80FFEACB") - - static let gray_main2_800 = Color(hex: "#22334D") - static let gray_main2_800_alpha_65 = Color(hex: "#A622334D") - static let gray_main2_700 = Color(hex: "#364860") - static let gray_main2_600 = Color(hex: "#4E6074") - static let gray_main2_500 = Color(hex: "#6C7A87") - static let gray_main2_400 = Color(hex: "#9AABB5") - static let gray_main2_300 = Color(hex: "#C0D1D9") - static let gray_main2_200 = Color(hex: "#DFECF2") - static let gray_main2_100 = Color(hex: "#EEF6F8") - - static let gray_100 = Color(hex: "#F9F9F9") - static let gray_200 = Color(hex: "#EDEDED") - static let gray_300 = Color(hex: "#C9C9C9") - static let gray_400 = Color(hex: "#949494") - static let gray_500 = Color(hex: "#4E4E4E") - static let gray_600 = Color(hex: "#2E3030") - static let gray_900 = Color(hex: "#070707") - - static let red_danger_200 = Color(hex: "#F5CCBE") - static let red_danger_500 = Color(hex: "#DD5F5F") - static let red_danger_700 = Color(hex: "#9E3548") - - static let green_success_500 = Color(hex: "#4FAE80") - static let green_success_700 = Color(hex: "#377D71") - static let green_success_200 = Color(hex: "#ACF5C1") - - static let blue_info_500 = Color(hex: "#4AA8FF") - - static let orange_warning_600 = Color(hex: "#DBB820") - - static let orange_away = Color(hex: "#FFA645") + + 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 a, r, g, b: UInt64 + let alpha, red, green, blue: UInt64 switch hex.count { case 3: // RGB (12-bit) - (a, r, g, b) = (255, (int >> 8) * 17, (int >> 4 & 0xF) * 17, (int & 0xF) * 17) + (alpha, red, green, blue) = (255, (int >> 8) * 17, (int >> 4 & 0xF) * 17, (int & 0xF) * 17) case 6: // RGB (24-bit) - (a, r, g, b) = (255, int >> 16, int >> 8 & 0xFF, int & 0xFF) + (alpha, red, green, blue) = (255, int >> 16, int >> 8 & 0xFF, int & 0xFF) case 8: // ARGB (32-bit) - (a, r, g, b) = (int >> 24, int >> 16 & 0xFF, int >> 8 & 0xFF, int & 0xFF) + (alpha, red, green, blue) = (int >> 24, int >> 16 & 0xFF, int >> 8 & 0xFF, int & 0xFF) default: - (a, r, g, b) = (1, 1, 1, 0) + (alpha, red, green, blue) = (1, 1, 1, 0) } - + self.init( .sRGB, - red: Double(r) / 255, - green: Double(g) / 255, - blue: Double(b) / 255, - opacity: Double(a) / 255 + red: Double(red) / 255, + green: Double(green) / 255, + blue: Double(blue) / 255, + opacity: Double(alpha) / 255 ) } } diff --git a/Linphone/Utils/PermissionManager.swift b/Linphone/Utils/PermissionManager.swift index 6a830e412..a39833010 100644 --- a/Linphone/Utils/PermissionManager.swift +++ b/Linphone/Utils/PermissionManager.swift @@ -20,7 +20,7 @@ import Foundation import Photos -class PermissionManager : ObservableObject { +class PermissionManager: ObservableObject { static let shared = PermissionManager() @@ -29,7 +29,7 @@ class PermissionManager : ObservableObject { private init() {} - func photoLibraryRequestPermission(){ + func photoLibraryRequestPermission() { PHPhotoLibrary.requestAuthorization(for: .readWrite, handler: {status in DispatchQueue.main.async { self.photoLibraryPermissionGranted = (status == .authorized || status == .limited || status == .restricted) diff --git a/Linphone/Utils/TextExtension.swift b/Linphone/Utils/TextExtension.swift index ac514e2a2..e9d58ae0b 100644 --- a/Linphone/Utils/TextExtension.swift +++ b/Linphone/Utils/TextExtension.swift @@ -21,96 +21,96 @@ import Foundation import SwiftUI extension View { - - func default_text_style_300(styleSize: CGFloat) -> some View { - self.font(Font.custom("NotoSans-Light", size: styleSize)) - .foregroundStyle(Color.gray_main2_600) - } - - func default_text_style(styleSize: CGFloat) -> some View { - self.font(Font.custom("NotoSans-Regular", size: styleSize)) - .foregroundStyle(Color.gray_main2_600) - } - - func default_text_style_500(styleSize: CGFloat) -> some View { - self.font(Font.custom("NotoSans-Medium", size: styleSize)) - .foregroundStyle(Color.gray_main2_600) - } - - func default_text_style_600(styleSize: CGFloat) -> some View { - self.font(Font.custom("NotoSans-SemiBold", size: styleSize)) - .foregroundStyle(Color.gray_main2_600) - } - - func default_text_style_700(styleSize: CGFloat) -> some View { - self.font(Font.custom("NotoSans-Bold", size: styleSize)) - .foregroundStyle(Color.gray_main2_600) - } - - func default_text_style_800(styleSize: CGFloat) -> some View { - self.font(Font.custom("NotoSans-ExtraBold", size: styleSize)) - .foregroundStyle(Color.gray_main2_600) - } - - 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.orange_main_500) - } - - func default_text_style_orange(styleSize: CGFloat) -> some View { - self.font(Font.custom("NotoSans-Regular", size: styleSize)) - .foregroundStyle(Color.orange_main_500) - } - - func default_text_style_orange_500(styleSize: CGFloat) -> some View { - self.font(Font.custom("NotoSans-Medium", size: styleSize)) - .foregroundStyle(Color.orange_main_500) - } - - func default_text_style_orange_600(styleSize: CGFloat) -> some View { - self.font(Font.custom("NotoSans-SemiBold", size: styleSize)) - .foregroundStyle(Color.orange_main_500) - } - - func default_text_style_orange_700(styleSize: CGFloat) -> some View { - self.font(Font.custom("NotoSans-Bold", size: styleSize)) - .foregroundStyle(Color.orange_main_500) - } - - func default_text_style_orange_800(styleSize: CGFloat) -> some View { - self.font(Font.custom("NotoSans-ExtraBold", size: styleSize)) - .foregroundStyle(Color.orange_main_500) - } + + 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)) @@ -119,21 +119,21 @@ extension View { func welcome_text_style_gray_800(styleSize: CGFloat) -> some View { self.font(Font.custom("NotoSans-ExtraBold", size: styleSize)) - .foregroundStyle(Color.gray_main2_600) + .foregroundStyle(Color.grayMain2c600) } func welcome_text_style_gray(styleSize: CGFloat) -> some View { self.font(Font.custom("NotoSans-Regular", size: styleSize)) - .foregroundStyle(Color.gray_main2_600) + .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 profile_mode_text_style_gray_800(styleSize: CGFloat) -> some View { - self.font(Font.custom("NotoSans-ExtraBold", size: styleSize)) - .foregroundStyle(Color.gray_900) - } - - func profile_mode_text_style_gray(styleSize: CGFloat) -> some View { - self.font(Font.custom("NotoSans-Regular", size: styleSize)) - .foregroundStyle(Color.gray_main2_600) - } } diff --git a/Podfile b/Podfile index 4ecc8821a..18951b82a 100644 --- a/Podfile +++ b/Podfile @@ -23,3 +23,11 @@ target 'Linphone' do basic_pods end + +post_install do |installer| + installer.pods_project.targets.each do |target| + target.build_configurations.each do |config| + config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '15.0' + end + end +end \ No newline at end of file From ad09a2511aa433e1c44811eeee9b75fc09d87eee Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Wed, 11 Oct 2023 23:39:55 +0200 Subject: [PATCH 020/486] Add request permission view --- Linphone.xcodeproj/project.pbxproj | 78 +++--- .../Linphone.imageset/Contents.json | 2 +- .../bell-ringing.imageset/Contents.json | 21 ++ .../bell-ringing.imageset/bell-ringing.svg | 3 + .../microphone.imageset/Contents.json | 21 ++ .../microphone.imageset/microphone.svg | 3 + .../video-camera.imageset/Contents.json | 21 ++ .../video-camera.imageset/video-camera.svg | 3 + Linphone/Localizable.xcstrings | 24 ++ .../Assistant/Fragments/LoginFragment.swift | 18 +- .../Fragments/PermissionsFragment.swift | 227 ++++++++++++++++++ .../Fragments/RegisterFragment.swift | 24 +- Linphone/UI/Welcome/WelcomeView.swift | 182 +++++++------- 13 files changed, 481 insertions(+), 146 deletions(-) create mode 100644 Linphone/Assets.xcassets/bell-ringing.imageset/Contents.json create mode 100644 Linphone/Assets.xcassets/bell-ringing.imageset/bell-ringing.svg create mode 100644 Linphone/Assets.xcassets/microphone.imageset/Contents.json create mode 100644 Linphone/Assets.xcassets/microphone.imageset/microphone.svg create mode 100644 Linphone/Assets.xcassets/video-camera.imageset/Contents.json create mode 100644 Linphone/Assets.xcassets/video-camera.imageset/video-camera.svg create mode 100644 Linphone/UI/Assistant/Fragments/PermissionsFragment.swift diff --git a/Linphone.xcodeproj/project.pbxproj b/Linphone.xcodeproj/project.pbxproj index 461240c2c..c8f9d54ca 100644 --- a/Linphone.xcodeproj/project.pbxproj +++ b/Linphone.xcodeproj/project.pbxproj @@ -7,7 +7,6 @@ objects = { /* Begin PBXBuildFile section */ - 0DF2F35F000C9BBAE8FCB4A0 /* Pods_Linphone.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 7C92C314A5427A62F953EB70 /* Pods_Linphone.framework */; }; D70C93DE2AC2D0F60063CA3B /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = D70C93DD2AC2D0F60063CA3B /* Localizable.xcstrings */; }; D717071E2AC5922E0037746F /* ColorExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = D717071D2AC5922E0037746F /* ColorExtension.swift */; }; D71707202AC5989C0037746F /* TextExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = D717071F2AC5989C0037746F /* TextExtension.swift */; }; @@ -43,13 +42,14 @@ 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 */; }; + D7EAACCF2AD6ED8000AA6A8A /* PermissionsFragment.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7EAACCE2AD6ED8000AA6A8A /* PermissionsFragment.swift */; }; D7FB55112AD447FD00A5AB15 /* RegisterFragment.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7FB55102AD447FD00A5AB15 /* RegisterFragment.swift */; }; + F4BB8DFBA0FF08430EBA9351 /* Pods_Linphone.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 6F5B27C5576B1EAED2F205EB /* Pods_Linphone.framework */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ - 3A132937ACADB95696E2F906 /* Pods-Linphone.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Linphone.debug.xcconfig"; path = "Target Support Files/Pods-Linphone/Pods-Linphone.debug.xcconfig"; sourceTree = ""; }; - 3E2758142B8F42856C3A34DF /* Pods-Linphone.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Linphone.release.xcconfig"; path = "Target Support Files/Pods-Linphone/Pods-Linphone.release.xcconfig"; sourceTree = ""; }; - 7C92C314A5427A62F953EB70 /* Pods_Linphone.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Linphone.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 377E0B5C2B1F38192E694334 /* Pods-Linphone.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Linphone.debug.xcconfig"; path = "Target Support Files/Pods-Linphone/Pods-Linphone.debug.xcconfig"; sourceTree = ""; }; + 6F5B27C5576B1EAED2F205EB /* Pods_Linphone.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Linphone.framework; sourceTree = BUILT_PRODUCTS_DIR; }; D70C93DD2AC2D0F60063CA3B /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; 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 = ""; }; @@ -88,7 +88,9 @@ 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 = ""; }; + D7EAACCE2AD6ED8000AA6A8A /* PermissionsFragment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PermissionsFragment.swift; sourceTree = ""; }; D7FB55102AD447FD00A5AB15 /* RegisterFragment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RegisterFragment.swift; sourceTree = ""; }; + F76FB87556A3109F61F9E2D5 /* Pods-Linphone.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Linphone.release.xcconfig"; path = "Target Support Files/Pods-Linphone/Pods-Linphone.release.xcconfig"; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -96,17 +98,17 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - 0DF2F35F000C9BBAE8FCB4A0 /* Pods_Linphone.framework in Frameworks */, + F4BB8DFBA0FF08430EBA9351 /* Pods_Linphone.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ - 1110B8CBF3C1BFD76F8BBFB2 /* Frameworks */ = { + 52EFCC310713B3CA01062945 /* Frameworks */ = { isa = PBXGroup; children = ( - 7C92C314A5427A62F953EB70 /* Pods_Linphone.framework */, + 6F5B27C5576B1EAED2F205EB /* Pods_Linphone.framework */, ); name = Frameworks; sourceTree = ""; @@ -114,8 +116,8 @@ A31AF2AB8C6A3D7B7EA3B424 /* Pods */ = { isa = PBXGroup; children = ( - 3A132937ACADB95696E2F906 /* Pods-Linphone.debug.xcconfig */, - 3E2758142B8F42856C3A34DF /* Pods-Linphone.release.xcconfig */, + 377E0B5C2B1F38192E694334 /* Pods-Linphone.debug.xcconfig */, + F76FB87556A3109F61F9E2D5 /* Pods-Linphone.release.xcconfig */, ); path = Pods; sourceTree = ""; @@ -136,7 +138,7 @@ D719ABB52ABC67BF00B41C10 /* Linphone */, D719ABB42ABC67BF00B41C10 /* Products */, A31AF2AB8C6A3D7B7EA3B424 /* Pods */, - 1110B8CBF3C1BFD76F8BBFB2 /* Frameworks */, + 52EFCC310713B3CA01062945 /* Frameworks */, ); sourceTree = ""; }; @@ -294,11 +296,12 @@ isa = PBXGroup; children = ( D7DA67612ACCB2FA00E95002 /* LoginFragment.swift */, + D7EAACCE2AD6ED8000AA6A8A /* PermissionsFragment.swift */, D7DA67632ACCB31700E95002 /* ProfileModeFragment.swift */, - D748BF2B2ACD82D2004844EB /* ThirdPartySipAccountLoginFragment.swift */, - D748BF2D2ACD82E7004844EB /* ThirdPartySipAccountWarningFragment.swift */, D723432F2ACEFEF8009AA24E /* QrCodeScannerFragment.swift */, D7FB55102AD447FD00A5AB15 /* RegisterFragment.swift */, + D748BF2B2ACD82D2004844EB /* ThirdPartySipAccountLoginFragment.swift */, + D748BF2D2ACD82E7004844EB /* ThirdPartySipAccountWarningFragment.swift */, ); path = Fragments; sourceTree = ""; @@ -310,12 +313,12 @@ isa = PBXNativeTarget; buildConfigurationList = D719ABC22ABC67BF00B41C10 /* Build configuration list for PBXNativeTarget "Linphone" */; buildPhases = ( - EAE6BD221624F991B659AC2E /* [CP] Check Pods Manifest.lock */, + 6FE8573A5CFC1DA89D3172B5 /* [CP] Check Pods Manifest.lock */, D719ABAF2ABC67BF00B41C10 /* Sources */, D719ABB02ABC67BF00B41C10 /* Frameworks */, D719ABB12ABC67BF00B41C10 /* Resources */, D7FB55122AD53FE200A5AB15 /* Run Script */, - A35AE8ABA69001024776F16C /* [CP] Embed Pods Frameworks */, + 230129DD87A6EBB04DF458AD /* [CP] Embed Pods Frameworks */, ); buildRules = ( ); @@ -379,7 +382,7 @@ /* End PBXResourcesBuildPhase section */ /* Begin PBXShellScriptBuildPhase section */ - A35AE8ABA69001024776F16C /* [CP] Embed Pods Frameworks */ = { + 230129DD87A6EBB04DF458AD /* [CP] Embed Pods Frameworks */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; files = ( @@ -396,26 +399,7 @@ shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Linphone/Pods-Linphone-frameworks.sh\"\n"; showEnvVarsInLog = 0; }; - 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"; - }; - EAE6BD221624F991B659AC2E /* [CP] Check Pods Manifest.lock */ = { + 6FE8573A5CFC1DA89D3172B5 /* [CP] Check Pods Manifest.lock */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; files = ( @@ -437,6 +421,25 @@ shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; showEnvVarsInLog = 0; }; + 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 */ @@ -448,6 +451,7 @@ D719ABB92ABC67BF00B41C10 /* ContentView.swift in Sources */, D750D3392AD3E6EE00EC99C5 /* PopupLoadingView.swift in Sources */, D719ABC92ABC6FD700B41C10 /* CoreContext.swift in Sources */, + D7EAACCF2AD6ED8000AA6A8A /* PermissionsFragment.swift in Sources */, D7A03FBD2ACC2DB60081A588 /* ContactsView.swift in Sources */, D719ABCF2ABC779A00B41C10 /* AccountLoginViewModel.swift in Sources */, D74C9D012ACB098C0021626A /* PermissionManager.swift in Sources */, @@ -592,7 +596,7 @@ }; D719ABC32ABC67BF00B41C10 /* Debug */ = { isa = XCBuildConfiguration; - baseConfigurationReference = 3A132937ACADB95696E2F906 /* Pods-Linphone.debug.xcconfig */; + baseConfigurationReference = 377E0B5C2B1F38192E694334 /* Pods-Linphone.debug.xcconfig */; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; @@ -636,7 +640,7 @@ }; D719ABC42ABC67BF00B41C10 /* Release */ = { isa = XCBuildConfiguration; - baseConfigurationReference = 3E2758142B8F42856C3A34DF /* Pods-Linphone.release.xcconfig */; + baseConfigurationReference = F76FB87556A3109F61F9E2D5 /* Pods-Linphone.release.xcconfig */; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; diff --git a/Linphone/Assets.xcassets/Linphone.imageset/Contents.json b/Linphone/Assets.xcassets/Linphone.imageset/Contents.json index ff043ddb2..e87351a00 100644 --- a/Linphone/Assets.xcassets/Linphone.imageset/Contents.json +++ b/Linphone/Assets.xcassets/Linphone.imageset/Contents.json @@ -1,7 +1,7 @@ { "images" : [ { - "filename" : "linphone.svg", + "filename" : "Linphone.svg", "idiom" : "universal", "scale" : "1x" }, 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..247d2a164 --- /dev/null +++ b/Linphone/Assets.xcassets/bell-ringing.imageset/bell-ringing.svg @@ -0,0 +1,3 @@ + + + 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..c4aeceaeb --- /dev/null +++ b/Linphone/Assets.xcassets/microphone.imageset/microphone.svg @@ -0,0 +1,3 @@ + + + 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..1f42d14cc --- /dev/null +++ b/Linphone/Assets.xcassets/video-camera.imageset/video-camera.svg @@ -0,0 +1,3 @@ + + + diff --git a/Linphone/Localizable.xcstrings b/Linphone/Localizable.xcstrings index 6490fa139..220ead687 100644 --- a/Linphone/Localizable.xcstrings +++ b/Linphone/Localizable.xcstrings @@ -24,6 +24,18 @@ }, "[notre politique de confidentialité](https://linphone.org/privacy-policy)" : { + }, + "**Camera** : Pour capturer votre vidéo lors des appels vidéo et conférence." : { + + }, + "**Contacts** : Pour vous afficher vos contacts et retrouver qui utilise Linphone." : { + + }, + "**Micro** : Pour permettre à vos correspondants de vous entendre." : { + + }, + "**Notifications** : Pour vous informé quand vous recevez un message ou un appel." : { + }, "%lld Book (Example)" : { "extractionState" : "manual", @@ -121,12 +133,18 @@ }, "Continue" : { + }, + "D'accord" : { + }, "Default" : { }, "Default mode" : { + }, + "Demande d’autorisations" : { + }, "Deny all" : { @@ -201,6 +219,12 @@ }, "Personnalize your profil mode" : { + }, + "Plus tard" : { + + }, + "Pour vous permettre de vous profitez pleinement de Linphone nous avons besoin des autorisations suivantes :" : { + }, "QR code validated!" : { diff --git a/Linphone/UI/Assistant/Fragments/LoginFragment.swift b/Linphone/UI/Assistant/Fragments/LoginFragment.swift index b340a8ad4..0d5cac6b9 100644 --- a/Linphone/UI/Assistant/Fragments/LoginFragment.swift +++ b/Linphone/UI/Assistant/Fragments/LoginFragment.swift @@ -34,7 +34,6 @@ struct LoginFragment: View { @State private var linkActive = "" - @State private var isLinkQRActive = false @State private var isLinkSIPActive = false @State private var isLinkREGActive = false @@ -155,7 +154,7 @@ struct LoginFragment: View { } .padding(.bottom, 10) - NavigationLink(isActive: $isLinkQRActive, destination: { + NavigationLink(destination: { QrCodeScannerFragment() }, label: { HStack { @@ -172,7 +171,6 @@ struct LoginFragment: View { .frame(maxWidth: .infinity) }) - .disabled(!sharedMainViewModel.generalTermsAccepted) .padding(.horizontal, 20) .padding(.vertical, 10) .cornerRadius(60) @@ -182,18 +180,6 @@ struct LoginFragment: View { .stroke(Color.orangeMain500, lineWidth: 1) ) .padding(.bottom) - .simultaneousGesture( - TapGesture().onEnded { - self.linkActive = "QR" - if !sharedMainViewModel.generalTermsAccepted { - withAnimation { - self.isShowPopup.toggle() - } - } else { - self.isLinkQRActive = true - } - } - ) NavigationLink(isActive: $isLinkSIPActive, destination: { ThirdPartySipAccountWarningFragment(sharedMainViewModel: sharedMainViewModel, accountLoginViewModel: accountLoginViewModel) @@ -320,8 +306,6 @@ struct LoginFragment: View { sharedMainViewModel.changeGeneralTerms() self.isShowPopup.toggle() switch linkActive { - case "QR": - self.isLinkQRActive = true case "SIP": self.isLinkSIPActive = true case "REG": diff --git a/Linphone/UI/Assistant/Fragments/PermissionsFragment.swift b/Linphone/UI/Assistant/Fragments/PermissionsFragment.swift new file mode 100644 index 000000000..cbb56aa76 --- /dev/null +++ b/Linphone/UI/Assistant/Fragments/PermissionsFragment.swift @@ -0,0 +1,227 @@ +/* + * 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 var sharedMainViewModel: SharedMainViewModel + + 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(.top, -65) + .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) + .onTapGesture { + withAnimation { + dismiss() + } + } + } + .padding(16) + .background(Color.grayMain2c200) + .cornerRadius(40) + + Text("**Notifications** : Pour vous informé 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) + .onTapGesture { + withAnimation { + dismiss() + } + } + } + .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) + .onTapGesture { + withAnimation { + dismiss() + } + } + } + .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) + .onTapGesture { + withAnimation { + dismiss() + } + } + } + .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.cameraRequestPermission() + } 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.$cameraPermissionGranted, perform: { (granted) in + if granted { + withAnimation { + sharedMainViewModel.changeWelcomeView() + } + } + }) + } +} + +#Preview { + PermissionsFragment(sharedMainViewModel: SharedMainViewModel()) +} diff --git a/Linphone/UI/Assistant/Fragments/RegisterFragment.swift b/Linphone/UI/Assistant/Fragments/RegisterFragment.swift index 8b80a1101..194bd5b5a 100644 --- a/Linphone/UI/Assistant/Fragments/RegisterFragment.swift +++ b/Linphone/UI/Assistant/Fragments/RegisterFragment.swift @@ -1,9 +1,21 @@ -// -// RegisterFragment.swift -// Linphone -// -// Created by Benoît Martins on 09/10/2023. -// +/* + * 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 diff --git a/Linphone/UI/Welcome/WelcomeView.swift b/Linphone/UI/Welcome/WelcomeView.swift index b568f88f7..9bad70c3a 100644 --- a/Linphone/UI/Welcome/WelcomeView.swift +++ b/Linphone/UI/Welcome/WelcomeView.swift @@ -23,106 +23,118 @@ struct WelcomeView: View { @ObservedObject var sharedMainViewModel: SharedMainViewModel - var permissionManager = PermissionManager.shared - @State private var index = 0 var body: some View { - GeometryReader { geometry in - ScrollView { - VStack { - ZStack { - Image("mountain") - .resizable() - .scaledToFill() - .frame(width: geometry.size.width, height: 100) - .clipped() - - VStack(alignment: .trailing) { - Text("Skip") - .underline() - .default_text_style_600(styleSize: 15) + 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(sharedMainViewModel: sharedMainViewModel) + }, label: { + Text("Skip") + .underline() + .default_text_style_600(styleSize: 15) + + }) .padding(.top, -35) .padding(.trailing, 20) - .onTapGesture { - withAnimation { + .simultaneousGesture( + TapGesture().onEnded { self.index = 2 - permissionManager.cameraRequestPermission() + } + ) + 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() } } - 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) + } + .tabViewStyle(PageTabViewStyle(indexDisplayMode: .always)) + .frame(minHeight: 300) + .onAppear { + setupAppearance() + } } - .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() + + Spacer() + + if index == 2 { + NavigationLink(destination: { + PermissionsFragment(sharedMainViewModel: sharedMainViewModel) + }, 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 } - } - } - .tabViewStyle(PageTabViewStyle(indexDisplayMode: .always)) - .frame(minHeight: 300) - .onAppear { - setupAppearance() + }, 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) } } - - Spacer() - - Button(action: { - if index < 2 { - withAnimation { - index += 1 - } - } else if index == 2 { - permissionManager.cameraRequestPermission() - } - }, label: { - Text(index == 2 ? "Start" : "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) } - .frame(minHeight: geometry.size.height) } } - .onReceive(permissionManager.$cameraPermissionGranted, perform: { (granted) in - if granted { - withAnimation { - sharedMainViewModel.changeWelcomeView() - } - } - }) } func setupAppearance() { From 2abc2e65000554c9a49c04fd56b2b498b9d7e93a Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Thu, 12 Oct 2023 14:49:27 +0200 Subject: [PATCH 021/486] Navigation bar added --- Linphone/UI/Assistant/AssistantView.swift | 2 +- Linphone/UI/Main/ContentView.swift | 162 ++++++++++++++++++++-- Linphone/UI/Welcome/WelcomeView.swift | 1 + 3 files changed, 155 insertions(+), 10 deletions(-) diff --git a/Linphone/UI/Assistant/AssistantView.swift b/Linphone/UI/Assistant/AssistantView.swift index c3cc5e2bd..1819486d8 100644 --- a/Linphone/UI/Assistant/AssistantView.swift +++ b/Linphone/UI/Assistant/AssistantView.swift @@ -34,5 +34,5 @@ struct AssistantView: View { } #Preview { - AssistantView(sharedMainViewModel: SharedMainViewModel()) + LoginFragment(accountLoginViewModel: AccountLoginViewModel(), sharedMainViewModel: SharedMainViewModel()) } diff --git a/Linphone/UI/Main/ContentView.swift b/Linphone/UI/Main/ContentView.swift index ccb8e9a67..cf6d798d6 100644 --- a/Linphone/UI/Main/ContentView.swift +++ b/Linphone/UI/Main/ContentView.swift @@ -24,27 +24,171 @@ struct ContentView: View { @ObservedObject var sharedMainViewModel: SharedMainViewModel @ObservedObject private var coreContext = CoreContext.shared + @State var index = 0 + @State private var orientation = UIDevice.current.orientation + var body: some View { if !sharedMainViewModel.welcomeViewDisplayed { WelcomeView(sharedMainViewModel: sharedMainViewModel) } else if coreContext.mCore.defaultAccount == nil || sharedMainViewModel.displayProfileMode { AssistantView(sharedMainViewModel: sharedMainViewModel) } else { - TabView { - ContactsView() - .tabItem { - Label("Contacts", image: "address-book") - } - - HistoryView() - .tabItem { - Label("Calls", image: "phone") + GeometryReader { geometry in + NavigationView { + if orientation == .landscapeLeft || orientation == .landscapeRight || geometry.size.width > geometry.size.height { + HStack(spacing: 0) { + VStack { + Group { + Spacer() + Button(action: { + self.index = 0 + }, 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) + } + } + }) + + Spacer() + + Button(action: { + self.index = 1 + }, 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) + } + } + }) + + Spacer() + } + } + .frame(width: 60) + .padding(.leading, orientation == .landscapeRight && geometry.safeAreaInsets.bottom > 0 ? -geometry.safeAreaInsets.leading : 0) + .background(Color.gray100) + + VStack { + if self.index == 0 { + ContactsView() + } else if self.index == 1 { + HistoryView() + } + } + .frame(maxWidth: .infinity) + } + } else { + VStack(spacing: 0) { + VStack { + if self.index == 0 { + ContactsView() + } else if self.index == 1 { + HistoryView() + } + } + .frame(maxWidth: .infinity) + + HStack { + Group { + Spacer() + Button(action: { + self.index = 0 + }, 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) + + Spacer() + + Button(action: { + self.index = 1 + }, 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) + + Spacer() + } + } + .background(Color.gray100) + } } + } + .onRotate { newOrientation in + orientation = newOrientation + } } } } } +struct DeviceRotationViewModifier: ViewModifier { + 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 { + action(UIDevice.current.orientation) + } + } + } +} + +extension View { + func onRotate(perform action: @escaping (UIDeviceOrientation) -> Void) -> some View { + self.modifier(DeviceRotationViewModifier(action: action)) + } +} + #Preview { ContentView(sharedMainViewModel: SharedMainViewModel()) } diff --git a/Linphone/UI/Welcome/WelcomeView.swift b/Linphone/UI/Welcome/WelcomeView.swift index 9bad70c3a..5a4d6e700 100644 --- a/Linphone/UI/Welcome/WelcomeView.swift +++ b/Linphone/UI/Welcome/WelcomeView.swift @@ -135,6 +135,7 @@ struct WelcomeView: View { } } } + .navigationViewStyle(StackNavigationViewStyle()) } func setupAppearance() { From 8f733195d4c0a571d8810dcc07d82238d6333b77 Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Mon, 16 Oct 2023 16:57:04 +0200 Subject: [PATCH 022/486] Add home page --- Linphone.xcodeproj/project.pbxproj | 60 +++++- .../contacts.imageset/Contents.json | 21 ++ .../contacts.imageset/contacts.svg | 3 + .../filtres.imageset/Contents.json | 21 ++ .../filtres.imageset/filtres.svg | 3 + .../illus-belledonne1.imageset/Contents.json | 21 ++ .../illus-belledonne1.svg | 65 ++++++ .../more.imageset/Contents.json | 21 ++ .../Assets.xcassets/more.imageset/more.svg | 3 + .../Contents.json | 21 ++ .../profile-image-example.png | Bin 0 -> 20925 bytes .../search.imageset/Contents.json | 21 ++ .../search.imageset/search.svg | 3 + Linphone/LinphoneApp.swift | 2 +- Linphone/Localizable.xcstrings | 14 +- Linphone/UI/Main/Contacts/ContactsView.swift | 106 +++++++++- .../Contacts/Fragments/ContactFragment.swift | 77 ++++++++ .../Contacts/ViewModel/ContactViewModel.swift | 27 +++ Linphone/UI/Main/ContentView.swift | 185 +++++++++--------- .../DeviceRotationViewModifier.swift | 46 +++++ .../Fragments/HistoryContactFragment.swift | 34 ++++ Linphone/UI/Main/History/HistoryView.swift | 55 +++++- .../History/ViewModel/HistoryViewModel.swift | 27 +++ 23 files changed, 724 insertions(+), 112 deletions(-) create mode 100644 Linphone/Assets.xcassets/contacts.imageset/Contents.json create mode 100644 Linphone/Assets.xcassets/contacts.imageset/contacts.svg create mode 100644 Linphone/Assets.xcassets/filtres.imageset/Contents.json create mode 100644 Linphone/Assets.xcassets/filtres.imageset/filtres.svg create mode 100644 Linphone/Assets.xcassets/illus-belledonne1.imageset/Contents.json create mode 100644 Linphone/Assets.xcassets/illus-belledonne1.imageset/illus-belledonne1.svg create mode 100644 Linphone/Assets.xcassets/more.imageset/Contents.json create mode 100644 Linphone/Assets.xcassets/more.imageset/more.svg create mode 100644 Linphone/Assets.xcassets/profile-image-example.imageset/Contents.json create mode 100644 Linphone/Assets.xcassets/profile-image-example.imageset/profile-image-example.png create mode 100644 Linphone/Assets.xcassets/search.imageset/Contents.json create mode 100644 Linphone/Assets.xcassets/search.imageset/search.svg create mode 100644 Linphone/UI/Main/Contacts/Fragments/ContactFragment.swift create mode 100644 Linphone/UI/Main/Contacts/ViewModel/ContactViewModel.swift create mode 100644 Linphone/UI/Main/Fragments/DeviceRotationViewModifier.swift create mode 100644 Linphone/UI/Main/History/Fragments/HistoryContactFragment.swift create mode 100644 Linphone/UI/Main/History/ViewModel/HistoryViewModel.swift diff --git a/Linphone.xcodeproj/project.pbxproj b/Linphone.xcodeproj/project.pbxproj index c8f9d54ca..8dd980a3b 100644 --- a/Linphone.xcodeproj/project.pbxproj +++ b/Linphone.xcodeproj/project.pbxproj @@ -7,6 +7,7 @@ objects = { /* Begin PBXBuildFile section */ + D706BA822ADD72D100278F45 /* DeviceRotationViewModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = D706BA812ADD72D100278F45 /* DeviceRotationViewModifier.swift */; }; D70C93DE2AC2D0F60063CA3B /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = D70C93DD2AC2D0F60063CA3B /* Localizable.xcstrings */; }; D717071E2AC5922E0037746F /* ColorExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = D717071D2AC5922E0037746F /* ColorExtension.swift */; }; D71707202AC5989C0037746F /* TextExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = D717071F2AC5989C0037746F /* TextExtension.swift */; }; @@ -17,10 +18,12 @@ 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 */; }; + D72250632ADE9615008FB426 /* HistoryViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D72250622ADE9615008FB426 /* HistoryViewModel.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 */; }; + D72992392ADD7F68003AF125 /* HistoryContactFragment.swift in Sources */ = {isa = PBXBuildFile; fileRef = D72992382ADD7F68003AF125 /* HistoryContactFragment.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 */; }; @@ -30,6 +33,8 @@ D74C9D012ACB098C0021626A /* PermissionManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = D74C9D002ACB098C0021626A /* PermissionManager.swift */; }; D750D3392AD3E6EE00EC99C5 /* PopupLoadingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D750D3382AD3E6EE00EC99C5 /* PopupLoadingView.swift */; }; D7702EF22AC7205000557C00 /* WelcomeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7702EF12AC7205000557C00 /* WelcomeView.swift */; }; + D78290B82ADD3910004AA85C /* ContactFragment.swift in Sources */ = {isa = PBXBuildFile; fileRef = D78290B72ADD3910004AA85C /* ContactFragment.swift */; }; + D78290BB2ADD40B2004AA85C /* ContactViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D78290BA2ADD40B2004AA85C /* ContactViewModel.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 */; }; @@ -50,6 +55,7 @@ /* Begin PBXFileReference section */ 377E0B5C2B1F38192E694334 /* Pods-Linphone.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Linphone.debug.xcconfig"; path = "Target Support Files/Pods-Linphone/Pods-Linphone.debug.xcconfig"; sourceTree = ""; }; 6F5B27C5576B1EAED2F205EB /* Pods_Linphone.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Linphone.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + D706BA812ADD72D100278F45 /* DeviceRotationViewModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceRotationViewModifier.swift; sourceTree = ""; }; D70C93DD2AC2D0F60063CA3B /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; 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 = ""; }; @@ -62,10 +68,12 @@ 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 = ""; }; + D72250622ADE9615008FB426 /* HistoryViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HistoryViewModel.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 = ""; }; + D72992382ADD7F68003AF125 /* HistoryContactFragment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HistoryContactFragment.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 = ""; }; @@ -75,6 +83,8 @@ D74C9D002ACB098C0021626A /* PermissionManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PermissionManager.swift; sourceTree = ""; }; D750D3382AD3E6EE00EC99C5 /* PopupLoadingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PopupLoadingView.swift; sourceTree = ""; }; D7702EF12AC7205000557C00 /* WelcomeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WelcomeView.swift; sourceTree = ""; }; + D78290B72ADD3910004AA85C /* ContactFragment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactFragment.swift; sourceTree = ""; }; + D78290BA2ADD40B2004AA85C /* ContactViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactViewModel.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 = ""; }; @@ -226,6 +236,22 @@ path = Viewmodel; sourceTree = ""; }; + D72250612ADE95E4008FB426 /* ViewModel */ = { + isa = PBXGroup; + children = ( + D72250622ADE9615008FB426 /* HistoryViewModel.swift */, + ); + path = ViewModel; + sourceTree = ""; + }; + D72992372ADD7F1C003AF125 /* Fragments */ = { + isa = PBXGroup; + children = ( + D72992382ADD7F68003AF125 /* HistoryContactFragment.swift */, + ); + path = Fragments; + sourceTree = ""; + }; D74C9CF62ACACEB70021626A /* Fragments */ = { isa = PBXGroup; children = ( @@ -242,6 +268,7 @@ D74C9CFE2ACAEC5E0021626A /* PopupView.swift */, D72343352AD037AF009AA24E /* ToastView.swift */, D750D3382AD3E6EE00EC99C5 /* PopupLoadingView.swift */, + D706BA812ADD72D100278F45 /* DeviceRotationViewModifier.swift */, ); path = Fragments; sourceTree = ""; @@ -255,9 +282,27 @@ path = Welcome; sourceTree = ""; }; + D78290B62ADD38F9004AA85C /* Fragments */ = { + isa = PBXGroup; + children = ( + D78290B72ADD3910004AA85C /* ContactFragment.swift */, + ); + path = Fragments; + sourceTree = ""; + }; + D78290B92ADD409D004AA85C /* ViewModel */ = { + isa = PBXGroup; + children = ( + D78290BA2ADD40B2004AA85C /* ContactViewModel.swift */, + ); + path = ViewModel; + sourceTree = ""; + }; D7A03FBB2ACC2D850081A588 /* Contacts */ = { isa = PBXGroup; children = ( + D78290B62ADD38F9004AA85C /* Fragments */, + D78290B92ADD409D004AA85C /* ViewModel */, D7A03FBC2ACC2DB60081A588 /* ContactsView.swift */, ); path = Contacts; @@ -266,6 +311,8 @@ D7A03FBE2ACC2E010081A588 /* History */ = { isa = PBXGroup; children = ( + D72992372ADD7F1C003AF125 /* Fragments */, + D72250612ADE95E4008FB426 /* ViewModel */, D7A03FBF2ACC2E390081A588 /* HistoryView.swift */, ); path = History; @@ -450,13 +497,17 @@ D71707202AC5989C0037746F /* TextExtension.swift in Sources */, D719ABB92ABC67BF00B41C10 /* ContentView.swift in Sources */, D750D3392AD3E6EE00EC99C5 /* PopupLoadingView.swift in Sources */, + D706BA822ADD72D100278F45 /* DeviceRotationViewModifier.swift in Sources */, D719ABC92ABC6FD700B41C10 /* CoreContext.swift in Sources */, D7EAACCF2AD6ED8000AA6A8A /* PermissionsFragment.swift in Sources */, D7A03FBD2ACC2DB60081A588 /* ContactsView.swift in Sources */, D719ABCF2ABC779A00B41C10 /* AccountLoginViewModel.swift in Sources */, + D78290BB2ADD40B2004AA85C /* ContactViewModel.swift in Sources */, + D72992392ADD7F68003AF125 /* HistoryContactFragment.swift in Sources */, D74C9D012ACB098C0021626A /* PermissionManager.swift in Sources */, D7702EF22AC7205000557C00 /* WelcomeView.swift in Sources */, D719ABB72ABC67BF00B41C10 /* LinphoneApp.swift in Sources */, + D72250632ADE9615008FB426 /* HistoryViewModel.swift in Sources */, D7A2EDD62AC18115005D90FC /* SharedMainViewModel.swift in Sources */, D7A03FC62ACC458A0081A588 /* SplashScreen.swift in Sources */, D7A03FC02ACC2E390081A588 /* HistoryView.swift in Sources */, @@ -469,6 +520,7 @@ D72343342ACEFFC3009AA24E /* QRScanner.swift in Sources */, D72343302ACEFEF8009AA24E /* QrCodeScannerFragment.swift in Sources */, D717071E2AC5922E0037746F /* ColorExtension.swift in Sources */, + D78290B82ADD3910004AA85C /* ContactFragment.swift in Sources */, D7DA67642ACCB31700E95002 /* ProfileModeFragment.swift in Sources */, D74C9CFC2ACACF370021626A /* WelcomePage3Fragment.swift in Sources */, D719ABCC2ABC769C00B41C10 /* AssistantView.swift in Sources */, @@ -621,8 +673,8 @@ "INFOPLIST_KEY_UILaunchScreen_Generation[sdk=iphonesimulator*]" = YES; "INFOPLIST_KEY_UIStatusBarStyle[sdk=iphoneos*]" = UIStatusBarStyleDefault; "INFOPLIST_KEY_UIStatusBarStyle[sdk=iphonesimulator*]" = UIStatusBarStyleDefault; - INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; - INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; @@ -665,8 +717,8 @@ "INFOPLIST_KEY_UILaunchScreen_Generation[sdk=iphonesimulator*]" = YES; "INFOPLIST_KEY_UIStatusBarStyle[sdk=iphoneos*]" = UIStatusBarStyleDefault; "INFOPLIST_KEY_UIStatusBarStyle[sdk=iphonesimulator*]" = UIStatusBarStyleDefault; - INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; - INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; diff --git a/Linphone/Assets.xcassets/contacts.imageset/Contents.json b/Linphone/Assets.xcassets/contacts.imageset/Contents.json new file mode 100644 index 000000000..4289db091 --- /dev/null +++ b/Linphone/Assets.xcassets/contacts.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "contacts.svg", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Linphone/Assets.xcassets/contacts.imageset/contacts.svg b/Linphone/Assets.xcassets/contacts.imageset/contacts.svg new file mode 100644 index 000000000..f1706dc95 --- /dev/null +++ b/Linphone/Assets.xcassets/contacts.imageset/contacts.svg @@ -0,0 +1,3 @@ + + + diff --git a/Linphone/Assets.xcassets/filtres.imageset/Contents.json b/Linphone/Assets.xcassets/filtres.imageset/Contents.json new file mode 100644 index 000000000..49440ce83 --- /dev/null +++ b/Linphone/Assets.xcassets/filtres.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "filtres.svg", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Linphone/Assets.xcassets/filtres.imageset/filtres.svg b/Linphone/Assets.xcassets/filtres.imageset/filtres.svg new file mode 100644 index 000000000..032a19982 --- /dev/null +++ b/Linphone/Assets.xcassets/filtres.imageset/filtres.svg @@ -0,0 +1,3 @@ + + + diff --git a/Linphone/Assets.xcassets/illus-belledonne1.imageset/Contents.json b/Linphone/Assets.xcassets/illus-belledonne1.imageset/Contents.json new file mode 100644 index 000000000..ca2746c0a --- /dev/null +++ b/Linphone/Assets.xcassets/illus-belledonne1.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "illus-belledonne1.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-belledonne1.imageset/illus-belledonne1.svg b/Linphone/Assets.xcassets/illus-belledonne1.imageset/illus-belledonne1.svg new file mode 100644 index 000000000..2980979dc --- /dev/null +++ b/Linphone/Assets.xcassets/illus-belledonne1.imageset/illus-belledonne1.svg @@ -0,0 +1,65 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Linphone/Assets.xcassets/more.imageset/Contents.json b/Linphone/Assets.xcassets/more.imageset/Contents.json new file mode 100644 index 000000000..259aa12c1 --- /dev/null +++ b/Linphone/Assets.xcassets/more.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "more.svg", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Linphone/Assets.xcassets/more.imageset/more.svg b/Linphone/Assets.xcassets/more.imageset/more.svg new file mode 100644 index 000000000..c9321f60f --- /dev/null +++ b/Linphone/Assets.xcassets/more.imageset/more.svg @@ -0,0 +1,3 @@ + + + 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 0000000000000000000000000000000000000000..d1e23f9998d38455f60ac192ecca7c6f5e6e19a1 GIT binary patch literal 20925 zcmV(^K-IsAP)&413lFCwZ(lhx6g85!}+vH$tcxi=ixuMhw9 z2OorkKdev0g2p98M`9^m!)f0Osh-yOZy_WkL1c*mQL^YITo{GUE`&2@a}?eG0VIL(LQ1`r5F z^$*Qj1y{vY(^T=V{G9{QZyRse4_C^zf6R;_YK zPY>Jle$4flgSL;2_UX;Fwv`bMhf%B5T0%DW3j4eH?mtB6z99qxjun3UyDuZRr8HPW zYG02v-cNn-;g5YpfBl7b{OpH$N1t?CFEH8*gd%bzc#XPwG@C}idXZkoO*q$BH>_~l zU*7*H9CRqnYyU2mE;r$JD8seZ6$?@cp^zTC+VD_sG%0)!-v2}aou1;DyKX)2tG{iX zZN(dp^M&Zz@5ZW)rNdkkp9?s?XZqLb=<_Yv=m|VyOhNwK@Y>RF@UdU`!#{f1ji>v6 zF`7(4tRJn5pSS1dQj`Fa9whMONa7$1? zC=zN5U%v;e2e^8?XrqEVV^zFqI)zvyNK16D2{D~I_Rrn^yVDLY8s6F=rWIaXs1?g` zgX(Po`{2)1d`KSw^PSI!^3U~gJvL|1KvQhnfm*Q@*GcYnxp zX;}fWEV`!?=ZN)QM(YPZFIW3hK3vJ8FkJfGPfm)qh zSirh)hqc<*4WNv7T|bEU?ooX8*#%^ZHB@R1GjLI|eyqhcmLCcQ(WJ+;<9*u$ zL;)>JwpQ*sPRp_buC;WhZTXJ%631~{p{AY3(Ei@x4f10+5=OmVYn{94I#X0MEuZ0~ zS*hqjf8*3X<>nKpe+bX&4)eD)^??cID0ljt-?c9mny+`m77O9ZKr7{($v9Kc2g- zkYeMzD!?3HE!p`o=XP4R)Bz;WdNdnV^(yl_uZdAT=xSo~e0&0@(NJM()(&tYz8GHMvHG?OrQ*Kbaa2fQFGyf&0kj^~tzx?M{caTLl=5py&xU zZ|Habp-T2zAu!DEL9uvEnvM@COl%ZQMWbOz>ZK1LK(CPJ>$SQHU*eyB$$R;noFE#F zQDF%*>Jp-eqUdgVM91q0Esk|vxAi+Z6=f}{C16J&tAlC{dhzo0dgS9PuWkL;h8oxP zVWbHg0UJUBEi1Q*2n6�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 literal 0 HcmV?d00001 diff --git a/Linphone/Assets.xcassets/search.imageset/Contents.json b/Linphone/Assets.xcassets/search.imageset/Contents.json new file mode 100644 index 000000000..6e7791884 --- /dev/null +++ b/Linphone/Assets.xcassets/search.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "search.svg", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Linphone/Assets.xcassets/search.imageset/search.svg b/Linphone/Assets.xcassets/search.imageset/search.svg new file mode 100644 index 000000000..201e5000d --- /dev/null +++ b/Linphone/Assets.xcassets/search.imageset/search.svg @@ -0,0 +1,3 @@ + + + diff --git a/Linphone/LinphoneApp.swift b/Linphone/LinphoneApp.swift index 87b0a4007..3dda9cf64 100644 --- a/Linphone/LinphoneApp.swift +++ b/Linphone/LinphoneApp.swift @@ -28,7 +28,7 @@ struct LinphoneApp: App { var body: some Scene { WindowGroup { if isActive { - ContentView(sharedMainViewModel: SharedMainViewModel()) + ContentView(sharedMainViewModel: SharedMainViewModel(), contactViewModel: ContactViewModel(), historyViewModel: HistoryViewModel()) .toast(isShowing: $coreContext.toastMessage) } else { SplashScreen(isActive: $isActive) diff --git a/Linphone/Localizable.xcstrings b/Linphone/Localizable.xcstrings index 220ead687..7d9056b79 100644 --- a/Linphone/Localizable.xcstrings +++ b/Linphone/Localizable.xcstrings @@ -36,6 +36,9 @@ }, "**Notifications** : Pour vous informé quand vous recevez un message ou un appel." : { + }, + "%lld" : { + }, "%lld Book (Example)" : { "extractionState" : "manual", @@ -127,9 +130,6 @@ }, "Contacts" : { - }, - "Contacts View" : { - }, "Continue" : { @@ -161,7 +161,7 @@ "Error" : { }, - "History View" : { + "History Contact fragment" : { }, "I prefere create an account" : { @@ -190,6 +190,12 @@ }, "Next" : { + }, + "No calls for the moment..." : { + + }, + "No contacts for the moment..." : { + }, "Not account yet?" : { diff --git a/Linphone/UI/Main/Contacts/ContactsView.swift b/Linphone/UI/Main/Contacts/ContactsView.swift index 1c5d3495a..8b44da54f 100644 --- a/Linphone/UI/Main/Contacts/ContactsView.swift +++ b/Linphone/UI/Main/Contacts/ContactsView.swift @@ -20,17 +20,109 @@ import SwiftUI struct ContactsView: View { + + @ObservedObject var contactViewModel: ContactViewModel + @ObservedObject var historyViewModel: HistoryViewModel + + @State private var orientation = UIDevice.current.orientation + @State private var selectedIndex = 0 + + var objects: [Int] = [ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, + 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39 + ] + var body: some View { - VStack { - Spacer() - Image("linphone") - .padding(.bottom, 20) - Text("Contacts View") - Spacer() + NavigationView { + ZStack(alignment: .bottomTrailing) { + VStack(spacing: 0) { + HStack { + Image("profile-image-example") + .resizable() + .frame(width: 40, height: 40) + .clipShape(Circle()) + + Text("Contacts") + .default_text_style_white_800(styleSize: 20) + .padding(.leading, 10) + + Spacer() + + Button { + + } label: { + Image("search") + } + + Button { + + } label: { + Image("filtres") + } + .padding(.leading) + } + .frame(maxWidth: .infinity) + .frame(height: 50) + .padding(.horizontal) + .background(Color.orangeMain500) + + VStack { + List { + ForEach(objects, id: \.self) { index in + Button { + withAnimation { + contactViewModel.contactTitle = String(index) + } + } label: { + Text("\(index)") + .frame( maxWidth: .infinity, alignment: .leading) + .foregroundStyle(Color.orangeMain500) + } + .buttonStyle(.borderless) + .listRowSeparator(.hidden) + } + } + .listStyle(.plain) + .overlay( + VStack { + if objects.isEmpty { + Spacer() + Image("illus-belledonne1") + .resizable() + .scaledToFit() + .clipped() + .padding(.all) + Text("No contacts for the moment...") + .default_text_style_800(styleSize: 16) + Spacer() + Spacer() + } + } + .padding(.all) + ) + } + } + .onRotate { newOrientation in + orientation = newOrientation + } + + Button { + // Action + } label: { + Image("contacts") + .padding() + .background(.white) + .clipShape(Circle()) + .shadow(color: .black.opacity(0.2), radius: 4) + + } + .padding() + } } + .navigationViewStyle(.stack) } } #Preview { - ContactsView() + ContactsView(contactViewModel: ContactViewModel(), historyViewModel: HistoryViewModel()) } diff --git a/Linphone/UI/Main/Contacts/Fragments/ContactFragment.swift b/Linphone/UI/Main/Contacts/Fragments/ContactFragment.swift new file mode 100644 index 000000000..9168b860e --- /dev/null +++ b/Linphone/UI/Main/Contacts/Fragments/ContactFragment.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 SwiftUI + +struct ContactFragment: View { + + @ObservedObject var contactViewModel: ContactViewModel + + @State private var orientation = UIDevice.current.orientation + + var body: some View { + VStack(alignment: .leading) { + + if !(orientation == .landscapeLeft || orientation == .landscapeRight || UIScreen.main.bounds.size.width > UIScreen.main.bounds.size.height) { + HStack { + Image("caret-left") + .renderingMode(.template) + .resizable() + .foregroundStyle(Color.grayMain2c500) + .frame(width: 25, height: 25, alignment: .leading) + .padding(.top, 20) + .onTapGesture { + withAnimation { + contactViewModel.contactTitle = "" + } + } + + Spacer() + } + .padding(.leading) + } + + Spacer() + + Text(contactViewModel.contactTitle) + .frame(maxWidth: .infinity) + + List { + ForEach(1...40, id: \.self) { index in + Button { + contactViewModel.contactTitle = String(index) + } label: { + Text("\(index)") + .frame( maxWidth: .infinity, alignment: .leading) + } + .buttonStyle(.borderless) + } + } + } + .navigationBarHidden(true) + .onRotate { newOrientation in + orientation = newOrientation + } + + } +} + +#Preview { + ContactFragment(contactViewModel: ContactViewModel()) +} diff --git a/Linphone/UI/Main/Contacts/ViewModel/ContactViewModel.swift b/Linphone/UI/Main/Contacts/ViewModel/ContactViewModel.swift new file mode 100644 index 000000000..9114b2607 --- /dev/null +++ b/Linphone/UI/Main/Contacts/ViewModel/ContactViewModel.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 + +class ContactViewModel: ObservableObject { + + @Published var contactTitle: String = "" + + init() {} +} diff --git a/Linphone/UI/Main/ContentView.swift b/Linphone/UI/Main/ContentView.swift index cf6d798d6..97991cd9d 100644 --- a/Linphone/UI/Main/ContentView.swift +++ b/Linphone/UI/Main/ContentView.swift @@ -22,6 +22,8 @@ import SwiftUI struct ContentView: View { @ObservedObject var sharedMainViewModel: SharedMainViewModel + @ObservedObject var contactViewModel: ContactViewModel + @ObservedObject var historyViewModel: HistoryViewModel @ObservedObject private var coreContext = CoreContext.shared @State var index = 0 @@ -34,79 +36,81 @@ struct ContentView: View { AssistantView(sharedMainViewModel: sharedMainViewModel) } else { GeometryReader { geometry in - NavigationView { - if orientation == .landscapeLeft || orientation == .landscapeRight || geometry.size.width > geometry.size.height { + ZStack { + VStack(spacing: 0) { HStack(spacing: 0) { - VStack { - Group { - Spacer() - Button(action: { - self.index = 0 - }, 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) + if orientation == .landscapeLeft || orientation == .landscapeRight || UIScreen.main.bounds.size.width > UIScreen.main.bounds.size.height { + VStack { + Group { + Spacer() + Button(action: { + self.index = 0 + }, 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) + } } - } - }) - - Spacer() - - Button(action: { - self.index = 1 - }, 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) + }) + + Spacer() + + Button(action: { + self.index = 1 + contactViewModel.contactTitle = "" + }, 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) + } } - } - }) - - Spacer() + }) + + Spacer() + } } + .frame(width: 75) + .padding(.leading, orientation == .landscapeRight && geometry.safeAreaInsets.bottom > 0 ? -geometry.safeAreaInsets.leading : 0) } - .frame(width: 60) - .padding(.leading, orientation == .landscapeRight && geometry.safeAreaInsets.bottom > 0 ? -geometry.safeAreaInsets.leading : 0) - .background(Color.gray100) VStack { if self.index == 0 { - ContactsView() + ContactsView(contactViewModel: contactViewModel, historyViewModel: historyViewModel) } else if self.index == 1 { HistoryView() } } - .frame(maxWidth: .infinity) + .frame(maxWidth: + (orientation == .landscapeLeft || orientation == .landscapeRight || UIScreen.main.bounds.size.width > UIScreen.main.bounds.size.height) + ? geometry.size.width/100*40 + : .infinity + ) + + if orientation == .landscapeLeft || orientation == .landscapeRight || UIScreen.main.bounds.size.width > UIScreen.main.bounds.size.height { + Spacer() + } } - } else { - VStack(spacing: 0) { - VStack { - if self.index == 0 { - ContactsView() - } else if self.index == 1 { - HistoryView() - } - } - .frame(maxWidth: .infinity) - + .shadow(color: Color.gray200, radius: 2) + + if !(orientation == .landscapeLeft || orientation == .landscapeRight || UIScreen.main.bounds.size.width > UIScreen.main.bounds.size.height) { HStack { Group { Spacer() @@ -134,6 +138,7 @@ struct ContentView: View { Button(action: { self.index = 1 + contactViewModel.contactTitle = "" }, label: { VStack { Image("phone") @@ -151,44 +156,48 @@ struct ContentView: View { } }) .padding(.top) - Spacer() } } - .background(Color.gray100) + .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.contactTitle.isEmpty || !historyViewModel.historyTitle.isEmpty { + 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) + .frame(maxWidth: .infinity) + .background(Color.gray100) + } else if self.index == 1 { + HistoryContactFragment() + .frame(maxWidth: .infinity) + .background(Color.gray100) + } + } + .padding(.leading, orientation == .landscapeRight && geometry.safeAreaInsets.bottom > 0 ? -geometry.safeAreaInsets.leading : 0) + .transition(.move(edge: .trailing)) + } } - .onRotate { newOrientation in - orientation = newOrientation - } + } + .onRotate { newOrientation in + orientation = newOrientation } } } } -struct DeviceRotationViewModifier: ViewModifier { - 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 { - action(UIDevice.current.orientation) - } - } - } -} - -extension View { - func onRotate(perform action: @escaping (UIDeviceOrientation) -> Void) -> some View { - self.modifier(DeviceRotationViewModifier(action: action)) - } -} - #Preview { - ContentView(sharedMainViewModel: SharedMainViewModel()) + ContentView(sharedMainViewModel: SharedMainViewModel(), contactViewModel: ContactViewModel(), historyViewModel: HistoryViewModel()) } 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/History/Fragments/HistoryContactFragment.swift b/Linphone/UI/Main/History/Fragments/HistoryContactFragment.swift new file mode 100644 index 000000000..ef7dc3419 --- /dev/null +++ b/Linphone/UI/Main/History/Fragments/HistoryContactFragment.swift @@ -0,0 +1,34 @@ +/* + * 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 HistoryContactFragment: View { + var body: some View { + VStack { + Spacer() + Text("History Contact fragment") + Spacer() + } + } +} + +#Preview { + HistoryContactFragment() +} diff --git a/Linphone/UI/Main/History/HistoryView.swift b/Linphone/UI/Main/History/HistoryView.swift index de3a16833..e2510ed9b 100644 --- a/Linphone/UI/Main/History/HistoryView.swift +++ b/Linphone/UI/Main/History/HistoryView.swift @@ -21,16 +21,55 @@ import SwiftUI struct HistoryView: View { - @ObservedObject private var coreContext = CoreContext.shared - var body: some View { - VStack { - Spacer() - Image("linphone") - .padding(.bottom, 20) - Text("History View") - Spacer() + NavigationView { + VStack(spacing: 0) { + HStack { + Image("profile-image-example") + .resizable() + .frame(width: 40, height: 40) + .clipShape(Circle()) + + Text("Calls") + .default_text_style_white_800(styleSize: 20) + .padding(.leading, 10) + + Spacer() + + Button { + + } label: { + Image("search") + } + + Button { + + } label: { + Image("more") + } + .padding(.leading) + } + .frame(maxWidth: .infinity) + .frame(height: 50) + .padding(.horizontal) + .background(Color.orangeMain500) + + VStack { + Spacer() + Image("illus-belledonne1") + .resizable() + .scaledToFit() + .clipped() + .padding(.all) + Text("No calls for the moment...") + .default_text_style_800(styleSize: 16) + Spacer() + Spacer() + } + .padding(.all) + } } + .navigationViewStyle(.stack) } } diff --git a/Linphone/UI/Main/History/ViewModel/HistoryViewModel.swift b/Linphone/UI/Main/History/ViewModel/HistoryViewModel.swift new file mode 100644 index 000000000..3222b6a0f --- /dev/null +++ b/Linphone/UI/Main/History/ViewModel/HistoryViewModel.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 + +class HistoryViewModel: ObservableObject { + + @Published var historyTitle: String = "" + + init() {} +} From 146682e5555d9418799a04b67a9406ab68cd0218 Mon Sep 17 00:00:00 2001 From: "benoit.martins" Date: Wed, 18 Oct 2023 16:48:25 +0200 Subject: [PATCH 023/486] Add side menu --- Linphone.xcodeproj/project.pbxproj | 4 + Linphone/Localizable.xcstrings | 9 + Linphone/UI/Main/Contacts/ContactsView.swift | 30 -- Linphone/UI/Main/ContentView.swift | 422 +++++++++++-------- Linphone/UI/Main/Fragments/SideMenu.swift | 63 +++ Linphone/UI/Main/History/HistoryView.swift | 30 -- 6 files changed, 321 insertions(+), 237 deletions(-) create mode 100644 Linphone/UI/Main/Fragments/SideMenu.swift diff --git a/Linphone.xcodeproj/project.pbxproj b/Linphone.xcodeproj/project.pbxproj index 8dd980a3b..3e27fc401 100644 --- a/Linphone.xcodeproj/project.pbxproj +++ b/Linphone.xcodeproj/project.pbxproj @@ -19,6 +19,7 @@ D719ABCC2ABC769C00B41C10 /* AssistantView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D719ABCB2ABC769C00B41C10 /* AssistantView.swift */; }; D719ABCF2ABC779A00B41C10 /* AccountLoginViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D719ABCE2ABC779A00B41C10 /* AccountLoginViewModel.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 */; }; @@ -69,6 +70,7 @@ D719ABCB2ABC769C00B41C10 /* AssistantView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AssistantView.swift; sourceTree = ""; }; D719ABCE2ABC779A00B41C10 /* AccountLoginViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountLoginViewModel.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 = ""; }; @@ -269,6 +271,7 @@ D72343352AD037AF009AA24E /* ToastView.swift */, D750D3382AD3E6EE00EC99C5 /* PopupLoadingView.swift */, D706BA812ADD72D100278F45 /* DeviceRotationViewModifier.swift */, + D72250682ADFBF2D008FB426 /* SideMenu.swift */, ); path = Fragments; sourceTree = ""; @@ -519,6 +522,7 @@ D72343322ACEFF58009AA24E /* QRScannerController.swift in Sources */, D72343342ACEFFC3009AA24E /* QRScanner.swift in Sources */, D72343302ACEFEF8009AA24E /* QrCodeScannerFragment.swift in Sources */, + D72250692ADFBF2D008FB426 /* SideMenu.swift in Sources */, D717071E2AC5922E0037746F /* ColorExtension.swift in Sources */, D78290B82ADD3910004AA85C /* ContactFragment.swift in Sources */, D7DA67642ACCB31700E95002 /* ProfileModeFragment.swift in Sources */, diff --git a/Linphone/Localizable.xcstrings b/Linphone/Localizable.xcstrings index 7d9056b79..8bd88f0a8 100644 --- a/Linphone/Localizable.xcstrings +++ b/Linphone/Localizable.xcstrings @@ -187,6 +187,12 @@ }, "Log out" : { + }, + "Logout" : { + + }, + "My Profile" : { + }, "Next" : { @@ -228,6 +234,9 @@ }, "Plus tard" : { + }, + "Posts" : { + }, "Pour vous permettre de vous profitez pleinement de Linphone nous avons besoin des autorisations suivantes :" : { diff --git a/Linphone/UI/Main/Contacts/ContactsView.swift b/Linphone/UI/Main/Contacts/ContactsView.swift index 8b44da54f..cecad9f2a 100644 --- a/Linphone/UI/Main/Contacts/ContactsView.swift +++ b/Linphone/UI/Main/Contacts/ContactsView.swift @@ -36,36 +36,6 @@ struct ContactsView: View { NavigationView { ZStack(alignment: .bottomTrailing) { VStack(spacing: 0) { - HStack { - Image("profile-image-example") - .resizable() - .frame(width: 40, height: 40) - .clipShape(Circle()) - - Text("Contacts") - .default_text_style_white_800(styleSize: 20) - .padding(.leading, 10) - - Spacer() - - Button { - - } label: { - Image("search") - } - - Button { - - } label: { - Image("filtres") - } - .padding(.leading) - } - .frame(maxWidth: .infinity) - .frame(height: 50) - .padding(.horizontal) - .background(Color.orangeMain500) - VStack { List { ForEach(objects, id: \.self) { index in diff --git a/Linphone/UI/Main/ContentView.swift b/Linphone/UI/Main/ContentView.swift index 97991cd9d..32bc8f301 100644 --- a/Linphone/UI/Main/ContentView.swift +++ b/Linphone/UI/Main/ContentView.swift @@ -20,184 +20,252 @@ import SwiftUI struct ContentView: View { - - @ObservedObject var sharedMainViewModel: SharedMainViewModel - @ObservedObject var contactViewModel: ContactViewModel - @ObservedObject var historyViewModel: HistoryViewModel - @ObservedObject private var coreContext = CoreContext.shared - - @State var index = 0 - @State private var orientation = UIDevice.current.orientation - - var body: some View { - if !sharedMainViewModel.welcomeViewDisplayed { - WelcomeView(sharedMainViewModel: sharedMainViewModel) - } else if coreContext.mCore.defaultAccount == nil || sharedMainViewModel.displayProfileMode { - AssistantView(sharedMainViewModel: sharedMainViewModel) - } else { - GeometryReader { geometry in - ZStack { - VStack(spacing: 0) { - HStack(spacing: 0) { - if orientation == .landscapeLeft || orientation == .landscapeRight || UIScreen.main.bounds.size.width > UIScreen.main.bounds.size.height { - VStack { - Group { - Spacer() - Button(action: { - self.index = 0 - }, 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) - } - } - }) - - Spacer() - - Button(action: { - self.index = 1 - contactViewModel.contactTitle = "" - }, 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) - } - } - }) - - Spacer() - } - } - .frame(width: 75) - .padding(.leading, orientation == .landscapeRight && geometry.safeAreaInsets.bottom > 0 ? -geometry.safeAreaInsets.leading : 0) - } - - VStack { - if self.index == 0 { - ContactsView(contactViewModel: contactViewModel, historyViewModel: historyViewModel) - } else if self.index == 1 { - HistoryView() - } - } - .frame(maxWidth: - (orientation == .landscapeLeft || orientation == .landscapeRight || UIScreen.main.bounds.size.width > UIScreen.main.bounds.size.height) - ? geometry.size.width/100*40 - : .infinity - ) - - if orientation == .landscapeLeft || orientation == .landscapeRight || UIScreen.main.bounds.size.width > UIScreen.main.bounds.size.height { - Spacer() - } - } - .shadow(color: Color.gray200, radius: 2) - - if !(orientation == .landscapeLeft || orientation == .landscapeRight || UIScreen.main.bounds.size.width > UIScreen.main.bounds.size.height) { - HStack { - Group { - Spacer() - Button(action: { - self.index = 0 - }, 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) - - Spacer() - - Button(action: { - self.index = 1 - contactViewModel.contactTitle = "" - }, 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) - 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.contactTitle.isEmpty || !historyViewModel.historyTitle.isEmpty { - 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) - .frame(maxWidth: .infinity) - .background(Color.gray100) - } else if self.index == 1 { - HistoryContactFragment() - .frame(maxWidth: .infinity) - .background(Color.gray100) - } - } - .padding(.leading, orientation == .landscapeRight && geometry.safeAreaInsets.bottom > 0 ? -geometry.safeAreaInsets.leading : 0) - .transition(.move(edge: .trailing)) - } - } - } - .onRotate { newOrientation in - orientation = newOrientation - } - } - } + + @ObservedObject var sharedMainViewModel: SharedMainViewModel + @ObservedObject var contactViewModel: ContactViewModel + @ObservedObject var historyViewModel: HistoryViewModel + @ObservedObject private var coreContext = CoreContext.shared + + @State var index = 0 + @State private var orientation = UIDevice.current.orientation + @State var menuOpen: Bool = false + + var body: some View { + if !sharedMainViewModel.welcomeViewDisplayed { + WelcomeView(sharedMainViewModel: sharedMainViewModel) + } else if coreContext.mCore.defaultAccount == nil || sharedMainViewModel.displayProfileMode { + AssistantView(sharedMainViewModel: sharedMainViewModel) + } else { + GeometryReader { geometry in + ZStack { + VStack(spacing: 0) { + HStack(spacing: 0) { + if orientation == .landscapeLeft + || orientation == .landscapeRight + || UIScreen.main.bounds.size.width > UIScreen.main.bounds.size.height { + VStack { + Group { + Spacer() + Button(action: { + self.index = 0 + }, 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) + } + } + }) + + Spacer() + + Button(action: { + self.index = 1 + contactViewModel.contactTitle = "" + }, 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) + } + } + }) + + Spacer() + } + } + .frame(width: 75) + .padding(.leading, + orientation == .landscapeRight && geometry.safeAreaInsets.bottom > 0 + ? -geometry.safeAreaInsets.leading + : 0) + } + + VStack(spacing: 0) { + HStack { + Image("profile-image-example") + .resizable() + .frame(width: 40, height: 40) + .clipShape(Circle()) + .onTapGesture { + openMenu() + } + + Text(index == 0 ? "Contacts" : "Calls") + .default_text_style_white_800(styleSize: 20) + .padding(.leading, 10) + + Spacer() + + Button { + + } label: { + Image("search") + } + + Button { + + } label: { + Image(index == 0 ? "filtres" : "more") + } + .padding(.leading) + } + .frame(maxWidth: .infinity) + .frame(height: 50) + .padding(.horizontal) + .background(Color.orangeMain500) + + if self.index == 0 { + ContactsView(contactViewModel: contactViewModel, historyViewModel: historyViewModel) + } else if self.index == 1 { + HistoryView() + } + } + .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) { + HStack { + Group { + Spacer() + Button(action: { + self.index = 0 + }, 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) + + Spacer() + + Button(action: { + self.index = 1 + contactViewModel.contactTitle = "" + }, 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) + 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.contactTitle.isEmpty || !historyViewModel.historyTitle.isEmpty { + 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) + .frame(maxWidth: .infinity) + .background(Color.gray100) + } else if self.index == 1 { + HistoryContactFragment() + .frame(maxWidth: .infinity) + .background(Color.gray100) + } + } + .padding(.leading, + orientation == .landscapeRight && geometry.safeAreaInsets.bottom > 0 + ? -geometry.safeAreaInsets.leading + : 0) + .transition(.move(edge: .trailing)) + } + + SideMenu( + width: geometry.size.width / 5 * 4, + isOpen: self.menuOpen, + menuClose: self.openMenu, + safeAreaInsets: geometry.safeAreaInsets + ) + .ignoresSafeArea(.all) + } + } + .onRotate { newOrientation in + orientation = newOrientation + } + } + } + + func openMenu() { + withAnimation { + self.menuOpen.toggle() + } + } } #Preview { - ContentView(sharedMainViewModel: SharedMainViewModel(), contactViewModel: ContactViewModel(), historyViewModel: HistoryViewModel()) + ContentView(sharedMainViewModel: SharedMainViewModel(), contactViewModel: ContactViewModel(), historyViewModel: HistoryViewModel()) } diff --git a/Linphone/UI/Main/Fragments/SideMenu.swift b/Linphone/UI/Main/Fragments/SideMenu.swift new file mode 100644 index 000000000..317490553 --- /dev/null +++ b/Linphone/UI/Main/Fragments/SideMenu.swift @@ -0,0 +1,63 @@ +/* + * 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 SideMenu: View { + let width: CGFloat + let isOpen: Bool + let menuClose: () -> Void + let safeAreaInsets: EdgeInsets + + var body: some View { + ZStack { + GeometryReader { _ in + EmptyView() + } + .background(Color.gray.opacity(0.3)) + .opacity(self.isOpen ? 1.0 : 0.0) + .onTapGesture { + self.menuClose() + } + + HStack { + List { + Text("My Profile").onTapGesture { + print("My Profile") + } + Text("Posts").onTapGesture { + print("Posts") + } + Text("Logout").onTapGesture { + print("Logout") + } + } + .frame(width: self.width - safeAreaInsets.leading) + .background(Color.white) + .offset(x: self.isOpen ? 0 : -self.width) + + Spacer() + } + .padding(.leading, safeAreaInsets.leading) + .padding(.top, safeAreaInsets.top) + .padding(.bottom, safeAreaInsets.bottom) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } +} diff --git a/Linphone/UI/Main/History/HistoryView.swift b/Linphone/UI/Main/History/HistoryView.swift index e2510ed9b..c876cf67e 100644 --- a/Linphone/UI/Main/History/HistoryView.swift +++ b/Linphone/UI/Main/History/HistoryView.swift @@ -24,36 +24,6 @@ struct HistoryView: View { var body: some View { NavigationView { VStack(spacing: 0) { - HStack { - Image("profile-image-example") - .resizable() - .frame(width: 40, height: 40) - .clipShape(Circle()) - - Text("Calls") - .default_text_style_white_800(styleSize: 20) - .padding(.leading, 10) - - Spacer() - - Button { - - } label: { - Image("search") - } - - Button { - - } label: { - Image("more") - } - .padding(.leading) - } - .frame(maxWidth: .infinity) - .frame(height: 50) - .padding(.horizontal) - .background(Color.orangeMain500) - VStack { Spacer() Image("illus-belledonne1") From 9ef96bbd780aef4eae03d13e4741af9a12f6bbfc Mon Sep 17 00:00:00 2001 From: "benoit.martins" Date: Thu, 19 Oct 2023 17:25:50 +0200 Subject: [PATCH 024/486] Add contacts list --- Linphone.xcodeproj/project.pbxproj | 114 ++-- .../xcshareddata/xcschemes/Linphone.xcscheme | 77 +++ .../caret-up.imageset/Contents.json | 21 + .../caret-up.imageset/caret-up.svg | 1 + .../check.imageset/Contents.json | 21 + .../Assets.xcassets/check.imageset/check.svg | 1 + .../green-check.imageset/Contents.json | 21 + .../green-check.imageset/green-check.svg | 3 + .../Assets.xcassets/x.imageset/Contents.json | 21 + Linphone/Assets.xcassets/x.imageset/x.svg | 1 + Linphone/Contacts/ContactsManager.swift | 250 +++++++ Linphone/Core/CoreContext.swift | 3 + Linphone/LinphoneApp.swift | 2 +- Linphone/Localizable.xcstrings | 15 +- .../Fragments/PermissionsFragment.swift | 1 + Linphone/UI/Main/Contacts/ContactsView.swift | 52 +- .../Contacts/Fragments/ContactFragment.swift | 88 ++- .../Contacts/Fragments/ContactsFragment.swift | 80 +++ .../Fragments/ContactsListFragment.swift | 115 ++++ .../FavoriteContactsListFragment.swift | 80 +++ .../Contacts/ViewModel/ContactViewModel.swift | 6 +- .../ViewModel/ContactsListViewModel.swift | 31 + .../FavoriteContactsListViewModel.swift | 25 + Linphone/UI/Main/ContentView.swift | 637 +++++++++++------- Linphone/Utils/MagicSearchSingleton.swift | 70 ++ Linphone/Utils/PermissionManager.swift | 11 + Linphone/Utils/TextExtension.swift | 5 + 27 files changed, 1376 insertions(+), 376 deletions(-) create mode 100644 Linphone.xcodeproj/xcshareddata/xcschemes/Linphone.xcscheme create mode 100644 Linphone/Assets.xcassets/caret-up.imageset/Contents.json create mode 100644 Linphone/Assets.xcassets/caret-up.imageset/caret-up.svg create mode 100644 Linphone/Assets.xcassets/check.imageset/Contents.json create mode 100644 Linphone/Assets.xcassets/check.imageset/check.svg create mode 100644 Linphone/Assets.xcassets/green-check.imageset/Contents.json create mode 100644 Linphone/Assets.xcassets/green-check.imageset/green-check.svg create mode 100644 Linphone/Assets.xcassets/x.imageset/Contents.json create mode 100644 Linphone/Assets.xcassets/x.imageset/x.svg create mode 100644 Linphone/Contacts/ContactsManager.swift create mode 100644 Linphone/UI/Main/Contacts/Fragments/ContactsFragment.swift create mode 100644 Linphone/UI/Main/Contacts/Fragments/ContactsListFragment.swift create mode 100644 Linphone/UI/Main/Contacts/Fragments/FavoriteContactsListFragment.swift create mode 100644 Linphone/UI/Main/Contacts/ViewModel/ContactsListViewModel.swift create mode 100644 Linphone/UI/Main/Contacts/ViewModel/FavoriteContactsListViewModel.swift create mode 100644 Linphone/Utils/MagicSearchSingleton.swift diff --git a/Linphone.xcodeproj/project.pbxproj b/Linphone.xcodeproj/project.pbxproj index 3e27fc401..1ab50b742 100644 --- a/Linphone.xcodeproj/project.pbxproj +++ b/Linphone.xcodeproj/project.pbxproj @@ -7,6 +7,7 @@ objects = { /* Begin PBXBuildFile section */ + 886500223A8E518D3EE5FCB7 /* Pods_Linphone.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 1A334B8FDAD2893691A734BE /* Pods_Linphone.framework */; }; D706BA822ADD72D100278F45 /* DeviceRotationViewModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = D706BA812ADD72D100278F45 /* DeviceRotationViewModifier.swift */; }; D70C93DE2AC2D0F60063CA3B /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = D70C93DD2AC2D0F60063CA3B /* Localizable.xcstrings */; }; D717071E2AC5922E0037746F /* ColorExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = D717071D2AC5922E0037746F /* ColorExtension.swift */; }; @@ -18,6 +19,9 @@ 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 */; }; + 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 */; }; 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 */; }; @@ -34,12 +38,14 @@ D74C9D012ACB098C0021626A /* PermissionManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = D74C9D002ACB098C0021626A /* PermissionManager.swift */; }; D750D3392AD3E6EE00EC99C5 /* PopupLoadingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D750D3382AD3E6EE00EC99C5 /* PopupLoadingView.swift */; }; D7702EF22AC7205000557C00 /* WelcomeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7702EF12AC7205000557C00 /* WelcomeView.swift */; }; - D78290B82ADD3910004AA85C /* ContactFragment.swift in Sources */ = {isa = PBXBuildFile; fileRef = D78290B72ADD3910004AA85C /* ContactFragment.swift */; }; + D777DBB32AE12C5900565A99 /* ContactsManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = D777DBB22AE12C5900565A99 /* ContactsManager.swift */; }; + D78290B82ADD3910004AA85C /* ContactsFragment.swift in Sources */ = {isa = PBXBuildFile; fileRef = D78290B72ADD3910004AA85C /* ContactsFragment.swift */; }; D78290BB2ADD40B2004AA85C /* ContactViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D78290BA2ADD40B2004AA85C /* ContactViewModel.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 */; }; D7A2EDD62AC18115005D90FC /* SharedMainViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7A2EDD52AC18115005D90FC /* SharedMainViewModel.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 */; }; @@ -48,14 +54,15 @@ 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 */; }; + D7E6D0492AE933AD00A57AAF /* FavoriteContactsListFragment.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7E6D0482AE933AD00A57AAF /* FavoriteContactsListFragment.swift */; }; + D7E6D04B2AE9347D00A57AAF /* FavoriteContactsListViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7E6D04A2AE9347D00A57AAF /* FavoriteContactsListViewModel.swift */; }; D7EAACCF2AD6ED8000AA6A8A /* PermissionsFragment.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7EAACCE2AD6ED8000AA6A8A /* PermissionsFragment.swift */; }; D7FB55112AD447FD00A5AB15 /* RegisterFragment.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7FB55102AD447FD00A5AB15 /* RegisterFragment.swift */; }; - F4BB8DFBA0FF08430EBA9351 /* Pods_Linphone.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 6F5B27C5576B1EAED2F205EB /* Pods_Linphone.framework */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ - 377E0B5C2B1F38192E694334 /* Pods-Linphone.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Linphone.debug.xcconfig"; path = "Target Support Files/Pods-Linphone/Pods-Linphone.debug.xcconfig"; sourceTree = ""; }; - 6F5B27C5576B1EAED2F205EB /* Pods_Linphone.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Linphone.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 1A334B8FDAD2893691A734BE /* Pods_Linphone.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Linphone.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 1DE4CD5FD6E1F01639F27E3B /* Pods-Linphone.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Linphone.release.xcconfig"; path = "Target Support Files/Pods-Linphone/Pods-Linphone.release.xcconfig"; sourceTree = ""; }; D706BA812ADD72D100278F45 /* DeviceRotationViewModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceRotationViewModifier.swift; sourceTree = ""; }; D70C93DD2AC2D0F60063CA3B /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; sourceTree = ""; }; D717071D2AC5922E0037746F /* ColorExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ColorExtension.swift; sourceTree = ""; }; @@ -69,6 +76,9 @@ 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 = ""; }; + 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 = ""; }; 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 = ""; }; @@ -85,13 +95,15 @@ D74C9D002ACB098C0021626A /* PermissionManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PermissionManager.swift; sourceTree = ""; }; D750D3382AD3E6EE00EC99C5 /* PopupLoadingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PopupLoadingView.swift; sourceTree = ""; }; D7702EF12AC7205000557C00 /* WelcomeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WelcomeView.swift; sourceTree = ""; }; - D78290B72ADD3910004AA85C /* ContactFragment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactFragment.swift; sourceTree = ""; }; + D777DBB22AE12C5900565A99 /* ContactsManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactsManager.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 = ""; }; 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 = ""; }; D7A2EDD52AC18115005D90FC /* SharedMainViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SharedMainViewModel.swift; sourceTree = ""; }; D7A2EDDA2AC19EEC005D90FC /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; 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 = ""; }; @@ -100,9 +112,11 @@ 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 = ""; }; + D7E6D0482AE933AD00A57AAF /* FavoriteContactsListFragment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FavoriteContactsListFragment.swift; sourceTree = ""; }; + D7E6D04A2AE9347D00A57AAF /* FavoriteContactsListViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FavoriteContactsListViewModel.swift; sourceTree = ""; }; D7EAACCE2AD6ED8000AA6A8A /* PermissionsFragment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PermissionsFragment.swift; sourceTree = ""; }; D7FB55102AD447FD00A5AB15 /* RegisterFragment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RegisterFragment.swift; sourceTree = ""; }; - F76FB87556A3109F61F9E2D5 /* Pods-Linphone.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Linphone.release.xcconfig"; path = "Target Support Files/Pods-Linphone/Pods-Linphone.release.xcconfig"; sourceTree = ""; }; + FB718F405DAF7B9993AEB878 /* Pods-Linphone.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Linphone.debug.xcconfig"; path = "Target Support Files/Pods-Linphone/Pods-Linphone.debug.xcconfig"; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -110,17 +124,17 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - F4BB8DFBA0FF08430EBA9351 /* Pods_Linphone.framework in Frameworks */, + 886500223A8E518D3EE5FCB7 /* Pods_Linphone.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ - 52EFCC310713B3CA01062945 /* Frameworks */ = { + 1CD95087B17CAD149119B7C2 /* Frameworks */ = { isa = PBXGroup; children = ( - 6F5B27C5576B1EAED2F205EB /* Pods_Linphone.framework */, + 1A334B8FDAD2893691A734BE /* Pods_Linphone.framework */, ); name = Frameworks; sourceTree = ""; @@ -128,8 +142,8 @@ A31AF2AB8C6A3D7B7EA3B424 /* Pods */ = { isa = PBXGroup; children = ( - 377E0B5C2B1F38192E694334 /* Pods-Linphone.debug.xcconfig */, - F76FB87556A3109F61F9E2D5 /* Pods-Linphone.release.xcconfig */, + FB718F405DAF7B9993AEB878 /* Pods-Linphone.debug.xcconfig */, + 1DE4CD5FD6E1F01639F27E3B /* Pods-Linphone.release.xcconfig */, ); path = Pods; sourceTree = ""; @@ -140,6 +154,7 @@ D717071D2AC5922E0037746F /* ColorExtension.swift */, D717071F2AC5989C0037746F /* TextExtension.swift */, D74C9D002ACB098C0021626A /* PermissionManager.swift */, + D7D1698B2AE66FA500109A5C /* MagicSearchSingleton.swift */, ); path = Utils; sourceTree = ""; @@ -150,7 +165,7 @@ D719ABB52ABC67BF00B41C10 /* Linphone */, D719ABB42ABC67BF00B41C10 /* Products */, A31AF2AB8C6A3D7B7EA3B424 /* Pods */, - 52EFCC310713B3CA01062945 /* Frameworks */, + 1CD95087B17CAD149119B7C2 /* Frameworks */, ); sourceTree = ""; }; @@ -167,6 +182,7 @@ children = ( D7A03FC52ACC458A0081A588 /* SplashScreen.swift */, D719ABB62ABC67BF00B41C10 /* LinphoneApp.swift */, + D777DBB12AE12C4000565A99 /* Contacts */, D719ABC72ABC6FB200B41C10 /* Core */, D719ABC52ABC6EE800B41C10 /* UI */, D717071C2AC591EF0037746F /* Utils */, @@ -285,10 +301,21 @@ path = Welcome; sourceTree = ""; }; + D777DBB12AE12C4000565A99 /* Contacts */ = { + isa = PBXGroup; + children = ( + D777DBB22AE12C5900565A99 /* ContactsManager.swift */, + ); + path = Contacts; + sourceTree = ""; + }; D78290B62ADD38F9004AA85C /* Fragments */ = { isa = PBXGroup; children = ( - D78290B72ADD3910004AA85C /* ContactFragment.swift */, + D78290B72ADD3910004AA85C /* ContactsFragment.swift */, + D71FCA802AE14CFC00D2E43E /* ContactsListFragment.swift */, + D71FCA822AE14D6E00D2E43E /* ContactFragment.swift */, + D7E6D0482AE933AD00A57AAF /* FavoriteContactsListFragment.swift */, ); path = Fragments; sourceTree = ""; @@ -297,6 +324,8 @@ isa = PBXGroup; children = ( D78290BA2ADD40B2004AA85C /* ContactViewModel.swift */, + D71FCA7E2AE1397200D2E43E /* ContactsListViewModel.swift */, + D7E6D04A2AE9347D00A57AAF /* FavoriteContactsListViewModel.swift */, ); path = ViewModel; sourceTree = ""; @@ -363,12 +392,12 @@ isa = PBXNativeTarget; buildConfigurationList = D719ABC22ABC67BF00B41C10 /* Build configuration list for PBXNativeTarget "Linphone" */; buildPhases = ( - 6FE8573A5CFC1DA89D3172B5 /* [CP] Check Pods Manifest.lock */, + BE9432280D0A11AA770A50FD /* [CP] Check Pods Manifest.lock */, D719ABAF2ABC67BF00B41C10 /* Sources */, D719ABB02ABC67BF00B41C10 /* Frameworks */, D719ABB12ABC67BF00B41C10 /* Resources */, D7FB55122AD53FE200A5AB15 /* Run Script */, - 230129DD87A6EBB04DF458AD /* [CP] Embed Pods Frameworks */, + D5CA1ECD620857DB91E334A5 /* [CP] Embed Pods Frameworks */, ); buildRules = ( ); @@ -432,24 +461,7 @@ /* End PBXResourcesBuildPhase section */ /* Begin PBXShellScriptBuildPhase section */ - 230129DD87A6EBB04DF458AD /* [CP] Embed Pods Frameworks */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - inputFileListPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-Linphone/Pods-Linphone-frameworks-${CONFIGURATION}-input-files.xcfilelist", - ); - name = "[CP] Embed Pods Frameworks"; - outputFileListPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-Linphone/Pods-Linphone-frameworks-${CONFIGURATION}-output-files.xcfilelist", - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Linphone/Pods-Linphone-frameworks.sh\"\n"; - showEnvVarsInLog = 0; - }; - 6FE8573A5CFC1DA89D3172B5 /* [CP] Check Pods Manifest.lock */ = { + BE9432280D0A11AA770A50FD /* [CP] Check Pods Manifest.lock */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; files = ( @@ -471,6 +483,23 @@ shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; showEnvVarsInLog = 0; }; + D5CA1ECD620857DB91E334A5 /* [CP] Embed Pods Frameworks */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Linphone/Pods-Linphone-frameworks-${CONFIGURATION}-input-files.xcfilelist", + ); + name = "[CP] Embed Pods Frameworks"; + outputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Linphone/Pods-Linphone-frameworks-${CONFIGURATION}-output-files.xcfilelist", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Linphone/Pods-Linphone-frameworks.sh\"\n"; + showEnvVarsInLog = 0; + }; D7FB55122AD53FE200A5AB15 /* Run Script */ = { isa = PBXShellScriptBuildPhase; alwaysOutOfDate = 1; @@ -499,16 +528,21 @@ files = ( D71707202AC5989C0037746F /* TextExtension.swift in Sources */, D719ABB92ABC67BF00B41C10 /* ContentView.swift in Sources */, + D71FCA832AE14D6E00D2E43E /* ContactFragment.swift in Sources */, D750D3392AD3E6EE00EC99C5 /* PopupLoadingView.swift in Sources */, + D7E6D0492AE933AD00A57AAF /* FavoriteContactsListFragment.swift in Sources */, D706BA822ADD72D100278F45 /* DeviceRotationViewModifier.swift in Sources */, D719ABC92ABC6FD700B41C10 /* CoreContext.swift in Sources */, D7EAACCF2AD6ED8000AA6A8A /* PermissionsFragment.swift in Sources */, + D777DBB32AE12C5900565A99 /* ContactsManager.swift in Sources */, D7A03FBD2ACC2DB60081A588 /* ContactsView.swift in Sources */, D719ABCF2ABC779A00B41C10 /* AccountLoginViewModel.swift in Sources */, D78290BB2ADD40B2004AA85C /* ContactViewModel.swift in Sources */, D72992392ADD7F68003AF125 /* HistoryContactFragment.swift in Sources */, D74C9D012ACB098C0021626A /* PermissionManager.swift in Sources */, D7702EF22AC7205000557C00 /* WelcomeView.swift in Sources */, + D71FCA7F2AE1397200D2E43E /* ContactsListViewModel.swift in Sources */, + D71FCA812AE14CFC00D2E43E /* ContactsListFragment.swift in Sources */, D719ABB72ABC67BF00B41C10 /* LinphoneApp.swift in Sources */, D72250632ADE9615008FB426 /* HistoryViewModel.swift in Sources */, D7A2EDD62AC18115005D90FC /* SharedMainViewModel.swift in Sources */, @@ -522,12 +556,14 @@ D72343322ACEFF58009AA24E /* QRScannerController.swift in Sources */, D72343342ACEFFC3009AA24E /* QRScanner.swift in Sources */, D72343302ACEFEF8009AA24E /* QrCodeScannerFragment.swift in Sources */, + D7D1698C2AE66FA500109A5C /* MagicSearchSingleton.swift in Sources */, D72250692ADFBF2D008FB426 /* SideMenu.swift in Sources */, D717071E2AC5922E0037746F /* ColorExtension.swift in Sources */, - D78290B82ADD3910004AA85C /* ContactFragment.swift in Sources */, + D78290B82ADD3910004AA85C /* ContactsFragment.swift in Sources */, D7DA67642ACCB31700E95002 /* ProfileModeFragment.swift in Sources */, D74C9CFC2ACACF370021626A /* WelcomePage3Fragment.swift in Sources */, D719ABCC2ABC769C00B41C10 /* AssistantView.swift in Sources */, + D7E6D04B2AE9347D00A57AAF /* FavoriteContactsListViewModel.swift in Sources */, D74C9CFA2ACACF2D0021626A /* WelcomePage2Fragment.swift in Sources */, D74C9CFF2ACAEC5E0021626A /* PopupView.swift in Sources */, D7DA67622ACCB2FA00E95002 /* LoginFragment.swift in Sources */, @@ -652,7 +688,7 @@ }; D719ABC32ABC67BF00B41C10 /* Debug */ = { isa = XCBuildConfiguration; - baseConfigurationReference = 377E0B5C2B1F38192E694334 /* Pods-Linphone.debug.xcconfig */; + baseConfigurationReference = FB718F405DAF7B9993AEB878 /* Pods-Linphone.debug.xcconfig */; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; @@ -668,7 +704,8 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = Linphone/Info.plist; INFOPLIST_KEY_NSCameraUsageDescription = "Share photos with your friends and customize avatars"; - INFOPLIST_KEY_NSPhotoLibraryUsageDescription = ""; + INFOPLIST_KEY_NSContactsUsageDescription = "Make calls with your friends"; + INFOPLIST_KEY_NSPhotoLibraryUsageDescription = "Share photos with your friends and customize avatars"; "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES; "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES; "INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES; @@ -696,7 +733,7 @@ }; D719ABC42ABC67BF00B41C10 /* Release */ = { isa = XCBuildConfiguration; - baseConfigurationReference = F76FB87556A3109F61F9E2D5 /* Pods-Linphone.release.xcconfig */; + baseConfigurationReference = 1DE4CD5FD6E1F01639F27E3B /* Pods-Linphone.release.xcconfig */; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; @@ -712,7 +749,8 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = Linphone/Info.plist; INFOPLIST_KEY_NSCameraUsageDescription = "Share photos with your friends and customize avatars"; - INFOPLIST_KEY_NSPhotoLibraryUsageDescription = ""; + INFOPLIST_KEY_NSContactsUsageDescription = "Make calls with your friends"; + INFOPLIST_KEY_NSPhotoLibraryUsageDescription = "Share photos with your friends and customize avatars"; "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES; "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES; "INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES; 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/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..dacc592b1 --- /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/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..a8d374215 --- /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/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/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..707720548 --- /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..5de3eb683 --- /dev/null +++ b/Linphone/Contacts/ContactsManager.swift @@ -0,0 +1,250 @@ +/* + * 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 Contacts +import SwiftUI + +final class ContactsManager: ObservableObject { + + static let shared = ContactsManager() + + private var coreContext = CoreContext.shared + + @Published var contacts: [Contact] = [] + + private let nativeAddressBookFriendList = "Native address-book" + let linphoneAddressBookFirendList = "Linphone address-book" + + private init() { + fetchContacts() + } + + func fetchContacts() { + contacts.removeAll() + DispatchQueue.global().async { + let store = CNContactStore() + store.requestAccess(for: .contacts) { (granted, error) in + if let error = error { + print("failed to request access", error) + return + } + if granted { + let keys = [CNContactEmailAddressesKey, CNContactPhoneNumbersKey, + CNContactFamilyNameKey, CNContactGivenNameKey, CNContactNicknameKey, + CNContactPostalAddressesKey, CNContactIdentifierKey, + CNInstantMessageAddressUsernameKey, CNContactInstantMessageAddressesKey, + CNContactImageDataKey, CNContactThumbnailImageDataKey, CNContactOrganizationNameKey] + let request = CNContactFetchRequest(keysToFetch: keys as [CNKeyDescriptor]) + do { + try store.enumerateContacts(with: request, usingBlock: { (contact, _) in + + DispatchQueue.main.sync { + self.contacts.append( + Contact( + firstName: contact.givenName, + lastName: contact.familyName, + organizationName: contact.organizationName, + displayName: contact.nickname, + sipAddresses: contact.instantMessageAddresses.map { $0.value.service == "SIP" ? $0.value.username : "" }, + phoneNumbers: contact.phoneNumbers.map { PhoneNumber(numLabel: $0.label ?? "", num: $0.value.stringValue)}, + imageData: self.saveImage( + image: + UIImage(data: contact.thumbnailImageData ?? Data()) + ?? 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.identifier) + ) + ) + } + self.contacts.sort(by: { + $0.firstName.folding( + options: .diacriticInsensitive, locale: .current + ) < $1.firstName.folding( + options: .diacriticInsensitive, locale: .current + ) + }) + }) + + } catch let error { + print("Failed to enumerate contact", error) + } + + } else { + print("access denied") + } + } + + var friends: [Friend] = [] + + self.contacts.forEach { contact in + do { + let friend = try self.coreContext.mCore.createFriend() + friend.edit() + try friend.setName(newValue: contact.firstName + " " + contact.lastName) + friend.organization = contact.organizationName + + var friendAddresses: [Address] = [] + contact.sipAddresses.forEach { sipAddress in + let address = self.coreContext.mCore.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] = [] + contact.phoneNumbers.forEach { phone in + do { + if (friendPhoneNumbers.firstIndex(where: {$0.numLabel == phone.numLabel})) == nil { + let phoneNumber = try Factory.Instance.createFriendPhoneNumber(phoneNumber: phone.num, label: phone.numLabel) + friend.addPhoneNumberWithLabel(phoneNumber: phoneNumber) + friendPhoneNumbers.append(phone) + } + } catch let error { + print("Failed to enumerate contact", error) + } + } + + let contactImage = contact.imageData.dropFirst(8) + friend.photo = "file:/" + contactImage + + friend.done() + friends.append(friend) + + } catch let error { + print("Failed to enumerate contact", error) + } + } + + if self.coreContext.mCore.globalState == GlobalState.Shutdown || self.coreContext.mCore.globalState == GlobalState.Off { + print("$TAG Core is being stopped or already destroyed, abort") + } else if friends.isEmpty { + print("$TAG No friend created!") + } else { + print("$TAG ${friends.size} friends created") + + let fetchedFriends = friends + + let nativeFriendList = self.coreContext.mCore.getFriendListByName(name: self.nativeAddressBookFriendList) + var friendList = nativeFriendList + if friendList == nil { + do { + friendList = try self.coreContext.mCore.createFriendList() + } catch let error { + print("Failed to enumerate contact", error) + } + } + + if friendList!.displayName == nil || friendList!.displayName!.isEmpty { + print( + "$TAG Friend list [$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 + self.coreContext.mCore.addFriendList(list: friendList!) + } else { + print( + "$TAG Friend list [$LINPHONE_ADDRESS_BOOK_FRIEND_LIST] found, removing existing friends if any" + ) + friendList!.friends.forEach { friend in + _ = friendList!.removeFriend(linphoneFriend: friend) + } + } + + fetchedFriends.forEach { friend in + _ = friendList!.addLocalFriend(linphoneFriend: friend) + } + + friends.removeAll() + + print("$TAG Friends added") + + friendList!.updateSubscriptions() + print("$TAG Subscription(s) updated") + } + } + } + + func saveImage(image: UIImage, name: String) -> String { + guard let data = image.jpegData(compressionQuality: 1) ?? image.pngData() else { + return "" + } + let directory = FileManager.default.temporaryDirectory + print("FileManagerFileManager \(directory.absoluteString)") + do { + try data.write(to: directory.appendingPathComponent(name + ".png")) + return directory.appendingPathComponent(name + ".png").absoluteString + } catch { + print(error.localizedDescription) + return "" + } + } + + func textToImage(firstName: String, lastName: String) -> UIImage { + + let lblNameInitialize = UILabel() + lblNameInitialize.frame.size = CGSize(width: 100.0, height: 100.0) + lblNameInitialize.font = UIFont(name: "NotoSans-ExtraBold", size: 40) + 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 + } +} + +struct PhoneNumber { + var numLabel: String + var num: String +} + +struct Contact: Identifiable { + var id = UUID() + var firstName: String + var lastName: String + var organizationName: String + var displayName: String + var sipAddresses: [String] = [] + var phoneNumbers: [PhoneNumber] = [] + var imageData: String +} diff --git a/Linphone/Core/CoreContext.swift b/Linphone/Core/CoreContext.swift index f20316b7f..560cb3cbc 100644 --- a/Linphone/Core/CoreContext.swift +++ b/Linphone/Core/CoreContext.swift @@ -40,6 +40,9 @@ final class CoreContext: ObservableObject { let factory = Factory.Instance let configDir = factory.getConfigDir(context: nil) try? mCore = Factory.Instance.createCore(configPath: "\(configDir)/MyConfig", factoryConfigPath: "", systemContext: nil) + + mCore.friendsDatabasePath = "\(configDir)/friends.db" + try? mCore.start() // Create a Core listener to listen for the callback we need diff --git a/Linphone/LinphoneApp.swift b/Linphone/LinphoneApp.swift index 3dda9cf64..b6eba8007 100644 --- a/Linphone/LinphoneApp.swift +++ b/Linphone/LinphoneApp.swift @@ -28,7 +28,7 @@ struct LinphoneApp: App { var body: some Scene { WindowGroup { if isActive { - ContentView(sharedMainViewModel: SharedMainViewModel(), contactViewModel: ContactViewModel(), historyViewModel: HistoryViewModel()) + ContentView(sharedMainViewModel: SharedMainViewModel(), contactViewModel: ContactViewModel(), historyViewModel: HistoryViewModel()) .toast(isShowing: $coreContext.toastMessage) } else { SplashScreen(isActive: $isActive) diff --git a/Linphone/Localizable.xcstrings b/Linphone/Localizable.xcstrings index 8bd88f0a8..28e74c979 100644 --- a/Linphone/Localizable.xcstrings +++ b/Linphone/Localizable.xcstrings @@ -36,9 +36,6 @@ }, "**Notifications** : Pour vous informé quand vous recevez un message ou un appel." : { - }, - "%lld" : { - }, "%lld Book (Example)" : { "extractionState" : "manual", @@ -95,6 +92,9 @@ }, "Accept all" : { + }, + "All contacts" : { + }, "assistant_account_login" : { "extractionState" : "manual", @@ -160,6 +160,9 @@ }, "Error" : { + }, + "Favourites" : { + }, "History Contact fragment" : { @@ -252,6 +255,12 @@ }, "Sécurisé" : { + }, + "See all" : { + + }, + "See Linphone contact" : { + }, "sip.linphone.org" : { diff --git a/Linphone/UI/Assistant/Fragments/PermissionsFragment.swift b/Linphone/UI/Assistant/Fragments/PermissionsFragment.swift index cbb56aa76..979428da3 100644 --- a/Linphone/UI/Assistant/Fragments/PermissionsFragment.swift +++ b/Linphone/UI/Assistant/Fragments/PermissionsFragment.swift @@ -192,6 +192,7 @@ struct PermissionsFragment: View { .padding(.horizontal) Button { + permissionManager.contactsRequestPermission() permissionManager.cameraRequestPermission() } label: { Text("D'accord") diff --git a/Linphone/UI/Main/Contacts/ContactsView.swift b/Linphone/UI/Main/Contacts/ContactsView.swift index cecad9f2a..74098595a 100644 --- a/Linphone/UI/Main/Contacts/ContactsView.swift +++ b/Linphone/UI/Main/Contacts/ContactsView.swift @@ -23,58 +23,12 @@ struct ContactsView: View { @ObservedObject var contactViewModel: ContactViewModel @ObservedObject var historyViewModel: HistoryViewModel - - @State private var orientation = UIDevice.current.orientation - @State private var selectedIndex = 0 - - var objects: [Int] = [ - 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, - 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39 - ] - + var body: some View { NavigationView { ZStack(alignment: .bottomTrailing) { - VStack(spacing: 0) { - VStack { - List { - ForEach(objects, id: \.self) { index in - Button { - withAnimation { - contactViewModel.contactTitle = String(index) - } - } label: { - Text("\(index)") - .frame( maxWidth: .infinity, alignment: .leading) - .foregroundStyle(Color.orangeMain500) - } - .buttonStyle(.borderless) - .listRowSeparator(.hidden) - } - } - .listStyle(.plain) - .overlay( - VStack { - if objects.isEmpty { - Spacer() - Image("illus-belledonne1") - .resizable() - .scaledToFit() - .clipped() - .padding(.all) - Text("No contacts for the moment...") - .default_text_style_800(styleSize: 16) - Spacer() - Spacer() - } - } - .padding(.all) - ) - } - } - .onRotate { newOrientation in - orientation = newOrientation - } + + ContactsFragment(contactViewModel: contactViewModel) Button { // Action diff --git a/Linphone/UI/Main/Contacts/Fragments/ContactFragment.swift b/Linphone/UI/Main/Contacts/Fragments/ContactFragment.swift index 9168b860e..c3684a141 100644 --- a/Linphone/UI/Main/Contacts/Fragments/ContactFragment.swift +++ b/Linphone/UI/Main/Contacts/Fragments/ContactFragment.swift @@ -20,58 +20,50 @@ import SwiftUI struct ContactFragment: View { - - @ObservedObject var contactViewModel: ContactViewModel - - @State private var orientation = UIDevice.current.orientation - + + @ObservedObject var contactViewModel: ContactViewModel + + @State private var orientation = UIDevice.current.orientation + var body: some View { - VStack(alignment: .leading) { - - if !(orientation == .landscapeLeft || orientation == .landscapeRight || UIScreen.main.bounds.size.width > UIScreen.main.bounds.size.height) { - HStack { - Image("caret-left") - .renderingMode(.template) - .resizable() - .foregroundStyle(Color.grayMain2c500) - .frame(width: 25, height: 25, alignment: .leading) - .padding(.top, 20) - .onTapGesture { - withAnimation { - contactViewModel.contactTitle = "" - } - } - - Spacer() - } - .padding(.leading) - } - - Spacer() - - Text(contactViewModel.contactTitle) - .frame(maxWidth: .infinity) - - List { - ForEach(1...40, id: \.self) { index in - Button { - contactViewModel.contactTitle = String(index) - } label: { - Text("\(index)") - .frame( maxWidth: .infinity, alignment: .leading) - } - .buttonStyle(.borderless) - } - } - } - .navigationBarHidden(true) - .onRotate { newOrientation in - orientation = newOrientation - } + VStack(alignment: .leading) { + + if !(orientation == .landscapeLeft + || orientation == .landscapeRight + || UIScreen.main.bounds.size.width > UIScreen.main.bounds.size.height) { + HStack { + Image("caret-left") + .renderingMode(.template) + .resizable() + .foregroundStyle(Color.grayMain2c500) + .frame(width: 25, height: 25, alignment: .leading) + .padding(.top, 20) + .onTapGesture { + withAnimation { + contactViewModel.contactTitle = "" + } + } + + Spacer() + } + .padding(.leading) + } + + Spacer() + + Text("Contact Fragment " + contactViewModel.contactTitle) + .frame(maxWidth: .infinity) + + Spacer() + } + .navigationBarHidden(true) + .onRotate { newOrientation in + orientation = newOrientation + } } } #Preview { - ContactFragment(contactViewModel: ContactViewModel()) + ContactFragment(contactViewModel: ContactViewModel()) } diff --git a/Linphone/UI/Main/Contacts/Fragments/ContactsFragment.swift b/Linphone/UI/Main/Contacts/Fragments/ContactsFragment.swift new file mode 100644 index 000000000..00e11cc3a --- /dev/null +++ b/Linphone/UI/Main/Contacts/Fragments/ContactsFragment.swift @@ -0,0 +1,80 @@ +/* + * 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 { + + @ObservedObject var contactViewModel: ContactViewModel + + @State private var orientation = UIDevice.current.orientation + + @State var isFavoriteOpen: Bool = true + + var body: some View { + VStack(alignment: .leading) { + 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(.top, 30) + .padding(.horizontal, 16) + .background(.white) + .onTapGesture { + withAnimation { + isFavoriteOpen.toggle() + } + } + + if isFavoriteOpen { + FavoriteContactsListFragment(contactViewModel: contactViewModel, favoriteContactsListViewModel: FavoriteContactsListViewModel()) + .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) + + ContactsListFragment(contactViewModel: contactViewModel, contactsListViewModel: ContactsListViewModel()) + } + .navigationBarHidden(true) + .onRotate { newOrientation in + orientation = newOrientation + } + + } +} + +#Preview { + ContactsFragment(contactViewModel: ContactViewModel()) +} diff --git a/Linphone/UI/Main/Contacts/Fragments/ContactsListFragment.swift b/Linphone/UI/Main/Contacts/Fragments/ContactsListFragment.swift new file mode 100644 index 000000000..cd1c984a1 --- /dev/null +++ b/Linphone/UI/Main/Contacts/Fragments/ContactsListFragment.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 +import linphonesw + +struct ContactsListFragment: View { + + @ObservedObject var magicSearch = MagicSearchSingleton.shared + + @ObservedObject var contactViewModel: ContactViewModel + @ObservedObject var contactsListViewModel: ContactsListViewModel + + var body: some View { + VStack { + List { + ForEach(0... + */ + +import SwiftUI + +struct FavoriteContactsListFragment: View { + + @ObservedObject var magicSearch = MagicSearchSingleton.shared + + @ObservedObject var contactViewModel: ContactViewModel + @ObservedObject var favoriteContactsListViewModel: FavoriteContactsListViewModel + + var body: some View { + ScrollView(.horizontal) { + HStack { + ForEach(0... */ -import Foundation +import linphonesw class ContactViewModel: ObservableObject { - - @Published var contactTitle: String = "" + + @Published var contactTitle: String = "" init() {} } diff --git a/Linphone/UI/Main/Contacts/ViewModel/ContactsListViewModel.swift b/Linphone/UI/Main/Contacts/ViewModel/ContactsListViewModel.swift new file mode 100644 index 000000000..565f9a6c4 --- /dev/null +++ b/Linphone/UI/Main/Contacts/ViewModel/ContactsListViewModel.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 linphonesw + +class ContactsListViewModel: ObservableObject { + + private var magicSearch = MagicSearchSingleton.shared + private var coreContext = CoreContext.shared + + init() { + magicSearch.searchForContacts( + sourceFlags: MagicSearch.Source.Friends.rawValue | MagicSearch.Source.LdapServers.rawValue) + } +} 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 index 32bc8f301..ee2bb49c3 100644 --- a/Linphone/UI/Main/ContentView.swift +++ b/Linphone/UI/Main/ContentView.swift @@ -18,252 +18,421 @@ */ import SwiftUI +import linphonesw struct ContentView: View { - @ObservedObject var sharedMainViewModel: SharedMainViewModel - @ObservedObject var contactViewModel: ContactViewModel - @ObservedObject var historyViewModel: HistoryViewModel - @ObservedObject private var coreContext = CoreContext.shared - - @State var index = 0 - @State private var orientation = UIDevice.current.orientation - @State var menuOpen: Bool = false - - var body: some View { - if !sharedMainViewModel.welcomeViewDisplayed { - WelcomeView(sharedMainViewModel: sharedMainViewModel) - } else if coreContext.mCore.defaultAccount == nil || sharedMainViewModel.displayProfileMode { - AssistantView(sharedMainViewModel: sharedMainViewModel) - } else { - GeometryReader { geometry in - ZStack { - VStack(spacing: 0) { - HStack(spacing: 0) { - if orientation == .landscapeLeft - || orientation == .landscapeRight - || UIScreen.main.bounds.size.width > UIScreen.main.bounds.size.height { - VStack { - Group { - Spacer() - Button(action: { - self.index = 0 - }, 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) + var contactManager = ContactsManager.shared + var magicSearch = MagicSearchSingleton.shared + + @ObservedObject var sharedMainViewModel: SharedMainViewModel + @ObservedObject var contactViewModel: ContactViewModel + @ObservedObject var historyViewModel: HistoryViewModel + @ObservedObject private var coreContext = CoreContext.shared + + @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 var isMenuOpen: Bool = false + + var body: some View { + if !sharedMainViewModel.welcomeViewDisplayed { + WelcomeView(sharedMainViewModel: sharedMainViewModel) + } else if coreContext.mCore.defaultAccount == nil || sharedMainViewModel.displayProfileMode { + AssistantView(sharedMainViewModel: sharedMainViewModel) + } else { + GeometryReader { geometry in + ZStack { + VStack(spacing: 0) { + HStack(spacing: 0) { + if orientation == .landscapeLeft + || orientation == .landscapeRight + || UIScreen.main.bounds.size.width > UIScreen.main.bounds.size.height { + VStack { + Group { + Spacer() + Button(action: { + self.index = 0 + }, 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) + } + } + }) + + Spacer() + + Button(action: { + self.index = 1 + contactViewModel.contactTitle = "" + }, 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) + } + } + }) + + Spacer() + } + } + .frame(width: 75) + .padding(.leading, + orientation == .landscapeRight && geometry.safeAreaInsets.bottom > 0 + ? -geometry.safeAreaInsets.leading + : 0) + } + + 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" : "Calls") + .default_text_style_white_800(styleSize: 20) + .padding(.leading, 10) + + Spacer() + + Button { + withAnimation { + searchIsActive.toggle() + } + } label: { + Image("search") + } + + Menu { + Button { + isMenuOpen = false + magicSearch.allContact = true + magicSearch.searchForContacts( + sourceFlags: MagicSearch.Source.Friends.rawValue | MagicSearch.Source.LdapServers.rawValue) + } label: { + HStack { + Text("See all") + Spacer() + if magicSearch.allContact { + Image("green-check") + } } } - }) - - Spacer() - - Button(action: { - self.index = 1 - contactViewModel.contactTitle = "" - }, 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) + + Button { + isMenuOpen = false + magicSearch.allContact = false + magicSearch.searchForContacts( + sourceFlags: MagicSearch.Source.Friends.rawValue | MagicSearch.Source.LdapServers.rawValue) + } label: { + HStack { + Text("See Linphone contact") + Spacer() + if !magicSearch.allContact { + Image("green-check") + } } } - }) - - Spacer() - } - } - .frame(width: 75) - .padding(.leading, - orientation == .landscapeRight && geometry.safeAreaInsets.bottom > 0 - ? -geometry.safeAreaInsets.leading - : 0) - } - - VStack(spacing: 0) { - HStack { - Image("profile-image-example") - .resizable() - .frame(width: 40, height: 40) - .clipShape(Circle()) + } label: { + Image(index == 0 ? "filtres" : "more") + } + .padding(.leading) .onTapGesture { - openMenu() + isMenuOpen = true } - - Text(index == 0 ? "Contacts" : "Calls") - .default_text_style_white_800(styleSize: 20) - .padding(.leading, 10) - - Spacer() - - Button { - - } label: { - Image("search") - } - - Button { - - } label: { - Image(index == 0 ? "filtres" : "more") - } - .padding(.leading) - } - .frame(maxWidth: .infinity) - .frame(height: 50) - .padding(.horizontal) - .background(Color.orangeMain500) - - if self.index == 0 { - ContactsView(contactViewModel: contactViewModel, historyViewModel: historyViewModel) - } else if self.index == 1 { - HistoryView() - } - } - .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) { - HStack { - Group { - Spacer() - Button(action: { - self.index = 0 - }, 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) - - Spacer() - - Button(action: { - self.index = 1 - contactViewModel.contactTitle = "" - }, 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) - 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)) - ) - } + } + .frame(maxWidth: .infinity) + .frame(height: 50) + .padding(.horizontal) + .padding(.bottom, 5) + .background(Color.orangeMain500) + } else { + HStack { + Button { + withAnimation { + self.focusedField = false + searchIsActive.toggle() + } + + text = "" + magicSearch.currentFilter = "" + magicSearch.searchForContacts( + sourceFlags: MagicSearch.Source.Friends.rawValue | MagicSearch.Source.LdapServers.rawValue) + } label: { + Image("caret-left") + .renderingMode(.template) + .resizable() + .foregroundStyle(.white) + .frame(width: 25, height: 25, alignment: .leading) + } + + 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) + .accentColor(.white) + .scrollContentBackground(.hidden) + .focused($focusedField) + .onAppear { + self.focusedField = true + } + .onChange(of: text) { newValue in + magicSearch.currentFilter = newValue + magicSearch.searchForContacts( + sourceFlags: MagicSearch.Source.Friends.rawValue | MagicSearch.Source.LdapServers.rawValue) + } + } 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_white_700(styleSize: 15) + .padding(.all, 6) + .accentColor(.white) + .focused($focusedField) + .onAppear { + self.focusedField = true + } + .onChange(of: text) { newValue in + magicSearch.currentFilter = newValue + magicSearch.searchForContacts( + sourceFlags: MagicSearch.Source.Friends.rawValue | MagicSearch.Source.LdapServers.rawValue) + } + } + + Button { + text = "" + } label: { + Image("x") + .renderingMode(.template) + .resizable() + .foregroundStyle(.white) + .frame(width: 25, height: 25, alignment: .leading) + } + .padding(.leading) + } + .frame(maxWidth: .infinity) + .frame(height: 50) + .padding(.horizontal) + .padding(.bottom, 5) + .background(Color.orangeMain500) + } + + if self.index == 0 { + ContactsView(contactViewModel: contactViewModel, historyViewModel: historyViewModel) + } else if self.index == 1 { + HistoryView() + } + } + .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 + }, 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) + + Spacer() + + Button(action: { + self.index = 1 + contactViewModel.contactTitle = "" + }, 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) + 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.contactTitle.isEmpty || !historyViewModel.historyTitle.isEmpty { + 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) + .frame(maxWidth: .infinity) + .background(Color.gray100) + .ignoresSafeArea(.keyboard) + } else if self.index == 1 { + HistoryContactFragment() + .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 + ) + .ignoresSafeArea(.all) + .zIndex(2) + } + } + .overlay { + if isMenuOpen { + Color.white.opacity(0.001) + .ignoresSafeArea() + .frame(maxWidth: .infinity, maxHeight: .infinity) + .onTapGesture { + isMenuOpen = false } - - if !contactViewModel.contactTitle.isEmpty || !historyViewModel.historyTitle.isEmpty { - 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) - .frame(maxWidth: .infinity) - .background(Color.gray100) - } else if self.index == 1 { - HistoryContactFragment() - .frame(maxWidth: .infinity) - .background(Color.gray100) - } - } - .padding(.leading, - orientation == .landscapeRight && geometry.safeAreaInsets.bottom > 0 - ? -geometry.safeAreaInsets.leading - : 0) - .transition(.move(edge: .trailing)) - } - - SideMenu( - width: geometry.size.width / 5 * 4, - isOpen: self.menuOpen, - menuClose: self.openMenu, - safeAreaInsets: geometry.safeAreaInsets - ) - .ignoresSafeArea(.all) } } - .onRotate { newOrientation in - orientation = newOrientation - } - } - } - - func openMenu() { - withAnimation { - self.menuOpen.toggle() - } - } + .onRotate { newOrientation in + if (!contactViewModel.contactTitle.isEmpty || !historyViewModel.historyTitle.isEmpty) && searchIsActive { + self.focusedField = false + } else if searchIsActive { + self.focusedField = true + } + orientation = newOrientation + } + } + } + + func openMenu() { + withAnimation { + self.sideMenuIsOpen.toggle() + } + } } #Preview { diff --git a/Linphone/Utils/MagicSearchSingleton.swift b/Linphone/Utils/MagicSearchSingleton.swift new file mode 100644 index 000000000..2ee38c8cc --- /dev/null +++ b/Linphone/Utils/MagicSearchSingleton.swift @@ -0,0 +1,70 @@ +/* + * 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 + +final class MagicSearchSingleton: ObservableObject { + + static let shared = MagicSearchSingleton() + private var coreContext = CoreContext.shared + + private var magicSearch: MagicSearch! + var magicSearchDelegate: MagicSearchDelegate? + + @objc var currentFilter: String = "" + var previousFilter: String? + + var needUpdateLastSearchContacts = false + + @Published var lastSearch: [SearchResult] = [] + + private var limitSearchToLinphoneAccounts = true + + @Published var allContact = false + private var domainDefaultAccount = "" + + private init() { + domainDefaultAccount = coreContext.mCore.defaultAccount!.params!.domain! + + magicSearch = try? coreContext.mCore.createMagicSearch() + magicSearch.limitedSearch = false + + magicSearchDelegate = MagicSearchDelegateStub(onSearchResultsReceived: { (magicSearch: MagicSearch) in + self.needUpdateLastSearchContacts = true + self.lastSearch = magicSearch.lastSearch + }) + + magicSearch.addDelegate(delegate: magicSearchDelegate!) + } + + func searchForContacts(sourceFlags: Int) { + if let oldFilter = previousFilter { + if oldFilter.count > currentFilter.count || oldFilter != currentFilter { + magicSearch.resetSearchCache() + } + } + previousFilter = currentFilter + + magicSearch.getContactsListAsync( + filter: currentFilter, + domain: allContact ? "" : domainDefaultAccount, + sourceFlags: sourceFlags, + aggregation: MagicSearch.Aggregation.Friend) + } +} diff --git a/Linphone/Utils/PermissionManager.swift b/Linphone/Utils/PermissionManager.swift index a39833010..f1d741f7e 100644 --- a/Linphone/Utils/PermissionManager.swift +++ b/Linphone/Utils/PermissionManager.swift @@ -19,6 +19,7 @@ import Foundation import Photos +import Contacts class PermissionManager: ObservableObject { @@ -26,6 +27,7 @@ class PermissionManager: ObservableObject { @Published var photoLibraryPermissionGranted = false @Published var cameraPermissionGranted = false + @Published var contactsPermissionGranted = false private init() {} @@ -44,4 +46,13 @@ class PermissionManager: ObservableObject { } }) } + + func contactsRequestPermission() { + let store = CNContactStore() + store.requestAccess(for: .contacts) { success, _ in + DispatchQueue.main.async { + self.contactsPermissionGranted = success + } + } + } } diff --git a/Linphone/Utils/TextExtension.swift b/Linphone/Utils/TextExtension.swift index e9d58ae0b..8465dfbff 100644 --- a/Linphone/Utils/TextExtension.swift +++ b/Linphone/Utils/TextExtension.swift @@ -136,4 +136,9 @@ extension 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) + } } From 219ee2d438bbd6d9e473658ca187df1f827d4aaf Mon Sep 17 00:00:00 2001 From: "benoit.martins" Date: Thu, 26 Oct 2023 17:00:54 +0200 Subject: [PATCH 025/486] Changes ContactsManager --- .../Contents.json | 21 + .../profil-picture-default.svg | 18 + Linphone/Contacts/ContactsManager.swift | 321 ++++--- Linphone/LinphoneApp.swift | 13 +- .../Contacts/Fragments/ContactsFragment.swift | 68 +- .../Fragments/ContactsListFragment.swift | 4 +- .../FavoriteContactsListFragment.swift | 8 +- .../Contacts/ViewModel/ContactViewModel.swift | 6 +- .../ViewModel/ContactsListViewModel.swift | 8 +- Linphone/UI/Main/ContentView.swift | 793 +++++++++--------- 10 files changed, 643 insertions(+), 617 deletions(-) create mode 100644 Linphone/Assets.xcassets/profil-picture-default.imageset/Contents.json create mode 100644 Linphone/Assets.xcassets/profil-picture-default.imageset/profil-picture-default.svg 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..b2bea3b29 --- /dev/null +++ b/Linphone/Assets.xcassets/profil-picture-default.imageset/profil-picture-default.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/Linphone/Contacts/ContactsManager.swift b/Linphone/Contacts/ContactsManager.swift index 5de3eb683..a7a845569 100644 --- a/Linphone/Contacts/ContactsManager.swift +++ b/Linphone/Contacts/ContactsManager.swift @@ -24,213 +24,198 @@ import SwiftUI final class ContactsManager: ObservableObject { static let shared = ContactsManager() - + private var coreContext = CoreContext.shared - - @Published var contacts: [Contact] = [] + private var magicSearch = MagicSearchSingleton.shared private let nativeAddressBookFriendList = "Native address-book" let linphoneAddressBookFirendList = "Linphone address-book" + + @Published var friendList: FriendList? private init() { fetchContacts() } func fetchContacts() { - contacts.removeAll() DispatchQueue.global().async { - let store = CNContactStore() - store.requestAccess(for: .contacts) { (granted, error) in - if let error = error { - print("failed to request access", error) - return - } - if granted { - let keys = [CNContactEmailAddressesKey, CNContactPhoneNumbersKey, - CNContactFamilyNameKey, CNContactGivenNameKey, CNContactNicknameKey, - CNContactPostalAddressesKey, CNContactIdentifierKey, - CNInstantMessageAddressUsernameKey, CNContactInstantMessageAddressesKey, - CNContactImageDataKey, CNContactThumbnailImageDataKey, CNContactOrganizationNameKey] - let request = CNContactFetchRequest(keysToFetch: keys as [CNKeyDescriptor]) - do { - try store.enumerateContacts(with: request, usingBlock: { (contact, _) in - - DispatchQueue.main.sync { - self.contacts.append( - Contact( - firstName: contact.givenName, - lastName: contact.familyName, - organizationName: contact.organizationName, - displayName: contact.nickname, - sipAddresses: contact.instantMessageAddresses.map { $0.value.service == "SIP" ? $0.value.username : "" }, - phoneNumbers: contact.phoneNumbers.map { PhoneNumber(numLabel: $0.label ?? "", num: $0.value.stringValue)}, - imageData: self.saveImage( - image: - UIImage(data: contact.thumbnailImageData ?? Data()) - ?? 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.identifier) - ) - ) - } - self.contacts.sort(by: { - $0.firstName.folding( - options: .diacriticInsensitive, locale: .current - ) < $1.firstName.folding( - options: .diacriticInsensitive, locale: .current - ) - }) - }) - - } catch let error { - print("Failed to enumerate contact", error) - } - - } else { - print("access denied") - } - } - - var friends: [Friend] = [] - - self.contacts.forEach { contact in - do { - let friend = try self.coreContext.mCore.createFriend() - friend.edit() - try friend.setName(newValue: contact.firstName + " " + contact.lastName) - friend.organization = contact.organizationName - - var friendAddresses: [Address] = [] - contact.sipAddresses.forEach { sipAddress in - let address = self.coreContext.mCore.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] = [] - contact.phoneNumbers.forEach { phone in - do { - if (friendPhoneNumbers.firstIndex(where: {$0.numLabel == phone.numLabel})) == nil { - let phoneNumber = try Factory.Instance.createFriendPhoneNumber(phoneNumber: phone.num, label: phone.numLabel) - friend.addPhoneNumberWithLabel(phoneNumber: phoneNumber) - friendPhoneNumbers.append(phone) - } - } catch let error { - print("Failed to enumerate contact", error) - } - } - - let contactImage = contact.imageData.dropFirst(8) - friend.photo = "file:/" + contactImage - - friend.done() - friends.append(friend) - - } catch let error { - print("Failed to enumerate contact", error) - } - } - if self.coreContext.mCore.globalState == GlobalState.Shutdown || self.coreContext.mCore.globalState == GlobalState.Off { print("$TAG Core is being stopped or already destroyed, abort") - } else if friends.isEmpty { - print("$TAG No friend created!") - } else { + } else { print("$TAG ${friends.size} friends created") - let fetchedFriends = friends - - let nativeFriendList = self.coreContext.mCore.getFriendListByName(name: self.nativeAddressBookFriendList) - var friendList = nativeFriendList - if friendList == nil { + self.friendList = self.coreContext.mCore.getFriendListByName(name: self.nativeAddressBookFriendList) + if self.friendList == nil { do { - friendList = try self.coreContext.mCore.createFriendList() + self.friendList = try self.coreContext.mCore.createFriendList() } catch let error { print("Failed to enumerate contact", error) } } - if friendList!.displayName == nil || friendList!.displayName!.isEmpty { + if self.friendList!.displayName == nil || self.friendList!.displayName!.isEmpty { print( "$TAG Friend list [$nativeAddressBookFriendList] didn't exist yet, let's create it" ) - friendList?.databaseStorageEnabled = false // We don't want to store local address-book in DB + self.friendList!.databaseStorageEnabled = false // We don't want to store local address-book in DB - friendList!.displayName = self.nativeAddressBookFriendList - self.coreContext.mCore.addFriendList(list: friendList!) + self.friendList!.displayName = self.nativeAddressBookFriendList + self.coreContext.mCore.addFriendList(list: self.friendList!) } else { print( "$TAG Friend list [$LINPHONE_ADDRESS_BOOK_FRIEND_LIST] found, removing existing friends if any" ) - friendList!.friends.forEach { friend in - _ = friendList!.removeFriend(linphoneFriend: friend) + self.friendList!.friends.forEach { friend in + _ = self.friendList!.removeFriend(linphoneFriend: friend) } } - - fetchedFriends.forEach { friend in - _ = friendList!.addLocalFriend(linphoneFriend: friend) - } - - friends.removeAll() - - print("$TAG Friends added") - - friendList!.updateSubscriptions() - print("$TAG Subscription(s) updated") } + + let store = CNContactStore() + store.requestAccess(for: .contacts) { (granted, error) in + if let error = error { + print("failed to request access", error) + return + } + if granted { + let keys = [CNContactEmailAddressesKey, CNContactPhoneNumbersKey, + CNContactFamilyNameKey, CNContactGivenNameKey, CNContactNicknameKey, + CNContactPostalAddressesKey, CNContactIdentifierKey, + CNInstantMessageAddressUsernameKey, CNContactInstantMessageAddressesKey, + CNContactImageDataKey, CNContactThumbnailImageDataKey, CNContactOrganizationNameKey] + let request = CNContactFetchRequest(keysToFetch: keys as [CNKeyDescriptor]) + do { + try store.enumerateContacts(with: request, usingBlock: { (contact, _) in + + DispatchQueue.main.sync { + let newContact = Contact( + firstName: contact.givenName, + lastName: contact.familyName, + organizationName: contact.organizationName, + displayName: contact.nickname, + sipAddresses: contact.instantMessageAddresses.map { $0.value.service == "SIP" ? $0.value.username : "" }, + phoneNumbers: contact.phoneNumbers.map { PhoneNumber(numLabel: $0.label ?? "", num: $0.value.stringValue)}, + imageData: "" + ) + + self.saveImage( + image: + UIImage(data: contact.thumbnailImageData ?? Data()) + ?? 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 + String(Int.random(in: 1...1000)), + contact: newContact) + } + }) + + } catch let error { + print("Failed to enumerate contact", error) + } + + } else { + print("access denied") + } + } + self.magicSearch.searchForContacts(sourceFlags: MagicSearch.Source.Friends.rawValue | MagicSearch.Source.LdapServers.rawValue) } } + + func textToImage(firstName: String, lastName: String) -> UIImage { + let lblNameInitialize = UILabel() + lblNameInitialize.frame.size = CGSize(width: 100.0, height: 100.0) + lblNameInitialize.font = UIFont(name: "NotoSans-ExtraBold", size: 40) + 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) -> String { + func saveImage(image: UIImage, name: String, contact: Contact) { guard let data = image.jpegData(compressionQuality: 1) ?? image.pngData() else { - return "" - } - let directory = FileManager.default.temporaryDirectory - print("FileManagerFileManager \(directory.absoluteString)") - do { - try data.write(to: directory.appendingPathComponent(name + ".png")) - return directory.appendingPathComponent(name + ".png").absoluteString - } catch { - print(error.localizedDescription) - return "" + return } + + awaitDataWrite(data: data, name: name) { _, result in + do { + let friend = try self.coreContext.mCore.createFriend() + friend.edit() + try friend.setName(newValue: contact.firstName + " " + contact.lastName) + friend.organization = contact.organizationName + + var friendAddresses: [Address] = [] + contact.sipAddresses.forEach { sipAddress in + let address = self.coreContext.mCore.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] = [] + contact.phoneNumbers.forEach { phone in + do { + if (friendPhoneNumbers.firstIndex(where: {$0.numLabel == phone.numLabel})) == nil { + let phoneNumber = try Factory.Instance.createFriendPhoneNumber(phoneNumber: phone.num, label: phone.numLabel) + friend.addPhoneNumberWithLabel(phoneNumber: phoneNumber) + friendPhoneNumbers.append(phone) + } + } catch let error { + print("Failed to enumerate contact", error) + } + } + + let contactImage = result.dropFirst(8) + friend.photo = "file:/" + contactImage + + friend.done() + + _ = self.friendList!.addLocalFriend(linphoneFriend: friend) + + self.friendList!.updateSubscriptions() + + } catch let error { + print("Failed to enumerate contact", error) + } + } } - - func textToImage(firstName: String, lastName: String) -> UIImage { - - let lblNameInitialize = UILabel() - lblNameInitialize.frame.size = CGSize(width: 100.0, height: 100.0) - lblNameInitialize.font = UIFont(name: "NotoSans-ExtraBold", size: 40) - 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 awaitDataWrite(data: Data, name: String, completion: @escaping ((), String) -> Void) { + let directory = FileManager.default.temporaryDirectory + + DispatchQueue.main.async { + do { + let decodedData: () = try data.write(to: directory.appendingPathComponent(name + ".png")) + completion(decodedData, directory.appendingPathComponent(name + ".png").absoluteString) // <--- here, return the results + } catch { + print("Error: ", error) // need to deal with errors + completion((), "") // <--- here, should return the error + } + } + } } struct PhoneNumber { diff --git a/Linphone/LinphoneApp.swift b/Linphone/LinphoneApp.swift index b6eba8007..0efeb7e77 100644 --- a/Linphone/LinphoneApp.swift +++ b/Linphone/LinphoneApp.swift @@ -23,13 +23,22 @@ import SwiftUI struct LinphoneApp: App { @ObservedObject private var coreContext = CoreContext.shared + @ObservedObject private var sharedMainViewModel = SharedMainViewModel() + @State private var isActive = false var body: some Scene { WindowGroup { if isActive { - ContentView(sharedMainViewModel: SharedMainViewModel(), contactViewModel: ContactViewModel(), historyViewModel: HistoryViewModel()) - .toast(isShowing: $coreContext.toastMessage) + if !sharedMainViewModel.welcomeViewDisplayed { + WelcomeView(sharedMainViewModel: sharedMainViewModel) + } else if coreContext.mCore.defaultAccount == nil || sharedMainViewModel.displayProfileMode { + AssistantView(sharedMainViewModel: sharedMainViewModel) + .toast(isShowing: $coreContext.toastMessage) + } else { + ContentView(contactViewModel: ContactViewModel(), historyViewModel: HistoryViewModel()) + .toast(isShowing: $coreContext.toastMessage) + } } else { SplashScreen(isActive: $isActive) } diff --git a/Linphone/UI/Main/Contacts/Fragments/ContactsFragment.swift b/Linphone/UI/Main/Contacts/Fragments/ContactsFragment.swift index 00e11cc3a..155004e04 100644 --- a/Linphone/UI/Main/Contacts/Fragments/ContactsFragment.swift +++ b/Linphone/UI/Main/Contacts/Fragments/ContactsFragment.swift @@ -21,6 +21,7 @@ import SwiftUI struct ContactsFragment: View { + @ObservedObject var magicSearch = MagicSearchSingleton.shared @ObservedObject var contactViewModel: ContactViewModel @State private var orientation = UIDevice.current.orientation @@ -29,42 +30,43 @@ struct ContactsFragment: View { var body: some View { VStack(alignment: .leading) { - 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(.top, 30) - .padding(.horizontal, 16) - .background(.white) - .onTapGesture { - withAnimation { - isFavoriteOpen.toggle() + if !magicSearch.lastSearch.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(.top, 30) + .padding(.horizontal, 16) + .background(.white) + .onTapGesture { + withAnimation { + isFavoriteOpen.toggle() + } } - } - - if isFavoriteOpen { - FavoriteContactsListFragment(contactViewModel: contactViewModel, favoriteContactsListViewModel: FavoriteContactsListViewModel()) - .zIndex(-1) - .transition(.move(edge: .top)) - } - - HStack(alignment: .center) { - Text("All contacts") - .default_text_style_800(styleSize: 16) - Spacer() + if isFavoriteOpen { + FavoriteContactsListFragment(contactViewModel: contactViewModel, favoriteContactsListViewModel: FavoriteContactsListViewModel()) + .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) } - .padding(.top, 10) - .padding(.horizontal, 16) - ContactsListFragment(contactViewModel: contactViewModel, contactsListViewModel: ContactsListViewModel()) } .navigationBarHidden(true) diff --git a/Linphone/UI/Main/Contacts/Fragments/ContactsListFragment.swift b/Linphone/UI/Main/Contacts/Fragments/ContactsListFragment.swift index cd1c984a1..174f00fa0 100644 --- a/Linphone/UI/Main/Contacts/Fragments/ContactsListFragment.swift +++ b/Linphone/UI/Main/Contacts/Fragments/ContactsListFragment.swift @@ -64,7 +64,7 @@ struct ContactsListFragment: View { .frame(width: 45, height: 45) .clipShape(Circle()) case .failure: - Image("profile-image-example") + Image("profil-picture-default") .resizable() .frame(width: 45, height: 45) .clipShape(Circle()) @@ -73,7 +73,7 @@ struct ContactsListFragment: View { } } } else { - Image("profile-image-example") + Image("profil-picture-default") .resizable() .frame(width: 45, height: 45) .clipShape(Circle()) diff --git a/Linphone/UI/Main/Contacts/Fragments/FavoriteContactsListFragment.swift b/Linphone/UI/Main/Contacts/Fragments/FavoriteContactsListFragment.swift index 73275c15e..c013cbd61 100644 --- a/Linphone/UI/Main/Contacts/Fragments/FavoriteContactsListFragment.swift +++ b/Linphone/UI/Main/Contacts/Fragments/FavoriteContactsListFragment.swift @@ -29,7 +29,7 @@ struct FavoriteContactsListFragment: View { var body: some View { ScrollView(.horizontal) { HStack { - ForEach(0.. UIScreen.main.bounds.size.height { - VStack { - Group { - Spacer() - Button(action: { - self.index = 0 - }, 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) - } - } - }) - - Spacer() - - Button(action: { - self.index = 1 - contactViewModel.contactTitle = "" - }, 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) - } - } - }) - - Spacer() - } - } - .frame(width: 75) - .padding(.leading, - orientation == .landscapeRight && geometry.safeAreaInsets.bottom > 0 - ? -geometry.safeAreaInsets.leading - : 0) - } - - 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" : "Calls") - .default_text_style_white_800(styleSize: 20) - .padding(.leading, 10) - - Spacer() - - Button { - withAnimation { - searchIsActive.toggle() - } - } label: { - Image("search") - } - - Menu { - Button { - isMenuOpen = false - magicSearch.allContact = true - magicSearch.searchForContacts( - sourceFlags: MagicSearch.Source.Friends.rawValue | MagicSearch.Source.LdapServers.rawValue) - } label: { - HStack { - Text("See all") - Spacer() - if magicSearch.allContact { - Image("green-check") - } - } + + var body: some View { + GeometryReader { geometry in + ZStack { + VStack(spacing: 0) { + HStack(spacing: 0) { + if orientation == .landscapeLeft + || orientation == .landscapeRight + || UIScreen.main.bounds.size.width > UIScreen.main.bounds.size.height { + VStack { + Group { + Spacer() + Button(action: { + self.index = 0 + }, 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) } - - Button { - isMenuOpen = false - magicSearch.allContact = false - magicSearch.searchForContacts( - sourceFlags: MagicSearch.Source.Friends.rawValue | MagicSearch.Source.LdapServers.rawValue) - } label: { - HStack { - Text("See Linphone contact") - Spacer() - if !magicSearch.allContact { - Image("green-check") - } - } - } - } label: { - Image(index == 0 ? "filtres" : "more") - } - .padding(.leading) - .onTapGesture { - isMenuOpen = true } - } - .frame(maxWidth: .infinity) - .frame(height: 50) - .padding(.horizontal) - .padding(.bottom, 5) - .background(Color.orangeMain500) - } else { - HStack { - Button { - withAnimation { - self.focusedField = false - searchIsActive.toggle() - } - - text = "" - magicSearch.currentFilter = "" + }) + + Spacer() + + Button(action: { + self.index = 1 + contactViewModel.contactTitle = "" + }, 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) + } + } + }) + + Spacer() + } + } + .frame(width: 75) + .padding(.leading, + orientation == .landscapeRight && geometry.safeAreaInsets.bottom > 0 + ? -geometry.safeAreaInsets.leading + : 0) + } + + 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" : "Calls") + .default_text_style_white_800(styleSize: 20) + .padding(.leading, 10) + + Spacer() + + Button { + withAnimation { + searchIsActive.toggle() + } + } label: { + Image("search") + } + + Menu { + Button { + isMenuOpen = false + magicSearch.allContact = true magicSearch.searchForContacts( sourceFlags: MagicSearch.Source.Friends.rawValue | MagicSearch.Source.LdapServers.rawValue) - } label: { - Image("caret-left") - .renderingMode(.template) - .resizable() - .foregroundStyle(.white) - .frame(width: 25, height: 25, alignment: .leading) - } - - 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) - .accentColor(.white) - .scrollContentBackground(.hidden) - .focused($focusedField) - .onAppear { - self.focusedField = true - } - .onChange(of: text) { newValue in - magicSearch.currentFilter = newValue - magicSearch.searchForContacts( - sourceFlags: MagicSearch.Source.Friends.rawValue | MagicSearch.Source.LdapServers.rawValue) - } - } 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_white_700(styleSize: 15) - .padding(.all, 6) - .accentColor(.white) - .focused($focusedField) - .onAppear { - self.focusedField = true - } - .onChange(of: text) { newValue in - magicSearch.currentFilter = newValue - magicSearch.searchForContacts( - sourceFlags: MagicSearch.Source.Friends.rawValue | MagicSearch.Source.LdapServers.rawValue) - } - } - - Button { - text = "" - } label: { - Image("x") - .renderingMode(.template) - .resizable() - .foregroundStyle(.white) - .frame(width: 25, height: 25, alignment: .leading) - } - .padding(.leading) - } - .frame(maxWidth: .infinity) - .frame(height: 50) - .padding(.horizontal) - .padding(.bottom, 5) - .background(Color.orangeMain500) - } - - if self.index == 0 { - ContactsView(contactViewModel: contactViewModel, historyViewModel: historyViewModel) - } else if self.index == 1 { - HistoryView() - } - } - .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 - }, 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) - - Spacer() - - Button(action: { - self.index = 1 - contactViewModel.contactTitle = "" - }, 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) - 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.contactTitle.isEmpty || !historyViewModel.historyTitle.isEmpty { - 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) - .frame(maxWidth: .infinity) - .background(Color.gray100) - .ignoresSafeArea(.keyboard) - } else if self.index == 1 { - HistoryContactFragment() - .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 - ) - .ignoresSafeArea(.all) - .zIndex(2) - } - } - .overlay { - if isMenuOpen { - Color.white.opacity(0.001) + } label: { + HStack { + Text("See all") + Spacer() + if magicSearch.allContact { + Image("green-check") + } + } + } + + Button { + isMenuOpen = false + magicSearch.allContact = false + magicSearch.searchForContacts( + sourceFlags: MagicSearch.Source.Friends.rawValue | MagicSearch.Source.LdapServers.rawValue) + } label: { + HStack { + Text("See Linphone contact") + Spacer() + if !magicSearch.allContact { + Image("green-check") + } + } + } + } label: { + Image(index == 0 ? "filtres" : "more") + } + .padding(.leading) + .onTapGesture { + isMenuOpen = true + } + } + .frame(maxWidth: .infinity) + .frame(height: 50) + .padding(.horizontal) + .padding(.bottom, 5) + .background(Color.orangeMain500) + } else { + HStack { + Button { + withAnimation { + self.focusedField = false + searchIsActive.toggle() + } + + text = "" + magicSearch.currentFilter = "" + magicSearch.searchForContacts( + sourceFlags: MagicSearch.Source.Friends.rawValue | MagicSearch.Source.LdapServers.rawValue) + } label: { + Image("caret-left") + .renderingMode(.template) + .resizable() + .foregroundStyle(.white) + .frame(width: 25, height: 25, alignment: .leading) + } + + 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) + .accentColor(.white) + .scrollContentBackground(.hidden) + .focused($focusedField) + .onAppear { + self.focusedField = true + } + .onChange(of: text) { newValue in + magicSearch.currentFilter = newValue + magicSearch.searchForContacts( + sourceFlags: MagicSearch.Source.Friends.rawValue | MagicSearch.Source.LdapServers.rawValue) + } + } 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_white_700(styleSize: 15) + .padding(.all, 6) + .accentColor(.white) + .focused($focusedField) + .onAppear { + self.focusedField = true + } + .onChange(of: text) { newValue in + magicSearch.currentFilter = newValue + magicSearch.searchForContacts( + sourceFlags: MagicSearch.Source.Friends.rawValue | MagicSearch.Source.LdapServers.rawValue) + } + } + + Button { + text = "" + } label: { + Image("x") + .renderingMode(.template) + .resizable() + .foregroundStyle(.white) + .frame(width: 25, height: 25, alignment: .leading) + } + .padding(.leading) + } + .frame(maxWidth: .infinity) + .frame(height: 50) + .padding(.horizontal) + .padding(.bottom, 5) + .background(Color.orangeMain500) + } + + if self.index == 0 { + ContactsView(contactViewModel: contactViewModel, historyViewModel: historyViewModel) + } else if self.index == 1 { + HistoryView() + } + } + .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 + }, 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) + + Spacer() + + Button(action: { + self.index = 1 + contactViewModel.contactTitle = "" + }, 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) + 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.contactTitle.isEmpty || !historyViewModel.historyTitle.isEmpty { + 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) + .frame(maxWidth: .infinity) + .background(Color.gray100) + .ignoresSafeArea(.keyboard) + } else if self.index == 1 { + HistoryContactFragment() + .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 + ) + .ignoresSafeArea(.all) + .zIndex(2) + } + } + .overlay { + if isMenuOpen { + Color.white.opacity(0.001) .ignoresSafeArea() .frame(maxWidth: .infinity, maxHeight: .infinity) .onTapGesture { isMenuOpen = false } - } } - .onRotate { newOrientation in - if (!contactViewModel.contactTitle.isEmpty || !historyViewModel.historyTitle.isEmpty) && searchIsActive { - self.focusedField = false - } else if searchIsActive { - self.focusedField = true - } - orientation = newOrientation - } - } - } - - func openMenu() { - withAnimation { - self.sideMenuIsOpen.toggle() - } - } + } + .onRotate { newOrientation in + if (!contactViewModel.contactTitle.isEmpty || !historyViewModel.historyTitle.isEmpty) && searchIsActive { + self.focusedField = false + } else if searchIsActive { + self.focusedField = true + } + orientation = newOrientation + } + } + + func openMenu() { + withAnimation { + self.sideMenuIsOpen.toggle() + } + } } #Preview { - ContentView(sharedMainViewModel: SharedMainViewModel(), contactViewModel: ContactViewModel(), historyViewModel: HistoryViewModel()) + ContentView(contactViewModel: ContactViewModel(), historyViewModel: HistoryViewModel()) } From 5dc38b6c9194a9a3b2de8720049efbb436b251b0 Mon Sep 17 00:00:00 2001 From: "benoit.martins" Date: Fri, 27 Oct 2023 17:04:11 +0200 Subject: [PATCH 026/486] add bottom sheet to add a friend to the favorites list --- Linphone.xcodeproj/project.pbxproj | 12 ++ .../heart.imageset/Contents.json | 21 ++ .../Assets.xcassets/heart.imageset/heart.svg | 1 + .../share-network.imageset/Contents.json | 21 ++ .../share-network.imageset/share-network.svg | 1 + .../trash-simple.imageset/Contents.json | 21 ++ .../trash-simple.imageset/trash-simple.svg | 1 + Linphone/Localizable.xcstrings | 12 ++ .../Contacts/Fragments/ContactsFragment.swift | 57 +----- .../Fragments/ContactsInnerFragment.swift | 78 ++++++++ .../Fragments/ContactsListBottomSheet.swift | 155 +++++++++++++++ .../Fragments/ContactsListFragment.swift | 188 ++++++++++-------- .../FavoriteContactsListFragment.swift | 32 ++- .../Contacts/ViewModel/ContactViewModel.swift | 2 + .../UI/Main/Fragments/CustomBottomSheet.swift | 107 ++++++++++ 15 files changed, 568 insertions(+), 141 deletions(-) create mode 100644 Linphone/Assets.xcassets/heart.imageset/Contents.json create mode 100644 Linphone/Assets.xcassets/heart.imageset/heart.svg create mode 100644 Linphone/Assets.xcassets/share-network.imageset/Contents.json create mode 100644 Linphone/Assets.xcassets/share-network.imageset/share-network.svg create mode 100644 Linphone/Assets.xcassets/trash-simple.imageset/Contents.json create mode 100644 Linphone/Assets.xcassets/trash-simple.imageset/trash-simple.svg create mode 100644 Linphone/UI/Main/Contacts/Fragments/ContactsInnerFragment.swift create mode 100644 Linphone/UI/Main/Contacts/Fragments/ContactsListBottomSheet.swift create mode 100644 Linphone/UI/Main/Fragments/CustomBottomSheet.swift diff --git a/Linphone.xcodeproj/project.pbxproj b/Linphone.xcodeproj/project.pbxproj index 1ab50b742..83de29f47 100644 --- a/Linphone.xcodeproj/project.pbxproj +++ b/Linphone.xcodeproj/project.pbxproj @@ -56,6 +56,9 @@ D7DA67642ACCB31700E95002 /* ProfileModeFragment.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7DA67632ACCB31700E95002 /* ProfileModeFragment.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 */; }; D7FB55112AD447FD00A5AB15 /* RegisterFragment.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7FB55102AD447FD00A5AB15 /* RegisterFragment.swift */; }; /* End PBXBuildFile section */ @@ -114,6 +117,9 @@ D7DA67632ACCB31700E95002 /* ProfileModeFragment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileModeFragment.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 = ""; }; D7FB55102AD447FD00A5AB15 /* RegisterFragment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RegisterFragment.swift; sourceTree = ""; }; FB718F405DAF7B9993AEB878 /* Pods-Linphone.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Linphone.debug.xcconfig"; path = "Target Support Files/Pods-Linphone/Pods-Linphone.debug.xcconfig"; sourceTree = ""; }; @@ -288,6 +294,7 @@ D750D3382AD3E6EE00EC99C5 /* PopupLoadingView.swift */, D706BA812ADD72D100278F45 /* DeviceRotationViewModifier.swift */, D72250682ADFBF2D008FB426 /* SideMenu.swift */, + D7E6D04C2AEBD77600A57AAF /* CustomBottomSheet.swift */, ); path = Fragments; sourceTree = ""; @@ -316,6 +323,8 @@ D71FCA802AE14CFC00D2E43E /* ContactsListFragment.swift */, D71FCA822AE14D6E00D2E43E /* ContactFragment.swift */, D7E6D0482AE933AD00A57AAF /* FavoriteContactsListFragment.swift */, + D7E6D0502AEBDBD500A57AAF /* ContactsListBottomSheet.swift */, + D7E6D0542AEBFCCE00A57AAF /* ContactsInnerFragment.swift */, ); path = Fragments; sourceTree = ""; @@ -539,18 +548,21 @@ D719ABCF2ABC779A00B41C10 /* AccountLoginViewModel.swift in Sources */, D78290BB2ADD40B2004AA85C /* ContactViewModel.swift in Sources */, D72992392ADD7F68003AF125 /* HistoryContactFragment.swift in Sources */, + D7E6D04D2AEBD77600A57AAF /* CustomBottomSheet.swift in Sources */, D74C9D012ACB098C0021626A /* PermissionManager.swift in Sources */, D7702EF22AC7205000557C00 /* WelcomeView.swift in Sources */, D71FCA7F2AE1397200D2E43E /* ContactsListViewModel.swift in Sources */, D71FCA812AE14CFC00D2E43E /* ContactsListFragment.swift in Sources */, D719ABB72ABC67BF00B41C10 /* LinphoneApp.swift in Sources */, D72250632ADE9615008FB426 /* HistoryViewModel.swift in Sources */, + D7E6D0512AEBDBD500A57AAF /* ContactsListBottomSheet.swift in Sources */, D7A2EDD62AC18115005D90FC /* SharedMainViewModel.swift in Sources */, D7A03FC62ACC458A0081A588 /* SplashScreen.swift in Sources */, D7A03FC02ACC2E390081A588 /* HistoryView.swift in Sources */, D748BF2E2ACD82E7004844EB /* ThirdPartySipAccountWarningFragment.swift in Sources */, D748BF2C2ACD82D2004844EB /* ThirdPartySipAccountLoginFragment.swift in Sources */, D74C9CF82ACACECE0021626A /* WelcomePage1Fragment.swift in Sources */, + D7E6D0552AEBFCCE00A57AAF /* ContactsInnerFragment.swift in Sources */, D72343362AD037AF009AA24E /* ToastView.swift in Sources */, D7FB55112AD447FD00A5AB15 /* RegisterFragment.swift in Sources */, D72343322ACEFF58009AA24E /* QRScannerController.swift in Sources */, 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..98674b649 --- /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/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..835f930ac --- /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/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..6e48d22fc --- /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/Localizable.xcstrings b/Linphone/Localizable.xcstrings index 28e74c979..ab4385540 100644 --- a/Linphone/Localizable.xcstrings +++ b/Linphone/Localizable.xcstrings @@ -92,6 +92,9 @@ }, "Accept all" : { + }, + "Add to favourites" : { + }, "All contacts" : { @@ -142,6 +145,9 @@ }, "Default mode" : { + }, + "Delete" : { + }, "Demande d’autorisations" : { @@ -249,6 +255,9 @@ }, "Register" : { + }, + "Remove to favourites" : { + }, "Scan QR code" : { @@ -261,6 +270,9 @@ }, "See Linphone contact" : { + }, + "Share" : { + }, "sip.linphone.org" : { diff --git a/Linphone/UI/Main/Contacts/Fragments/ContactsFragment.swift b/Linphone/UI/Main/Contacts/Fragments/ContactsFragment.swift index 155004e04..5c0b65bb0 100644 --- a/Linphone/UI/Main/Contacts/Fragments/ContactsFragment.swift +++ b/Linphone/UI/Main/Contacts/Fragments/ContactsFragment.swift @@ -21,57 +21,22 @@ import SwiftUI struct ContactsFragment: View { - @ObservedObject var magicSearch = MagicSearchSingleton.shared @ObservedObject var contactViewModel: ContactViewModel - @State private var orientation = UIDevice.current.orientation - - @State var isFavoriteOpen: Bool = true + @State private var showingSheet = false var body: some View { - VStack(alignment: .leading) { - if !magicSearch.lastSearch.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) + if #available(iOS 16.0, *) { + ContactsInnerFragment(contactViewModel: contactViewModel, showingSheet: $showingSheet) + .sheet(isPresented: $showingSheet) { + ContactsListBottomSheet(contactViewModel: contactViewModel, showingSheet: $showingSheet) + .presentationDetents([.fraction(0.2)]) } - .padding(.top, 30) - .padding(.horizontal, 16) - .background(.white) - .onTapGesture { - withAnimation { - isFavoriteOpen.toggle() - } - } - - if isFavoriteOpen { - FavoriteContactsListFragment(contactViewModel: contactViewModel, favoriteContactsListViewModel: FavoriteContactsListViewModel()) - .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) - } - ContactsListFragment(contactViewModel: contactViewModel, contactsListViewModel: ContactsListViewModel()) - } - .navigationBarHidden(true) - .onRotate { newOrientation in - orientation = newOrientation + } else { + ContactsInnerFragment(contactViewModel: contactViewModel, showingSheet: $showingSheet) + .halfSheet(showSheet: $showingSheet) { + ContactsListBottomSheet(contactViewModel: contactViewModel, showingSheet: $showingSheet) + } onDismiss: {} } } diff --git a/Linphone/UI/Main/Contacts/Fragments/ContactsInnerFragment.swift b/Linphone/UI/Main/Contacts/Fragments/ContactsInnerFragment.swift new file mode 100644 index 000000000..a04b3bc68 --- /dev/null +++ b/Linphone/UI/Main/Contacts/Fragments/ContactsInnerFragment.swift @@ -0,0 +1,78 @@ +/* + * 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 ContactsInnerFragment: View { + + @ObservedObject var magicSearch = MagicSearchSingleton.shared + @ObservedObject var contactViewModel: ContactViewModel + + @State private var isFavoriteOpen = true + + @Binding var showingSheet: Bool + + var body: some View { + VStack(alignment: .leading) { + if !magicSearch.lastSearch.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(.top, 30) + .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) + } + ContactsListFragment(contactViewModel: contactViewModel, contactsListViewModel: ContactsListViewModel(), showingSheet: $showingSheet) + } + .navigationBarHidden(true) + } +} + +#Preview { + ContactsInnerFragment(contactViewModel: ContactViewModel(), showingSheet: .constant(false)) +} diff --git a/Linphone/UI/Main/Contacts/Fragments/ContactsListBottomSheet.swift b/Linphone/UI/Main/Contacts/Fragments/ContactsListBottomSheet.swift new file mode 100644 index 000000000..a55a0b96b --- /dev/null +++ b/Linphone/UI/Main/Contacts/Fragments/ContactsListBottomSheet.swift @@ -0,0 +1,155 @@ +/* + * 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 ContactsListBottomSheet: View { + + @ObservedObject var magicSearch = MagicSearchSingleton.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 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!.starred.toggle() + } + self.magicSearch.searchForContacts(sourceFlags: MagicSearch.Source.Friends.rawValue | MagicSearch.Source.LdapServers.rawValue) + + if #available(iOS 16.0, *) { + showingSheet.toggle() + } else { + showingSheet.toggle() + dismiss() + } + } label: { + HStack { + Image("heart") + .renderingMode(.template) + .resizable() + .foregroundStyle(Color.grayMain2c500) + .frame(width: 25, height: 25, alignment: .leading) + Text(contactViewModel.selectedFriend != nil && contactViewModel.selectedFriend!.starred == true + ? "Remove to 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, *) { + showingSheet.toggle() + } else { + showingSheet.toggle() + dismiss() + } + } label: { + HStack { + Image("share-network") + .renderingMode(.template) + .resizable() + .foregroundStyle(Color.grayMain2c500) + .frame(width: 25, height: 25, alignment: .leading) + 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 { + contactViewModel.selectedFriend!.remove() + } + self.magicSearch.searchForContacts(sourceFlags: MagicSearch.Source.Friends.rawValue | MagicSearch.Source.LdapServers.rawValue) + + if #available(iOS 16.0, *) { + showingSheet.toggle() + } else { + showingSheet.toggle() + dismiss() + } + } label: { + HStack { + Image("trash-simple") + .renderingMode(.template) + .resizable() + .foregroundStyle(Color.redDanger500) + .frame(width: 25, height: 25, alignment: .leading) + Text("Delete") + .foregroundStyle(Color.redDanger500) + .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) + } +} diff --git a/Linphone/UI/Main/Contacts/Fragments/ContactsListFragment.swift b/Linphone/UI/Main/Contacts/Fragments/ContactsListFragment.swift index 174f00fa0..527488c95 100644 --- a/Linphone/UI/Main/Contacts/Fragments/ContactsListFragment.swift +++ b/Linphone/UI/Main/Contacts/Fragments/ContactsListFragment.swift @@ -21,95 +21,109 @@ import SwiftUI import linphonesw struct ContactsListFragment: View { - - @ObservedObject var magicSearch = MagicSearchSingleton.shared - - @ObservedObject var contactViewModel: ContactViewModel - @ObservedObject var contactsListViewModel: ContactsListViewModel - - var body: some View { - VStack { - List { - ForEach(0... + */ + +import SwiftUI + +extension View { + //binding show bariable... + func halfSheet( + showSheet: Binding, + @ViewBuilder content: @escaping () -> Content, + onDismiss: @escaping () -> Void + ) -> some View { + return self + .background( + HalfSheetHelper(sheetView: content(), showSheet: showSheet, onDismiss: onDismiss) + ) + } +} + +// UIKit integration +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) + } + } + + //on dismiss... + final class Coordinator: NSObject, UISheetPresentationControllerDelegate { + + var parent: HalfSheetHelper + + init(parent: HalfSheetHelper) { + self.parent = parent + } + + func presentationControllerWillDismiss(_ presentationController: UIPresentationController) { + parent.showSheet = false + } + } +} + +// Custom UIHostingController for halfSheet... +final class CustomHostingController: UIHostingController { + override func viewDidLoad() { + view.backgroundColor = .clear + if let presentationController = presentationController as? UISheetPresentationController { + presentationController.detents = [ + .medium() + ] + + //MARK: - sheet grabber visbility + presentationController.prefersGrabberVisible = false // i wanted to design my own grabber hehehe + + // this allows you to scroll even during medium detent + presentationController.prefersScrollingExpandsWhenScrolledToEdge = false + + //MARK: - sheet corner radius + presentationController.preferredCornerRadius = 30 + + // for more sheet customisation check out this great article https://sarunw.com/posts/bottom-sheet-in-ios-15-with-uisheetpresentationcontroller/#scrolling + } + } +} + +public struct LazyView: View { + private let build: () -> Content + public init(_ build: @autoclosure @escaping () -> Content) { + self.build = build + } + public var body: Content { + build() + } +} From 2292512e4d209fea0ef62fa449d22ad93f4c0aee Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Fri, 27 Oct 2023 17:37:20 +0200 Subject: [PATCH 027/486] Add contact fragment (detail) --- Linphone.xcodeproj/project.pbxproj | 8 + .../bell-ringing_slash.imageset/Contents.json | 21 + .../bell-ringing_slash.svg | 3 + .../chat-teardrop-text.imageset/Contents.json | 21 + .../chat-teardrop-text.svg | 1 + .../copy.imageset/Contents.json | 21 + .../Assets.xcassets/copy.imageset/copy.svg | 1 + .../empty.imageset/Contents.json | 21 + .../Assets.xcassets/empty.imageset/empty.svg | 3 + .../Contents.json | 21 + .../envelope-simple-open.svg | 1 + .../pencil-simple.imageset/Contents.json | 21 + .../pencil-simple.imageset/pencil-simple.svg | 1 + Linphone/Contacts/ContactsManager.swift | 5 +- Linphone/Localizable.xcstrings | 69 ++ .../Fragments/PermissionsFragment.swift | 20 - .../ThirdPartySipAccountWarningFragment.swift | 10 - Linphone/UI/Main/Contacts/ContactsView.swift | 8 +- .../Contacts/Fragments/ContactFragment.swift | 66 +- .../Fragments/ContactInnerFragment.swift | 564 ++++++++++++ .../Fragments/ContactListBottomSheet.swift | 156 ++++ .../Contacts/Fragments/ContactsFragment.swift | 31 +- .../Fragments/ContactsInnerFragment.swift | 9 +- .../Fragments/ContactsListBottomSheet.swift | 5 +- .../Fragments/ContactsListFragment.swift | 22 +- .../FavoriteContactsListFragment.swift | 13 +- .../Contacts/ViewModel/ContactViewModel.swift | 3 +- Linphone/UI/Main/ContentView.swift | 850 +++++++++--------- .../UI/Main/Fragments/CustomBottomSheet.swift | 11 +- 29 files changed, 1465 insertions(+), 521 deletions(-) create mode 100644 Linphone/Assets.xcassets/bell-ringing_slash.imageset/Contents.json create mode 100644 Linphone/Assets.xcassets/bell-ringing_slash.imageset/bell-ringing_slash.svg create mode 100644 Linphone/Assets.xcassets/chat-teardrop-text.imageset/Contents.json create mode 100644 Linphone/Assets.xcassets/chat-teardrop-text.imageset/chat-teardrop-text.svg create mode 100644 Linphone/Assets.xcassets/copy.imageset/Contents.json create mode 100644 Linphone/Assets.xcassets/copy.imageset/copy.svg create mode 100644 Linphone/Assets.xcassets/empty.imageset/Contents.json create mode 100644 Linphone/Assets.xcassets/empty.imageset/empty.svg create mode 100644 Linphone/Assets.xcassets/envelope-simple-open.imageset/Contents.json create mode 100644 Linphone/Assets.xcassets/envelope-simple-open.imageset/envelope-simple-open.svg create mode 100644 Linphone/Assets.xcassets/pencil-simple.imageset/Contents.json create mode 100644 Linphone/Assets.xcassets/pencil-simple.imageset/pencil-simple.svg create mode 100644 Linphone/UI/Main/Contacts/Fragments/ContactInnerFragment.swift create mode 100644 Linphone/UI/Main/Contacts/Fragments/ContactListBottomSheet.swift diff --git a/Linphone.xcodeproj/project.pbxproj b/Linphone.xcodeproj/project.pbxproj index 83de29f47..5e3cbc295 100644 --- a/Linphone.xcodeproj/project.pbxproj +++ b/Linphone.xcodeproj/project.pbxproj @@ -45,6 +45,8 @@ D7A03FC02ACC2E390081A588 /* HistoryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7A03FBF2ACC2E390081A588 /* HistoryView.swift */; }; D7A03FC62ACC458A0081A588 /* SplashScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7A03FC52ACC458A0081A588 /* SplashScreen.swift */; }; D7A2EDD62AC18115005D90FC /* SharedMainViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7A2EDD52AC18115005D90FC /* SharedMainViewModel.swift */; }; + D7B5066D2AEFA9B900CEB4E9 /* ContactInnerFragment.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7B5066C2AEFA9B900CEB4E9 /* ContactInnerFragment.swift */; }; + D7C365082AEFAB7F00FE6142 /* ContactListBottomSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7C365072AEFAB7F00FE6142 /* ContactListBottomSheet.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 */; }; @@ -106,6 +108,8 @@ D7A03FC52ACC458A0081A588 /* SplashScreen.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SplashScreen.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 = ""; }; + D7B5066C2AEFA9B900CEB4E9 /* ContactInnerFragment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactInnerFragment.swift; sourceTree = ""; }; + D7C365072AEFAB7F00FE6142 /* ContactListBottomSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactListBottomSheet.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 = ""; }; @@ -325,6 +329,8 @@ D7E6D0482AE933AD00A57AAF /* FavoriteContactsListFragment.swift */, D7E6D0502AEBDBD500A57AAF /* ContactsListBottomSheet.swift */, D7E6D0542AEBFCCE00A57AAF /* ContactsInnerFragment.swift */, + D7B5066C2AEFA9B900CEB4E9 /* ContactInnerFragment.swift */, + D7C365072AEFAB7F00FE6142 /* ContactListBottomSheet.swift */, ); path = Fragments; sourceTree = ""; @@ -548,6 +554,7 @@ D719ABCF2ABC779A00B41C10 /* AccountLoginViewModel.swift in Sources */, D78290BB2ADD40B2004AA85C /* ContactViewModel.swift in Sources */, D72992392ADD7F68003AF125 /* HistoryContactFragment.swift in Sources */, + D7B5066D2AEFA9B900CEB4E9 /* ContactInnerFragment.swift in Sources */, D7E6D04D2AEBD77600A57AAF /* CustomBottomSheet.swift in Sources */, D74C9D012ACB098C0021626A /* PermissionManager.swift in Sources */, D7702EF22AC7205000557C00 /* WelcomeView.swift in Sources */, @@ -575,6 +582,7 @@ D7DA67642ACCB31700E95002 /* ProfileModeFragment.swift in Sources */, D74C9CFC2ACACF370021626A /* WelcomePage3Fragment.swift in Sources */, D719ABCC2ABC769C00B41C10 /* AssistantView.swift in Sources */, + D7C365082AEFAB7F00FE6142 /* ContactListBottomSheet.swift in Sources */, D7E6D04B2AE9347D00A57AAF /* FavoriteContactsListViewModel.swift in Sources */, D74C9CFA2ACACF2D0021626A /* WelcomePage2Fragment.swift in Sources */, D74C9CFF2ACAEC5E0021626A /* PopupView.swift in Sources */, diff --git a/Linphone/Assets.xcassets/bell-ringing_slash.imageset/Contents.json b/Linphone/Assets.xcassets/bell-ringing_slash.imageset/Contents.json new file mode 100644 index 000000000..8ebcc98af --- /dev/null +++ b/Linphone/Assets.xcassets/bell-ringing_slash.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "bell-ringing_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-ringing_slash.imageset/bell-ringing_slash.svg b/Linphone/Assets.xcassets/bell-ringing_slash.imageset/bell-ringing_slash.svg new file mode 100644 index 000000000..16e263982 --- /dev/null +++ b/Linphone/Assets.xcassets/bell-ringing_slash.imageset/bell-ringing_slash.svg @@ -0,0 +1,3 @@ + + + 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..8f7d61055 --- /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/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..1b1334c76 --- /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/empty.imageset/Contents.json b/Linphone/Assets.xcassets/empty.imageset/Contents.json new file mode 100644 index 000000000..f14fd78f2 --- /dev/null +++ b/Linphone/Assets.xcassets/empty.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "empty.svg", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Linphone/Assets.xcassets/empty.imageset/empty.svg b/Linphone/Assets.xcassets/empty.imageset/empty.svg new file mode 100644 index 000000000..3de6a876d --- /dev/null +++ b/Linphone/Assets.xcassets/empty.imageset/empty.svg @@ -0,0 +1,3 @@ + + + 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..42d30e72f --- /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/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/Contacts/ContactsManager.swift b/Linphone/Contacts/ContactsManager.swift index a7a845569..1e31e8716 100644 --- a/Linphone/Contacts/ContactsManager.swift +++ b/Linphone/Contacts/ContactsManager.swift @@ -179,7 +179,8 @@ final class ContactsManager: ObservableObject { contact.phoneNumbers.forEach { phone in do { if (friendPhoneNumbers.firstIndex(where: {$0.numLabel == phone.numLabel})) == nil { - let phoneNumber = try Factory.Instance.createFriendPhoneNumber(phoneNumber: phone.num, label: phone.numLabel) + 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) } @@ -190,6 +191,8 @@ final class ContactsManager: ObservableObject { let contactImage = result.dropFirst(8) friend.photo = "file:/" + contactImage + + friend.organization = contact.organizationName friend.done() diff --git a/Linphone/Localizable.xcstrings b/Linphone/Localizable.xcstrings index ab4385540..79915ed4f 100644 --- a/Linphone/Localizable.xcstrings +++ b/Linphone/Localizable.xcstrings @@ -27,6 +27,9 @@ }, "**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." : { @@ -98,6 +101,9 @@ }, "All contacts" : { + }, + "Appel" : { + }, "assistant_account_login" : { "extractionState" : "manual", @@ -115,9 +121,21 @@ } } } + }, + "Block" : { + + }, + "Block the address" : { + + }, + "Block the number" : { + }, "Calls" : { + }, + "Cancel" : { + }, "Ce mode vous permet d’être interopérable avec d’autres services SIP.\nVos communications seront chiffrées de point à point. " : { @@ -136,6 +154,12 @@ }, "Continue" : { + }, + "Copy address" : { + + }, + "Copy number" : { + }, "D'accord" : { @@ -148,6 +172,12 @@ }, "Delete" : { + }, + "Delete %@?" : { + + }, + "Delete this contact" : { + }, "Demande d’autorisations" : { @@ -160,12 +190,21 @@ }, "Domain" : { + }, + "Edit" : { + }, "En continuant, vous acceptez ces conditions, " : { + }, + "En ligne" : { + }, "Error" : { + }, + "Error Name" : { + }, "Favourites" : { @@ -178,6 +217,9 @@ }, "I understand" : { + }, + "Information" : { + }, "Interoperable" : { @@ -190,6 +232,9 @@ }, "Invalide URI" : { + }, + "Invitation" : { + }, "Linphone" : { @@ -199,6 +244,12 @@ }, "Logout" : { + }, + "Message" : { + + }, + "Mute" : { + }, "My Profile" : { @@ -214,12 +265,18 @@ }, "Not account yet?" : { + }, + "Ok" : { + }, "Open source" : { }, "Opération en cours..." : { + }, + "Other actions" : { + }, "password" : { "extractionState" : "manual", @@ -240,6 +297,9 @@ }, "Personnalize your profil mode" : { + }, + "Phone (%@) :" : { + }, "Plus tard" : { @@ -273,6 +333,9 @@ }, "Share" : { + }, + "SIP address :" : { + }, "sip.linphone.org" : { @@ -288,6 +351,9 @@ }, "The user name or password is incorrects" : { + }, + "This contact will be deleted definitively." : { + }, "TLS" : { @@ -329,6 +395,9 @@ } } } + }, + "Video Call" : { + }, "Vos communications sont en sécurité grâce aux **Chiffrement de bout en bout**." : { diff --git a/Linphone/UI/Assistant/Fragments/PermissionsFragment.swift b/Linphone/UI/Assistant/Fragments/PermissionsFragment.swift index 979428da3..efb732cd4 100644 --- a/Linphone/UI/Assistant/Fragments/PermissionsFragment.swift +++ b/Linphone/UI/Assistant/Fragments/PermissionsFragment.swift @@ -79,11 +79,6 @@ struct PermissionsFragment: View { .resizable() .foregroundStyle(Color.grayMain2c500) .frame(width: 20, height: 20, alignment: .leading) - .onTapGesture { - withAnimation { - dismiss() - } - } } .padding(16) .background(Color.grayMain2c200) @@ -102,11 +97,6 @@ struct PermissionsFragment: View { .resizable() .foregroundStyle(Color.grayMain2c500) .frame(width: 20, height: 20, alignment: .leading) - .onTapGesture { - withAnimation { - dismiss() - } - } } .padding(16) .background(Color.grayMain2c200) @@ -125,11 +115,6 @@ struct PermissionsFragment: View { .resizable() .foregroundStyle(Color.grayMain2c500) .frame(width: 20, height: 20, alignment: .leading) - .onTapGesture { - withAnimation { - dismiss() - } - } } .padding(16) .background(Color.grayMain2c200) @@ -148,11 +133,6 @@ struct PermissionsFragment: View { .resizable() .foregroundStyle(Color.grayMain2c500) .frame(width: 20, height: 20, alignment: .leading) - .onTapGesture { - withAnimation { - dismiss() - } - } } .padding(16) .background(Color.grayMain2c200) diff --git a/Linphone/UI/Assistant/Fragments/ThirdPartySipAccountWarningFragment.swift b/Linphone/UI/Assistant/Fragments/ThirdPartySipAccountWarningFragment.swift index 4ce290e27..85d3f32a1 100644 --- a/Linphone/UI/Assistant/Fragments/ThirdPartySipAccountWarningFragment.swift +++ b/Linphone/UI/Assistant/Fragments/ThirdPartySipAccountWarningFragment.swift @@ -77,11 +77,6 @@ struct ThirdPartySipAccountWarningFragment: View { .resizable() .foregroundStyle(Color.grayMain2c500) .frame(width: 20, height: 20, alignment: .leading) - .onTapGesture { - withAnimation { - dismiss() - } - } } .padding(16) .background(Color.grayMain2c200) @@ -94,11 +89,6 @@ struct ThirdPartySipAccountWarningFragment: View { .resizable() .foregroundStyle(Color.grayMain2c500) .frame(width: 20, height: 20, alignment: .leading) - .onTapGesture { - withAnimation { - dismiss() - } - } } .padding(16) .background(Color.grayMain2c200) diff --git a/Linphone/UI/Main/Contacts/ContactsView.swift b/Linphone/UI/Main/Contacts/ContactsView.swift index 74098595a..2b9bde65a 100644 --- a/Linphone/UI/Main/Contacts/ContactsView.swift +++ b/Linphone/UI/Main/Contacts/ContactsView.swift @@ -23,12 +23,14 @@ struct ContactsView: View { @ObservedObject var contactViewModel: ContactViewModel @ObservedObject var historyViewModel: HistoryViewModel - + + @Binding var isShowDeletePopup: Bool + var body: some View { NavigationView { ZStack(alignment: .bottomTrailing) { - ContactsFragment(contactViewModel: contactViewModel) + ContactsFragment(contactViewModel: contactViewModel, isShowDeletePopup: $isShowDeletePopup) Button { // Action @@ -48,5 +50,5 @@ struct ContactsView: View { } #Preview { - ContactsView(contactViewModel: ContactViewModel(), historyViewModel: HistoryViewModel()) + ContactsView(contactViewModel: ContactViewModel(), historyViewModel: HistoryViewModel(), isShowDeletePopup: .constant(false)) } diff --git a/Linphone/UI/Main/Contacts/Fragments/ContactFragment.swift b/Linphone/UI/Main/Contacts/Fragments/ContactFragment.swift index c3684a141..042169629 100644 --- a/Linphone/UI/Main/Contacts/Fragments/ContactFragment.swift +++ b/Linphone/UI/Main/Contacts/Fragments/ContactFragment.swift @@ -20,50 +20,30 @@ import SwiftUI struct ContactFragment: View { - - @ObservedObject var contactViewModel: ContactViewModel - - @State private var orientation = UIDevice.current.orientation - - var body: some View { - VStack(alignment: .leading) { - - if !(orientation == .landscapeLeft - || orientation == .landscapeRight - || UIScreen.main.bounds.size.width > UIScreen.main.bounds.size.height) { - HStack { - Image("caret-left") - .renderingMode(.template) - .resizable() - .foregroundStyle(Color.grayMain2c500) - .frame(width: 25, height: 25, alignment: .leading) - .padding(.top, 20) - .onTapGesture { - withAnimation { - contactViewModel.contactTitle = "" - } - } - - Spacer() - } - .padding(.leading) - } - - Spacer() - - Text("Contact Fragment " + contactViewModel.contactTitle) - .frame(maxWidth: .infinity) - - Spacer() - } - .navigationBarHidden(true) - .onRotate { newOrientation in - orientation = newOrientation - } - - } + + @ObservedObject var contactViewModel: ContactViewModel + + @Binding var isShowDeletePopup: Bool + + @State private var showingSheet = false + + var body: some View { + if #available(iOS 16.0, *) { + ContactInnerFragment(contactViewModel: contactViewModel, isShowDeletePopup: $isShowDeletePopup, showingSheet: $showingSheet) + .sheet(isPresented: $showingSheet) { + ContactListBottomSheet(contactViewModel: contactViewModel, showingSheet: $showingSheet) + .presentationDetents([.fraction(0.2)]) + } + } else { + ContactInnerFragment(contactViewModel: contactViewModel, isShowDeletePopup: $isShowDeletePopup, showingSheet: $showingSheet) + .halfSheet(showSheet: $showingSheet) { + ContactListBottomSheet(contactViewModel: contactViewModel, showingSheet: $showingSheet) + } onDismiss: {} + } + + } } #Preview { - ContactFragment(contactViewModel: ContactViewModel()) + ContactFragment(contactViewModel: ContactViewModel(), isShowDeletePopup: .constant(false)) } diff --git a/Linphone/UI/Main/Contacts/Fragments/ContactInnerFragment.swift b/Linphone/UI/Main/Contacts/Fragments/ContactInnerFragment.swift new file mode 100644 index 000000000..fab0b5d73 --- /dev/null +++ b/Linphone/UI/Main/Contacts/Fragments/ContactInnerFragment.swift @@ -0,0 +1,564 @@ +/* + * 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 ContactInnerFragment: View { + + @ObservedObject var contactViewModel: ContactViewModel + + @State private var orientation = UIDevice.current.orientation + + @State private var informationIsOpen = true + + @Binding var isShowDeletePopup: Bool + + @Binding var showingSheet: Bool + + var body: some View { + 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(.top, 2) + .onTapGesture { + withAnimation { + contactViewModel.displayedFriend = nil + } + } + } + + Spacer() + + Image("pencil-simple") + .renderingMode(.template) + .resizable() + .foregroundStyle(Color.orangeMain500) + .frame(width: 25, height: 25, alignment: .leading) + .padding(.top, 2) + .onTapGesture { + withAnimation { + + } + } + } + .frame(maxWidth: .infinity) + .frame(height: 50) + .padding(.horizontal) + .padding(.bottom, 4) + .background(.white) + + ScrollView { + VStack(spacing: 0) { + VStack(spacing: 0) { + if contactViewModel.displayedFriend != nil + && contactViewModel.displayedFriend!.photo != nil + && !contactViewModel.displayedFriend!.photo!.isEmpty { + AsyncImage(url: URL(string: contactViewModel.displayedFriend!.photo!)) { image in + switch image { + case .empty: + ProgressView() + .frame(width: 100, height: 100) + case .success(let image): + image + .resizable() + .frame(width: 100, height: 100) + .clipShape(Circle()) + case .failure: + Image("profil-picture-default") + .resizable() + .frame(width: 100, height: 100) + .clipShape(Circle()) + @unknown default: + EmptyView() + } + } + } else if contactViewModel.displayedFriend != nil { + Image("profil-picture-default") + .resizable() + .frame(width: 100, height: 100) + .clipShape(Circle()) + } + if contactViewModel.displayedFriend != nil && contactViewModel.displayedFriend?.name != nil { + Text((contactViewModel.displayedFriend?.name)!) + .foregroundStyle(Color.grayMain2c700) + .multilineTextAlignment(.center) + .default_text_style(styleSize: 14) + .frame(maxWidth: .infinity) + .padding(.top, 10) + + Text("En ligne") + .foregroundStyle(Color.greenSuccess500) + .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: { + + }, label: { + VStack { + HStack(alignment: .center) { + Image("phone") + .renderingMode(.template) + .resizable() + .foregroundStyle(Color.grayMain2c600) + .frame(width: 25, height: 25) + .onTapGesture { + withAnimation { + + } + } + } + .padding(16) + .background(Color.grayMain2c200) + .cornerRadius(40) + + Text("Appel") + .default_text_style(styleSize: 14) + } + }) + + Spacer() + + Button(action: { + + }, label: { + VStack { + HStack(alignment: .center) { + Image("chat-teardrop-text") + .renderingMode(.template) + .resizable() + .foregroundStyle(Color.grayMain2c600) + .frame(width: 25, height: 25) + .onTapGesture { + withAnimation { + + } + } + } + .padding(16) + .background(Color.grayMain2c200) + .cornerRadius(40) + + Text("Message") + .default_text_style(styleSize: 14) + } + }) + + Spacer() + + Button(action: { + + }, label: { + VStack { + HStack(alignment: .center) { + Image("video-camera") + .renderingMode(.template) + .resizable() + .foregroundStyle(Color.grayMain2c600) + .frame(width: 25, height: 25) + .onTapGesture { + withAnimation { + + } + } + } + .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) + + 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(.top, 30) + .padding(.bottom, 10) + .padding(.horizontal, 16) + .background(Color.gray100) + .onTapGesture { + withAnimation { + informationIsOpen.toggle() + } + } + + if informationIsOpen { + VStack(spacing: 0) { + if contactViewModel.displayedFriend != nil { + ForEach(0... + */ + +import SwiftUI +import UniformTypeIdentifiers + +struct ContactListBottomSheet: View { + + @ObservedObject var magicSearch = MagicSearchSingleton.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 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() + } + } label: { + HStack { + Image("copy") + .renderingMode(.template) + .resizable() + .foregroundStyle(Color.grayMain2c500) + .frame(width: 25, height: 25, alignment: .leading) + 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, *) { + showingSheet.toggle() + } else { + showingSheet.toggle() + dismiss() + } + } label: { + HStack { + Image("envelope-simple-open") + .renderingMode(.template) + .resizable() + .foregroundStyle(Color.grayMain2c500) + .frame(width: 25, height: 25, alignment: .leading) + 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("empty") + .renderingMode(.template) + .resizable() + .foregroundStyle(Color.grayMain2c500) + .frame(width: 25, height: 25, alignment: .leading) + 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 index 5c0b65bb0..9196f23cc 100644 --- a/Linphone/UI/Main/Contacts/Fragments/ContactsFragment.swift +++ b/Linphone/UI/Main/Contacts/Fragments/ContactsFragment.swift @@ -22,26 +22,29 @@ import SwiftUI struct ContactsFragment: View { @ObservedObject var contactViewModel: ContactViewModel + + @Binding var isShowDeletePopup: Bool @State private var showingSheet = false var body: some View { - if #available(iOS 16.0, *) { - ContactsInnerFragment(contactViewModel: contactViewModel, showingSheet: $showingSheet) - .sheet(isPresented: $showingSheet) { - ContactsListBottomSheet(contactViewModel: contactViewModel, showingSheet: $showingSheet) - .presentationDetents([.fraction(0.2)]) - } - } else { - ContactsInnerFragment(contactViewModel: contactViewModel, showingSheet: $showingSheet) - .halfSheet(showSheet: $showingSheet) { - ContactsListBottomSheet(contactViewModel: contactViewModel, showingSheet: $showingSheet) - } onDismiss: {} - } - + ZStack { + if #available(iOS 16.0, *) { + ContactsInnerFragment(contactViewModel: contactViewModel, showingSheet: $showingSheet) + .sheet(isPresented: $showingSheet) { + ContactsListBottomSheet(contactViewModel: contactViewModel, isShowDeletePopup: $isShowDeletePopup, showingSheet: $showingSheet) + .presentationDetents([.fraction(0.2)]) + } + } else { + ContactsInnerFragment(contactViewModel: contactViewModel, showingSheet: $showingSheet) + .halfSheet(showSheet: $showingSheet) { + ContactsListBottomSheet(contactViewModel: contactViewModel, isShowDeletePopup: $isShowDeletePopup, showingSheet: $showingSheet) + } onDismiss: {} + } + } } } #Preview { - ContactsFragment(contactViewModel: ContactViewModel()) + ContactsFragment(contactViewModel: ContactViewModel(), isShowDeletePopup: .constant(false)) } diff --git a/Linphone/UI/Main/Contacts/Fragments/ContactsInnerFragment.swift b/Linphone/UI/Main/Contacts/Fragments/ContactsInnerFragment.swift index a04b3bc68..abfd51b4d 100644 --- a/Linphone/UI/Main/Contacts/Fragments/ContactsInnerFragment.swift +++ b/Linphone/UI/Main/Contacts/Fragments/ContactsInnerFragment.swift @@ -53,9 +53,12 @@ struct ContactsInnerFragment: View { } if isFavoriteOpen { - FavoriteContactsListFragment(contactViewModel: contactViewModel, favoriteContactsListViewModel: FavoriteContactsListViewModel(), showingSheet: $showingSheet) - .zIndex(-1) - .transition(.move(edge: .top)) + FavoriteContactsListFragment( + contactViewModel: contactViewModel, + favoriteContactsListViewModel: FavoriteContactsListViewModel(), + showingSheet: $showingSheet) + .zIndex(-1) + .transition(.move(edge: .top)) } HStack(alignment: .center) { diff --git a/Linphone/UI/Main/Contacts/Fragments/ContactsListBottomSheet.swift b/Linphone/UI/Main/Contacts/Fragments/ContactsListBottomSheet.swift index a55a0b96b..cd0dbd455 100644 --- a/Linphone/UI/Main/Contacts/Fragments/ContactsListBottomSheet.swift +++ b/Linphone/UI/Main/Contacts/Fragments/ContactsListBottomSheet.swift @@ -29,6 +29,8 @@ struct ContactsListBottomSheet: View { @State private var orientation = UIDevice.current.orientation @Environment(\.dismiss) var dismiss + + @Binding var isShowDeletePopup: Bool @Binding var showingSheet: Bool @@ -118,9 +120,8 @@ struct ContactsListBottomSheet: View { Button { if contactViewModel.selectedFriend != nil { - contactViewModel.selectedFriend!.remove() + isShowDeletePopup.toggle() } - self.magicSearch.searchForContacts(sourceFlags: MagicSearch.Source.Friends.rawValue | MagicSearch.Source.LdapServers.rawValue) if #available(iOS 16.0, *) { showingSheet.toggle() diff --git a/Linphone/UI/Main/Contacts/Fragments/ContactsListFragment.swift b/Linphone/UI/Main/Contacts/Fragments/ContactsListFragment.swift index 527488c95..600d7f179 100644 --- a/Linphone/UI/Main/Contacts/Fragments/ContactsListFragment.swift +++ b/Linphone/UI/Main/Contacts/Fragments/ContactsListFragment.swift @@ -36,9 +36,21 @@ struct ContactsListFragment: View { Button { } label: { HStack { - if index == 0 || magicSearch.lastSearch[index].friend?.name!.lowercased().folding(options: .diacriticInsensitive, locale: .current).first != - magicSearch.lastSearch[index-1].friend?.name!.lowercased().folding(options: .diacriticInsensitive, locale: .current).first { - Text(String((magicSearch.lastSearch[index].friend?.name!.uppercased().folding(options: .diacriticInsensitive, locale: .current).first)!)) + if index == 0 + || magicSearch.lastSearch[index].friend?.name!.lowercased().folding( + options: .diacriticInsensitive, + locale: .current + ).first + != magicSearch.lastSearch[index-1].friend?.name!.lowercased().folding( + options: .diacriticInsensitive, + locale: .current + ).first { + Text( + String( + (magicSearch.lastSearch[index].friend?.name!.uppercased().folding( + options: .diacriticInsensitive, + locale: .current + ).first)!)) .contact_text_style_500(styleSize: 20) .frame(width: 18) .padding(.leading, -5) @@ -79,7 +91,7 @@ struct ContactsListFragment: View { } Text((magicSearch.lastSearch[index].friend?.name)!) .default_text_style(styleSize: 16) - .frame( maxWidth: .infinity, alignment: .leading) + .frame(maxWidth: .infinity, alignment: .leading) .foregroundStyle(Color.orangeMain500) } } @@ -94,7 +106,7 @@ struct ContactsListFragment: View { TapGesture() .onEnded { _ in withAnimation { - contactViewModel.contactTitle = (magicSearch.lastSearch[index].friend?.name)! + contactViewModel.displayedFriend = magicSearch.lastSearch[index].friend } } ) diff --git a/Linphone/UI/Main/Contacts/Fragments/FavoriteContactsListFragment.swift b/Linphone/UI/Main/Contacts/Fragments/FavoriteContactsListFragment.swift index b917a2a38..dc4434c64 100644 --- a/Linphone/UI/Main/Contacts/Fragments/FavoriteContactsListFragment.swift +++ b/Linphone/UI/Main/Contacts/Fragments/FavoriteContactsListFragment.swift @@ -38,7 +38,9 @@ struct FavoriteContactsListFragment: View { VStack { if magicSearch.lastSearch.filter({ $0.friend?.starred == true })[index].friend!.photo != nil && !magicSearch.lastSearch.filter({ $0.friend?.starred == true })[index].friend!.photo!.isEmpty { - AsyncImage(url: URL(string: magicSearch.lastSearch.filter({ $0.friend?.starred == true })[index].friend!.photo!)) { image in + AsyncImage( + url: URL(string: magicSearch.lastSearch.filter({ $0.friend?.starred == true })[index].friend!.photo!) + ) { image in switch image { case .empty: ProgressView() @@ -79,7 +81,9 @@ struct FavoriteContactsListFragment: View { TapGesture() .onEnded { _ in withAnimation { - contactViewModel.contactTitle = (magicSearch.lastSearch.filter({ $0.friend?.starred == true })[index].friend?.name)! + contactViewModel.displayedFriend = ( + magicSearch.lastSearch.filter({ $0.friend?.starred == true })[index].friend + )! } } ) @@ -92,5 +96,8 @@ struct FavoriteContactsListFragment: View { } #Preview { - FavoriteContactsListFragment(contactViewModel: ContactViewModel(), favoriteContactsListViewModel: FavoriteContactsListViewModel(), showingSheet: .constant(false)) + FavoriteContactsListFragment( + contactViewModel: ContactViewModel(), + favoriteContactsListViewModel: FavoriteContactsListViewModel(), + showingSheet: .constant(false)) } diff --git a/Linphone/UI/Main/Contacts/ViewModel/ContactViewModel.swift b/Linphone/UI/Main/Contacts/ViewModel/ContactViewModel.swift index 4074721ba..7cca97313 100644 --- a/Linphone/UI/Main/Contacts/ViewModel/ContactViewModel.swift +++ b/Linphone/UI/Main/Contacts/ViewModel/ContactViewModel.swift @@ -21,7 +21,8 @@ import linphonesw class ContactViewModel: ObservableObject { - @Published var contactTitle: String = "" + @Published var displayedFriend: Friend? + var stringToCopy: String = "" var selectedFriend: Friend? diff --git a/Linphone/UI/Main/ContentView.swift b/Linphone/UI/Main/ContentView.swift index a6fc69720..386304205 100644 --- a/Linphone/UI/Main/ContentView.swift +++ b/Linphone/UI/Main/ContentView.swift @@ -21,413 +21,451 @@ import SwiftUI import linphonesw struct ContentView: View { - - var contactManager = ContactsManager.shared - var magicSearch = MagicSearchSingleton.shared - - @ObservedObject var contactViewModel: ContactViewModel - @ObservedObject var historyViewModel: HistoryViewModel - @ObservedObject private var coreContext = CoreContext.shared - - @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 var isMenuOpen: Bool = false - - var body: some View { - GeometryReader { geometry in - ZStack { - VStack(spacing: 0) { - HStack(spacing: 0) { - if orientation == .landscapeLeft - || orientation == .landscapeRight - || UIScreen.main.bounds.size.width > UIScreen.main.bounds.size.height { - VStack { - Group { - Spacer() - Button(action: { - self.index = 0 - }, 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) - } - } - }) - - Spacer() - - Button(action: { - self.index = 1 - contactViewModel.contactTitle = "" - }, 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) - } - } - }) - - Spacer() - } - } - .frame(width: 75) - .padding(.leading, - orientation == .landscapeRight && geometry.safeAreaInsets.bottom > 0 - ? -geometry.safeAreaInsets.leading - : 0) - } - - 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" : "Calls") - .default_text_style_white_800(styleSize: 20) - .padding(.leading, 10) - - Spacer() - - Button { - withAnimation { - searchIsActive.toggle() - } - } label: { - Image("search") - } - - Menu { - Button { - isMenuOpen = false - magicSearch.allContact = true - magicSearch.searchForContacts( - sourceFlags: MagicSearch.Source.Friends.rawValue | MagicSearch.Source.LdapServers.rawValue) - } label: { - HStack { - Text("See all") - Spacer() - if magicSearch.allContact { - Image("green-check") - } - } - } - - Button { - isMenuOpen = false - magicSearch.allContact = false - magicSearch.searchForContacts( - sourceFlags: MagicSearch.Source.Friends.rawValue | MagicSearch.Source.LdapServers.rawValue) - } label: { - HStack { - Text("See Linphone contact") - Spacer() - if !magicSearch.allContact { - Image("green-check") - } - } - } - } label: { - Image(index == 0 ? "filtres" : "more") - } - .padding(.leading) - .onTapGesture { - isMenuOpen = true - } - } - .frame(maxWidth: .infinity) - .frame(height: 50) - .padding(.horizontal) - .padding(.bottom, 5) - .background(Color.orangeMain500) - } else { - HStack { - Button { - withAnimation { - self.focusedField = false - searchIsActive.toggle() - } - - text = "" - magicSearch.currentFilter = "" - magicSearch.searchForContacts( - sourceFlags: MagicSearch.Source.Friends.rawValue | MagicSearch.Source.LdapServers.rawValue) - } label: { - Image("caret-left") - .renderingMode(.template) - .resizable() - .foregroundStyle(.white) - .frame(width: 25, height: 25, alignment: .leading) - } - - 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) - .accentColor(.white) - .scrollContentBackground(.hidden) - .focused($focusedField) - .onAppear { - self.focusedField = true - } - .onChange(of: text) { newValue in - magicSearch.currentFilter = newValue - magicSearch.searchForContacts( - sourceFlags: MagicSearch.Source.Friends.rawValue | MagicSearch.Source.LdapServers.rawValue) - } - } 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_white_700(styleSize: 15) - .padding(.all, 6) - .accentColor(.white) - .focused($focusedField) - .onAppear { - self.focusedField = true - } - .onChange(of: text) { newValue in - magicSearch.currentFilter = newValue - magicSearch.searchForContacts( - sourceFlags: MagicSearch.Source.Friends.rawValue | MagicSearch.Source.LdapServers.rawValue) - } - } - - Button { - text = "" - } label: { - Image("x") - .renderingMode(.template) - .resizable() - .foregroundStyle(.white) - .frame(width: 25, height: 25, alignment: .leading) - } - .padding(.leading) - } - .frame(maxWidth: .infinity) - .frame(height: 50) - .padding(.horizontal) - .padding(.bottom, 5) - .background(Color.orangeMain500) - } - - if self.index == 0 { - ContactsView(contactViewModel: contactViewModel, historyViewModel: historyViewModel) - } else if self.index == 1 { - HistoryView() - } - } - .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 - }, 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) - - Spacer() - - Button(action: { - self.index = 1 - contactViewModel.contactTitle = "" - }, 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) - 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.contactTitle.isEmpty || !historyViewModel.historyTitle.isEmpty { - 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) - .frame(maxWidth: .infinity) - .background(Color.gray100) - .ignoresSafeArea(.keyboard) - } else if self.index == 1 { - HistoryContactFragment() - .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 - ) - .ignoresSafeArea(.all) - .zIndex(2) - } - } - .overlay { - if isMenuOpen { - Color.white.opacity(0.001) - .ignoresSafeArea() - .frame(maxWidth: .infinity, maxHeight: .infinity) - .onTapGesture { - isMenuOpen = false - } - } - } - .onRotate { newOrientation in - if (!contactViewModel.contactTitle.isEmpty || !historyViewModel.historyTitle.isEmpty) && searchIsActive { - self.focusedField = false - } else if searchIsActive { - self.focusedField = true - } - orientation = newOrientation - } - } - - func openMenu() { - withAnimation { - self.sideMenuIsOpen.toggle() - } - } + + var contactManager = ContactsManager.shared + var magicSearch = MagicSearchSingleton.shared + + @ObservedObject var contactViewModel: ContactViewModel + @ObservedObject var historyViewModel: HistoryViewModel + @ObservedObject private var coreContext = CoreContext.shared + + @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 var isMenuOpen: Bool = false + @State var isShowDeletePopup = false + + var body: some View { + GeometryReader { geometry in + ZStack { + VStack(spacing: 0) { + HStack(spacing: 0) { + if orientation == .landscapeLeft + || orientation == .landscapeRight + || UIScreen.main.bounds.size.width > UIScreen.main.bounds.size.height { + VStack { + Group { + Spacer() + Button(action: { + self.index = 0 + }, 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) + } + } + }) + + Spacer() + + Button(action: { + self.index = 1 + contactViewModel.displayedFriend = nil + }, 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) + } + } + }) + + Spacer() + } + } + .frame(width: 75) + .padding(.leading, + orientation == .landscapeRight && geometry.safeAreaInsets.bottom > 0 + ? -geometry.safeAreaInsets.leading + : 0) + } + + 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" : "Calls") + .default_text_style_white_800(styleSize: 20) + .padding(.leading, 10) + + Spacer() + + Button { + withAnimation { + searchIsActive.toggle() + } + } label: { + Image("search") + } + + Menu { + Button { + isMenuOpen = false + magicSearch.allContact = true + magicSearch.searchForContacts( + sourceFlags: MagicSearch.Source.Friends.rawValue | MagicSearch.Source.LdapServers.rawValue) + } label: { + HStack { + Text("See all") + Spacer() + if magicSearch.allContact { + Image("green-check") + } + } + } + + Button { + isMenuOpen = false + magicSearch.allContact = false + magicSearch.searchForContacts( + sourceFlags: MagicSearch.Source.Friends.rawValue | MagicSearch.Source.LdapServers.rawValue) + } label: { + HStack { + Text("See Linphone contact") + Spacer() + if !magicSearch.allContact { + Image("green-check") + } + } + } + } label: { + Image(index == 0 ? "filtres" : "more") + } + .padding(.leading) + .onTapGesture { + isMenuOpen = true + } + } + .frame(maxWidth: .infinity) + .frame(height: 50) + .padding(.horizontal) + .padding(.bottom, 5) + .background(Color.orangeMain500) + } else { + HStack { + Button { + withAnimation { + self.focusedField = false + searchIsActive.toggle() + } + + text = "" + magicSearch.currentFilter = "" + magicSearch.searchForContacts( + sourceFlags: MagicSearch.Source.Friends.rawValue | MagicSearch.Source.LdapServers.rawValue) + } label: { + Image("caret-left") + .renderingMode(.template) + .resizable() + .foregroundStyle(.white) + .frame(width: 25, height: 25, alignment: .leading) + } + + 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) + .accentColor(.white) + .scrollContentBackground(.hidden) + .focused($focusedField) + .onAppear { + self.focusedField = true + } + .onChange(of: text) { newValue in + magicSearch.currentFilter = newValue + magicSearch.searchForContacts( + sourceFlags: MagicSearch.Source.Friends.rawValue | MagicSearch.Source.LdapServers.rawValue) + } + } 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_white_700(styleSize: 15) + .padding(.all, 6) + .accentColor(.white) + .focused($focusedField) + .onAppear { + self.focusedField = true + } + .onChange(of: text) { newValue in + magicSearch.currentFilter = newValue + magicSearch.searchForContacts( + sourceFlags: MagicSearch.Source.Friends.rawValue | MagicSearch.Source.LdapServers.rawValue) + } + } + + Button { + text = "" + } label: { + Image("x") + .renderingMode(.template) + .resizable() + .foregroundStyle(.white) + .frame(width: 25, height: 25, alignment: .leading) + } + .padding(.leading) + } + .frame(maxWidth: .infinity) + .frame(height: 50) + .padding(.horizontal) + .padding(.bottom, 5) + .background(Color.orangeMain500) + } + + if self.index == 0 { + ContactsView(contactViewModel: contactViewModel, historyViewModel: historyViewModel, isShowDeletePopup: $isShowDeletePopup) + } else if self.index == 1 { + HistoryView() + } + } + .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 + }, 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) + + Spacer() + + Button(action: { + self.index = 1 + contactViewModel.displayedFriend = nil + }, 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) + 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.displayedFriend != nil || !historyViewModel.historyTitle.isEmpty { + 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, isShowDeletePopup: $isShowDeletePopup) + .frame(maxWidth: .infinity) + .background(Color.gray100) + .ignoresSafeArea(.keyboard) + } else if self.index == 1 { + HistoryContactFragment() + .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 + ) + .ignoresSafeArea(.all) + .zIndex(2) + + if isShowDeletePopup { + PopupView(sharedMainViewModel: SharedMainViewModel(), isShowPopup: $isShowDeletePopup, + title: Text( + contactViewModel.selectedFriend != nil + ? "Delete \(contactViewModel.selectedFriend!.name!)?" + : (contactViewModel.displayedFriend != nil + ? "Delete \(contactViewModel.displayedFriend!.name!)?" + : "Error Name")), + content: Text("This contact will be deleted definitively."), + titleFirstButton: Text("Cancel"), + actionFirstButton: {self.isShowDeletePopup.toggle()}, + titleSecondButton: Text("Ok"), + actionSecondButton: { + if contactViewModel.selectedFriend != nil { + contactViewModel.selectedFriend!.remove() + if contactViewModel.displayedFriend != nil && contactViewModel.selectedFriend!.name == contactViewModel.displayedFriend!.name { + withAnimation { + contactViewModel.displayedFriend = nil + } + } + } else if contactViewModel.displayedFriend != nil { + contactViewModel.displayedFriend!.remove() + withAnimation { + contactViewModel.displayedFriend = nil + } + } + magicSearch.searchForContacts( + sourceFlags: MagicSearch.Source.Friends.rawValue | MagicSearch.Source.LdapServers.rawValue) + self.isShowDeletePopup.toggle() + }) + .background(.black.opacity(0.65)) + .zIndex(3) + .onTapGesture { + self.isShowDeletePopup.toggle() + } + } + } + } + .overlay { + if isMenuOpen { + Color.white.opacity(0.001) + .ignoresSafeArea() + .frame(maxWidth: .infinity, maxHeight: .infinity) + .onTapGesture { + isMenuOpen = false + } + } + } + .onRotate { newOrientation in + if (contactViewModel.displayedFriend != nil || !historyViewModel.historyTitle.isEmpty) && searchIsActive { + self.focusedField = false + } else if searchIsActive { + self.focusedField = true + } + orientation = newOrientation + } + } + + func openMenu() { + withAnimation { + self.sideMenuIsOpen.toggle() + } + } } #Preview { - ContentView(contactViewModel: ContactViewModel(), historyViewModel: HistoryViewModel()) + ContentView(contactViewModel: ContactViewModel(), historyViewModel: HistoryViewModel()) } diff --git a/Linphone/UI/Main/Fragments/CustomBottomSheet.swift b/Linphone/UI/Main/Fragments/CustomBottomSheet.swift index cff7bb1d3..18683b01f 100644 --- a/Linphone/UI/Main/Fragments/CustomBottomSheet.swift +++ b/Linphone/UI/Main/Fragments/CustomBottomSheet.swift @@ -20,7 +20,6 @@ import SwiftUI extension View { - //binding show bariable... func halfSheet( showSheet: Binding, @ViewBuilder content: @escaping () -> Content, @@ -33,7 +32,6 @@ extension View { } } -// UIKit integration struct HalfSheetHelper: UIViewControllerRepresentable { var sheetView: Content @@ -58,7 +56,6 @@ struct HalfSheetHelper: UIViewControllerRepresentable { } } - //on dismiss... final class Coordinator: NSObject, UISheetPresentationControllerDelegate { var parent: HalfSheetHelper @@ -73,7 +70,6 @@ struct HalfSheetHelper: UIViewControllerRepresentable { } } -// Custom UIHostingController for halfSheet... final class CustomHostingController: UIHostingController { override func viewDidLoad() { view.backgroundColor = .clear @@ -82,16 +78,11 @@ final class CustomHostingController: UIHostingController .medium() ] - //MARK: - sheet grabber visbility - presentationController.prefersGrabberVisible = false // i wanted to design my own grabber hehehe + presentationController.prefersGrabberVisible = false - // this allows you to scroll even during medium detent presentationController.prefersScrollingExpandsWhenScrolledToEdge = false - //MARK: - sheet corner radius presentationController.preferredCornerRadius = 30 - - // for more sheet customisation check out this great article https://sarunw.com/posts/bottom-sheet-in-ios-15-with-uisheetpresentationcontroller/#scrolling } } } From a71d86e34fe364b92e889e2948923c2898f0eabe Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Mon, 30 Oct 2023 14:31:49 +0100 Subject: [PATCH 028/486] Add all images --- .../address-book.imageset/address-book.svg | 2 +- .../Contents.json | 21 +++++++++++++++++++ .../arrow-bend-up-left-bold.svg | 1 + .../Contents.json | 21 +++++++++++++++++++ .../arrow-bend-up-right-bold.svg | 1 + .../arrow-clockwise.imageset/Contents.json | 21 +++++++++++++++++++ .../arrow-clockwise.svg | 1 + .../arrow-right-fill.imageset/Contents.json | 21 +++++++++++++++++++ .../arrow-right-fill.svg | 1 + .../backspace-fill.imageset/Contents.json | 21 +++++++++++++++++++ .../backspace-fill.svg | 1 + .../bell-ringing.imageset/bell-ringing.svg | 4 +--- .../bell-ringing_slash.svg | 3 --- .../bell-simple-slash.imageset/Contents.json | 21 +++++++++++++++++++ .../bell-simple-slash.svg | 1 + .../bell-simple.imageset/Contents.json | 21 +++++++++++++++++++ .../bell-simple.imageset/bell-simple.svg | 1 + .../bluetooth.imageset/Contents.json | 21 +++++++++++++++++++ .../bluetooth.imageset/bluetooth.svg | 1 + .../calendar-blank.imageset/Contents.json | 21 +++++++++++++++++++ .../calendar-blank.svg | 1 + .../Contents.json | 2 +- .../calendar.imageset/calendar.svg | 1 + .../camera-rotate.imageset/Contents.json | 21 +++++++++++++++++++ .../camera-rotate.imageset/camera-rotate.svg | 1 + .../Contents.json | 2 +- .../camera.imageset/camera.svg | 1 + .../caret-down.imageset/caret-down.svg | 2 +- .../caret-left.imageset/caret-left.svg | 2 +- .../caret-right.imageset/Contents.json | 21 +++++++++++++++++++ .../caret-right.imageset/caret-right.svg | 1 + .../caret-up.imageset/caret-up.svg | 2 +- .../cell-signal-full.imageset/Contents.json | 21 +++++++++++++++++++ .../cell-signal-full.svg | 1 + .../cell-signal-low.imageset/Contents.json | 21 +++++++++++++++++++ .../cell-signal-low.svg | 1 + .../chat-dots.imageset/Contents.json | 21 +++++++++++++++++++ .../chat-dots.imageset/chat-dots.svg | 1 + .../Contents.json | 21 +++++++++++++++++++ .../chat-teardrop-text-slash.svg} | 0 .../chat-teardrop-text.svg | 2 +- .../chat-text.imageset/Contents.json | 21 +++++++++++++++++++ .../chat-text.imageset/chat-text.svg | 1 + .../Contents.json | 21 +++++++++++++++++++ .../check-square-offset.svg | 1 + .../Assets.xcassets/check.imageset/check.svg | 2 +- .../Contents.json | 2 +- .../checks.imageset/checks.svg | 1 + .../clock-countdown.imageset/Contents.json | 21 +++++++++++++++++++ .../clock-countdown.svg | 1 + .../Contents.json | 2 +- .../Assets.xcassets/clock.imageset/clock.svg | 1 + .../contacts.imageset/contacts.svg | 3 --- .../Assets.xcassets/copy.imageset/copy.svg | 2 +- .../danger.imageset/danger.svg | 3 --- .../detective.imageset/Contents.json | 21 +++++++++++++++++++ .../detective.imageset/detective.svg | 1 + .../Contents.json | 21 +++++++++++++++++++ .../dots-three-vertical.svg | 1 + .../Contents.json | 2 +- Linphone/Assets.xcassets/ear.imageset/ear.svg | 1 + .../envelope-simple-open.svg | 2 +- .../envelope-simple.imageset/Contents.json | 21 +++++++++++++++++++ .../envelope-simple.svg | 1 + .../eye-slash.imageset/eye-slash.svg | 2 +- Linphone/Assets.xcassets/eye.imageset/eye.svg | 2 +- .../file-text.imageset/Contents.json | 21 +++++++++++++++++++ .../file-text.imageset/file-text.svg | 1 + .../filtres.imageset/filtres.svg | 3 --- .../funnel.imageset/Contents.json | 21 +++++++++++++++++++ .../funnel.imageset/funnel.svg | 1 + .../gear.imageset/Contents.json | 21 +++++++++++++++++++ .../Assets.xcassets/gear.imageset/gear.svg | 1 + .../Contents.json | 21 +++++++++++++++++++ .../globe-hemisphere-west.svg | 1 + .../Contents.json | 2 +- .../headset.imageset/headset.svg | 1 + .../heart-fill.imageset/Contents.json | 21 +++++++++++++++++++ .../heart-fill.imageset/heart-fill.svg | 1 + .../Assets.xcassets/heart.imageset/heart.svg | 2 +- .../illus-belledonne.imageset/Contents.json | 21 +++++++++++++++++++ .../illus-belledonne.svg} | 0 .../illus-belledonne1.imageset/Contents.json | 21 ------------------- .../in-progress.imageset/Contents.json | 21 +++++++++++++++++++ .../in-progress.imageset/in_progress.svg | 5 +++++ .../Contents.json | 21 +++++++++++++++++++ .../incoming_call_missed.svg | 3 +++ .../Contents.json | 21 +++++++++++++++++++ .../incoming_call_rejected.svg | 3 +++ .../incoming-call.imageset/Contents.json | 21 +++++++++++++++++++ .../incoming-call.imageset/incoming_call.svg | 3 +++ .../Assets.xcassets/info.imageset/info.svg | 2 +- .../keyboard.imageset/Contents.json | 21 +++++++++++++++++++ .../keyboard.imageset/keyboard.svg | 1 + .../linphone.imageset/linphone.svg | 6 ++++-- .../magnifying-glass.imageset/Contents.json | 21 +++++++++++++++++++ .../magnifying-glass.svg | 1 + .../Contents.json | 21 +++++++++++++++++++ .../media_encryption_srtp.svg | 6 ++++++ .../Contents.json | 21 +++++++++++++++++++ .../media_encryption_zrtp_pq.svg | 6 ++++++ .../microphone-slash.imageset/Contents.json | 21 +++++++++++++++++++ .../microphone-slash.svg | 1 + .../microphone-stage.imageset/Contents.json | 21 +++++++++++++++++++ .../microphone-stage.svg | 1 + .../microphone.imageset/microphone.svg | 4 +--- .../Assets.xcassets/more.imageset/more.svg | 3 --- .../mountains.imageset/Contents.json | 21 +++++++++++++++++++ .../mountains.imageset/mountains.svg | 14 +++++++++++++ .../not-trusted.imageset/Contents.json | 21 +++++++++++++++++++ .../not-trusted.imageset/not_trusted.svg | 13 ++++++++++++ .../open-source.imageset/Contents.json | 2 +- .../open-source.imageset/open-source.svg | 3 --- .../open-source.imageset/open_source.svg | 3 +++ .../Contents.json | 21 +++++++++++++++++++ .../outgoing_call_missed.svg | 3 +++ .../Contents.json | 21 +++++++++++++++++++ .../outgoing_call_rejected.svg | 3 +++ .../outgoing-call.imageset/Contents.json | 21 +++++++++++++++++++ .../outgoing-call.imageset/outgoing_call.svg | 3 +++ .../paper-plane-tilt.imageset/Contents.json | 21 +++++++++++++++++++ .../paper-plane-tilt.svg | 1 + .../paperclip.imageset/Contents.json | 21 +++++++++++++++++++ .../paperclip.imageset/paperclip.svg | 1 + .../pause.imageset/Contents.json | 21 +++++++++++++++++++ .../Assets.xcassets/pause.imageset/pause.svg | 1 + .../pencil-simple.imageset/pencil-simple.svg | 2 +- .../phone-call.imageset/Contents.json | 21 +++++++++++++++++++ .../phone-call.imageset/phone-call.svg | 1 + .../phone-disconnect.imageset/Contents.json | 21 +++++++++++++++++++ .../phone-disconnect.svg | 1 + .../phone-plus.imageset/Contents.json | 21 +++++++++++++++++++ .../phone-plus.imageset/phone-plus.svg | 1 + .../Assets.xcassets/phone.imageset/phone.svg | 2 +- .../play.imageset/Contents.json | 21 +++++++++++++++++++ .../Assets.xcassets/play.imageset/play.svg | 1 + .../plus-circle.imageset/Contents.json | 21 +++++++++++++++++++ .../plus-circle.imageset/plus-circle.svg | 1 + .../qr-code.imageset/qr-code.svg | 2 +- .../question.imageset/Contents.json | 21 +++++++++++++++++++ .../question.imageset/question.svg | 1 + .../record-fill.imageset/Contents.json | 21 +++++++++++++++++++ .../record-fill.imageset/record-fill.svg | 1 + .../search.imageset/search.svg | 3 --- .../secure-image.imageset/Contents.json | 21 ------------------- .../secure-image.imageset/secure-image.svg | 6 ------ .../secured.imageset/Contents.json | 21 +++++++++++++++++++ .../secured.imageset/secured.svg | 10 +++++++++ .../share-network.imageset/share-network.svg | 2 +- .../sign-out.imageset/Contents.json | 21 +++++++++++++++++++ .../sign-out.imageset/sign-out.svg | 1 + .../slideshow.imageset/Contents.json | 21 +++++++++++++++++++ .../slideshow.imageset/slideshow.svg | 1 + .../smiley.imageset/Contents.json | 21 +++++++++++++++++++ .../smiley.imageset/smiley.svg | 1 + .../Contents.json | 2 +- .../speaker-high.imageset/speaker-high.svg | 1 + .../speaker-slash.imageset/Contents.json | 21 +++++++++++++++++++ .../speaker-slash.imageset/speaker-slash.svg | 1 + .../success.imageset/Contents.json | 21 ------------------- .../success.imageset/success.svg | 3 --- .../trash-simple.imageset/trash-simple.svg | 2 +- .../trusted.imageset/Contents.json | 21 +++++++++++++++++++ .../trusted.imageset/trusted.svg | 6 ++++++ .../user-circle-gear.imageset/Contents.json | 21 +++++++++++++++++++ .../user-circle-gear.svg | 1 + .../user-circle.imageset/Contents.json | 21 +++++++++++++++++++ .../user-circle.imageset/user-circle.svg | 1 + .../user-plus.imageset/Contents.json | 21 +++++++++++++++++++ .../user-plus.imageset/user-plus.svg | 1 + .../user-square.imageset/Contents.json | 21 +++++++++++++++++++ .../user-square.imageset/user-square.svg | 1 + .../users-three.imageset/Contents.json | 21 +++++++++++++++++++ .../users-three.imageset/users-three.svg | 1 + .../users.imageset/Contents.json | 21 +++++++++++++++++++ .../Assets.xcassets/users.imageset/users.svg | 1 + .../video-call.imageset/Contents.json | 21 ------------------- .../video-call.imageset/VideoCall.svg | 3 --- .../Contents.json | 2 +- .../video-camera-slash.svg | 1 + .../video-camera.imageset/video-camera.svg | 4 +--- .../warning-circle.imageset/Contents.json | 21 +++++++++++++++++++ .../warning-circle.svg | 1 + .../wifi-high.imageset/Contents.json | 21 +++++++++++++++++++ .../wifi-high.imageset/wifi-high.svg | 1 + .../wifi-low.imageset/Contents.json | 21 +++++++++++++++++++ .../wifi-low.imageset/wifi-low.svg | 1 + .../wrench.imageset/Contents.json | 21 +++++++++++++++++++ .../wrench.imageset/wrench.svg | 1 + .../x-circle.imageset/Contents.json | 21 +++++++++++++++++++ .../x-circle.svg} | 0 Linphone/Assets.xcassets/x.imageset/x.svg | 2 +- .../ThirdPartySipAccountWarningFragment.swift | 4 ++-- Linphone/UI/Main/Contacts/ContactsView.swift | 2 +- .../Fragments/ContactInnerFragment.swift | 7 ++++--- .../Fragments/ContactListBottomSheet.swift | 2 +- .../Fragments/ContactsListBottomSheet.swift | 2 +- .../Fragments/ContactsListFragment.swift | 2 +- Linphone/UI/Main/ContentView.swift | 16 ++++++++++++-- Linphone/UI/Main/Fragments/ToastView.swift | 4 +++- Linphone/UI/Main/History/HistoryView.swift | 2 +- .../Fragments/WelcomePage2Fragment.swift | 2 +- 202 files changed, 1654 insertions(+), 169 deletions(-) create mode 100644 Linphone/Assets.xcassets/arrow-bend-up-left-bold.imageset/Contents.json create mode 100644 Linphone/Assets.xcassets/arrow-bend-up-left-bold.imageset/arrow-bend-up-left-bold.svg create mode 100644 Linphone/Assets.xcassets/arrow-bend-up-right-bold.imageset/Contents.json create mode 100644 Linphone/Assets.xcassets/arrow-bend-up-right-bold.imageset/arrow-bend-up-right-bold.svg create mode 100644 Linphone/Assets.xcassets/arrow-clockwise.imageset/Contents.json create mode 100644 Linphone/Assets.xcassets/arrow-clockwise.imageset/arrow-clockwise.svg create mode 100644 Linphone/Assets.xcassets/arrow-right-fill.imageset/Contents.json create mode 100644 Linphone/Assets.xcassets/arrow-right-fill.imageset/arrow-right-fill.svg create mode 100644 Linphone/Assets.xcassets/backspace-fill.imageset/Contents.json create mode 100644 Linphone/Assets.xcassets/backspace-fill.imageset/backspace-fill.svg delete mode 100644 Linphone/Assets.xcassets/bell-ringing_slash.imageset/bell-ringing_slash.svg create mode 100644 Linphone/Assets.xcassets/bell-simple-slash.imageset/Contents.json create mode 100644 Linphone/Assets.xcassets/bell-simple-slash.imageset/bell-simple-slash.svg create mode 100644 Linphone/Assets.xcassets/bell-simple.imageset/Contents.json create mode 100644 Linphone/Assets.xcassets/bell-simple.imageset/bell-simple.svg create mode 100644 Linphone/Assets.xcassets/bluetooth.imageset/Contents.json create mode 100644 Linphone/Assets.xcassets/bluetooth.imageset/bluetooth.svg create mode 100644 Linphone/Assets.xcassets/calendar-blank.imageset/Contents.json create mode 100644 Linphone/Assets.xcassets/calendar-blank.imageset/calendar-blank.svg rename Linphone/Assets.xcassets/{contacts.imageset => calendar.imageset}/Contents.json (88%) create mode 100644 Linphone/Assets.xcassets/calendar.imageset/calendar.svg create mode 100644 Linphone/Assets.xcassets/camera-rotate.imageset/Contents.json create mode 100644 Linphone/Assets.xcassets/camera-rotate.imageset/camera-rotate.svg rename Linphone/Assets.xcassets/{search.imageset => camera.imageset}/Contents.json (89%) create mode 100644 Linphone/Assets.xcassets/camera.imageset/camera.svg create mode 100644 Linphone/Assets.xcassets/caret-right.imageset/Contents.json create mode 100644 Linphone/Assets.xcassets/caret-right.imageset/caret-right.svg create mode 100644 Linphone/Assets.xcassets/cell-signal-full.imageset/Contents.json create mode 100644 Linphone/Assets.xcassets/cell-signal-full.imageset/cell-signal-full.svg create mode 100644 Linphone/Assets.xcassets/cell-signal-low.imageset/Contents.json create mode 100644 Linphone/Assets.xcassets/cell-signal-low.imageset/cell-signal-low.svg create mode 100644 Linphone/Assets.xcassets/chat-dots.imageset/Contents.json create mode 100644 Linphone/Assets.xcassets/chat-dots.imageset/chat-dots.svg create mode 100644 Linphone/Assets.xcassets/chat-teardrop-text-slash.imageset/Contents.json rename Linphone/Assets.xcassets/{conversation.imageset/conversation.svg => chat-teardrop-text-slash.imageset/chat-teardrop-text-slash.svg} (100%) create mode 100644 Linphone/Assets.xcassets/chat-text.imageset/Contents.json create mode 100644 Linphone/Assets.xcassets/chat-text.imageset/chat-text.svg create mode 100644 Linphone/Assets.xcassets/check-square-offset.imageset/Contents.json create mode 100644 Linphone/Assets.xcassets/check-square-offset.imageset/check-square-offset.svg rename Linphone/Assets.xcassets/{danger.imageset => checks.imageset}/Contents.json (89%) create mode 100644 Linphone/Assets.xcassets/checks.imageset/checks.svg create mode 100644 Linphone/Assets.xcassets/clock-countdown.imageset/Contents.json create mode 100644 Linphone/Assets.xcassets/clock-countdown.imageset/clock-countdown.svg rename Linphone/Assets.xcassets/{empty.imageset => clock.imageset}/Contents.json (89%) create mode 100644 Linphone/Assets.xcassets/clock.imageset/clock.svg delete mode 100644 Linphone/Assets.xcassets/contacts.imageset/contacts.svg delete mode 100644 Linphone/Assets.xcassets/danger.imageset/danger.svg create mode 100644 Linphone/Assets.xcassets/detective.imageset/Contents.json create mode 100644 Linphone/Assets.xcassets/detective.imageset/detective.svg create mode 100644 Linphone/Assets.xcassets/dots-three-vertical.imageset/Contents.json create mode 100644 Linphone/Assets.xcassets/dots-three-vertical.imageset/dots-three-vertical.svg rename Linphone/Assets.xcassets/{more.imageset => ear.imageset}/Contents.json (89%) create mode 100644 Linphone/Assets.xcassets/ear.imageset/ear.svg create mode 100644 Linphone/Assets.xcassets/envelope-simple.imageset/Contents.json create mode 100644 Linphone/Assets.xcassets/envelope-simple.imageset/envelope-simple.svg create mode 100644 Linphone/Assets.xcassets/file-text.imageset/Contents.json create mode 100644 Linphone/Assets.xcassets/file-text.imageset/file-text.svg delete mode 100644 Linphone/Assets.xcassets/filtres.imageset/filtres.svg create mode 100644 Linphone/Assets.xcassets/funnel.imageset/Contents.json create mode 100644 Linphone/Assets.xcassets/funnel.imageset/funnel.svg create mode 100644 Linphone/Assets.xcassets/gear.imageset/Contents.json create mode 100644 Linphone/Assets.xcassets/gear.imageset/gear.svg create mode 100644 Linphone/Assets.xcassets/globe-hemisphere-west.imageset/Contents.json create mode 100644 Linphone/Assets.xcassets/globe-hemisphere-west.imageset/globe-hemisphere-west.svg rename Linphone/Assets.xcassets/{filtres.imageset => headset.imageset}/Contents.json (88%) create mode 100644 Linphone/Assets.xcassets/headset.imageset/headset.svg create mode 100644 Linphone/Assets.xcassets/heart-fill.imageset/Contents.json create mode 100644 Linphone/Assets.xcassets/heart-fill.imageset/heart-fill.svg create mode 100644 Linphone/Assets.xcassets/illus-belledonne.imageset/Contents.json rename Linphone/Assets.xcassets/{illus-belledonne1.imageset/illus-belledonne1.svg => illus-belledonne.imageset/illus-belledonne.svg} (100%) delete mode 100644 Linphone/Assets.xcassets/illus-belledonne1.imageset/Contents.json create mode 100644 Linphone/Assets.xcassets/in-progress.imageset/Contents.json create mode 100644 Linphone/Assets.xcassets/in-progress.imageset/in_progress.svg create mode 100644 Linphone/Assets.xcassets/incoming-call-missed.imageset/Contents.json create mode 100644 Linphone/Assets.xcassets/incoming-call-missed.imageset/incoming_call_missed.svg create mode 100644 Linphone/Assets.xcassets/incoming-call-rejected.imageset/Contents.json create mode 100644 Linphone/Assets.xcassets/incoming-call-rejected.imageset/incoming_call_rejected.svg create mode 100644 Linphone/Assets.xcassets/incoming-call.imageset/Contents.json create mode 100644 Linphone/Assets.xcassets/incoming-call.imageset/incoming_call.svg create mode 100644 Linphone/Assets.xcassets/keyboard.imageset/Contents.json create mode 100644 Linphone/Assets.xcassets/keyboard.imageset/keyboard.svg create mode 100644 Linphone/Assets.xcassets/magnifying-glass.imageset/Contents.json create mode 100644 Linphone/Assets.xcassets/magnifying-glass.imageset/magnifying-glass.svg create mode 100644 Linphone/Assets.xcassets/media-encryption-srtp.imageset/Contents.json create mode 100644 Linphone/Assets.xcassets/media-encryption-srtp.imageset/media_encryption_srtp.svg create mode 100644 Linphone/Assets.xcassets/media-encryption-zrtp-pq.imageset/Contents.json create mode 100644 Linphone/Assets.xcassets/media-encryption-zrtp-pq.imageset/media_encryption_zrtp_pq.svg create mode 100644 Linphone/Assets.xcassets/microphone-slash.imageset/Contents.json create mode 100644 Linphone/Assets.xcassets/microphone-slash.imageset/microphone-slash.svg create mode 100644 Linphone/Assets.xcassets/microphone-stage.imageset/Contents.json create mode 100644 Linphone/Assets.xcassets/microphone-stage.imageset/microphone-stage.svg delete mode 100644 Linphone/Assets.xcassets/more.imageset/more.svg create mode 100644 Linphone/Assets.xcassets/mountains.imageset/Contents.json create mode 100644 Linphone/Assets.xcassets/mountains.imageset/mountains.svg create mode 100644 Linphone/Assets.xcassets/not-trusted.imageset/Contents.json create mode 100644 Linphone/Assets.xcassets/not-trusted.imageset/not_trusted.svg delete mode 100644 Linphone/Assets.xcassets/open-source.imageset/open-source.svg create mode 100644 Linphone/Assets.xcassets/open-source.imageset/open_source.svg create mode 100644 Linphone/Assets.xcassets/outgoing-call-missed.imageset/Contents.json create mode 100644 Linphone/Assets.xcassets/outgoing-call-missed.imageset/outgoing_call_missed.svg create mode 100644 Linphone/Assets.xcassets/outgoing-call-rejected.imageset/Contents.json create mode 100644 Linphone/Assets.xcassets/outgoing-call-rejected.imageset/outgoing_call_rejected.svg create mode 100644 Linphone/Assets.xcassets/outgoing-call.imageset/Contents.json create mode 100644 Linphone/Assets.xcassets/outgoing-call.imageset/outgoing_call.svg create mode 100644 Linphone/Assets.xcassets/paper-plane-tilt.imageset/Contents.json create mode 100644 Linphone/Assets.xcassets/paper-plane-tilt.imageset/paper-plane-tilt.svg create mode 100644 Linphone/Assets.xcassets/paperclip.imageset/Contents.json create mode 100644 Linphone/Assets.xcassets/paperclip.imageset/paperclip.svg create mode 100644 Linphone/Assets.xcassets/pause.imageset/Contents.json create mode 100644 Linphone/Assets.xcassets/pause.imageset/pause.svg create mode 100644 Linphone/Assets.xcassets/phone-call.imageset/Contents.json create mode 100644 Linphone/Assets.xcassets/phone-call.imageset/phone-call.svg create mode 100644 Linphone/Assets.xcassets/phone-disconnect.imageset/Contents.json create mode 100644 Linphone/Assets.xcassets/phone-disconnect.imageset/phone-disconnect.svg create mode 100644 Linphone/Assets.xcassets/phone-plus.imageset/Contents.json create mode 100644 Linphone/Assets.xcassets/phone-plus.imageset/phone-plus.svg create mode 100644 Linphone/Assets.xcassets/play.imageset/Contents.json create mode 100644 Linphone/Assets.xcassets/play.imageset/play.svg create mode 100644 Linphone/Assets.xcassets/plus-circle.imageset/Contents.json create mode 100644 Linphone/Assets.xcassets/plus-circle.imageset/plus-circle.svg create mode 100644 Linphone/Assets.xcassets/question.imageset/Contents.json create mode 100644 Linphone/Assets.xcassets/question.imageset/question.svg create mode 100644 Linphone/Assets.xcassets/record-fill.imageset/Contents.json create mode 100644 Linphone/Assets.xcassets/record-fill.imageset/record-fill.svg delete mode 100644 Linphone/Assets.xcassets/search.imageset/search.svg delete mode 100644 Linphone/Assets.xcassets/secure-image.imageset/Contents.json delete mode 100644 Linphone/Assets.xcassets/secure-image.imageset/secure-image.svg create mode 100644 Linphone/Assets.xcassets/secured.imageset/Contents.json create mode 100644 Linphone/Assets.xcassets/secured.imageset/secured.svg create mode 100644 Linphone/Assets.xcassets/sign-out.imageset/Contents.json create mode 100644 Linphone/Assets.xcassets/sign-out.imageset/sign-out.svg create mode 100644 Linphone/Assets.xcassets/slideshow.imageset/Contents.json create mode 100644 Linphone/Assets.xcassets/slideshow.imageset/slideshow.svg create mode 100644 Linphone/Assets.xcassets/smiley.imageset/Contents.json create mode 100644 Linphone/Assets.xcassets/smiley.imageset/smiley.svg rename Linphone/Assets.xcassets/{conversation.imageset => speaker-high.imageset}/Contents.json (87%) create mode 100644 Linphone/Assets.xcassets/speaker-high.imageset/speaker-high.svg create mode 100644 Linphone/Assets.xcassets/speaker-slash.imageset/Contents.json create mode 100644 Linphone/Assets.xcassets/speaker-slash.imageset/speaker-slash.svg delete mode 100644 Linphone/Assets.xcassets/success.imageset/Contents.json delete mode 100644 Linphone/Assets.xcassets/success.imageset/success.svg create mode 100644 Linphone/Assets.xcassets/trusted.imageset/Contents.json create mode 100644 Linphone/Assets.xcassets/trusted.imageset/trusted.svg create mode 100644 Linphone/Assets.xcassets/user-circle-gear.imageset/Contents.json create mode 100644 Linphone/Assets.xcassets/user-circle-gear.imageset/user-circle-gear.svg create mode 100644 Linphone/Assets.xcassets/user-circle.imageset/Contents.json create mode 100644 Linphone/Assets.xcassets/user-circle.imageset/user-circle.svg create mode 100644 Linphone/Assets.xcassets/user-plus.imageset/Contents.json create mode 100644 Linphone/Assets.xcassets/user-plus.imageset/user-plus.svg create mode 100644 Linphone/Assets.xcassets/user-square.imageset/Contents.json create mode 100644 Linphone/Assets.xcassets/user-square.imageset/user-square.svg create mode 100644 Linphone/Assets.xcassets/users-three.imageset/Contents.json create mode 100644 Linphone/Assets.xcassets/users-three.imageset/users-three.svg create mode 100644 Linphone/Assets.xcassets/users.imageset/Contents.json create mode 100644 Linphone/Assets.xcassets/users.imageset/users.svg delete mode 100644 Linphone/Assets.xcassets/video-call.imageset/Contents.json delete mode 100644 Linphone/Assets.xcassets/video-call.imageset/VideoCall.svg rename Linphone/Assets.xcassets/{bell-ringing_slash.imageset => video-camera-slash.imageset}/Contents.json (85%) create mode 100644 Linphone/Assets.xcassets/video-camera-slash.imageset/video-camera-slash.svg create mode 100644 Linphone/Assets.xcassets/warning-circle.imageset/Contents.json create mode 100644 Linphone/Assets.xcassets/warning-circle.imageset/warning-circle.svg create mode 100644 Linphone/Assets.xcassets/wifi-high.imageset/Contents.json create mode 100644 Linphone/Assets.xcassets/wifi-high.imageset/wifi-high.svg create mode 100644 Linphone/Assets.xcassets/wifi-low.imageset/Contents.json create mode 100644 Linphone/Assets.xcassets/wifi-low.imageset/wifi-low.svg create mode 100644 Linphone/Assets.xcassets/wrench.imageset/Contents.json create mode 100644 Linphone/Assets.xcassets/wrench.imageset/wrench.svg create mode 100644 Linphone/Assets.xcassets/x-circle.imageset/Contents.json rename Linphone/Assets.xcassets/{empty.imageset/empty.svg => x-circle.imageset/x-circle.svg} (100%) diff --git a/Linphone/Assets.xcassets/address-book.imageset/address-book.svg b/Linphone/Assets.xcassets/address-book.imageset/address-book.svg index 9dc0b9ec9..11cabcde0 100644 --- a/Linphone/Assets.xcassets/address-book.imageset/address-book.svg +++ b/Linphone/Assets.xcassets/address-book.imageset/address-book.svg @@ -1 +1 @@ - \ No newline at end of file + \ 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/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/bell-ringing.svg b/Linphone/Assets.xcassets/bell-ringing.imageset/bell-ringing.svg index 247d2a164..0d7b4de7d 100644 --- a/Linphone/Assets.xcassets/bell-ringing.imageset/bell-ringing.svg +++ b/Linphone/Assets.xcassets/bell-ringing.imageset/bell-ringing.svg @@ -1,3 +1 @@ - - - + \ No newline at end of file diff --git a/Linphone/Assets.xcassets/bell-ringing_slash.imageset/bell-ringing_slash.svg b/Linphone/Assets.xcassets/bell-ringing_slash.imageset/bell-ringing_slash.svg deleted file mode 100644 index 16e263982..000000000 --- a/Linphone/Assets.xcassets/bell-ringing_slash.imageset/bell-ringing_slash.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - 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/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/contacts.imageset/Contents.json b/Linphone/Assets.xcassets/calendar.imageset/Contents.json similarity index 88% rename from Linphone/Assets.xcassets/contacts.imageset/Contents.json rename to Linphone/Assets.xcassets/calendar.imageset/Contents.json index 4289db091..14f636381 100644 --- a/Linphone/Assets.xcassets/contacts.imageset/Contents.json +++ b/Linphone/Assets.xcassets/calendar.imageset/Contents.json @@ -1,7 +1,7 @@ { "images" : [ { - "filename" : "contacts.svg", + "filename" : "calendar.svg", "idiom" : "universal", "scale" : "1x" }, 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/search.imageset/Contents.json b/Linphone/Assets.xcassets/camera.imageset/Contents.json similarity index 89% rename from Linphone/Assets.xcassets/search.imageset/Contents.json rename to Linphone/Assets.xcassets/camera.imageset/Contents.json index 6e7791884..5375d722d 100644 --- a/Linphone/Assets.xcassets/search.imageset/Contents.json +++ b/Linphone/Assets.xcassets/camera.imageset/Contents.json @@ -1,7 +1,7 @@ { "images" : [ { - "filename" : "search.svg", + "filename" : "camera.svg", "idiom" : "universal", "scale" : "1x" }, 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/caret-down.svg b/Linphone/Assets.xcassets/caret-down.imageset/caret-down.svg index 42f37b716..5b5218a2f 100644 --- a/Linphone/Assets.xcassets/caret-down.imageset/caret-down.svg +++ b/Linphone/Assets.xcassets/caret-down.imageset/caret-down.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/Linphone/Assets.xcassets/caret-left.imageset/caret-left.svg b/Linphone/Assets.xcassets/caret-left.imageset/caret-left.svg index a3a1e39a6..178311847 100644 --- a/Linphone/Assets.xcassets/caret-left.imageset/caret-left.svg +++ b/Linphone/Assets.xcassets/caret-left.imageset/caret-left.svg @@ -1 +1 @@ - \ No newline at end of file + \ 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/caret-up.svg b/Linphone/Assets.xcassets/caret-up.imageset/caret-up.svg index dacc592b1..27a7d9701 100644 --- a/Linphone/Assets.xcassets/caret-up.imageset/caret-up.svg +++ b/Linphone/Assets.xcassets/caret-up.imageset/caret-up.svg @@ -1 +1 @@ - \ No newline at end of file + \ 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..8a04f8fed --- /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-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..fac7f934c --- /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/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/conversation.imageset/conversation.svg b/Linphone/Assets.xcassets/chat-teardrop-text-slash.imageset/chat-teardrop-text-slash.svg similarity index 100% rename from Linphone/Assets.xcassets/conversation.imageset/conversation.svg rename to Linphone/Assets.xcassets/chat-teardrop-text-slash.imageset/chat-teardrop-text-slash.svg 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 index 8f7d61055..d07e384d6 100644 --- a/Linphone/Assets.xcassets/chat-teardrop-text.imageset/chat-teardrop-text.svg +++ b/Linphone/Assets.xcassets/chat-teardrop-text.imageset/chat-teardrop-text.svg @@ -1 +1 @@ - \ No newline at end of file + \ 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/check.svg b/Linphone/Assets.xcassets/check.imageset/check.svg index a8d374215..2e308611c 100644 --- a/Linphone/Assets.xcassets/check.imageset/check.svg +++ b/Linphone/Assets.xcassets/check.imageset/check.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/Linphone/Assets.xcassets/danger.imageset/Contents.json b/Linphone/Assets.xcassets/checks.imageset/Contents.json similarity index 89% rename from Linphone/Assets.xcassets/danger.imageset/Contents.json rename to Linphone/Assets.xcassets/checks.imageset/Contents.json index 63a1157a7..79cbf5b7a 100644 --- a/Linphone/Assets.xcassets/danger.imageset/Contents.json +++ b/Linphone/Assets.xcassets/checks.imageset/Contents.json @@ -1,7 +1,7 @@ { "images" : [ { - "filename" : "danger.svg", + "filename" : "checks.svg", "idiom" : "universal", "scale" : "1x" }, 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..548aeabcd --- /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/empty.imageset/Contents.json b/Linphone/Assets.xcassets/clock.imageset/Contents.json similarity index 89% rename from Linphone/Assets.xcassets/empty.imageset/Contents.json rename to Linphone/Assets.xcassets/clock.imageset/Contents.json index f14fd78f2..c78a16f5e 100644 --- a/Linphone/Assets.xcassets/empty.imageset/Contents.json +++ b/Linphone/Assets.xcassets/clock.imageset/Contents.json @@ -1,7 +1,7 @@ { "images" : [ { - "filename" : "empty.svg", + "filename" : "clock.svg", "idiom" : "universal", "scale" : "1x" }, 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/contacts.imageset/contacts.svg b/Linphone/Assets.xcassets/contacts.imageset/contacts.svg deleted file mode 100644 index f1706dc95..000000000 --- a/Linphone/Assets.xcassets/contacts.imageset/contacts.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/Linphone/Assets.xcassets/copy.imageset/copy.svg b/Linphone/Assets.xcassets/copy.imageset/copy.svg index 1b1334c76..f371da500 100644 --- a/Linphone/Assets.xcassets/copy.imageset/copy.svg +++ b/Linphone/Assets.xcassets/copy.imageset/copy.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/Linphone/Assets.xcassets/danger.imageset/danger.svg b/Linphone/Assets.xcassets/danger.imageset/danger.svg deleted file mode 100644 index 7f47d7312..000000000 --- a/Linphone/Assets.xcassets/danger.imageset/danger.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - 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/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/more.imageset/Contents.json b/Linphone/Assets.xcassets/ear.imageset/Contents.json similarity index 89% rename from Linphone/Assets.xcassets/more.imageset/Contents.json rename to Linphone/Assets.xcassets/ear.imageset/Contents.json index 259aa12c1..d092470f2 100644 --- a/Linphone/Assets.xcassets/more.imageset/Contents.json +++ b/Linphone/Assets.xcassets/ear.imageset/Contents.json @@ -1,7 +1,7 @@ { "images" : [ { - "filename" : "more.svg", + "filename" : "ear.svg", "idiom" : "universal", "scale" : "1x" }, 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/envelope-simple-open.imageset/envelope-simple-open.svg b/Linphone/Assets.xcassets/envelope-simple-open.imageset/envelope-simple-open.svg index 42d30e72f..5c826c63b 100644 --- a/Linphone/Assets.xcassets/envelope-simple-open.imageset/envelope-simple-open.svg +++ b/Linphone/Assets.xcassets/envelope-simple-open.imageset/envelope-simple-open.svg @@ -1 +1 @@ - \ No newline at end of file + \ 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/eye-slash.svg b/Linphone/Assets.xcassets/eye-slash.imageset/eye-slash.svg index 6dc0e47a4..aadbe3ca5 100644 --- a/Linphone/Assets.xcassets/eye-slash.imageset/eye-slash.svg +++ b/Linphone/Assets.xcassets/eye-slash.imageset/eye-slash.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/Linphone/Assets.xcassets/eye.imageset/eye.svg b/Linphone/Assets.xcassets/eye.imageset/eye.svg index 36ed4da10..61b225273 100644 --- a/Linphone/Assets.xcassets/eye.imageset/eye.svg +++ b/Linphone/Assets.xcassets/eye.imageset/eye.svg @@ -1 +1 @@ - \ No newline at end of file + \ 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..0b256101e --- /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/filtres.imageset/filtres.svg b/Linphone/Assets.xcassets/filtres.imageset/filtres.svg deleted file mode 100644 index 032a19982..000000000 --- a/Linphone/Assets.xcassets/filtres.imageset/filtres.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - 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/filtres.imageset/Contents.json b/Linphone/Assets.xcassets/headset.imageset/Contents.json similarity index 88% rename from Linphone/Assets.xcassets/filtres.imageset/Contents.json rename to Linphone/Assets.xcassets/headset.imageset/Contents.json index 49440ce83..f302ef8ae 100644 --- a/Linphone/Assets.xcassets/filtres.imageset/Contents.json +++ b/Linphone/Assets.xcassets/headset.imageset/Contents.json @@ -1,7 +1,7 @@ { "images" : [ { - "filename" : "filtres.svg", + "filename" : "headset.svg", "idiom" : "universal", "scale" : "1x" }, 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/heart.svg b/Linphone/Assets.xcassets/heart.imageset/heart.svg index 98674b649..89f2caf8e 100644 --- a/Linphone/Assets.xcassets/heart.imageset/heart.svg +++ b/Linphone/Assets.xcassets/heart.imageset/heart.svg @@ -1 +1 @@ - \ No newline at end of file + \ 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-belledonne1.imageset/illus-belledonne1.svg b/Linphone/Assets.xcassets/illus-belledonne.imageset/illus-belledonne.svg similarity index 100% rename from Linphone/Assets.xcassets/illus-belledonne1.imageset/illus-belledonne1.svg rename to Linphone/Assets.xcassets/illus-belledonne.imageset/illus-belledonne.svg diff --git a/Linphone/Assets.xcassets/illus-belledonne1.imageset/Contents.json b/Linphone/Assets.xcassets/illus-belledonne1.imageset/Contents.json deleted file mode 100644 index ca2746c0a..000000000 --- a/Linphone/Assets.xcassets/illus-belledonne1.imageset/Contents.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "images" : [ - { - "filename" : "illus-belledonne1.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/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/info.svg b/Linphone/Assets.xcassets/info.imageset/info.svg index 40cce74f7..2f26d80a1 100644 --- a/Linphone/Assets.xcassets/info.imageset/info.svg +++ b/Linphone/Assets.xcassets/info.imageset/info.svg @@ -1 +1 @@ - \ No newline at end of file + \ 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/linphone.svg b/Linphone/Assets.xcassets/linphone.imageset/linphone.svg index 44f54349b..005da361d 100644 --- a/Linphone/Assets.xcassets/linphone.imageset/linphone.svg +++ b/Linphone/Assets.xcassets/linphone.imageset/linphone.svg @@ -1,4 +1,6 @@ - - + + + + 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/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/microphone.svg b/Linphone/Assets.xcassets/microphone.imageset/microphone.svg index c4aeceaeb..36f7b4e0a 100644 --- a/Linphone/Assets.xcassets/microphone.imageset/microphone.svg +++ b/Linphone/Assets.xcassets/microphone.imageset/microphone.svg @@ -1,3 +1 @@ - - - + \ No newline at end of file diff --git a/Linphone/Assets.xcassets/more.imageset/more.svg b/Linphone/Assets.xcassets/more.imageset/more.svg deleted file mode 100644 index c9321f60f..000000000 --- a/Linphone/Assets.xcassets/more.imageset/more.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - 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/open-source.imageset/Contents.json b/Linphone/Assets.xcassets/open-source.imageset/Contents.json index f63666669..a65928f62 100644 --- a/Linphone/Assets.xcassets/open-source.imageset/Contents.json +++ b/Linphone/Assets.xcassets/open-source.imageset/Contents.json @@ -1,7 +1,7 @@ { "images" : [ { - "filename" : "open-source.svg", + "filename" : "open_source.svg", "idiom" : "universal", "scale" : "1x" }, diff --git a/Linphone/Assets.xcassets/open-source.imageset/open-source.svg b/Linphone/Assets.xcassets/open-source.imageset/open-source.svg deleted file mode 100644 index 9bbb7658b..000000000 --- a/Linphone/Assets.xcassets/open-source.imageset/open-source.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - 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.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/pencil-simple.svg b/Linphone/Assets.xcassets/pencil-simple.imageset/pencil-simple.svg index ceb292bbf..35cfc71c7 100644 --- a/Linphone/Assets.xcassets/pencil-simple.imageset/pencil-simple.svg +++ b/Linphone/Assets.xcassets/pencil-simple.imageset/pencil-simple.svg @@ -1 +1 @@ - \ No newline at end of file + \ 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-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.imageset/phone.svg b/Linphone/Assets.xcassets/phone.imageset/phone.svg index 6eb862926..ac3ff5a1c 100644 --- a/Linphone/Assets.xcassets/phone.imageset/phone.svg +++ b/Linphone/Assets.xcassets/phone.imageset/phone.svg @@ -1 +1 @@ - \ No newline at end of file + \ 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..051b34cda --- /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/qr-code.imageset/qr-code.svg b/Linphone/Assets.xcassets/qr-code.imageset/qr-code.svg index d5cd44274..2d6248f02 100644 --- a/Linphone/Assets.xcassets/qr-code.imageset/qr-code.svg +++ b/Linphone/Assets.xcassets/qr-code.imageset/qr-code.svg @@ -1 +1 @@ - \ No newline at end of file + \ 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/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/search.imageset/search.svg b/Linphone/Assets.xcassets/search.imageset/search.svg deleted file mode 100644 index 201e5000d..000000000 --- a/Linphone/Assets.xcassets/search.imageset/search.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/Linphone/Assets.xcassets/secure-image.imageset/Contents.json b/Linphone/Assets.xcassets/secure-image.imageset/Contents.json deleted file mode 100644 index ade67196d..000000000 --- a/Linphone/Assets.xcassets/secure-image.imageset/Contents.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "images" : [ - { - "filename" : "secure-image.svg", - "idiom" : "universal", - "scale" : "1x" - }, - { - "idiom" : "universal", - "scale" : "2x" - }, - { - "idiom" : "universal", - "scale" : "3x" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/Linphone/Assets.xcassets/secure-image.imageset/secure-image.svg b/Linphone/Assets.xcassets/secure-image.imageset/secure-image.svg deleted file mode 100644 index 7c8d6ee3a..000000000 --- a/Linphone/Assets.xcassets/secure-image.imageset/secure-image.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - 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/share-network.imageset/share-network.svg b/Linphone/Assets.xcassets/share-network.imageset/share-network.svg index 835f930ac..02d8619a1 100644 --- a/Linphone/Assets.xcassets/share-network.imageset/share-network.svg +++ b/Linphone/Assets.xcassets/share-network.imageset/share-network.svg @@ -1 +1 @@ - \ No newline at end of file + \ 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/conversation.imageset/Contents.json b/Linphone/Assets.xcassets/speaker-high.imageset/Contents.json similarity index 87% rename from Linphone/Assets.xcassets/conversation.imageset/Contents.json rename to Linphone/Assets.xcassets/speaker-high.imageset/Contents.json index 949fb1205..d1d2b5aba 100644 --- a/Linphone/Assets.xcassets/conversation.imageset/Contents.json +++ b/Linphone/Assets.xcassets/speaker-high.imageset/Contents.json @@ -1,7 +1,7 @@ { "images" : [ { - "filename" : "conversation.svg", + "filename" : "speaker-high.svg", "idiom" : "universal", "scale" : "1x" }, 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/success.imageset/Contents.json b/Linphone/Assets.xcassets/success.imageset/Contents.json deleted file mode 100644 index a4aa98205..000000000 --- a/Linphone/Assets.xcassets/success.imageset/Contents.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "images" : [ - { - "filename" : "success.svg", - "idiom" : "universal", - "scale" : "1x" - }, - { - "idiom" : "universal", - "scale" : "2x" - }, - { - "idiom" : "universal", - "scale" : "3x" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/Linphone/Assets.xcassets/success.imageset/success.svg b/Linphone/Assets.xcassets/success.imageset/success.svg deleted file mode 100644 index 633f0687c..000000000 --- a/Linphone/Assets.xcassets/success.imageset/success.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/Linphone/Assets.xcassets/trash-simple.imageset/trash-simple.svg b/Linphone/Assets.xcassets/trash-simple.imageset/trash-simple.svg index 6e48d22fc..0c2db4850 100644 --- a/Linphone/Assets.xcassets/trash-simple.imageset/trash-simple.svg +++ b/Linphone/Assets.xcassets/trash-simple.imageset/trash-simple.svg @@ -1 +1 @@ - \ No newline at end of file + \ 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..c406aac63 --- /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..761ce7d97 --- /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.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-call.imageset/Contents.json b/Linphone/Assets.xcassets/video-call.imageset/Contents.json deleted file mode 100644 index 0617b9467..000000000 --- a/Linphone/Assets.xcassets/video-call.imageset/Contents.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "images" : [ - { - "filename" : "VideoCall.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-call.imageset/VideoCall.svg b/Linphone/Assets.xcassets/video-call.imageset/VideoCall.svg deleted file mode 100644 index 5bfd91cd2..000000000 --- a/Linphone/Assets.xcassets/video-call.imageset/VideoCall.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/Linphone/Assets.xcassets/bell-ringing_slash.imageset/Contents.json b/Linphone/Assets.xcassets/video-camera-slash.imageset/Contents.json similarity index 85% rename from Linphone/Assets.xcassets/bell-ringing_slash.imageset/Contents.json rename to Linphone/Assets.xcassets/video-camera-slash.imageset/Contents.json index 8ebcc98af..6b8eb58cb 100644 --- a/Linphone/Assets.xcassets/bell-ringing_slash.imageset/Contents.json +++ b/Linphone/Assets.xcassets/video-camera-slash.imageset/Contents.json @@ -1,7 +1,7 @@ { "images" : [ { - "filename" : "bell-ringing_slash.svg", + "filename" : "video-camera-slash.svg", "idiom" : "universal", "scale" : "1x" }, 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/video-camera.svg b/Linphone/Assets.xcassets/video-camera.imageset/video-camera.svg index 1f42d14cc..0383a7c7b 100644 --- a/Linphone/Assets.xcassets/video-camera.imageset/video-camera.svg +++ b/Linphone/Assets.xcassets/video-camera.imageset/video-camera.svg @@ -1,3 +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..a04e6ff79 --- /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/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/empty.imageset/empty.svg b/Linphone/Assets.xcassets/x-circle.imageset/x-circle.svg similarity index 100% rename from Linphone/Assets.xcassets/empty.imageset/empty.svg rename to Linphone/Assets.xcassets/x-circle.imageset/x-circle.svg diff --git a/Linphone/Assets.xcassets/x.imageset/x.svg b/Linphone/Assets.xcassets/x.imageset/x.svg index 707720548..52756cbd9 100644 --- a/Linphone/Assets.xcassets/x.imageset/x.svg +++ b/Linphone/Assets.xcassets/x.imageset/x.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/Linphone/UI/Assistant/Fragments/ThirdPartySipAccountWarningFragment.swift b/Linphone/UI/Assistant/Fragments/ThirdPartySipAccountWarningFragment.swift index 85d3f32a1..3d85dcd02 100644 --- a/Linphone/UI/Assistant/Fragments/ThirdPartySipAccountWarningFragment.swift +++ b/Linphone/UI/Assistant/Fragments/ThirdPartySipAccountWarningFragment.swift @@ -72,7 +72,7 @@ struct ThirdPartySipAccountWarningFragment: View { HStack { Spacer() HStack(alignment: .center) { - Image("conversation") + Image("chat-teardrop-text-slash") .renderingMode(.template) .resizable() .foregroundStyle(Color.grayMain2c500) @@ -84,7 +84,7 @@ struct ThirdPartySipAccountWarningFragment: View { .padding(.horizontal) HStack(alignment: .center) { - Image("video-call") + Image("video-camera-slash") .renderingMode(.template) .resizable() .foregroundStyle(Color.grayMain2c500) diff --git a/Linphone/UI/Main/Contacts/ContactsView.swift b/Linphone/UI/Main/Contacts/ContactsView.swift index 2b9bde65a..4d00f8bdb 100644 --- a/Linphone/UI/Main/Contacts/ContactsView.swift +++ b/Linphone/UI/Main/Contacts/ContactsView.swift @@ -35,7 +35,7 @@ struct ContactsView: View { Button { // Action } label: { - Image("contacts") + Image("user-plus") .padding() .background(.white) .clipShape(Circle()) diff --git a/Linphone/UI/Main/Contacts/Fragments/ContactInnerFragment.swift b/Linphone/UI/Main/Contacts/Fragments/ContactInnerFragment.swift index fab0b5d73..dc31e9c35 100644 --- a/Linphone/UI/Main/Contacts/Fragments/ContactInnerFragment.swift +++ b/Linphone/UI/Main/Contacts/Fragments/ContactInnerFragment.swift @@ -415,11 +415,12 @@ struct ContactInnerFragment: View { Button { if contactViewModel.displayedFriend != nil { + contactViewModel.objectWillChange.send() contactViewModel.displayedFriend!.starred.toggle() } } label: { HStack { - Image("heart") + Image(contactViewModel.displayedFriend != nil && contactViewModel.displayedFriend!.starred == true ? "heart-fill" : "heart") .renderingMode(.template) .resizable() .foregroundStyle(Color.grayMain2c600) @@ -470,7 +471,7 @@ struct ContactInnerFragment: View { Button { } label: { HStack { - Image("bell-ringing_slash") + Image("bell-simple-slash") .renderingMode(.template) .resizable() .foregroundStyle(Color.grayMain2c600) @@ -495,7 +496,7 @@ struct ContactInnerFragment: View { Button { } label: { HStack { - Image("empty") + Image("x-circle") .renderingMode(.template) .resizable() .foregroundStyle(Color.grayMain2c600) diff --git a/Linphone/UI/Main/Contacts/Fragments/ContactListBottomSheet.swift b/Linphone/UI/Main/Contacts/Fragments/ContactListBottomSheet.swift index 3564b6dee..889272b47 100644 --- a/Linphone/UI/Main/Contacts/Fragments/ContactListBottomSheet.swift +++ b/Linphone/UI/Main/Contacts/Fragments/ContactListBottomSheet.swift @@ -127,7 +127,7 @@ struct ContactListBottomSheet: View { } } label: { HStack { - Image("empty") + Image("x-circle") .renderingMode(.template) .resizable() .foregroundStyle(Color.grayMain2c500) diff --git a/Linphone/UI/Main/Contacts/Fragments/ContactsListBottomSheet.swift b/Linphone/UI/Main/Contacts/Fragments/ContactsListBottomSheet.swift index cd0dbd455..a5d27c278 100644 --- a/Linphone/UI/Main/Contacts/Fragments/ContactsListBottomSheet.swift +++ b/Linphone/UI/Main/Contacts/Fragments/ContactsListBottomSheet.swift @@ -69,7 +69,7 @@ struct ContactsListBottomSheet: View { } } label: { HStack { - Image("heart") + Image(contactViewModel.selectedFriend != nil && contactViewModel.selectedFriend!.starred == true ? "heart-fill" : "heart") .renderingMode(.template) .resizable() .foregroundStyle(Color.grayMain2c500) diff --git a/Linphone/UI/Main/Contacts/Fragments/ContactsListFragment.swift b/Linphone/UI/Main/Contacts/Fragments/ContactsListFragment.swift index 600d7f179..355f67d0f 100644 --- a/Linphone/UI/Main/Contacts/Fragments/ContactsListFragment.swift +++ b/Linphone/UI/Main/Contacts/Fragments/ContactsListFragment.swift @@ -119,7 +119,7 @@ struct ContactsListFragment: View { VStack { if magicSearch.lastSearch.isEmpty { Spacer() - Image("illus-belledonne1") + Image("illus-belledonne") .resizable() .scaledToFit() .clipped() diff --git a/Linphone/UI/Main/ContentView.swift b/Linphone/UI/Main/ContentView.swift index 386304205..2be1c0356 100644 --- a/Linphone/UI/Main/ContentView.swift +++ b/Linphone/UI/Main/ContentView.swift @@ -123,7 +123,11 @@ struct ContentView: View { searchIsActive.toggle() } } label: { - Image("search") + Image("magnifying-glass") + .renderingMode(.template) + .resizable() + .foregroundStyle(.white) + .frame(width: 25, height: 25, alignment: .leading) } Menu { @@ -138,6 +142,8 @@ struct ContentView: View { Spacer() if magicSearch.allContact { Image("green-check") + .resizable() + .frame(width: 25, height: 25, alignment: .leading) } } } @@ -153,11 +159,17 @@ struct ContentView: View { Spacer() if !magicSearch.allContact { Image("green-check") + .resizable() + .frame(width: 25, height: 25, alignment: .leading) } } } } label: { - Image(index == 0 ? "filtres" : "more") + Image(index == 0 ? "funnel" : "dots-three-vertical") + .renderingMode(.template) + .resizable() + .foregroundStyle(.white) + .frame(width: 25, height: 25, alignment: .leading) } .padding(.leading) .onTapGesture { diff --git a/Linphone/UI/Main/Fragments/ToastView.swift b/Linphone/UI/Main/Fragments/ToastView.swift index ab092763d..b24ba953e 100644 --- a/Linphone/UI/Main/Fragments/ToastView.swift +++ b/Linphone/UI/Main/Fragments/ToastView.swift @@ -36,9 +36,11 @@ struct ToastView: ViewModifier { VStack { if !isShowing.isEmpty { HStack { - Image(isShowing == "Successful" ? "success" : "danger") + Image(isShowing == "Successful" ? "smiley" : "warning-circle") .resizable() + .renderingMode(.template) .frame(width: 25, height: 25, alignment: .leading) + .foregroundStyle(isShowing == "Successful" ? Color.greenSuccess500 : Color.redDanger500) switch isShowing { case "Successful": diff --git a/Linphone/UI/Main/History/HistoryView.swift b/Linphone/UI/Main/History/HistoryView.swift index c876cf67e..25d6b0b1e 100644 --- a/Linphone/UI/Main/History/HistoryView.swift +++ b/Linphone/UI/Main/History/HistoryView.swift @@ -26,7 +26,7 @@ struct HistoryView: View { VStack(spacing: 0) { VStack { Spacer() - Image("illus-belledonne1") + Image("illus-belledonne") .resizable() .scaledToFit() .clipped() diff --git a/Linphone/UI/Welcome/Fragments/WelcomePage2Fragment.swift b/Linphone/UI/Welcome/Fragments/WelcomePage2Fragment.swift index 45f1e8fa2..b8cb97f3f 100644 --- a/Linphone/UI/Welcome/Fragments/WelcomePage2Fragment.swift +++ b/Linphone/UI/Welcome/Fragments/WelcomePage2Fragment.swift @@ -26,7 +26,7 @@ struct WelcomePage2Fragment: View { VStack { Spacer() VStack { - Image("secure-image") + Image("secured") .renderingMode(.template) .resizable() .foregroundStyle(Color.orangeMain500) From ac7f4da260a333e09e8b3e8d239ccacd255ea67e Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Mon, 30 Oct 2023 16:16:41 +0100 Subject: [PATCH 029/486] Fix QRCode provisioning --- Linphone/Core/CoreContext.swift | 9 +++++++++ Linphone/LinphoneApp.swift | 2 +- .../UI/Assistant/Fragments/LoginFragment.swift | 13 +------------ .../UI/Main/Viewmodel/SharedMainViewModel.swift | 16 ++++++---------- 4 files changed, 17 insertions(+), 23 deletions(-) diff --git a/Linphone/Core/CoreContext.swift b/Linphone/Core/CoreContext.swift index 560cb3cbc..efa6b73af 100644 --- a/Linphone/Core/CoreContext.swift +++ b/Linphone/Core/CoreContext.swift @@ -72,6 +72,15 @@ final class CoreContext: ObservableObject { self.toastMessage = "Registration failed" self.loggingInProgress = false self.loggedIn = false + + let params = account.params + let clonedParams = params?.clone() + clonedParams?.registerEnabled = false + account.params = clonedParams + + self.mCore!.removeAccount(account: account) + self.mCore!.clearAccounts() + self.mCore!.clearAllAuthInfo() } } ) diff --git a/Linphone/LinphoneApp.swift b/Linphone/LinphoneApp.swift index 0efeb7e77..b507a0c87 100644 --- a/Linphone/LinphoneApp.swift +++ b/Linphone/LinphoneApp.swift @@ -35,7 +35,7 @@ struct LinphoneApp: App { } else if coreContext.mCore.defaultAccount == nil || sharedMainViewModel.displayProfileMode { AssistantView(sharedMainViewModel: sharedMainViewModel) .toast(isShowing: $coreContext.toastMessage) - } else { + } else if coreContext.mCore.defaultAccount != nil { ContentView(contactViewModel: ContactViewModel(), historyViewModel: HistoryViewModel()) .toast(isShowing: $coreContext.toastMessage) } diff --git a/Linphone/UI/Assistant/Fragments/LoginFragment.swift b/Linphone/UI/Assistant/Fragments/LoginFragment.swift index 0d5cac6b9..61677f584 100644 --- a/Linphone/UI/Assistant/Fragments/LoginFragment.swift +++ b/Linphone/UI/Assistant/Fragments/LoginFragment.swift @@ -117,6 +117,7 @@ struct LoginFragment: View { .padding(.bottom) Button(action: { + sharedMainViewModel.changeDisplayProfileMode() self.accountLoginViewModel.login() }, label: { Text(coreContext.loggedIn ? "Log out" : "assistant_account_login") @@ -280,23 +281,11 @@ struct LoginFragment: View { } } } - .onAppear { - sharedMainViewModel.changeDisplayProfileMode() - } if coreContext.loggingInProgress { PopupLoadingView(sharedMainViewModel: sharedMainViewModel) .background(.black.opacity(0.65)) } - - if !coreContext.loggingInProgress && !coreContext.loggedIn { - ZStack { - - }.onAppear { - self.accountLoginViewModel.unregister() - self.accountLoginViewModel.delete() - } - } } } .navigationViewStyle(StackNavigationViewStyle()) diff --git a/Linphone/UI/Main/Viewmodel/SharedMainViewModel.swift b/Linphone/UI/Main/Viewmodel/SharedMainViewModel.swift index 5e8f6d39a..d95e98499 100644 --- a/Linphone/UI/Main/Viewmodel/SharedMainViewModel.swift +++ b/Linphone/UI/Main/Viewmodel/SharedMainViewModel.swift @@ -25,33 +25,33 @@ class SharedMainViewModel: ObservableObject { @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 init() { let preferences = UserDefaults.standard - let welcomeViewKey = "welcome_view" - if preferences.object(forKey: welcomeViewKey) == nil { preferences.set(welcomeViewDisplayed, forKey: welcomeViewKey) } else { welcomeViewDisplayed = preferences.bool(forKey: welcomeViewKey) } - let generalTermsKey = "general_terms" - if preferences.object(forKey: generalTermsKey) == nil { preferences.set(generalTermsAccepted, forKey: generalTermsKey) } else { generalTermsAccepted = preferences.bool(forKey: generalTermsKey) } - let displayProfileModeKey = "display_profile_mode" - if preferences.object(forKey: displayProfileModeKey) == nil { + print("displayProfileModeKeydisplayProfileModeKey nil") preferences.set(displayProfileMode, forKey: displayProfileModeKey) } else { displayProfileMode = preferences.bool(forKey: displayProfileModeKey) + print("displayProfileModeKeydisplayProfileModeKey \(displayProfileMode)") } } @@ -59,7 +59,6 @@ class SharedMainViewModel: ObservableObject { let preferences = UserDefaults.standard welcomeViewDisplayed = true - let welcomeViewKey = "welcome_view" preferences.set(welcomeViewDisplayed, forKey: welcomeViewKey) } @@ -67,7 +66,6 @@ class SharedMainViewModel: ObservableObject { let preferences = UserDefaults.standard generalTermsAccepted = true - let generalTermsKey = "general_terms" preferences.set(generalTermsAccepted, forKey: generalTermsKey) } @@ -75,7 +73,6 @@ class SharedMainViewModel: ObservableObject { let preferences = UserDefaults.standard displayProfileMode = true - let displayProfileModeKey = "display_profile_mode" preferences.set(displayProfileMode, forKey: displayProfileModeKey) } @@ -83,7 +80,6 @@ class SharedMainViewModel: ObservableObject { let preferences = UserDefaults.standard displayProfileMode = false - let displayProfileModeKey = "display_profile_mode" preferences.set(displayProfileMode, forKey: displayProfileModeKey) } } From 3fe7dd8884b67c34f007c4e6771c493a424e443a Mon Sep 17 00:00:00 2001 From: QuentinArguillere Date: Fri, 3 Nov 2023 16:21:21 +0100 Subject: [PATCH 030/486] Pod deintegrate --- Linphone.xcodeproj/project.pbxproj | 59 ------------------------------ 1 file changed, 59 deletions(-) diff --git a/Linphone.xcodeproj/project.pbxproj b/Linphone.xcodeproj/project.pbxproj index 5e3cbc295..d22bf1fbe 100644 --- a/Linphone.xcodeproj/project.pbxproj +++ b/Linphone.xcodeproj/project.pbxproj @@ -7,7 +7,6 @@ objects = { /* Begin PBXBuildFile section */ - 886500223A8E518D3EE5FCB7 /* Pods_Linphone.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 1A334B8FDAD2893691A734BE /* Pods_Linphone.framework */; }; D706BA822ADD72D100278F45 /* DeviceRotationViewModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = D706BA812ADD72D100278F45 /* DeviceRotationViewModifier.swift */; }; D70C93DE2AC2D0F60063CA3B /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = D70C93DD2AC2D0F60063CA3B /* Localizable.xcstrings */; }; D717071E2AC5922E0037746F /* ColorExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = D717071D2AC5922E0037746F /* ColorExtension.swift */; }; @@ -66,8 +65,6 @@ /* End PBXBuildFile section */ /* Begin PBXFileReference section */ - 1A334B8FDAD2893691A734BE /* Pods_Linphone.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Linphone.framework; sourceTree = BUILT_PRODUCTS_DIR; }; - 1DE4CD5FD6E1F01639F27E3B /* Pods-Linphone.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Linphone.release.xcconfig"; path = "Target Support Files/Pods-Linphone/Pods-Linphone.release.xcconfig"; sourceTree = ""; }; D706BA812ADD72D100278F45 /* DeviceRotationViewModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceRotationViewModifier.swift; sourceTree = ""; }; D70C93DD2AC2D0F60063CA3B /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; sourceTree = ""; }; D717071D2AC5922E0037746F /* ColorExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ColorExtension.swift; sourceTree = ""; }; @@ -126,7 +123,6 @@ D7E6D0542AEBFCCE00A57AAF /* ContactsInnerFragment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactsInnerFragment.swift; sourceTree = ""; }; D7EAACCE2AD6ED8000AA6A8A /* PermissionsFragment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PermissionsFragment.swift; sourceTree = ""; }; D7FB55102AD447FD00A5AB15 /* RegisterFragment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RegisterFragment.swift; sourceTree = ""; }; - FB718F405DAF7B9993AEB878 /* Pods-Linphone.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Linphone.debug.xcconfig"; path = "Target Support Files/Pods-Linphone/Pods-Linphone.debug.xcconfig"; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -134,26 +130,15 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - 886500223A8E518D3EE5FCB7 /* Pods_Linphone.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ - 1CD95087B17CAD149119B7C2 /* Frameworks */ = { - isa = PBXGroup; - children = ( - 1A334B8FDAD2893691A734BE /* Pods_Linphone.framework */, - ); - name = Frameworks; - sourceTree = ""; - }; A31AF2AB8C6A3D7B7EA3B424 /* Pods */ = { isa = PBXGroup; children = ( - FB718F405DAF7B9993AEB878 /* Pods-Linphone.debug.xcconfig */, - 1DE4CD5FD6E1F01639F27E3B /* Pods-Linphone.release.xcconfig */, ); path = Pods; sourceTree = ""; @@ -175,7 +160,6 @@ D719ABB52ABC67BF00B41C10 /* Linphone */, D719ABB42ABC67BF00B41C10 /* Products */, A31AF2AB8C6A3D7B7EA3B424 /* Pods */, - 1CD95087B17CAD149119B7C2 /* Frameworks */, ); sourceTree = ""; }; @@ -407,12 +391,10 @@ isa = PBXNativeTarget; buildConfigurationList = D719ABC22ABC67BF00B41C10 /* Build configuration list for PBXNativeTarget "Linphone" */; buildPhases = ( - BE9432280D0A11AA770A50FD /* [CP] Check Pods Manifest.lock */, D719ABAF2ABC67BF00B41C10 /* Sources */, D719ABB02ABC67BF00B41C10 /* Frameworks */, D719ABB12ABC67BF00B41C10 /* Resources */, D7FB55122AD53FE200A5AB15 /* Run Script */, - D5CA1ECD620857DB91E334A5 /* [CP] Embed Pods Frameworks */, ); buildRules = ( ); @@ -476,45 +458,6 @@ /* End PBXResourcesBuildPhase section */ /* Begin PBXShellScriptBuildPhase section */ - BE9432280D0A11AA770A50FD /* [CP] Check Pods Manifest.lock */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - inputFileListPaths = ( - ); - inputPaths = ( - "${PODS_PODFILE_DIR_PATH}/Podfile.lock", - "${PODS_ROOT}/Manifest.lock", - ); - name = "[CP] Check Pods Manifest.lock"; - outputFileListPaths = ( - ); - outputPaths = ( - "$(DERIVED_FILE_DIR)/Pods-Linphone-checkManifestLockResult.txt", - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; - showEnvVarsInLog = 0; - }; - D5CA1ECD620857DB91E334A5 /* [CP] Embed Pods Frameworks */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - inputFileListPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-Linphone/Pods-Linphone-frameworks-${CONFIGURATION}-input-files.xcfilelist", - ); - name = "[CP] Embed Pods Frameworks"; - outputFileListPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-Linphone/Pods-Linphone-frameworks-${CONFIGURATION}-output-files.xcfilelist", - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Linphone/Pods-Linphone-frameworks.sh\"\n"; - showEnvVarsInLog = 0; - }; D7FB55122AD53FE200A5AB15 /* Run Script */ = { isa = PBXShellScriptBuildPhase; alwaysOutOfDate = 1; @@ -708,7 +651,6 @@ }; D719ABC32ABC67BF00B41C10 /* Debug */ = { isa = XCBuildConfiguration; - baseConfigurationReference = FB718F405DAF7B9993AEB878 /* Pods-Linphone.debug.xcconfig */; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; @@ -753,7 +695,6 @@ }; D719ABC42ABC67BF00B41C10 /* Release */ = { isa = XCBuildConfiguration; - baseConfigurationReference = 1DE4CD5FD6E1F01639F27E3B /* Pods-Linphone.release.xcconfig */; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; From a3befe61cf0407cfa2e8d49621e8e220244e5ee7 Mon Sep 17 00:00:00 2001 From: QuentinArguillere Date: Fri, 3 Nov 2023 17:31:59 +0100 Subject: [PATCH 031/486] Move all Core related code to another dispatch queue. requires sdk built with feature/swift_wrapper_async_helpers --- Linphone.xcodeproj/project.pbxproj | 2 + Linphone/Contacts/ContactsManager.swift | 94 ++++++----- Linphone/Core/CoreContext.swift | 100 ++++++++---- Linphone/LinphoneApp.swift | 4 +- Linphone/SplashScreen.swift | 2 +- .../Viewmodel/AccountLoginViewModel.swift | 152 ++++++++++-------- .../UI/Assistant/Viewmodel/QRScanner.swift | 11 +- Linphone/UI/Main/ContentView.swift | 10 +- Linphone/Utils/MagicSearchSingleton.swift | 60 ++++--- 9 files changed, 245 insertions(+), 190 deletions(-) diff --git a/Linphone.xcodeproj/project.pbxproj b/Linphone.xcodeproj/project.pbxproj index d22bf1fbe..8cf1f215c 100644 --- a/Linphone.xcodeproj/project.pbxproj +++ b/Linphone.xcodeproj/project.pbxproj @@ -540,6 +540,7 @@ 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"; @@ -600,6 +601,7 @@ 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"; diff --git a/Linphone/Contacts/ContactsManager.swift b/Linphone/Contacts/ContactsManager.swift index 1e31e8716..770c4a72a 100644 --- a/Linphone/Contacts/ContactsManager.swift +++ b/Linphone/Contacts/ContactsManager.swift @@ -38,16 +38,16 @@ final class ContactsManager: ObservableObject { } func fetchContacts() { - DispatchQueue.global().async { - if self.coreContext.mCore.globalState == GlobalState.Shutdown || self.coreContext.mCore.globalState == GlobalState.Off { + coreContext.doOnCoreQueue { core in + if core.globalState == GlobalState.Shutdown || core.globalState == GlobalState.Off { print("$TAG Core is being stopped or already destroyed, abort") } else { print("$TAG ${friends.size} friends created") - self.friendList = self.coreContext.mCore.getFriendListByName(name: self.nativeAddressBookFriendList) + self.friendList = core.getFriendListByName(name: self.nativeAddressBookFriendList) if self.friendList == nil { do { - self.friendList = try self.coreContext.mCore.createFriendList() + self.friendList = try core.createFriendList() } catch let error { print("Failed to enumerate contact", error) } @@ -61,7 +61,7 @@ final class ContactsManager: ObservableObject { self.friendList!.databaseStorageEnabled = false // We don't want to store local address-book in DB self.friendList!.displayName = self.nativeAddressBookFriendList - self.coreContext.mCore.addFriendList(list: self.friendList!) + core.addFriendList(list: self.friendList!) } else { print( "$TAG Friend list [$LINPHONE_ADDRESS_BOOK_FRIEND_LIST] found, removing existing friends if any" @@ -159,52 +159,58 @@ final class ContactsManager: ObservableObject { } awaitDataWrite(data: data, name: name) { _, result in - do { - let friend = try self.coreContext.mCore.createFriend() - friend.edit() - try friend.setName(newValue: contact.firstName + " " + contact.lastName) - friend.organization = contact.organizationName - - var friendAddresses: [Address] = [] - contact.sipAddresses.forEach { sipAddress in - let address = self.coreContext.mCore.interpretUrl(url: sipAddress, applyInternationalPrefix: true) + + self.coreContext.doOnCoreQueue() { core in + do { + var friend = try core.createFriend() - if address != nil && ((friendAddresses.firstIndex(where: {$0.asString() == address?.asString()})) == nil) { - friend.addAddress(address: address!) - friendAddresses.append(address!) - } - } - - var friendPhoneNumbers: [PhoneNumber] = [] - contact.phoneNumbers.forEach { phone in - do { - if (friendPhoneNumbers.firstIndex(where: {$0.numLabel == phone.numLabel})) == 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) + friend.edit() + try friend.setName(newValue: contact.firstName + " " + contact.lastName) + friend.organization = contact.organizationName + + var friendAddresses: [Address] = [] + contact.sipAddresses.forEach { sipAddress in + 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!) } - } catch let error { - print("Failed to enumerate contact", error) } + + var friendPhoneNumbers: [PhoneNumber] = [] + contact.phoneNumbers.forEach { phone in + do { + if (friendPhoneNumbers.firstIndex(where: {$0.numLabel == phone.numLabel})) == 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("Failed to enumerate contact", error) + } + } + + let contactImage = result.dropFirst(8) + friend.photo = "file:/" + contactImage + + friend.organization = contact.organizationName + + friend.done() + + DispatchQueue.main.async { + _ = self.friendList!.addLocalFriend(linphoneFriend: friend) + + self.friendList!.updateSubscriptions() + } + } catch let error { + print("Failed to enumerate contact", error) } - let contactImage = result.dropFirst(8) - friend.photo = "file:/" + contactImage - - friend.organization = contact.organizationName - - friend.done() - - _ = self.friendList!.addLocalFriend(linphoneFriend: friend) - - self.friendList!.updateSubscriptions() - - } catch let error { - print("Failed to enumerate contact", error) } } - } + } func awaitDataWrite(data: Data, name: String, completion: @escaping ((), String) -> Void) { let directory = FileManager.default.temporaryDirectory diff --git a/Linphone/Core/CoreContext.swift b/Linphone/Core/CoreContext.swift index efa6b73af..a68b25127 100644 --- a/Linphone/Core/CoreContext.swift +++ b/Linphone/Core/CoreContext.swift @@ -17,74 +17,104 @@ * along with this program. If not, see . */ +// swiftlint:disable large_tuple import linphonesw +import Combine final class CoreContext: ObservableObject { static let shared = CoreContext() - var mCore: Core! - var mRegistrationDelegate: CoreDelegate! - var mConfigurationDelegate: CoreDelegate! - var coreVersion: String = Core.getVersion @Published var loggedIn: Bool = false @Published var loggingInProgress: Bool = false @Published var toastMessage: String = "" + @Published var defaultAccount: Account? + + private var mCore: Core! + private var mIteratePublisher: AnyCancellable? private init() {} - func initialiseCore() async throws { + func doOnCoreQueue(synchronous : Bool = false, lambda: @escaping (Core) -> Void) { + if synchronous { + coreQueue.sync { + lambda(self.mCore) + } + } else { + coreQueue.async { + lambda(self.mCore) + } + } + } + + func initialiseCore() throws { LoggingService.Instance.logLevel = LogLevel.Debug - let factory = Factory.Instance - let configDir = factory.getConfigDir(context: nil) - try? mCore = Factory.Instance.createCore(configPath: "\(configDir)/MyConfig", factoryConfigPath: "", systemContext: nil) - - mCore.friendsDatabasePath = "\(configDir)/friends.db" - - try? mCore.start() - - // Create a Core listener to listen for the callback we need - // In this case, we want to know about the account registration status - - mRegistrationDelegate = - CoreDelegateStub( - onConfiguringStatus: {(_: Core, state: Config.ConfiguringState, message: String) in - NSLog("New configuration state is \(state) = \(message)\n") - if state == .Successful { + coreQueue.async { + let configDir = Factory.Instance.getConfigDir(context: nil) + try? self.mCore = Factory.Instance.createCore(configPath: "\(configDir)/MyConfig", factoryConfigPath: "", systemContext: nil) + self.mCore.autoIterateEnabled = false + self.mCore.friendsDatabasePath = "\(configDir)/friends.db" + + self.mCore.publisher?.onGlobalStateChanged?.postOnMainQueue { (cbVal: (core: Core, state: GlobalState, message: String)) in + if cbVal.state == GlobalState.On { + self.defaultAccount = self.mCore.defaultAccount + } else if cbVal.state == GlobalState.Off { + self.defaultAccount = nil + } + } + try? self.mCore.start() + + // Create a Core listener to listen for the callback we need + // In this case, we want to know about the account registration status + self.mCore.publisher?.onConfiguringStatus?.postOnMainQueue { (cbVal: (core: Core, status: Config.ConfiguringState, message: String)) in + NSLog("New configuration state is \(cbVal.status) = \(cbVal.message)\n") + if cbVal.status == Config.ConfiguringState.Successful { self.toastMessage = "Successful" } else { self.toastMessage = "Failed" } - }, + } - onAccountRegistrationStateChanged: {(_: Core, account: Account, state: RegistrationState, message: String) in + self.mCore.publisher?.onAccountRegistrationStateChanged?.postOnMainQueue { (cbVal: (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. - NSLog("New registration state is \(state) for user id \( String(describing: account.params?.identityAddress?.asString())) = \(message)\n") - if state == .Ok { + NSLog("New registration state is \(cbVal.state) for user id " + + "\( String(describing: cbVal.account.params?.identityAddress?.asString())) = \(cbVal.message)\n") + if cbVal.state == .Ok { self.loggingInProgress = false self.loggedIn = true - } else if state == .Progress { + } else if cbVal.state == .Progress { self.loggingInProgress = true } else { self.toastMessage = "Registration failed" self.loggingInProgress = false self.loggedIn = false - - let params = account.params + } + }.postOnCoreQueue { (cbVal: (core: Core, account: Account, state: RegistrationState, message: String)) in + // If registration failed, remove account from core + if cbVal.state != .Ok && cbVal.state != .Progress { + let params = cbVal.account.params let clonedParams = params?.clone() clonedParams?.registerEnabled = false - account.params = clonedParams + cbVal.account.params = clonedParams - self.mCore!.removeAccount(account: account) - self.mCore!.clearAccounts() - self.mCore!.clearAllAuthInfo() + cbVal.core.removeAccount(account: cbVal.account) + cbVal.core.clearAccounts() + cbVal.core.clearAllAuthInfo() } } - ) - - mCore.addDelegate(delegate: mRegistrationDelegate) + + self.mIteratePublisher = Timer.publish(every: 0.02, on: .main, in: .common) + .autoconnect() + .receive(on: coreQueue) + .sink { _ in + self.mCore.iterate() + } + + } } } + +// swiftlint:enable large_tuple diff --git a/Linphone/LinphoneApp.swift b/Linphone/LinphoneApp.swift index b507a0c87..17257a99f 100644 --- a/Linphone/LinphoneApp.swift +++ b/Linphone/LinphoneApp.swift @@ -32,10 +32,10 @@ struct LinphoneApp: App { if isActive { if !sharedMainViewModel.welcomeViewDisplayed { WelcomeView(sharedMainViewModel: sharedMainViewModel) - } else if coreContext.mCore.defaultAccount == nil || sharedMainViewModel.displayProfileMode { + } else if coreContext.defaultAccount == nil || sharedMainViewModel.displayProfileMode { AssistantView(sharedMainViewModel: sharedMainViewModel) .toast(isShowing: $coreContext.toastMessage) - } else if coreContext.mCore.defaultAccount != nil { + } else if coreContext.defaultAccount != nil { ContentView(contactViewModel: ContactViewModel(), historyViewModel: HistoryViewModel()) .toast(isShowing: $coreContext.toastMessage) } diff --git a/Linphone/SplashScreen.swift b/Linphone/SplashScreen.swift index 64d59e258..350fc93ad 100644 --- a/Linphone/SplashScreen.swift +++ b/Linphone/SplashScreen.swift @@ -40,7 +40,7 @@ struct SplashScreen: View { .ignoresSafeArea(.all) .onAppear { Task { - try await coreContext.initialiseCore() + try coreContext.initialiseCore() withAnimation { self.isActive = true } diff --git a/Linphone/UI/Assistant/Viewmodel/AccountLoginViewModel.swift b/Linphone/UI/Assistant/Viewmodel/AccountLoginViewModel.swift index 6f778145a..d85b4233e 100644 --- a/Linphone/UI/Assistant/Viewmodel/AccountLoginViewModel.swift +++ b/Linphone/UI/Assistant/Viewmodel/AccountLoginViewModel.swift @@ -33,83 +33,95 @@ class AccountLoginViewModel: ObservableObject { init() {} func login() { - do { - // Get the transport protocol to use. - // TLS is strongly recommended - // Only use UDP if you don't have the choice - var transport: TransportType - if transportType == "TLS" { - transport = TransportType.Tls - } else if 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: username, userid: "", passwd: passwd, ha1: "", realm: "", domain: 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 coreContext.mCore!.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:" + username + "@" + 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:" + 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 - - // Now that our AccountParams is configured, we can create the Account object - let account = try coreContext.mCore!.createAccount(params: accountParams) - - // Now let's add our objects to the Core - coreContext.mCore!.addAuthInfo(info: authInfo) - try coreContext.mCore!.addAccount(account: account) - - // Also set the newly added account as default - coreContext.mCore!.defaultAccount = account - - } catch { NSLog(error.localizedDescription) } + coreContext.doOnCoreQueue { core in + do { + // 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 + + // 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 + DispatchQueue.main.async { + self.coreContext.defaultAccount = account + } + + } catch { NSLog(error.localizedDescription) } + } } func unregister() { - // Here we will disable the registration of our Account - if let account = coreContext.mCore!.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 + 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() { - // To completely remove an Account - if let account = coreContext.mCore!.defaultAccount { - coreContext.mCore!.removeAccount(account: account) - - // To remove all accounts use - coreContext.mCore!.clearAccounts() - - // Same for auth info - coreContext.mCore!.clearAllAuthInfo() + coreContext.doOnCoreQueue { core in + // To completely remove an Account + if let account = core.defaultAccount { + core.removeAccount(account: account) + DispatchQueue.main.async { + self.coreContext.defaultAccount = nil + } + + // 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 index d5dd78159..5d546ef96 100644 --- a/Linphone/UI/Assistant/Viewmodel/QRScanner.swift +++ b/Linphone/UI/Assistant/Viewmodel/QRScanner.swift @@ -70,14 +70,11 @@ class Coordinator: NSObject, AVCaptureMetadataOutputObjectsDelegate { if let url = NSURL(string: result) { if UIApplication.shared.canOpenURL(url as URL) { lastResult = result - do { - try coreContext.mCore.setProvisioninguri(newValue: result) - coreContext.mCore.stop() - try coreContext.mCore.start() - } catch { - + coreContext.doOnCoreQueue { core in + try? core.setProvisioninguri(newValue: result) + core.stop() + try? core.start() } - } else { coreContext.toastMessage = "Invalide URI" } diff --git a/Linphone/UI/Main/ContentView.swift b/Linphone/UI/Main/ContentView.swift index 2be1c0356..913b29b45 100644 --- a/Linphone/UI/Main/ContentView.swift +++ b/Linphone/UI/Main/ContentView.swift @@ -416,11 +416,11 @@ struct ContentView: View { if isShowDeletePopup { PopupView(sharedMainViewModel: SharedMainViewModel(), isShowPopup: $isShowDeletePopup, title: Text( - contactViewModel.selectedFriend != nil - ? "Delete \(contactViewModel.selectedFriend!.name!)?" - : (contactViewModel.displayedFriend != nil - ? "Delete \(contactViewModel.displayedFriend!.name!)?" - : "Error Name")), + contactViewModel.selectedFriend != nil + ? "Delete \(contactViewModel.selectedFriend!.name!)?" + : (contactViewModel.displayedFriend != nil + ? "Delete \(contactViewModel.displayedFriend!.name!)?" + : "Error Name")), content: Text("This contact will be deleted definitively."), titleFirstButton: Text("Cancel"), actionFirstButton: {self.isShowDeletePopup.toggle()}, diff --git a/Linphone/Utils/MagicSearchSingleton.swift b/Linphone/Utils/MagicSearchSingleton.swift index 2ee38c8cc..50c9f02e9 100644 --- a/Linphone/Utils/MagicSearchSingleton.swift +++ b/Linphone/Utils/MagicSearchSingleton.swift @@ -25,9 +25,8 @@ final class MagicSearchSingleton: ObservableObject { private var coreContext = CoreContext.shared private var magicSearch: MagicSearch! - var magicSearchDelegate: MagicSearchDelegate? - @objc var currentFilter: String = "" + var currentFilter: String = "" var previousFilter: String? var needUpdateLastSearchContacts = false @@ -35,36 +34,45 @@ final class MagicSearchSingleton: ObservableObject { @Published var lastSearch: [SearchResult] = [] private var limitSearchToLinphoneAccounts = true - - @Published var allContact = false - private var domainDefaultAccount = "" + + @Published var allContact = false + private var domainDefaultAccount = "" private init() { - domainDefaultAccount = coreContext.mCore.defaultAccount!.params!.domain! - - magicSearch = try? coreContext.mCore.createMagicSearch() - magicSearch.limitedSearch = false - - magicSearchDelegate = MagicSearchDelegateStub(onSearchResultsReceived: { (magicSearch: MagicSearch) in - self.needUpdateLastSearchContacts = true - self.lastSearch = magicSearch.lastSearch - }) - - magicSearch.addDelegate(delegate: magicSearchDelegate!) + coreContext.doOnCoreQueue{ core in + self.domainDefaultAccount = core.defaultAccount?.params?.domain ?? "" + + self.magicSearch = try? core.createMagicSearch() + self.magicSearch.limitedSearch = false + + self.magicSearch.publisher?.onSearchResultsReceived?.postOnMainQueue { (magicSearch: MagicSearch) in + self.needUpdateLastSearchContacts = true + self.lastSearch = magicSearch.lastSearch + } + } } func searchForContacts(sourceFlags: Int) { - if let oldFilter = previousFilter { - if oldFilter.count > currentFilter.count || oldFilter != currentFilter { - magicSearch.resetSearchCache() + coreContext.doOnCoreQueue{ core 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) } - previousFilter = currentFilter - - magicSearch.getContactsListAsync( - filter: currentFilter, - domain: allContact ? "" : domainDefaultAccount, - sourceFlags: sourceFlags, - aggregation: MagicSearch.Aggregation.Friend) } } From abd5461f544c169f5a8c4783a869ee5b8149e07e Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Mon, 30 Oct 2023 17:00:24 +0100 Subject: [PATCH 032/486] Add edit contact view --- Linphone.xcodeproj/project.pbxproj | 12 + .../profil-picture-default.svg | 8 +- Linphone/Contacts/ContactsManager.swift | 264 +++-- Linphone/LinphoneApp.swift | 2 +- Linphone/Localizable.xcstrings | 54 +- Linphone/UI/Main/Contacts/ContactsView.swift | 17 +- .../Contacts/Fragments/ContactFragment.swift | 43 +- .../Fragments/ContactInnerFragment.swift | 1011 +++++++++-------- .../Fragments/ContactListBottomSheet.swift | 6 +- .../Contacts/Fragments/ContactsFragment.swift | 33 +- .../Fragments/ContactsListBottomSheet.swift | 19 +- .../Fragments/ContactsListFragment.swift | 4 +- .../Fragments/EditContactFragment.swift | 517 +++++++++ .../FavoriteContactsListFragment.swift | 111 +- .../Contacts/ViewModel/ContactViewModel.swift | 6 +- .../ViewModel/EditContactViewModel.swift | 60 + Linphone/UI/Main/ContentView.swift | 91 +- .../Main/Viewmodel/SharedMainViewModel.swift | 2 - Linphone/Utils/PhotoPicker.swift | 85 ++ 19 files changed, 1643 insertions(+), 702 deletions(-) create mode 100644 Linphone/UI/Main/Contacts/Fragments/EditContactFragment.swift create mode 100644 Linphone/UI/Main/Contacts/ViewModel/EditContactViewModel.swift create mode 100644 Linphone/Utils/PhotoPicker.swift diff --git a/Linphone.xcodeproj/project.pbxproj b/Linphone.xcodeproj/project.pbxproj index 5e3cbc295..0dfea3d76 100644 --- a/Linphone.xcodeproj/project.pbxproj +++ b/Linphone.xcodeproj/project.pbxproj @@ -47,6 +47,9 @@ D7A2EDD62AC18115005D90FC /* SharedMainViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7A2EDD52AC18115005D90FC /* SharedMainViewModel.swift */; }; D7B5066D2AEFA9B900CEB4E9 /* ContactInnerFragment.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7B5066C2AEFA9B900CEB4E9 /* ContactInnerFragment.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 */; }; 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 */; }; @@ -110,6 +113,9 @@ D7A2EDDA2AC19EEC005D90FC /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; D7B5066C2AEFA9B900CEB4E9 /* ContactInnerFragment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactInnerFragment.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 = ""; }; 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 = ""; }; @@ -165,6 +171,7 @@ D717071F2AC5989C0037746F /* TextExtension.swift */, D74C9D002ACB098C0021626A /* PermissionManager.swift */, D7D1698B2AE66FA500109A5C /* MagicSearchSingleton.swift */, + D7C3650D2AF15BF200FE6142 /* PhotoPicker.swift */, ); path = Utils; sourceTree = ""; @@ -331,6 +338,7 @@ D7E6D0542AEBFCCE00A57AAF /* ContactsInnerFragment.swift */, D7B5066C2AEFA9B900CEB4E9 /* ContactInnerFragment.swift */, D7C365072AEFAB7F00FE6142 /* ContactListBottomSheet.swift */, + D7C365092AF001C300FE6142 /* EditContactFragment.swift */, ); path = Fragments; sourceTree = ""; @@ -341,6 +349,7 @@ D78290BA2ADD40B2004AA85C /* ContactViewModel.swift */, D71FCA7E2AE1397200D2E43E /* ContactsListViewModel.swift */, D7E6D04A2AE9347D00A57AAF /* FavoriteContactsListViewModel.swift */, + D7C3650B2AF0084000FE6142 /* EditContactViewModel.swift */, ); path = ViewModel; sourceTree = ""; @@ -541,15 +550,18 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + D7C3650E2AF15BF200FE6142 /* PhotoPicker.swift in Sources */, D71707202AC5989C0037746F /* TextExtension.swift in Sources */, D719ABB92ABC67BF00B41C10 /* ContentView.swift in Sources */, D71FCA832AE14D6E00D2E43E /* ContactFragment.swift in Sources */, + D7C3650C2AF0084000FE6142 /* EditContactViewModel.swift in Sources */, D750D3392AD3E6EE00EC99C5 /* PopupLoadingView.swift in Sources */, D7E6D0492AE933AD00A57AAF /* FavoriteContactsListFragment.swift in Sources */, D706BA822ADD72D100278F45 /* DeviceRotationViewModifier.swift in Sources */, D719ABC92ABC6FD700B41C10 /* CoreContext.swift in Sources */, D7EAACCF2AD6ED8000AA6A8A /* PermissionsFragment.swift in Sources */, D777DBB32AE12C5900565A99 /* ContactsManager.swift in Sources */, + D7C3650A2AF001C300FE6142 /* EditContactFragment.swift in Sources */, D7A03FBD2ACC2DB60081A588 /* ContactsView.swift in Sources */, D719ABCF2ABC779A00B41C10 /* AccountLoginViewModel.swift in Sources */, D78290BB2ADD40B2004AA85C /* ContactViewModel.swift in Sources */, 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 index b2bea3b29..557a5087a 100644 --- a/Linphone/Assets.xcassets/profil-picture-default.imageset/profil-picture-default.svg +++ b/Linphone/Assets.xcassets/profil-picture-default.imageset/profil-picture-default.svg @@ -1,10 +1,10 @@ - + - - + + - + diff --git a/Linphone/Contacts/ContactsManager.swift b/Linphone/Contacts/ContactsManager.swift index 1e31e8716..76eafa748 100644 --- a/Linphone/Contacts/ContactsManager.swift +++ b/Linphone/Contacts/ContactsManager.swift @@ -22,55 +22,76 @@ import Contacts import SwiftUI final class ContactsManager: ObservableObject { - - static let shared = ContactsManager() - private var coreContext = CoreContext.shared - private var magicSearch = MagicSearchSingleton.shared - - private let nativeAddressBookFriendList = "Native address-book" - let linphoneAddressBookFirendList = "Linphone address-book" + static let shared = ContactsManager() + + private var coreContext = CoreContext.shared + private var magicSearch = MagicSearchSingleton.shared + + private let nativeAddressBookFriendList = "Native address-book" + let linphoneAddressBookFirendList = "Linphone address-book" @Published var friendList: FriendList? - - private init() { - fetchContacts() - } - - func fetchContacts() { - DispatchQueue.global().async { - if self.coreContext.mCore.globalState == GlobalState.Shutdown || self.coreContext.mCore.globalState == GlobalState.Off { - print("$TAG Core is being stopped or already destroyed, abort") + @Published var linphoneFriendList: FriendList? + + private init() { + fetchContacts() + } + + func fetchContacts() { + DispatchQueue.global().async { + if self.coreContext.mCore.globalState == GlobalState.Shutdown || self.coreContext.mCore.globalState == GlobalState.Off { + print("$TAG Core is being stopped or already destroyed, abort") } else { - print("$TAG ${friends.size} friends created") - + print("$TAG ${friends.size} friends created") + self.friendList = self.coreContext.mCore.getFriendListByName(name: self.nativeAddressBookFriendList) if self.friendList == nil { - do { + do { self.friendList = try self.coreContext.mCore.createFriendList() - } catch let error { - print("Failed to enumerate contact", error) - } - } - + } catch let error { + print("Failed to enumerate contact", error) + } + } + if self.friendList!.displayName == nil || self.friendList!.displayName!.isEmpty { - print( - "$TAG Friend list [$nativeAddressBookFriendList] didn't exist yet, let's create it" - ) - + print( + "$TAG Friend list [$nativeAddressBookFriendList] didn't exist yet, let's create it" + ) + self.friendList!.databaseStorageEnabled = false // We don't want to store local address-book in DB - + self.friendList!.displayName = self.nativeAddressBookFriendList self.coreContext.mCore.addFriendList(list: self.friendList!) - } else { - print( - "$TAG Friend list [$LINPHONE_ADDRESS_BOOK_FRIEND_LIST] found, removing existing friends if any" - ) + } else { + print( + "$TAG Friend list [$LINPHONE_ADDRESS_BOOK_FRIEND_LIST] found, removing existing friends if any" + ) self.friendList!.friends.forEach { friend in _ = self.friendList!.removeFriend(linphoneFriend: friend) - } - } - } + } + } + + self.linphoneFriendList = self.coreContext.mCore.getFriendListByName(name: self.linphoneAddressBookFirendList) + if self.linphoneFriendList == nil { + do { + self.linphoneFriendList = try self.coreContext.mCore.createFriendList() + } catch let error { + print("Failed to enumerate contact", error) + } + } + + if self.linphoneFriendList!.displayName == nil || self.linphoneFriendList!.displayName!.isEmpty { + print( + "$TAG Friend list [$linphoneAddressBookFirendList] didn't exist yet, let's create it" + ) + + self.linphoneFriendList!.databaseStorageEnabled = true + + self.linphoneFriendList!.displayName = self.linphoneAddressBookFirendList + self.coreContext.mCore.addFriendList(list: self.linphoneFriendList!) + } + } let store = CNContactStore() store.requestAccess(for: .contacts) { (granted, error) in @@ -87,29 +108,29 @@ final class ContactsManager: ObservableObject { let request = CNContactFetchRequest(keysToFetch: keys as [CNKeyDescriptor]) do { try store.enumerateContacts(with: request, usingBlock: { (contact, _) in - DispatchQueue.main.sync { let newContact = Contact( - firstName: contact.givenName, - lastName: contact.familyName, - organizationName: contact.organizationName, - displayName: contact.nickname, - sipAddresses: contact.instantMessageAddresses.map { $0.value.service == "SIP" ? $0.value.username : "" }, - phoneNumbers: contact.phoneNumbers.map { PhoneNumber(numLabel: $0.label ?? "", num: $0.value.stringValue)}, - imageData: "" - ) + firstName: contact.givenName, + lastName: contact.familyName, + organizationName: contact.organizationName, + jobTitle: "", + displayName: contact.nickname, + sipAddresses: contact.instantMessageAddresses.map { $0.value.service == "SIP" ? $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: - UIImage(data: contact.thumbnailImageData ?? Data()) + 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 + String(Int.random(in: 1...1000)), - contact: newContact) + && contact.familyName.isEmpty + && contact.phoneNumbers.first?.value.stringValue != nil + ? contact.phoneNumbers.first!.value.stringValue + : contact.givenName, lastName: contact.familyName), + name: contact.givenName + contact.familyName + String(Int.random(in: 1...1000)) + ((imageThumbnail == nil) ? "-default" : ""), + contact: newContact, linphoneFriend: false, existingFriend: nil) } }) @@ -121,9 +142,9 @@ final class ContactsManager: ObservableObject { print("access denied") } } - self.magicSearch.searchForContacts(sourceFlags: MagicSearch.Source.Friends.rawValue | MagicSearch.Source.LdapServers.rawValue) - } - } + self.magicSearch.searchForContacts(sourceFlags: MagicSearch.Source.Friends.rawValue | MagicSearch.Source.LdapServers.rawValue) + } + } func textToImage(firstName: String, lastName: String) -> UIImage { let lblNameInitialize = UILabel() @@ -152,36 +173,70 @@ final class ContactsManager: ObservableObject { return IBImgViewUserProfile } - - func saveImage(image: UIImage, name: String, contact: Contact) { - guard let data = image.jpegData(compressionQuality: 1) ?? image.pngData() else { - return - } + + func saveImage(image: UIImage, name: String, contact: Contact, linphoneFriend: Bool, existingFriend: Friend?) { + guard let data = image.jpegData(compressionQuality: 1) ?? image.pngData() else { + return + } awaitDataWrite(data: data, name: name) { _, result in - do { - let friend = try self.coreContext.mCore.createFriend() - friend.edit() - try friend.setName(newValue: contact.firstName + " " + contact.lastName) - friend.organization = contact.organizationName + let resultFriend = self.saveFriend(result: result, contact: contact, existingFriend: existingFriend) + + if resultFriend != nil { + if linphoneFriend && existingFriend == nil { + _ = self.linphoneFriendList!.addLocalFriend(linphoneFriend: resultFriend!) + + self.linphoneFriendList!.updateSubscriptions() + } else if existingFriend == nil { + _ = self.friendList!.addLocalFriend(linphoneFriend: resultFriend!) + + self.friendList!.updateSubscriptions() + } + } + } + } + + func saveFriend(result: String, contact: Contact, existingFriend: Friend?) -> Friend? { + do { + let friend = (existingFriend != nil) ? existingFriend : try self.coreContext.mCore.createFriend() + + if friend != nil { + friend!.edit() + + 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 let address = self.coreContext.mCore.interpretUrl(url: sipAddress, applyInternationalPrefix: true) if address != nil && ((friendAddresses.firstIndex(where: {$0.asString() == address?.asString()})) == nil) { - friend.addAddress(address: address!) + 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.numLabel == phone.numLabel})) == 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) + 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 { @@ -189,50 +244,61 @@ final class ContactsManager: ObservableObject { } } - let contactImage = result.dropFirst(8) - friend.photo = "file:/" + contactImage - - friend.organization = contact.organizationName + friend!.photo = "file:/" + result - friend.done() + friend!.organization = contact.organizationName + friend!.jobTitle = contact.jobTitle - _ = self.friendList!.addLocalFriend(linphoneFriend: friend) - - self.friendList!.updateSubscriptions() - - } catch let error { - print("Failed to enumerate contact", error) + friend!.done() + return friend } + } catch let error { + print("Failed to enumerate contact", error) + return nil } - } + return 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, completion: @escaping ((), String) -> Void) { - let directory = FileManager.default.temporaryDirectory + let directory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first - DispatchQueue.main.async { + if directory != nil { + DispatchQueue.main.async { do { - let decodedData: () = try data.write(to: directory.appendingPathComponent(name + ".png")) - completion(decodedData, directory.appendingPathComponent(name + ".png").absoluteString) // <--- here, return the results + let urlName = URL(string: name) + let imagePath = urlName != nil ? urlName!.absoluteString.replacingOccurrences(of: "%", with: "") : String(Int.random(in: 1...1000)) + let decodedData: () = try data.write(to: directory!.appendingPathComponent(imagePath + ".png")) + completion(decodedData, imagePath + ".png") } catch { - print("Error: ", error) // need to deal with errors - completion((), "") // <--- here, should return the error + print("Error: ", error) + completion((), "") } + } } } } struct PhoneNumber { - var numLabel: String - var num: String + var numLabel: String + var num: String } struct Contact: Identifiable { - var id = UUID() - var firstName: String - var lastName: String - var organizationName: String - var displayName: String - var sipAddresses: [String] = [] - var phoneNumbers: [PhoneNumber] = [] - var imageData: String + var id = UUID() + var firstName: String + var lastName: String + var organizationName: String + var jobTitle: String + var displayName: String + var sipAddresses: [String] = [] + var phoneNumbers: [PhoneNumber] = [] + var imageData: String } diff --git a/Linphone/LinphoneApp.swift b/Linphone/LinphoneApp.swift index b507a0c87..5aa85a39b 100644 --- a/Linphone/LinphoneApp.swift +++ b/Linphone/LinphoneApp.swift @@ -36,7 +36,7 @@ struct LinphoneApp: App { AssistantView(sharedMainViewModel: sharedMainViewModel) .toast(isShowing: $coreContext.toastMessage) } else if coreContext.mCore.defaultAccount != nil { - ContentView(contactViewModel: ContactViewModel(), historyViewModel: HistoryViewModel()) + ContentView(contactViewModel: ContactViewModel(), editContactViewModel: EditContactViewModel(), historyViewModel: HistoryViewModel()) .toast(isShowing: $coreContext.toastMessage) } } else { diff --git a/Linphone/Localizable.xcstrings b/Linphone/Localizable.xcstrings index 79915ed4f..0a4d62d83 100644 --- a/Linphone/Localizable.xcstrings +++ b/Linphone/Localizable.xcstrings @@ -33,6 +33,9 @@ }, "**Contacts** : Pour vous afficher vos contacts et retrouver qui utilise Linphone." : { + }, + "**Job :** %@" : { + }, "**Micro** : Pour permettre à vos correspondants de vous entendre." : { @@ -95,12 +98,18 @@ }, "Accept all" : { + }, + "Add a picture" : { + }, "Add to favourites" : { }, "All contacts" : { + }, + "All modifications will be canceled." : { + }, "Appel" : { @@ -121,9 +130,6 @@ } } } - }, - "Block" : { - }, "Block the address" : { @@ -145,6 +151,9 @@ }, "Close" : { + }, + "Company" : { + }, "Conditions de service" : { @@ -190,9 +199,18 @@ }, "Domain" : { + }, + "Don’t save modifications?" : { + }, "Edit" : { + }, + "Edit contact" : { + + }, + "Edit picture" : { + }, "En continuant, vous acceptez ces conditions, " : { @@ -208,6 +226,12 @@ }, "Favourites" : { + }, + "First Name" : { + + }, + "First name*" : { + }, "History Contact fragment" : { @@ -235,6 +259,12 @@ }, "Invitation" : { + }, + "Job title" : { + + }, + "Last name" : { + }, "Linphone" : { @@ -248,10 +278,10 @@ "Message" : { }, - "Mute" : { + "My Profile" : { }, - "My Profile" : { + "New contact" : { }, "Next" : { @@ -297,9 +327,15 @@ }, "Personnalize your profil mode" : { + }, + "Phone :" : { + }, "Phone (%@) :" : { + }, + "Phone number" : { + }, "Plus tard" : { @@ -316,7 +352,10 @@ "Register" : { }, - "Remove to favourites" : { + "Remove from favourites" : { + + }, + "Remove picture" : { }, "Scan QR code" : { @@ -333,6 +372,9 @@ }, "Share" : { + }, + "SIP address" : { + }, "SIP address :" : { diff --git a/Linphone/UI/Main/Contacts/ContactsView.swift b/Linphone/UI/Main/Contacts/ContactsView.swift index 4d00f8bdb..a8191f047 100644 --- a/Linphone/UI/Main/Contacts/ContactsView.swift +++ b/Linphone/UI/Main/Contacts/ContactsView.swift @@ -23,17 +23,22 @@ struct ContactsView: View { @ObservedObject var contactViewModel: ContactViewModel @ObservedObject var historyViewModel: HistoryViewModel + @ObservedObject var editContactViewModel: EditContactViewModel + @Binding var isShowEditContactFragment: Bool @Binding var isShowDeletePopup: Bool var body: some View { NavigationView { ZStack(alignment: .bottomTrailing) { - ContactsFragment(contactViewModel: contactViewModel, isShowDeletePopup: $isShowDeletePopup) Button { - // Action + withAnimation { + editContactViewModel.selectedEditFriend = nil + editContactViewModel.resetValues() + isShowEditContactFragment.toggle() + } } label: { Image("user-plus") .padding() @@ -50,5 +55,11 @@ struct ContactsView: View { } #Preview { - ContactsView(contactViewModel: ContactViewModel(), historyViewModel: HistoryViewModel(), isShowDeletePopup: .constant(false)) + ContactsView( + contactViewModel: ContactViewModel(), + historyViewModel: HistoryViewModel(), + editContactViewModel: EditContactViewModel(), + isShowEditContactFragment: .constant(false), + isShowDeletePopup: .constant(false) + ) } diff --git a/Linphone/UI/Main/Contacts/Fragments/ContactFragment.swift b/Linphone/UI/Main/Contacts/Fragments/ContactFragment.swift index 042169629..abbd1492c 100644 --- a/Linphone/UI/Main/Contacts/Fragments/ContactFragment.swift +++ b/Linphone/UI/Main/Contacts/Fragments/ContactFragment.swift @@ -21,21 +21,50 @@ import SwiftUI struct ContactFragment: View { + private var idiom: UIUserInterfaceIdiom { UIDevice.current.userInterfaceIdiom } + @ObservedObject var contactViewModel: ContactViewModel + @ObservedObject var editContactViewModel: EditContactViewModel @Binding var isShowDeletePopup: Bool + @Binding var isShowDismissPopup: Bool @State private var showingSheet = false var body: some View { if #available(iOS 16.0, *) { - ContactInnerFragment(contactViewModel: contactViewModel, isShowDeletePopup: $isShowDeletePopup, showingSheet: $showingSheet) - .sheet(isPresented: $showingSheet) { - ContactListBottomSheet(contactViewModel: contactViewModel, showingSheet: $showingSheet) - .presentationDetents([.fraction(0.2)]) - } + if idiom != .pad { + ContactInnerFragment( + contactViewModel: contactViewModel, + editContactViewModel: editContactViewModel, + isShowDeletePopup: $isShowDeletePopup, + showingSheet: $showingSheet, + isShowDismissPopup: $isShowDismissPopup + ) + .sheet(isPresented: $showingSheet) { + ContactListBottomSheet(contactViewModel: contactViewModel, showingSheet: $showingSheet) + .presentationDetents([.fraction(0.2)]) + } + } else { + ContactInnerFragment( + contactViewModel: contactViewModel, + editContactViewModel: editContactViewModel, + isShowDeletePopup: $isShowDeletePopup, + showingSheet: $showingSheet, + isShowDismissPopup: $isShowDismissPopup + ) + .halfSheet(showSheet: $showingSheet) { + ContactListBottomSheet(contactViewModel: contactViewModel, showingSheet: $showingSheet) + } onDismiss: {} + } } else { - ContactInnerFragment(contactViewModel: contactViewModel, isShowDeletePopup: $isShowDeletePopup, showingSheet: $showingSheet) + ContactInnerFragment( + contactViewModel: contactViewModel, + editContactViewModel: editContactViewModel, + isShowDeletePopup: $isShowDeletePopup, + showingSheet: $showingSheet, + isShowDismissPopup: $isShowDismissPopup + ) .halfSheet(showSheet: $showingSheet) { ContactListBottomSheet(contactViewModel: contactViewModel, showingSheet: $showingSheet) } onDismiss: {} @@ -45,5 +74,5 @@ struct ContactFragment: View { } #Preview { - ContactFragment(contactViewModel: ContactViewModel(), isShowDeletePopup: .constant(false)) + ContactFragment(contactViewModel: ContactViewModel(), editContactViewModel: EditContactViewModel(), isShowDeletePopup: .constant(false), isShowDismissPopup: .constant(false)) } diff --git a/Linphone/UI/Main/Contacts/Fragments/ContactInnerFragment.swift b/Linphone/UI/Main/Contacts/Fragments/ContactInnerFragment.swift index dc31e9c35..63ccd2937 100644 --- a/Linphone/UI/Main/Contacts/Fragments/ContactInnerFragment.swift +++ b/Linphone/UI/Main/Contacts/Fragments/ContactInnerFragment.swift @@ -21,241 +21,132 @@ import SwiftUI struct ContactInnerFragment: View { + @ObservedObject private var sharedMainViewModel = SharedMainViewModel() + @ObservedObject var contactViewModel: ContactViewModel + @ObservedObject var editContactViewModel: EditContactViewModel + @ObservedObject var magicSearch = MagicSearchSingleton.shared @State private var orientation = UIDevice.current.orientation @State private var informationIsOpen = true @Binding var isShowDeletePopup: Bool - @Binding var showingSheet: Bool + @Binding var isShowDismissPopup: Bool var body: some View { - 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(.top, 2) - .onTapGesture { - withAnimation { - contactViewModel.displayedFriend = nil + NavigationView { + 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(.top, 2) + .onTapGesture { + withAnimation { + contactViewModel.indexDisplayedFriend = nil + } } - } - } - - Spacer() - - Image("pencil-simple") - .renderingMode(.template) - .resizable() - .foregroundStyle(Color.orangeMain500) - .frame(width: 25, height: 25, alignment: .leading) - .padding(.top, 2) - .onTapGesture { - withAnimation { - - } } - } - .frame(maxWidth: .infinity) - .frame(height: 50) - .padding(.horizontal) - .padding(.bottom, 4) - .background(.white) - - ScrollView { - VStack(spacing: 0) { + + Spacer() + + NavigationLink(destination: EditContactFragment(editContactViewModel: editContactViewModel, isShowEditContactFragment: .constant(false), isShowDismissPopup: $isShowDismissPopup)) { + Image("pencil-simple") + .renderingMode(.template) + .resizable() + .foregroundStyle(Color.orangeMain500) + .frame(width: 25, height: 25, alignment: .leading) + .padding(.top, 2) + } + .simultaneousGesture( + TapGesture().onEnded { + editContactViewModel.selectedEditFriend = magicSearch.lastSearch[contactViewModel.indexDisplayedFriend!].friend + editContactViewModel.resetValues() + } + ) + } + .frame(maxWidth: .infinity) + .frame(height: 50) + .padding(.horizontal) + .padding(.bottom, 4) + .background(.white) + + ScrollView { VStack(spacing: 0) { - if contactViewModel.displayedFriend != nil - && contactViewModel.displayedFriend!.photo != nil - && !contactViewModel.displayedFriend!.photo!.isEmpty { - AsyncImage(url: URL(string: contactViewModel.displayedFriend!.photo!)) { image in - switch image { - case .empty: - ProgressView() - .frame(width: 100, height: 100) - case .success(let image): - image - .resizable() - .frame(width: 100, height: 100) - .clipShape(Circle()) - case .failure: + VStack(spacing: 0) { + VStack(spacing: 0) { + if contactViewModel.indexDisplayedFriend != nil + && magicSearch.lastSearch[contactViewModel.indexDisplayedFriend!].friend != nil + && magicSearch.lastSearch[contactViewModel.indexDisplayedFriend!].friend!.photo != nil + && !magicSearch.lastSearch[contactViewModel.indexDisplayedFriend!].friend!.photo!.isEmpty { + AsyncImage(url: ContactsManager.shared.getImagePath(friendPhotoPath: magicSearch.lastSearch[contactViewModel.indexDisplayedFriend!].friend!.photo!)) { image in + switch image { + case .empty: + ProgressView() + .frame(width: 100, height: 100) + case .success(let image): + image + .resizable() + .frame(width: 100, height: 100) + .clipShape(Circle()) + case .failure: + Image("profil-picture-default") + .resizable() + .frame(width: 100, height: 100) + .clipShape(Circle()) + @unknown default: + EmptyView() + } + } + } else if contactViewModel.indexDisplayedFriend != nil && magicSearch.lastSearch[contactViewModel.indexDisplayedFriend!].friend != nil { Image("profil-picture-default") .resizable() .frame(width: 100, height: 100) .clipShape(Circle()) - @unknown default: - EmptyView() } - } - } else if contactViewModel.displayedFriend != nil { - Image("profil-picture-default") - .resizable() - .frame(width: 100, height: 100) - .clipShape(Circle()) - } - if contactViewModel.displayedFriend != nil && contactViewModel.displayedFriend?.name != nil { - Text((contactViewModel.displayedFriend?.name)!) - .foregroundStyle(Color.grayMain2c700) - .multilineTextAlignment(.center) - .default_text_style(styleSize: 14) - .frame(maxWidth: .infinity) - .padding(.top, 10) - - Text("En ligne") - .foregroundStyle(Color.greenSuccess500) - .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: { - - }, label: { - VStack { - HStack(alignment: .center) { - Image("phone") - .renderingMode(.template) - .resizable() - .foregroundStyle(Color.grayMain2c600) - .frame(width: 25, height: 25) - .onTapGesture { - withAnimation { - - } - } + if contactViewModel.indexDisplayedFriend != nil + && magicSearch.lastSearch[contactViewModel.indexDisplayedFriend!].friend != nil + && magicSearch.lastSearch[contactViewModel.indexDisplayedFriend!].friend?.name != nil { + Text((magicSearch.lastSearch[contactViewModel.indexDisplayedFriend!].friend?.name)!) + .foregroundStyle(Color.grayMain2c700) + .multilineTextAlignment(.center) + .default_text_style(styleSize: 14) + .frame(maxWidth: .infinity) + .padding(.top, 10) + + Text("En ligne") + .foregroundStyle(Color.greenSuccess500) + .multilineTextAlignment(.center) + .default_text_style_300(styleSize: 12) + .frame(maxWidth: .infinity) } - .padding(16) - .background(Color.grayMain2c200) - .cornerRadius(40) - Text("Appel") - .default_text_style(styleSize: 14) } - }) - - Spacer() - - Button(action: { + .frame(minHeight: 150) + .frame(maxWidth: .infinity) + .padding(.top, 10) + .background(Color.gray100) - }, label: { - VStack { - HStack(alignment: .center) { - Image("chat-teardrop-text") - .renderingMode(.template) - .resizable() - .foregroundStyle(Color.grayMain2c600) - .frame(width: 25, height: 25) - .onTapGesture { - withAnimation { - - } - } - } - .padding(16) - .background(Color.grayMain2c200) - .cornerRadius(40) + HStack { + Spacer() - Text("Message") - .default_text_style(styleSize: 14) - } - }) - - Spacer() - - Button(action: { - - }, label: { - VStack { - HStack(alignment: .center) { - Image("video-camera") - .renderingMode(.template) - .resizable() - .foregroundStyle(Color.grayMain2c600) - .frame(width: 25, height: 25) - .onTapGesture { - withAnimation { - - } - } - } - .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) - - 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(.top, 30) - .padding(.bottom, 10) - .padding(.horizontal, 16) - .background(Color.gray100) - .onTapGesture { - withAnimation { - informationIsOpen.toggle() - } - } - - if informationIsOpen { - VStack(spacing: 0) { - if contactViewModel.displayedFriend != nil { - ForEach(0.. UIScreen.main.bounds.size.height { + || UIScreen.main.bounds.size.width > UIScreen.main.bounds.size.height) { Spacer() HStack { Spacer() diff --git a/Linphone/UI/Main/Contacts/Fragments/ContactsFragment.swift b/Linphone/UI/Main/Contacts/Fragments/ContactsFragment.swift index 9196f23cc..e875bd037 100644 --- a/Linphone/UI/Main/Contacts/Fragments/ContactsFragment.swift +++ b/Linphone/UI/Main/Contacts/Fragments/ContactsFragment.swift @@ -20,21 +20,30 @@ import SwiftUI struct ContactsFragment: View { - - @ObservedObject var contactViewModel: ContactViewModel + + private var idiom: UIUserInterfaceIdiom { UIDevice.current.userInterfaceIdiom } + + @ObservedObject var contactViewModel: ContactViewModel @Binding var isShowDeletePopup: Bool - - @State private var showingSheet = false - - var body: some View { + + @State private var showingSheet = false + + var body: some View { ZStack { if #available(iOS 16.0, *) { - ContactsInnerFragment(contactViewModel: contactViewModel, showingSheet: $showingSheet) - .sheet(isPresented: $showingSheet) { - ContactsListBottomSheet(contactViewModel: contactViewModel, isShowDeletePopup: $isShowDeletePopup, showingSheet: $showingSheet) - .presentationDetents([.fraction(0.2)]) - } + if idiom != .pad { + ContactsInnerFragment(contactViewModel: contactViewModel, showingSheet: $showingSheet) + .sheet(isPresented: $showingSheet) { + ContactsListBottomSheet(contactViewModel: contactViewModel, isShowDeletePopup: $isShowDeletePopup, showingSheet: $showingSheet) + .presentationDetents([.fraction(0.2)]) + } + } else { + ContactsInnerFragment(contactViewModel: contactViewModel, showingSheet: $showingSheet) + .halfSheet(showSheet: $showingSheet) { + ContactsListBottomSheet(contactViewModel: contactViewModel, isShowDeletePopup: $isShowDeletePopup, showingSheet: $showingSheet) + } onDismiss: {} + } } else { ContactsInnerFragment(contactViewModel: contactViewModel, showingSheet: $showingSheet) .halfSheet(showSheet: $showingSheet) { @@ -42,7 +51,7 @@ struct ContactsFragment: View { } onDismiss: {} } } - } + } } #Preview { diff --git a/Linphone/UI/Main/Contacts/Fragments/ContactsListBottomSheet.swift b/Linphone/UI/Main/Contacts/Fragments/ContactsListBottomSheet.swift index a5d27c278..8e65df8a8 100644 --- a/Linphone/UI/Main/Contacts/Fragments/ContactsListBottomSheet.swift +++ b/Linphone/UI/Main/Contacts/Fragments/ContactsListBottomSheet.swift @@ -21,7 +21,9 @@ import SwiftUI import linphonesw struct ContactsListBottomSheet: View { - + + private var idiom: UIUserInterfaceIdiom { UIDevice.current.userInterfaceIdiom } + @ObservedObject var magicSearch = MagicSearchSingleton.shared @ObservedObject var contactViewModel: ContactViewModel @@ -36,9 +38,9 @@ struct ContactsListBottomSheet: View { var body: some View { VStack(alignment: .leading) { - if orientation == .landscapeLeft + if idiom != .pad && (orientation == .landscapeLeft || orientation == .landscapeRight - || UIScreen.main.bounds.size.width > UIScreen.main.bounds.size.height { + || UIScreen.main.bounds.size.width > UIScreen.main.bounds.size.height) { Spacer() HStack { Spacer() @@ -72,10 +74,10 @@ struct ContactsListBottomSheet: View { Image(contactViewModel.selectedFriend != nil && contactViewModel.selectedFriend!.starred == true ? "heart-fill" : "heart") .renderingMode(.template) .resizable() - .foregroundStyle(Color.grayMain2c500) + .foregroundStyle(contactViewModel.selectedFriend != nil && contactViewModel.selectedFriend!.starred == true ? Color.redDanger500 : Color.grayMain2c500) .frame(width: 25, height: 25, alignment: .leading) Text(contactViewModel.selectedFriend != nil && contactViewModel.selectedFriend!.starred == true - ? "Remove to favourites" + ? "Remove from favourites" : "Add to favourites") .default_text_style(styleSize: 16) Spacer() @@ -147,10 +149,13 @@ struct ContactsListBottomSheet: View { .background(Color.gray100) } + .background(Color.gray100) + .frame(maxWidth: .infinity) .onRotate { newOrientation in orientation = newOrientation } - .background(Color.gray100) - .frame(maxWidth: .infinity) + .onDisappear { + contactViewModel.selectedFriend = nil + } } } diff --git a/Linphone/UI/Main/Contacts/Fragments/ContactsListFragment.swift b/Linphone/UI/Main/Contacts/Fragments/ContactsListFragment.swift index 355f67d0f..b4696b326 100644 --- a/Linphone/UI/Main/Contacts/Fragments/ContactsListFragment.swift +++ b/Linphone/UI/Main/Contacts/Fragments/ContactsListFragment.swift @@ -64,7 +64,7 @@ struct ContactsListFragment: View { } if magicSearch.lastSearch[index].friend!.photo != nil && !magicSearch.lastSearch[index].friend!.photo!.isEmpty { - AsyncImage(url: URL(string: magicSearch.lastSearch[index].friend!.photo!)) { image in + AsyncImage(url: ContactsManager.shared.getImagePath(friendPhotoPath: magicSearch.lastSearch[index].friend!.photo!)) { image in switch image { case .empty: ProgressView() @@ -106,7 +106,7 @@ struct ContactsListFragment: View { TapGesture() .onEnded { _ in withAnimation { - contactViewModel.displayedFriend = magicSearch.lastSearch[index].friend + contactViewModel.indexDisplayedFriend = index } } ) diff --git a/Linphone/UI/Main/Contacts/Fragments/EditContactFragment.swift b/Linphone/UI/Main/Contacts/Fragments/EditContactFragment.swift new file mode 100644 index 000000000..a3259fc84 --- /dev/null +++ b/Linphone/UI/Main/Contacts/Fragments/EditContactFragment.swift @@ -0,0 +1,517 @@ +/* + * 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 EditContactFragment: View { + + @ObservedObject var editContactViewModel: EditContactViewModel + @ObservedObject private var sharedMainViewModel = SharedMainViewModel() + + @Environment(\.dismiss) var dismiss + + @Binding var isShowEditContactFragment: Bool + @Binding var isShowDismissPopup: Bool + + @State private var hasTimeElapsed = false + @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 { + Rectangle() + .foregroundColor(delayedColor) + .edgesIgnoringSafeArea(.top) + .frame(height: 0) + .task(delayColor) + } else { + Rectangle() + .foregroundColor(Color.orangeMain500) + .edgesIgnoringSafeArea(.top) + .frame(height: 0) + } + + HStack { + Image("caret-left") + .renderingMode(.template) + .resizable() + .foregroundStyle(Color.orangeMain500) + .frame(width: 25, height: 25, alignment: .leading) + .padding(.top, 2) + .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(.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 { + AsyncImage(url: ContactsManager.shared.getImagePath(friendPhotoPath: editContactViewModel.selectedEditFriend!.photo!)) { image in + switch image { + case .empty: + ProgressView() + .frame(width: 100, height: 100) + case .success(let image): + image + .resizable() + .frame(width: 100, height: 100) + .clipShape(Circle()) + case .failure: + Image("profil-picture-default") + .resizable() + .frame(width: 100, height: 100) + .clipShape(Circle()) + @unknown default: + EmptyView() + } + } + } else if selectedImage == nil { + Image("profil-picture-default") + .resizable() + .frame(width: 100, height: 100) + .clipShape(Circle()) + } else { + Image(uiImage: selectedImage!) + .resizable() + .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 linphonesw + +class EditContactViewModel: ObservableObject { + + @Published var selectedEditFriend: Friend? + + @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() { + firstName = (selectedEditFriend == nil ? "" : selectedEditFriend!.vcard?.givenName) ?? "" + lastName = (selectedEditFriend == nil ? "" : selectedEditFriend!.vcard?.familyName) ?? "" + sipAddresses = [] + phoneNumbers = [] + company = (selectedEditFriend == nil ? "" : selectedEditFriend!.organization) ?? "" + jobTitle = (selectedEditFriend == nil ? "" : selectedEditFriend!.jobTitle) ?? "" + + if selectedEditFriend != nil { + selectedEditFriend?.addresses.forEach({ address in + sipAddresses.append(String(address.asStringUriOnly().dropFirst(4))) + }) + + selectedEditFriend?.phoneNumbers.forEach({ phoneNumber in + phoneNumbers.append(phoneNumber) + }) + + } + + sipAddresses.append("") + phoneNumbers.append("") + } +} diff --git a/Linphone/UI/Main/ContentView.swift b/Linphone/UI/Main/ContentView.swift index 2be1c0356..3f60aa239 100644 --- a/Linphone/UI/Main/ContentView.swift +++ b/Linphone/UI/Main/ContentView.swift @@ -26,6 +26,7 @@ struct ContentView: View { var magicSearch = MagicSearchSingleton.shared @ObservedObject var contactViewModel: ContactViewModel + @ObservedObject var editContactViewModel: EditContactViewModel @ObservedObject var historyViewModel: HistoryViewModel @ObservedObject private var coreContext = CoreContext.shared @@ -36,8 +37,10 @@ struct ContentView: View { @State private var searchIsActive = false @State private var text = "" @FocusState private var focusedField: Bool - @State var isMenuOpen: Bool = false + @State var isMenuOpen = false @State var isShowDeletePopup = false + @State var isShowEditContactFragment = false + @State var isShowDismissPopup = false var body: some View { GeometryReader { geometry in @@ -73,7 +76,7 @@ struct ContentView: View { Button(action: { self.index = 1 - contactViewModel.displayedFriend = nil + contactViewModel.indexDisplayedFriend = nil }, label: { VStack { Image("phone") @@ -273,7 +276,13 @@ struct ContentView: View { } if self.index == 0 { - ContactsView(contactViewModel: contactViewModel, historyViewModel: historyViewModel, isShowDeletePopup: $isShowDeletePopup) + ContactsView( + contactViewModel: contactViewModel, + historyViewModel: historyViewModel, + editContactViewModel: editContactViewModel, + isShowEditContactFragment: $isShowEditContactFragment, + isShowDeletePopup: $isShowDeletePopup + ) } else if self.index == 1 { HistoryView() } @@ -328,7 +337,7 @@ struct ContentView: View { Button(action: { self.index = 1 - contactViewModel.displayedFriend = nil + contactViewModel.indexDisplayedFriend = nil }, label: { VStack { Image("phone") @@ -358,7 +367,7 @@ struct ContentView: View { } } - if contactViewModel.displayedFriend != nil || !historyViewModel.historyTitle.isEmpty { + if contactViewModel.indexDisplayedFriend != nil || !historyViewModel.historyTitle.isEmpty { HStack(spacing: 0) { Spacer() .frame(maxWidth: @@ -369,7 +378,7 @@ struct ContentView: View { : 0 ) if self.index == 0 { - ContactFragment(contactViewModel: contactViewModel, isShowDeletePopup: $isShowDeletePopup) + ContactFragment(contactViewModel: contactViewModel, editContactViewModel: editContactViewModel, isShowDeletePopup: $isShowDeletePopup, isShowDismissPopup: $isShowDismissPopup) .frame(maxWidth: .infinity) .background(Color.gray100) .ignoresSafeArea(.keyboard) @@ -413,31 +422,42 @@ struct ContentView: View { .ignoresSafeArea(.all) .zIndex(2) + if isShowEditContactFragment { + EditContactFragment(editContactViewModel: editContactViewModel, isShowEditContactFragment: $isShowEditContactFragment, isShowDismissPopup: $isShowDismissPopup) + .zIndex(3) + .transition(.move(edge: .bottom)) + .onAppear { + contactViewModel.indexDisplayedFriend = nil + } + } + if isShowDeletePopup { PopupView(sharedMainViewModel: SharedMainViewModel(), isShowPopup: $isShowDeletePopup, title: Text( - contactViewModel.selectedFriend != nil + contactViewModel.selectedFriend != nil ? "Delete \(contactViewModel.selectedFriend!.name!)?" - : (contactViewModel.displayedFriend != nil - ? "Delete \(contactViewModel.displayedFriend!.name!)?" + : (contactViewModel.indexDisplayedFriend != nil + ? "Delete \(magicSearch.lastSearch[contactViewModel.indexDisplayedFriend!].friend!.name!)?" : "Error Name")), content: Text("This contact will be deleted definitively."), titleFirstButton: Text("Cancel"), - actionFirstButton: {self.isShowDeletePopup.toggle()}, + actionFirstButton: { + self.isShowDeletePopup.toggle()}, titleSecondButton: Text("Ok"), actionSecondButton: { - if contactViewModel.selectedFriend != nil { - contactViewModel.selectedFriend!.remove() - if contactViewModel.displayedFriend != nil && contactViewModel.selectedFriend!.name == contactViewModel.displayedFriend!.name { + if contactViewModel.selectedFriendToDelete != nil { + if contactViewModel.indexDisplayedFriend != nil { withAnimation { - contactViewModel.displayedFriend = nil + contactViewModel.indexDisplayedFriend = nil } } - } else if contactViewModel.displayedFriend != nil { - contactViewModel.displayedFriend!.remove() + contactViewModel.selectedFriendToDelete!.remove() + } else if contactViewModel.indexDisplayedFriend != nil { + let tmpIndex = contactViewModel.indexDisplayedFriend withAnimation { - contactViewModel.displayedFriend = nil + contactViewModel.indexDisplayedFriend = nil } + magicSearch.lastSearch[tmpIndex!].friend!.remove() } magicSearch.searchForContacts( sourceFlags: MagicSearch.Source.Friends.rawValue | MagicSearch.Source.LdapServers.rawValue) @@ -448,6 +468,39 @@ struct ContentView: View { .onTapGesture { self.isShowDeletePopup.toggle() } + .onAppear { + contactViewModel.selectedFriendToDelete = contactViewModel.selectedFriend + } + } + + if isShowDismissPopup { + PopupView(sharedMainViewModel: SharedMainViewModel(), 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() + } } } } @@ -462,7 +515,7 @@ struct ContentView: View { } } .onRotate { newOrientation in - if (contactViewModel.displayedFriend != nil || !historyViewModel.historyTitle.isEmpty) && searchIsActive { + if (contactViewModel.indexDisplayedFriend != nil || !historyViewModel.historyTitle.isEmpty) && searchIsActive { self.focusedField = false } else if searchIsActive { self.focusedField = true @@ -479,5 +532,5 @@ struct ContentView: View { } #Preview { - ContentView(contactViewModel: ContactViewModel(), historyViewModel: HistoryViewModel()) + ContentView(contactViewModel: ContactViewModel(), editContactViewModel: EditContactViewModel(), historyViewModel: HistoryViewModel()) } diff --git a/Linphone/UI/Main/Viewmodel/SharedMainViewModel.swift b/Linphone/UI/Main/Viewmodel/SharedMainViewModel.swift index d95e98499..6adcc0dcc 100644 --- a/Linphone/UI/Main/Viewmodel/SharedMainViewModel.swift +++ b/Linphone/UI/Main/Viewmodel/SharedMainViewModel.swift @@ -47,11 +47,9 @@ class SharedMainViewModel: ObservableObject { } if preferences.object(forKey: displayProfileModeKey) == nil { - print("displayProfileModeKeydisplayProfileModeKey nil") preferences.set(displayProfileMode, forKey: displayProfileModeKey) } else { displayProfileMode = preferences.bool(forKey: displayProfileModeKey) - print("displayProfileModeKeydisplayProfileModeKey \(displayProfileMode)") } } diff --git a/Linphone/Utils/PhotoPicker.swift b/Linphone/Utils/PhotoPicker.swift new file mode 100644 index 000000000..8e4c951aa --- /dev/null +++ b/Linphone/Utils/PhotoPicker.swift @@ -0,0 +1,85 @@ +/* + * 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 + +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() + 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) + } + } + + 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) + } + } +} From 5d0ce2c8f321a97306bb0109b31f3a56ea4688dc Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Tue, 7 Nov 2023 17:02:03 +0100 Subject: [PATCH 033/486] Add edit view for native contact --- Linphone.xcodeproj/project.pbxproj | 4 + Linphone/Contacts/ContactsManager.swift | 54 ++++++++ Linphone/Localizable.xcstrings | 3 + .../Contacts/Fragments/ContactFragment.swift | 7 +- .../Fragments/ContactInnerFragment.swift | 65 ++++++--- .../Fragments/ContactsInnerFragment.swift | 123 ++++++++++-------- .../Fragments/EditContactFragment.swift | 1 + .../ViewModel/EditContactViewModel.swift | 2 + Linphone/Utils/EditContactController.swift | 96 ++++++++++++++ 9 files changed, 280 insertions(+), 75 deletions(-) create mode 100644 Linphone/Utils/EditContactController.swift diff --git a/Linphone.xcodeproj/project.pbxproj b/Linphone.xcodeproj/project.pbxproj index 0dfea3d76..d04b4595f 100644 --- a/Linphone.xcodeproj/project.pbxproj +++ b/Linphone.xcodeproj/project.pbxproj @@ -50,6 +50,7 @@ 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 */; }; 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 */; }; @@ -116,6 +117,7 @@ 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 = ""; }; 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 = ""; }; @@ -172,6 +174,7 @@ D74C9D002ACB098C0021626A /* PermissionManager.swift */, D7D1698B2AE66FA500109A5C /* MagicSearchSingleton.swift */, D7C3650D2AF15BF200FE6142 /* PhotoPicker.swift */, + D7C48DF32AFA66F900D938CB /* EditContactController.swift */, ); path = Utils; sourceTree = ""; @@ -568,6 +571,7 @@ D72992392ADD7F68003AF125 /* HistoryContactFragment.swift in Sources */, D7B5066D2AEFA9B900CEB4E9 /* ContactInnerFragment.swift in Sources */, D7E6D04D2AEBD77600A57AAF /* CustomBottomSheet.swift in Sources */, + D7C48DF42AFA66F900D938CB /* EditContactController.swift in Sources */, D74C9D012ACB098C0021626A /* PermissionManager.swift in Sources */, D7702EF22AC7205000557C00 /* WelcomeView.swift in Sources */, D71FCA7F2AE1397200D2E43E /* ContactsListViewModel.swift in Sources */, diff --git a/Linphone/Contacts/ContactsManager.swift b/Linphone/Contacts/ContactsManager.swift index 76eafa748..981bd7c6f 100644 --- a/Linphone/Contacts/ContactsManager.swift +++ b/Linphone/Contacts/ContactsManager.swift @@ -20,6 +20,7 @@ import linphonesw import Contacts import SwiftUI +import ContactsUI final class ContactsManager: ObservableObject { @@ -110,6 +111,7 @@ final class ContactsManager: ObservableObject { try store.enumerateContacts(with: request, usingBlock: { (contact, _) in DispatchQueue.main.sync { let newContact = Contact( + identifier: contact.identifier, firstName: contact.givenName, lastName: contact.familyName, organizationName: contact.organizationName, @@ -203,6 +205,8 @@ final class ContactsManager: ObservableObject { if friend != nil { friend!.edit() + friend!.nativeUri = contact.identifier + try friend!.setName(newValue: contact.firstName + " " + contact.lastName) let friendvCard = friend!.vcard @@ -284,6 +288,55 @@ final class ContactsManager: ObservableObject { } } } + + func getCNContact(friend: Friend, completion: @escaping (CNContact?) -> Void) { + DispatchQueue.global().async { + let store = CNContactStore() + store.requestAccess(for: .contacts) { (granted, error) in + if let error = error { + print("failed to request access", error) + return + } + if granted { + let keys = [CNContactEmailAddressesKey, CNContactPhoneNumbersKey, + CNContactFamilyNameKey, CNContactGivenNameKey, CNContactNicknameKey, + CNContactPostalAddressesKey, CNContactIdentifierKey, + CNInstantMessageAddressUsernameKey, CNContactInstantMessageAddressesKey, + CNContactImageDataKey, CNContactThumbnailImageDataKey, CNContactOrganizationNameKey] + let request = CNContactFetchRequest(keysToFetch: keys as [CNKeyDescriptor]) + do { + try store.enumerateContacts(with: request, usingBlock: { (contact, _) in + if contact.identifier == friend.nativeUri { + var contactFetched = contact + if !contactFetched.areKeysAvailable([CNContactViewController.descriptorForRequiredKeys()]) { + do { + contactFetched = try store.unifiedContact(withIdentifier: contact.identifier, keysToFetch: [CNContactViewController.descriptorForRequiredKeys()]) + completion(contactFetched) + } + catch { + completion(nil) + } + } + } + }) + } catch let error { + print("Failed to enumerate contact", error) + } + } else { + print("access denied") + } + } + } + } + + func getFriend(contact: Contact) -> Friend? { + if friendList != nil { + let friend = friendList!.friends.first(where: {$0.nativeUri == contact.identifier}) + return friend + } else { + return nil + } + } } struct PhoneNumber { @@ -293,6 +346,7 @@ struct PhoneNumber { struct Contact: Identifiable { var id = UUID() + var identifier: String var firstName: String var lastName: String var organizationName: String diff --git a/Linphone/Localizable.xcstrings b/Linphone/Localizable.xcstrings index 0a4d62d83..2742080e2 100644 --- a/Linphone/Localizable.xcstrings +++ b/Linphone/Localizable.xcstrings @@ -208,6 +208,9 @@ }, "Edit contact" : { + }, + "Edit Contact" : { + }, "Edit picture" : { diff --git a/Linphone/UI/Main/Contacts/Fragments/ContactFragment.swift b/Linphone/UI/Main/Contacts/Fragments/ContactFragment.swift index abbd1492c..02d537c6f 100644 --- a/Linphone/UI/Main/Contacts/Fragments/ContactFragment.swift +++ b/Linphone/UI/Main/Contacts/Fragments/ContactFragment.swift @@ -74,5 +74,10 @@ struct ContactFragment: View { } #Preview { - ContactFragment(contactViewModel: ContactViewModel(), editContactViewModel: EditContactViewModel(), isShowDeletePopup: .constant(false), isShowDismissPopup: .constant(false)) + ContactFragment( + contactViewModel: ContactViewModel(), + editContactViewModel: EditContactViewModel(), + isShowDeletePopup: .constant(false), + isShowDismissPopup: .constant(false) + ) } diff --git a/Linphone/UI/Main/Contacts/Fragments/ContactInnerFragment.swift b/Linphone/UI/Main/Contacts/Fragments/ContactInnerFragment.swift index 63ccd2937..3823d44df 100644 --- a/Linphone/UI/Main/Contacts/Fragments/ContactInnerFragment.swift +++ b/Linphone/UI/Main/Contacts/Fragments/ContactInnerFragment.swift @@ -18,6 +18,7 @@ */ import SwiftUI +import Contacts struct ContactInnerFragment: View { @@ -30,6 +31,8 @@ struct ContactInnerFragment: View { @State private var orientation = UIDevice.current.orientation @State private var informationIsOpen = true + @State private var presentingEditContact = false + @State private var cnContact: CNContact? @Binding var isShowDeletePopup: Bool @Binding var showingSheet: Bool @@ -44,8 +47,7 @@ struct ContactInnerFragment: View { .frame(height: 0) HStack { - if !(orientation == .landscapeLeft - || orientation == .landscapeRight + if !(orientation == .landscapeLeft || orientation == .landscapeRight || UIScreen.main.bounds.size.width > UIScreen.main.bounds.size.height) { Image("caret-left") .renderingMode(.template) @@ -61,21 +63,40 @@ struct ContactInnerFragment: View { } Spacer() - - NavigationLink(destination: EditContactFragment(editContactViewModel: editContactViewModel, isShowEditContactFragment: .constant(false), isShowDismissPopup: $isShowDismissPopup)) { - Image("pencil-simple") - .renderingMode(.template) - .resizable() - .foregroundStyle(Color.orangeMain500) - .frame(width: 25, height: 25, alignment: .leading) - .padding(.top, 2) - } - .simultaneousGesture( - TapGesture().onEnded { - editContactViewModel.selectedEditFriend = magicSearch.lastSearch[contactViewModel.indexDisplayedFriend!].friend - editContactViewModel.resetValues() + if contactViewModel.indexDisplayedFriend != nil + && magicSearch.lastSearch[contactViewModel.indexDisplayedFriend!].friend != nil + && magicSearch.lastSearch[contactViewModel.indexDisplayedFriend!].friend!.nativeUri != nil && !magicSearch.lastSearch[contactViewModel.indexDisplayedFriend!].friend!.nativeUri!.isEmpty { + Button(action: { + ContactsManager.shared.getCNContact(friend: magicSearch.lastSearch[contactViewModel.indexDisplayedFriend!].friend!) { result in + cnContact = result + if cnContact != nil { + presentingEditContact.toggle() + } + } + }, label: { + Image("pencil-simple") + .renderingMode(.template) + .resizable() + .foregroundStyle(Color.orangeMain500) + .frame(width: 25, height: 25, alignment: .leading) + .padding(.top, 2) + }) + } else { + NavigationLink(destination: EditContactFragment(editContactViewModel: editContactViewModel, isShowEditContactFragment: .constant(false), isShowDismissPopup: $isShowDismissPopup)) { + Image("pencil-simple") + .renderingMode(.template) + .resizable() + .foregroundStyle(Color.orangeMain500) + .frame(width: 25, height: 25, alignment: .leading) + .padding(.top, 2) } - ) + .simultaneousGesture( + TapGesture().onEnded { + editContactViewModel.selectedEditFriend = magicSearch.lastSearch[contactViewModel.indexDisplayedFriend!].friend + editContactViewModel.resetValues() + } + ) + } } .frame(maxWidth: .infinity) .frame(height: 50) @@ -143,7 +164,6 @@ struct ContactInnerFragment: View { Spacer() Button(action: { - }, label: { VStack { HStack(alignment: .center) { @@ -458,8 +478,7 @@ struct ContactInnerFragment: View { && magicSearch.lastSearch[contactViewModel.indexDisplayedFriend!].friend!.starred == true ? "heart-fill" : "heart") .renderingMode(.template) .resizable() - .foregroundStyle(contactViewModel.indexDisplayedFriend != nil - && magicSearch.lastSearch[contactViewModel.indexDisplayedFriend!].friend != nil + .foregroundStyle(contactViewModel.indexDisplayedFriend != nil && magicSearch.lastSearch[contactViewModel.indexDisplayedFriend!].friend != nil && magicSearch.lastSearch[contactViewModel.indexDisplayedFriend!].friend!.starred == true ? Color.redDanger500 : Color.grayMain2c500) .frame(width: 25, height: 25) Text(contactViewModel.indexDisplayedFriend != nil @@ -600,6 +619,14 @@ struct ContactInnerFragment: View { .onRotate { newOrientation in orientation = newOrientation } + .sheet(isPresented: $presentingEditContact) { + NavigationView { + AnyView(EditContactView(contact: $cnContact) + .navigationBarTitle("Edit Contact") + .navigationBarTitleDisplayMode(.inline)) + .edgesIgnoringSafeArea(.top) + } + } } .navigationViewStyle(.stack) } diff --git a/Linphone/UI/Main/Contacts/Fragments/ContactsInnerFragment.swift b/Linphone/UI/Main/Contacts/Fragments/ContactsInnerFragment.swift index abfd51b4d..8e09e519f 100644 --- a/Linphone/UI/Main/Contacts/Fragments/ContactsInnerFragment.swift +++ b/Linphone/UI/Main/Contacts/Fragments/ContactsInnerFragment.swift @@ -18,64 +18,77 @@ */ import SwiftUI +import linphonesw struct ContactsInnerFragment: View { - - @ObservedObject var magicSearch = MagicSearchSingleton.shared - @ObservedObject var contactViewModel: ContactViewModel - - @State private var isFavoriteOpen = true - - @Binding var showingSheet: Bool - - var body: some View { - VStack(alignment: .leading) { - if !magicSearch.lastSearch.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(.top, 30) - .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) - } - ContactsListFragment(contactViewModel: contactViewModel, contactsListViewModel: ContactsListViewModel(), showingSheet: $showingSheet) - } - .navigationBarHidden(true) - } + + @Environment(\.scenePhase) var scenePhase + + @ObservedObject var magicSearch = MagicSearchSingleton.shared + @ObservedObject var contactViewModel: ContactViewModel + + @State private var isFavoriteOpen = true + + @Binding var showingSheet: Bool + + var body: some View { + VStack(alignment: .leading) { + if !magicSearch.lastSearch.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(.top, 30) + .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) + } + ContactsListFragment(contactViewModel: contactViewModel, contactsListViewModel: ContactsListViewModel(), showingSheet: $showingSheet) + } + .navigationBarHidden(true) + .onChange(of: scenePhase) { newPhase in + if newPhase == .active { + ContactsManager.shared.fetchContacts() + print("Active") + } else if newPhase == .inactive { + print("Inactive") + } else if newPhase == .background { + print("Background") + } + } + } } #Preview { - ContactsInnerFragment(contactViewModel: ContactViewModel(), showingSheet: .constant(false)) + ContactsInnerFragment(contactViewModel: ContactViewModel(), showingSheet: .constant(false)) } diff --git a/Linphone/UI/Main/Contacts/Fragments/EditContactFragment.swift b/Linphone/UI/Main/Contacts/Fragments/EditContactFragment.swift index a3259fc84..f4666f2d2 100644 --- a/Linphone/UI/Main/Contacts/Fragments/EditContactFragment.swift +++ b/Linphone/UI/Main/Contacts/Fragments/EditContactFragment.swift @@ -472,6 +472,7 @@ struct EditContactFragment: View { func addOrEditFriend() { let newContact = Contact( + identifier: editContactViewModel.identifier, firstName: editContactViewModel.firstName, lastName: editContactViewModel.lastName, organizationName: editContactViewModel.company, diff --git a/Linphone/UI/Main/Contacts/ViewModel/EditContactViewModel.swift b/Linphone/UI/Main/Contacts/ViewModel/EditContactViewModel.swift index 57ad4f478..a66fa422b 100644 --- a/Linphone/UI/Main/Contacts/ViewModel/EditContactViewModel.swift +++ b/Linphone/UI/Main/Contacts/ViewModel/EditContactViewModel.swift @@ -23,6 +23,7 @@ class EditContactViewModel: ObservableObject { @Published var selectedEditFriend: Friend? + @Published var identifier: String = "" @Published var firstName: String = "" @Published var lastName: String = "" @Published var sipAddresses: [String] = [] @@ -36,6 +37,7 @@ class EditContactViewModel: ObservableObject { } func resetValues() { + identifier = (selectedEditFriend == nil ? "" : selectedEditFriend!.nativeUri) ?? "" firstName = (selectedEditFriend == nil ? "" : selectedEditFriend!.vcard?.givenName) ?? "" lastName = (selectedEditFriend == nil ? "" : selectedEditFriend!.vcard?.familyName) ?? "" sipAddresses = [] diff --git a/Linphone/Utils/EditContactController.swift b/Linphone/Utils/EditContactController.swift new file mode 100644 index 000000000..43a6e52a7 --- /dev/null +++ b/Linphone/Utils/EditContactController.swift @@ -0,0 +1,96 @@ +/* + * 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.global().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 + String(Int.random(in: 1...1000)) + ((imageThumbnail == nil) ? "-default" : ""), + contact: newContact, + linphoneFriend: false, + existingFriend: ContactsManager.shared.getFriend(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) { + } +} From 8aae1b2020680b17888ca67be16a338658607ab6 Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Wed, 8 Nov 2023 22:01:10 +0100 Subject: [PATCH 034/486] Edit native contact test --- Linphone.xcodeproj/project.pbxproj | 4 + Linphone/Contacts/ContactsManager.swift | 43 +- Linphone/Localizable.xcstrings | 3 + .../Contacts/Fragments/ContactFragment.swift | 6 +- .../ContactInnerActionsFragment.swift | 455 ++++++++++++++++++ .../Fragments/ContactInnerFragment.swift | 433 ++--------------- .../Fragments/ContactsListBottomSheet.swift | 6 +- .../Fragments/EditContactFragment.swift | 19 +- Linphone/UI/Main/ContentView.swift | 13 +- Linphone/Utils/EditContactController.swift | 2 +- 10 files changed, 551 insertions(+), 433 deletions(-) create mode 100644 Linphone/UI/Main/Contacts/Fragments/ContactInnerActionsFragment.swift diff --git a/Linphone.xcodeproj/project.pbxproj b/Linphone.xcodeproj/project.pbxproj index d04b4595f..565fc4601 100644 --- a/Linphone.xcodeproj/project.pbxproj +++ b/Linphone.xcodeproj/project.pbxproj @@ -51,6 +51,7 @@ 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 */; }; 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 */; }; @@ -118,6 +119,7 @@ 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 = ""; }; 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 = ""; }; @@ -342,6 +344,7 @@ D7B5066C2AEFA9B900CEB4E9 /* ContactInnerFragment.swift */, D7C365072AEFAB7F00FE6142 /* ContactListBottomSheet.swift */, D7C365092AF001C300FE6142 /* EditContactFragment.swift */, + D7C48DF52AFCDF4700D938CB /* ContactInnerActionsFragment.swift */, ); path = Fragments; sourceTree = ""; @@ -588,6 +591,7 @@ D7E6D0552AEBFCCE00A57AAF /* ContactsInnerFragment.swift in Sources */, D72343362AD037AF009AA24E /* ToastView.swift in Sources */, D7FB55112AD447FD00A5AB15 /* RegisterFragment.swift in Sources */, + D7C48DF62AFCDF4700D938CB /* ContactInnerActionsFragment.swift in Sources */, D72343322ACEFF58009AA24E /* QRScannerController.swift in Sources */, D72343342ACEFFC3009AA24E /* QRScanner.swift in Sources */, D72343302ACEFEF8009AA24E /* QrCodeScannerFragment.swift in Sources */, diff --git a/Linphone/Contacts/ContactsManager.swift b/Linphone/Contacts/ContactsManager.swift index 981bd7c6f..29506bbd5 100644 --- a/Linphone/Contacts/ContactsManager.swift +++ b/Linphone/Contacts/ContactsManager.swift @@ -95,6 +95,7 @@ final class ContactsManager: ObservableObject { } let store = CNContactStore() + store.requestAccess(for: .contacts) { (granted, error) in if let error = error { print("failed to request access", error) @@ -105,7 +106,7 @@ final class ContactsManager: ObservableObject { CNContactFamilyNameKey, CNContactGivenNameKey, CNContactNicknameKey, CNContactPostalAddressesKey, CNContactIdentifierKey, CNInstantMessageAddressUsernameKey, CNContactInstantMessageAddressesKey, - CNContactImageDataKey, CNContactThumbnailImageDataKey, CNContactOrganizationNameKey] + CNContactOrganizationNameKey, CNContactImageDataAvailableKey, CNContactImageDataKey, CNContactThumbnailImageDataKey] let request = CNContactFetchRequest(keysToFetch: keys as [CNKeyDescriptor]) do { try store.enumerateContacts(with: request, usingBlock: { (contact, _) in @@ -289,46 +290,6 @@ final class ContactsManager: ObservableObject { } } - func getCNContact(friend: Friend, completion: @escaping (CNContact?) -> Void) { - DispatchQueue.global().async { - let store = CNContactStore() - store.requestAccess(for: .contacts) { (granted, error) in - if let error = error { - print("failed to request access", error) - return - } - if granted { - let keys = [CNContactEmailAddressesKey, CNContactPhoneNumbersKey, - CNContactFamilyNameKey, CNContactGivenNameKey, CNContactNicknameKey, - CNContactPostalAddressesKey, CNContactIdentifierKey, - CNInstantMessageAddressUsernameKey, CNContactInstantMessageAddressesKey, - CNContactImageDataKey, CNContactThumbnailImageDataKey, CNContactOrganizationNameKey] - let request = CNContactFetchRequest(keysToFetch: keys as [CNKeyDescriptor]) - do { - try store.enumerateContacts(with: request, usingBlock: { (contact, _) in - if contact.identifier == friend.nativeUri { - var contactFetched = contact - if !contactFetched.areKeysAvailable([CNContactViewController.descriptorForRequiredKeys()]) { - do { - contactFetched = try store.unifiedContact(withIdentifier: contact.identifier, keysToFetch: [CNContactViewController.descriptorForRequiredKeys()]) - completion(contactFetched) - } - catch { - completion(nil) - } - } - } - }) - } catch let error { - print("Failed to enumerate contact", error) - } - } else { - print("access denied") - } - } - } - } - func getFriend(contact: Contact) -> Friend? { if friendList != nil { let friend = friendList!.friends.first(where: {$0.nativeUri == contact.identifier}) diff --git a/Linphone/Localizable.xcstrings b/Linphone/Localizable.xcstrings index 2742080e2..0062a8ce1 100644 --- a/Linphone/Localizable.xcstrings +++ b/Linphone/Localizable.xcstrings @@ -3,6 +3,9 @@ "strings" : { "" : { + }, + " " : { + }, " et " : { diff --git a/Linphone/UI/Main/Contacts/Fragments/ContactFragment.swift b/Linphone/UI/Main/Contacts/Fragments/ContactFragment.swift index 02d537c6f..aa8a09228 100644 --- a/Linphone/UI/Main/Contacts/Fragments/ContactFragment.swift +++ b/Linphone/UI/Main/Contacts/Fragments/ContactFragment.swift @@ -18,6 +18,7 @@ */ import SwiftUI +import Contacts struct ContactFragment: View { @@ -37,7 +38,8 @@ struct ContactFragment: View { ContactInnerFragment( contactViewModel: contactViewModel, editContactViewModel: editContactViewModel, - isShowDeletePopup: $isShowDeletePopup, + cnContact: CNContact(), + isShowDeletePopup: $isShowDeletePopup, showingSheet: $showingSheet, isShowDismissPopup: $isShowDismissPopup ) @@ -49,6 +51,7 @@ struct ContactFragment: View { ContactInnerFragment( contactViewModel: contactViewModel, editContactViewModel: editContactViewModel, + cnContact: CNContact(), isShowDeletePopup: $isShowDeletePopup, showingSheet: $showingSheet, isShowDismissPopup: $isShowDismissPopup @@ -61,6 +64,7 @@ struct ContactFragment: View { ContactInnerFragment( contactViewModel: contactViewModel, editContactViewModel: editContactViewModel, + cnContact: CNContact(), isShowDeletePopup: $isShowDeletePopup, showingSheet: $showingSheet, isShowDismissPopup: $isShowDismissPopup diff --git a/Linphone/UI/Main/Contacts/Fragments/ContactInnerActionsFragment.swift b/Linphone/UI/Main/Contacts/Fragments/ContactInnerActionsFragment.swift new file mode 100644 index 000000000..23eb79e93 --- /dev/null +++ b/Linphone/UI/Main/Contacts/Fragments/ContactInnerActionsFragment.swift @@ -0,0 +1,455 @@ +// +// ContactInnerActionsFragment.swift +// Linphone +// +// Created by Benoît Martins on 09/11/2023. +// + +import SwiftUI + +struct ContactInnerActionsFragment: View { + + @ObservedObject var magicSearch = MagicSearchSingleton.shared + @ObservedObject var contactViewModel: ContactViewModel + @ObservedObject var editContactViewModel: EditContactViewModel + + @State private var informationIsOpen = true + + @Binding var showingSheet: 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(.top, 30) + .padding(.bottom, 10) + .padding(.horizontal, 16) + .background(Color.gray100) + .onTapGesture { + withAnimation { + informationIsOpen.toggle() + } + } + + if informationIsOpen { + VStack(spacing: 0) { + if contactViewModel.indexDisplayedFriend != nil && magicSearch.lastSearch[contactViewModel.indexDisplayedFriend!].friend != nil { + ForEach(0.. Date: Thu, 9 Nov 2023 13:54:57 +0100 Subject: [PATCH 035/486] FIx rebase --- Linphone/Contacts/ContactsManager.swift | 35 +++++++++---------- Linphone/Core/CoreContext.swift | 6 ++-- .../Viewmodel/AccountLoginViewModel.swift | 9 ++++- .../ContactInnerActionsFragment.swift | 7 +++- .../Fragments/EditContactFragment.swift | 4 +-- Linphone/Utils/MagicSearchSingleton.swift | 4 +-- 6 files changed, 39 insertions(+), 26 deletions(-) diff --git a/Linphone/Contacts/ContactsManager.swift b/Linphone/Contacts/ContactsManager.swift index 5ff02becb..d8e870b16 100644 --- a/Linphone/Contacts/ContactsManager.swift +++ b/Linphone/Contacts/ContactsManager.swift @@ -183,25 +183,24 @@ final class ContactsManager: ObservableObject { } awaitDataWrite(data: data, name: name) { _, result in - let resultFriend = self.saveFriend(result: result, contact: contact, existingFriend: existingFriend) - - if resultFriend != nil { - if linphoneFriend && existingFriend == nil { - _ = self.linphoneFriendList!.addLocalFriend(linphoneFriend: resultFriend!) - - self.linphoneFriendList!.updateSubscriptions() - } else if existingFriend == nil { - _ = self.friendList!.addLocalFriend(linphoneFriend: resultFriend!) - - self.friendList!.updateSubscriptions() - } + self.saveFriend(result: result, contact: contact, existingFriend: existingFriend) { resultFriend in + if resultFriend != nil { + if linphoneFriend && existingFriend == nil { + _ = self.linphoneFriendList!.addLocalFriend(linphoneFriend: resultFriend!) + + self.linphoneFriendList!.updateSubscriptions() + } else if existingFriend == nil { + _ = self.friendList!.addLocalFriend(linphoneFriend: resultFriend!) + + self.friendList!.updateSubscriptions() + } + } } } } - func saveFriend(result: String, contact: Contact, existingFriend: Friend?) -> Friend? { - - self.coreContext.doOnCoreQueue() { core in + func saveFriend(result: String, contact: Contact, existingFriend: Friend?, completion: @escaping (Friend?) -> Void) { + self.coreContext.doOnCoreQueue { core in do { let friend = (existingFriend != nil) ? existingFriend : try core.createFriend() @@ -257,14 +256,14 @@ final class ContactsManager: ObservableObject { friend!.jobTitle = contact.jobTitle friend!.done() - return friend + completion(friend) } } catch let error { print("Failed to enumerate contact", error) - return nil + completion(nil) } } - return nil + completion(nil) } func getImagePath(friendPhotoPath: String) -> URL { diff --git a/Linphone/Core/CoreContext.swift b/Linphone/Core/CoreContext.swift index a68b25127..350fb6a0d 100644 --- a/Linphone/Core/CoreContext.swift +++ b/Linphone/Core/CoreContext.swift @@ -36,7 +36,7 @@ final class CoreContext: ObservableObject { private init() {} - func doOnCoreQueue(synchronous : Bool = false, lambda: @escaping (Core) -> Void) { + func doOnCoreQueue(synchronous: Bool = false, lambda: @escaping (Core) -> Void) { if synchronous { coreQueue.sync { lambda(self.mCore) @@ -77,7 +77,9 @@ final class CoreContext: ObservableObject { } } - self.mCore.publisher?.onAccountRegistrationStateChanged?.postOnMainQueue { (cbVal: (core: Core, account: Account, state: RegistrationState, message: String)) in + self.mCore.publisher?.onAccountRegistrationStateChanged?.postOnMainQueue {(cbVal: + (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. NSLog("New registration state is \(cbVal.state) for user id " + diff --git a/Linphone/UI/Assistant/Viewmodel/AccountLoginViewModel.swift b/Linphone/UI/Assistant/Viewmodel/AccountLoginViewModel.swift index d85b4233e..7462e16dd 100644 --- a/Linphone/UI/Assistant/Viewmodel/AccountLoginViewModel.swift +++ b/Linphone/UI/Assistant/Viewmodel/AccountLoginViewModel.swift @@ -52,7 +52,14 @@ class AccountLoginViewModel: ObservableObject { // 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) + 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 diff --git a/Linphone/UI/Main/Contacts/Fragments/ContactInnerActionsFragment.swift b/Linphone/UI/Main/Contacts/Fragments/ContactInnerActionsFragment.swift index 23eb79e93..0fbe4845b 100644 --- a/Linphone/UI/Main/Contacts/Fragments/ContactInnerActionsFragment.swift +++ b/Linphone/UI/Main/Contacts/Fragments/ContactInnerActionsFragment.swift @@ -192,7 +192,12 @@ struct ContactInnerActionsFragment: View { && !magicSearch.lastSearch[contactViewModel.indexDisplayedFriend!].friend!.jobTitle!.isEmpty { Text("**Job :** \(magicSearch.lastSearch[contactViewModel.indexDisplayedFriend!].friend!.jobTitle!)") .default_text_style(styleSize: 14) - .padding(.vertical, 15) + .padding(.top, + magicSearch.lastSearch[contactViewModel.indexDisplayedFriend!].friend!.organization != nil + && !magicSearch.lastSearch[contactViewModel.indexDisplayedFriend!].friend!.organization!.isEmpty + ? 0 : 15 + ) + .padding(.bottom, 15) .padding(.horizontal, 20) .frame(maxWidth: .infinity, alignment: .leading) } diff --git a/Linphone/UI/Main/Contacts/Fragments/EditContactFragment.swift b/Linphone/UI/Main/Contacts/Fragments/EditContactFragment.swift index 60f744217..5d24c8f72 100644 --- a/Linphone/UI/Main/Contacts/Fragments/EditContactFragment.swift +++ b/Linphone/UI/Main/Contacts/Fragments/EditContactFragment.swift @@ -493,10 +493,10 @@ struct EditContactFragment: View { if editContactViewModel.selectedEditFriend != nil && selectedImage == nil && !removedImage { - _ = ContactsManager.shared.saveFriend( + ContactsManager.shared.saveFriend( result: String(editContactViewModel.selectedEditFriend!.photo!.dropFirst(6)), contact: newContact, - existingFriend: editContactViewModel.selectedEditFriend + existingFriend: editContactViewModel.selectedEditFriend, completion: {_ in } ) } else { ContactsManager.shared.saveImage( diff --git a/Linphone/Utils/MagicSearchSingleton.swift b/Linphone/Utils/MagicSearchSingleton.swift index 50c9f02e9..7ce369b0a 100644 --- a/Linphone/Utils/MagicSearchSingleton.swift +++ b/Linphone/Utils/MagicSearchSingleton.swift @@ -39,7 +39,7 @@ final class MagicSearchSingleton: ObservableObject { private var domainDefaultAccount = "" private init() { - coreContext.doOnCoreQueue{ core in + coreContext.doOnCoreQueue { core in self.domainDefaultAccount = core.defaultAccount?.params?.domain ?? "" self.magicSearch = try? core.createMagicSearch() @@ -53,7 +53,7 @@ final class MagicSearchSingleton: ObservableObject { } func searchForContacts(sourceFlags: Int) { - coreContext.doOnCoreQueue{ core in + coreContext.doOnCoreQueue { _ in var needResetCache = false DispatchQueue.main.sync { From ada6065b21fda2b3110e708871e9a37f29449fb6 Mon Sep 17 00:00:00 2001 From: "benoit.martins" Date: Thu, 9 Nov 2023 16:58:50 +0100 Subject: [PATCH 036/486] Share contact vcard --- Linphone.xcodeproj/project.pbxproj | 4 + .../Contacts/Fragments/ContactFragment.swift | 45 ++- .../ContactInnerActionsFragment.swift | 24 +- .../Fragments/ContactInnerFragment.swift | 4 + .../Fragments/ContactListBottomSheet.swift | 7 +- .../Contacts/Fragments/ContactsFragment.swift | 35 ++- .../Fragments/ContactsListBottomSheet.swift | 272 ++++++++++-------- .../Fragments/ContactsListFragment.swift | 1 + .../Fragments/EditContactFragment.swift | 2 + .../FavoriteContactsListFragment.swift | 1 + .../Contacts/ViewModel/ContactViewModel.swift | 1 + Linphone/Utils/ShareSheetController.swift | 91 ++++++ 12 files changed, 323 insertions(+), 164 deletions(-) create mode 100644 Linphone/Utils/ShareSheetController.swift diff --git a/Linphone.xcodeproj/project.pbxproj b/Linphone.xcodeproj/project.pbxproj index 14382a983..64f20df16 100644 --- a/Linphone.xcodeproj/project.pbxproj +++ b/Linphone.xcodeproj/project.pbxproj @@ -28,6 +28,7 @@ D72343342ACEFFC3009AA24E /* QRScanner.swift in Sources */ = {isa = PBXBuildFile; fileRef = D72343332ACEFFC3009AA24E /* QRScanner.swift */; }; D72343362AD037AF009AA24E /* ToastView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D72343352AD037AF009AA24E /* ToastView.swift */; }; D72992392ADD7F68003AF125 /* HistoryContactFragment.swift in Sources */ = {isa = PBXBuildFile; fileRef = D72992382ADD7F68003AF125 /* HistoryContactFragment.swift */; }; + D732A9092AFD235500DB42BA /* ShareSheetController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D732A9082AFD235500DB42BA /* ShareSheetController.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 */; }; @@ -93,6 +94,7 @@ D72343332ACEFFC3009AA24E /* QRScanner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QRScanner.swift; sourceTree = ""; }; D72343352AD037AF009AA24E /* ToastView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToastView.swift; sourceTree = ""; }; D72992382ADD7F68003AF125 /* HistoryContactFragment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HistoryContactFragment.swift; sourceTree = ""; }; + D732A9082AFD235500DB42BA /* ShareSheetController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareSheetController.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 = ""; }; @@ -162,6 +164,7 @@ D7D1698B2AE66FA500109A5C /* MagicSearchSingleton.swift */, D7C3650D2AF15BF200FE6142 /* PhotoPicker.swift */, D7C48DF32AFA66F900D938CB /* EditContactController.swift */, + D732A9082AFD235500DB42BA /* ShareSheetController.swift */, ); path = Utils; sourceTree = ""; @@ -532,6 +535,7 @@ D748BF2C2ACD82D2004844EB /* ThirdPartySipAccountLoginFragment.swift in Sources */, D74C9CF82ACACECE0021626A /* WelcomePage1Fragment.swift in Sources */, D7E6D0552AEBFCCE00A57AAF /* ContactsInnerFragment.swift in Sources */, + D732A9092AFD235500DB42BA /* ShareSheetController.swift in Sources */, D72343362AD037AF009AA24E /* ToastView.swift in Sources */, D7FB55112AD447FD00A5AB15 /* RegisterFragment.swift in Sources */, D7C48DF62AFCDF4700D938CB /* ContactInnerActionsFragment.swift in Sources */, diff --git a/Linphone/UI/Main/Contacts/Fragments/ContactFragment.swift b/Linphone/UI/Main/Contacts/Fragments/ContactFragment.swift index aa8a09228..f1f427e2f 100644 --- a/Linphone/UI/Main/Contacts/Fragments/ContactFragment.swift +++ b/Linphone/UI/Main/Contacts/Fragments/ContactFragment.swift @@ -31,6 +31,7 @@ struct ContactFragment: View { @Binding var isShowDismissPopup: Bool @State private var showingSheet = false + @State private var showShareSheet = false var body: some View { if #available(iOS 16.0, *) { @@ -38,40 +39,56 @@ struct ContactFragment: View { ContactInnerFragment( contactViewModel: contactViewModel, editContactViewModel: editContactViewModel, - cnContact: CNContact(), - isShowDeletePopup: $isShowDeletePopup, + cnContact: CNContact(), + isShowDeletePopup: $isShowDeletePopup, showingSheet: $showingSheet, + showShareSheet: $showShareSheet, isShowDismissPopup: $isShowDismissPopup ) - .sheet(isPresented: $showingSheet) { - ContactListBottomSheet(contactViewModel: contactViewModel, showingSheet: $showingSheet) - .presentationDetents([.fraction(0.2)]) - } + .sheet(isPresented: $showingSheet) { + ContactListBottomSheet(contactViewModel: contactViewModel, showingSheet: $showingSheet) + .presentationDetents([.fraction(0.2)]) + } + .sheet(isPresented: $showShareSheet) { + ShareSheet(friendToShare: MagicSearchSingleton.shared.lastSearch[contactViewModel.indexDisplayedFriend!].friend!) + .presentationDetents([.medium]) + .edgesIgnoringSafeArea(.bottom) + } } else { ContactInnerFragment( contactViewModel: contactViewModel, editContactViewModel: editContactViewModel, - cnContact: CNContact(), + cnContact: CNContact(), isShowDeletePopup: $isShowDeletePopup, showingSheet: $showingSheet, + showShareSheet: $showShareSheet, isShowDismissPopup: $isShowDismissPopup ) - .halfSheet(showSheet: $showingSheet) { - ContactListBottomSheet(contactViewModel: contactViewModel, showingSheet: $showingSheet) - } onDismiss: {} + .halfSheet(showSheet: $showingSheet) { + ContactListBottomSheet(contactViewModel: contactViewModel, showingSheet: $showingSheet) + } onDismiss: {} + .sheet(isPresented: $showShareSheet) { + ShareSheet(friendToShare: MagicSearchSingleton.shared.lastSearch[contactViewModel.indexDisplayedFriend!].friend!) + .edgesIgnoringSafeArea(.bottom) + } } } else { ContactInnerFragment( contactViewModel: contactViewModel, editContactViewModel: editContactViewModel, - cnContact: CNContact(), + cnContact: CNContact(), isShowDeletePopup: $isShowDeletePopup, showingSheet: $showingSheet, + showShareSheet: $showShareSheet, isShowDismissPopup: $isShowDismissPopup ) - .halfSheet(showSheet: $showingSheet) { - ContactListBottomSheet(contactViewModel: contactViewModel, showingSheet: $showingSheet) - } onDismiss: {} + .halfSheet(showSheet: $showingSheet) { + ContactListBottomSheet(contactViewModel: contactViewModel, showingSheet: $showingSheet) + } onDismiss: {} + .sheet(isPresented: $showShareSheet) { + ShareSheet(friendToShare: MagicSearchSingleton.shared.lastSearch[contactViewModel.indexDisplayedFriend!].friend!) + .edgesIgnoringSafeArea(.bottom) + } } } diff --git a/Linphone/UI/Main/Contacts/Fragments/ContactInnerActionsFragment.swift b/Linphone/UI/Main/Contacts/Fragments/ContactInnerActionsFragment.swift index 0fbe4845b..ff8e3e2e7 100644 --- a/Linphone/UI/Main/Contacts/Fragments/ContactInnerActionsFragment.swift +++ b/Linphone/UI/Main/Contacts/Fragments/ContactInnerActionsFragment.swift @@ -16,6 +16,7 @@ struct ContactInnerActionsFragment: View { @State private var informationIsOpen = true @Binding var showingSheet: Bool + @Binding var showShareSheet: Bool @Binding var isShowDeletePopup: Bool @Binding var isShowDismissPopup: Bool @@ -278,27 +279,6 @@ struct ContactInnerActionsFragment: View { } ) } - /* - Button { - } label: { - HStack { - Image("pencil-simple") - .renderingMode(.template) - .resizable() - .foregroundStyle(Color.grayMain2c600) - .frame(width: 25, height: 25) - - Text("Edit") - .default_text_style(styleSize: 14) - .frame(maxWidth: .infinity, alignment: .leading) - .lineLimit(1) - .fixedSize(horizontal: false, vertical: true) - Spacer() - } - .padding(.vertical, 15) - .padding(.horizontal, 20) - } - */ VStack { Divider() @@ -340,6 +320,7 @@ struct ContactInnerActionsFragment: View { .padding(.horizontal) Button { + showShareSheet.toggle() } label: { HStack { Image("share-network") @@ -453,6 +434,7 @@ struct ContactInnerActionsFragment: View { contactViewModel: ContactViewModel(), editContactViewModel: EditContactViewModel(), showingSheet: .constant(false), + showShareSheet: .constant(false), isShowDeletePopup: .constant(false), isShowDismissPopup: .constant(false), actionEditButton: {} diff --git a/Linphone/UI/Main/Contacts/Fragments/ContactInnerFragment.swift b/Linphone/UI/Main/Contacts/Fragments/ContactInnerFragment.swift index 1a72c7c53..ac6a8e5cd 100644 --- a/Linphone/UI/Main/Contacts/Fragments/ContactInnerFragment.swift +++ b/Linphone/UI/Main/Contacts/Fragments/ContactInnerFragment.swift @@ -37,6 +37,7 @@ struct ContactInnerFragment: View { @Binding var isShowDeletePopup: Bool @Binding var showingSheet: Bool + @Binding var showShareSheet: Bool @Binding var isShowDismissPopup: Bool var body: some View { @@ -122,6 +123,7 @@ struct ContactInnerFragment: View { case .success(let image): image .resizable() + .aspectRatio(contentMode: .fill) .frame(width: 100, height: 100) .clipShape(Circle()) case .failure: @@ -253,6 +255,7 @@ struct ContactInnerFragment: View { contactViewModel: contactViewModel, editContactViewModel: editContactViewModel, showingSheet: $showingSheet, + showShareSheet: $showShareSheet, isShowDeletePopup: $isShowDeletePopup, isShowDismissPopup: $isShowDismissPopup, actionEditButton: editNativeContact @@ -305,6 +308,7 @@ struct ContactInnerFragment: View { editContactViewModel: EditContactViewModel(), isShowDeletePopup: .constant(false), showingSheet: .constant(false), + showShareSheet: .constant(false), isShowDismissPopup: .constant(false) ) } diff --git a/Linphone/UI/Main/Contacts/Fragments/ContactListBottomSheet.swift b/Linphone/UI/Main/Contacts/Fragments/ContactListBottomSheet.swift index 29793747f..bbf9ac0cc 100644 --- a/Linphone/UI/Main/Contacts/Fragments/ContactListBottomSheet.swift +++ b/Linphone/UI/Main/Contacts/Fragments/ContactListBottomSheet.swift @@ -93,7 +93,12 @@ struct ContactListBottomSheet: View { if contactViewModel.stringToCopy.prefix(4) != "sip:" { Button { if #available(iOS 16.0, *) { - showingSheet.toggle() + if idiom != .pad { + showingSheet.toggle() + } else { + showingSheet.toggle() + dismiss() + } } else { showingSheet.toggle() dismiss() diff --git a/Linphone/UI/Main/Contacts/Fragments/ContactsFragment.swift b/Linphone/UI/Main/Contacts/Fragments/ContactsFragment.swift index e875bd037..ef48b5e84 100644 --- a/Linphone/UI/Main/Contacts/Fragments/ContactsFragment.swift +++ b/Linphone/UI/Main/Contacts/Fragments/ContactsFragment.swift @@ -28,6 +28,7 @@ struct ContactsFragment: View { @Binding var isShowDeletePopup: Bool @State private var showingSheet = false + @State private var showShareSheet = false var body: some View { ZStack { @@ -35,20 +36,48 @@ struct ContactsFragment: View { if idiom != .pad { ContactsInnerFragment(contactViewModel: contactViewModel, showingSheet: $showingSheet) .sheet(isPresented: $showingSheet) { - ContactsListBottomSheet(contactViewModel: contactViewModel, isShowDeletePopup: $isShowDeletePopup, showingSheet: $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) .halfSheet(showSheet: $showingSheet) { - ContactsListBottomSheet(contactViewModel: contactViewModel, isShowDeletePopup: $isShowDeletePopup, showingSheet: $showingSheet) + ContactsListBottomSheet( + contactViewModel: contactViewModel, + isShowDeletePopup: $isShowDeletePopup, + showingSheet: $showingSheet, + showShareSheet: $showShareSheet + ) } onDismiss: {} + .sheet(isPresented: $showShareSheet) { + ShareSheet(friendToShare: contactViewModel.selectedFriendToShare!) + .edgesIgnoringSafeArea(.bottom) + } } } else { ContactsInnerFragment(contactViewModel: contactViewModel, showingSheet: $showingSheet) .halfSheet(showSheet: $showingSheet) { - ContactsListBottomSheet(contactViewModel: contactViewModel, isShowDeletePopup: $isShowDeletePopup, showingSheet: $showingSheet) + ContactsListBottomSheet( + contactViewModel: contactViewModel, + isShowDeletePopup: $isShowDeletePopup, + showingSheet: $showingSheet, + showShareSheet: $showShareSheet + ) } onDismiss: {} + .sheet(isPresented: $showShareSheet) { + ShareSheet(friendToShare: contactViewModel.selectedFriendToShare!) + .edgesIgnoringSafeArea(.bottom) + } } } } diff --git a/Linphone/UI/Main/Contacts/Fragments/ContactsListBottomSheet.swift b/Linphone/UI/Main/Contacts/Fragments/ContactsListBottomSheet.swift index 9ae204546..851eaef20 100644 --- a/Linphone/UI/Main/Contacts/Fragments/ContactsListBottomSheet.swift +++ b/Linphone/UI/Main/Contacts/Fragments/ContactsListBottomSheet.swift @@ -19,147 +19,169 @@ import SwiftUI import linphonesw +import Contacts struct ContactsListBottomSheet: View { + @Environment(\.dismiss) var dismiss + private var idiom: UIUserInterfaceIdiom { UIDevice.current.userInterfaceIdiom } - @ObservedObject var magicSearch = MagicSearchSingleton.shared - - @ObservedObject var contactViewModel: ContactViewModel - - @State private var orientation = UIDevice.current.orientation - - @Environment(\.dismiss) var dismiss + @ObservedObject var magicSearch = MagicSearchSingleton.shared + @ObservedObject var contactViewModel: ContactViewModel + + @State private var orientation = UIDevice.current.orientation @Binding var isShowDeletePopup: Bool - - @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 contactViewModel.selectedFriend != nil { - contactViewModel.selectedFriend!.starred.toggle() - } - self.magicSearch.searchForContacts(sourceFlags: MagicSearch.Source.Friends.rawValue | MagicSearch.Source.LdapServers.rawValue) - - if #available(iOS 16.0, *) { - showingSheet.toggle() - } else { - showingSheet.toggle() - dismiss() - } - } label: { - HStack { + @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!.starred.toggle() + } + self.magicSearch.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() + .renderingMode(.template) + .resizable() .foregroundStyle( contactViewModel.selectedFriend != nil && contactViewModel.selectedFriend!.starred == true ? Color.redDanger500 : Color.grayMain2c500 ) - .frame(width: 25, height: 25, alignment: .leading) - 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, *) { - showingSheet.toggle() - } else { - showingSheet.toggle() - dismiss() - } - } label: { - HStack { - Image("share-network") - .renderingMode(.template) - .resizable() - .foregroundStyle(Color.grayMain2c500) - .frame(width: 25, height: 25, alignment: .leading) - 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() - } + .frame(width: 25, height: 25, alignment: .leading) + 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() + } - if #available(iOS 16.0, *) { - showingSheet.toggle() - } else { - showingSheet.toggle() - dismiss() - } - } label: { - HStack { - Image("trash-simple") - .renderingMode(.template) - .resizable() - .foregroundStyle(Color.redDanger500) - .frame(width: 25, height: 25, alignment: .leading) - Text("Delete") - .foregroundStyle(Color.redDanger500) - .default_text_style(styleSize: 16) - Spacer() - } - .frame(maxHeight: .infinity) - } - .padding(.horizontal, 30) - .background(Color.gray100) - - } + contactViewModel.selectedFriendToShare = contactViewModel.selectedFriend + + DispatchQueue.global().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) + 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) + 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 - } + .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 index b4696b326..7b274e646 100644 --- a/Linphone/UI/Main/Contacts/Fragments/ContactsListFragment.swift +++ b/Linphone/UI/Main/Contacts/Fragments/ContactsListFragment.swift @@ -72,6 +72,7 @@ struct ContactsListFragment: View { case .success(let image): image .resizable() + .aspectRatio(contentMode: .fill) .frame(width: 45, height: 45) .clipShape(Circle()) case .failure: diff --git a/Linphone/UI/Main/Contacts/Fragments/EditContactFragment.swift b/Linphone/UI/Main/Contacts/Fragments/EditContactFragment.swift index 5d24c8f72..e7de05556 100644 --- a/Linphone/UI/Main/Contacts/Fragments/EditContactFragment.swift +++ b/Linphone/UI/Main/Contacts/Fragments/EditContactFragment.swift @@ -137,6 +137,7 @@ struct EditContactFragment: View { case .success(let image): image .resizable() + .aspectRatio(contentMode: .fill) .frame(width: 100, height: 100) .clipShape(Circle()) case .failure: @@ -156,6 +157,7 @@ struct EditContactFragment: View { } else { Image(uiImage: selectedImage!) .resizable() + .aspectRatio(contentMode: .fill) .frame(width: 100, height: 100) .clipShape(Circle()) } diff --git a/Linphone/UI/Main/Contacts/Fragments/FavoriteContactsListFragment.swift b/Linphone/UI/Main/Contacts/Fragments/FavoriteContactsListFragment.swift index 0c0f9c3a6..0eb2fa566 100644 --- a/Linphone/UI/Main/Contacts/Fragments/FavoriteContactsListFragment.swift +++ b/Linphone/UI/Main/Contacts/Fragments/FavoriteContactsListFragment.swift @@ -48,6 +48,7 @@ struct FavoriteContactsListFragment: View { case .success(let image): image .resizable() + .aspectRatio(contentMode: .fill) .frame(width: 45, height: 45) .clipShape(Circle()) case .failure: diff --git a/Linphone/UI/Main/Contacts/ViewModel/ContactViewModel.swift b/Linphone/UI/Main/Contacts/ViewModel/ContactViewModel.swift index 747a28279..a3f8443c7 100644 --- a/Linphone/UI/Main/Contacts/ViewModel/ContactViewModel.swift +++ b/Linphone/UI/Main/Contacts/ViewModel/ContactViewModel.swift @@ -26,6 +26,7 @@ class ContactViewModel: ObservableObject { var stringToCopy: String = "" var selectedFriend: Friend? + var selectedFriendToShare: Friend? var selectedFriendToDelete: Friend? private var magicSearch = MagicSearchSingleton.shared 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 + ) + */ + } +} From 6b5e456242bf4973f3bb6eb7d76615dc84f4d68f Mon Sep 17 00:00:00 2001 From: QuentinArguillere Date: Tue, 14 Nov 2023 14:37:53 +0100 Subject: [PATCH 037/486] Light refactoring of ContactsManager.swift : remove observableobject, fix typos, add traces --- Linphone/Contacts/ContactsManager.swift | 205 +++++++++++------------- 1 file changed, 92 insertions(+), 113 deletions(-) diff --git a/Linphone/Contacts/ContactsManager.swift b/Linphone/Contacts/ContactsManager.swift index d8e870b16..fa9843527 100644 --- a/Linphone/Contacts/ContactsManager.swift +++ b/Linphone/Contacts/ContactsManager.swift @@ -22,7 +22,7 @@ import Contacts import SwiftUI import ContactsUI -final class ContactsManager: ObservableObject { +final class ContactsManager { static let shared = ContactsManager() @@ -30,10 +30,10 @@ final class ContactsManager: ObservableObject { private var magicSearch = MagicSearchSingleton.shared private let nativeAddressBookFriendList = "Native address-book" - let linphoneAddressBookFirendList = "Linphone address-book" + let linphoneAddressBookFriendList = "Linphone address-book" - @Published var friendList: FriendList? - @Published var linphoneFriendList: FriendList? + var friendList: FriendList? + var linphoneFriendList: FriendList? private init() { fetchContacts() @@ -42,63 +42,50 @@ final class ContactsManager: ObservableObject { func fetchContacts() { coreContext.doOnCoreQueue { core in if core.globalState == GlobalState.Shutdown || core.globalState == GlobalState.Off { - print("$TAG Core is being stopped or already destroyed, abort") + print("\(#function) - Core is being stopped or already destroyed, abort") } else { - print("$TAG ${friends.size} friends created") - self.friendList = core.getFriendListByName(name: self.nativeAddressBookFriendList) - if self.friendList == nil { - do { - self.friendList = try core.createFriendList() - } catch let error { - print("Failed to enumerate contact", error) + 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) + } } } - if self.friendList!.displayName == nil || self.friendList!.displayName!.isEmpty { - print( - "$TAG Friend list [$nativeAddressBookFriendList] didn't exist yet, let's create it" - ) - - self.friendList!.databaseStorageEnabled = false // We don't want to store local address-book in DB - - self.friendList!.displayName = self.nativeAddressBookFriendList - core.addFriendList(list: self.friendList!) - } else { - print( - "$TAG Friend list [$LINPHONE_ADDRESS_BOOK_FRIEND_LIST] found, removing existing friends if any" - ) - self.friendList!.friends.forEach { friend in - _ = self.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)") } - self.linphoneFriendList = core.getFriendListByName(name: self.linphoneAddressBookFirendList) - if self.linphoneFriendList == nil { - do { - self.linphoneFriendList = try core.createFriendList() - } catch let error { - print("Failed to enumerate contact", 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) } } - - if self.linphoneFriendList!.displayName == nil || self.linphoneFriendList!.displayName!.isEmpty { - print( - "$TAG Friend list [$linphoneAddressBookFirendList] didn't exist yet, let's create it" - ) - - self.linphoneFriendList!.databaseStorageEnabled = true - - self.linphoneFriendList!.displayName = self.linphoneAddressBookFirendList - core.addFriendList(list: self.linphoneFriendList!) - } } let store = CNContactStore() store.requestAccess(for: .contacts) { (granted, error) in if let error = error { - print("failed to request access", error) + print("\(#function) - failed to request access", error) return } if granted { @@ -138,11 +125,11 @@ final class ContactsManager: ObservableObject { }) } catch let error { - print("Failed to enumerate contact", error) + print("\(#function) - Failed to enumerate contact", error) } } else { - print("access denied") + print("\(#function) - access denied") } } self.magicSearch.searchForContacts(sourceFlags: MagicSearch.Source.Friends.rawValue | MagicSearch.Source.LdapServers.rawValue) @@ -185,16 +172,14 @@ final class ContactsManager: ObservableObject { awaitDataWrite(data: data, name: name) { _, result in self.saveFriend(result: result, contact: contact, existingFriend: existingFriend) { resultFriend in if resultFriend != nil { - if linphoneFriend && existingFriend == nil { - _ = self.linphoneFriendList!.addLocalFriend(linphoneFriend: resultFriend!) - - self.linphoneFriendList!.updateSubscriptions() - } else if existingFriend == nil { - _ = self.friendList!.addLocalFriend(linphoneFriend: resultFriend!) - - self.friendList!.updateSubscriptions() - } - } + if linphoneFriend && existingFriend == nil { + _ = self.linphoneFriendList?.addLocalFriend(linphoneFriend: resultFriend!) + self.linphoneFriendList?.updateSubscriptions() + } else if existingFriend == nil { + _ = self.friendList?.addLocalFriend(linphoneFriend: resultFriend!) + self.friendList?.updateSubscriptions() + } + } } } } @@ -202,68 +187,62 @@ final class ContactsManager: ObservableObject { func saveFriend(result: String, contact: Contact, existingFriend: Friend?, completion: @escaping (Friend?) -> Void) { self.coreContext.doOnCoreQueue { core in do { - let friend = (existingFriend != nil) ? existingFriend : try core.createFriend() + let friend = try existingFriend ?? core.createFriend() - if friend != nil { - 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 - 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("Failed to enumerate contact", error) - } - } - - friend!.photo = "file:/" + result - - friend!.organization = contact.organizationName - friend!.jobTitle = contact.jobTitle - - friend!.done() - completion(friend) + 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 + 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 + + friend.done() + completion(friend) } catch let error { print("Failed to enumerate contact", error) completion(nil) } } - completion(nil) } func getImagePath(friendPhotoPath: String) -> URL { From 5cf66f28530b71539d47393b38aff1966cefd178 Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Fri, 10 Nov 2023 15:15:43 +0100 Subject: [PATCH 038/486] Add presence icon for Avatar --- Linphone.xcodeproj/project.pbxproj | 20 +++ .../presence-busy.imageset/Contents.json | 21 ++++ .../presence-busy.imageset/presence-busy.svg | 5 + .../presence-online.imageset/Contents.json | 21 ++++ .../presence-online.svg | 5 + Linphone/Core/CoreContext.swift | 25 +++- Linphone/Linphone.entitlements | 14 ++- Linphone/LinphoneApp.swift | 2 +- Linphone/Ressources/linphonerc-default | 39 ++++++ Linphone/Ressources/linphonerc-factory | 65 ++++++++++ .../Fragments/ContactInnerFragment.swift | 25 +--- .../Fragments/ContactsListFragment.swift | 23 +--- .../Fragments/EditContactFragment.swift | 23 +--- .../FavoriteContactsListFragment.swift | 24 +--- Linphone/UI/Main/ContentView.swift | 2 + Linphone/Utils/Avatar.swift | 118 ++++++++++++++++++ 16 files changed, 343 insertions(+), 89 deletions(-) create mode 100644 Linphone/Assets.xcassets/presence-busy.imageset/Contents.json create mode 100644 Linphone/Assets.xcassets/presence-busy.imageset/presence-busy.svg create mode 100644 Linphone/Assets.xcassets/presence-online.imageset/Contents.json create mode 100644 Linphone/Assets.xcassets/presence-online.imageset/presence-online.svg create mode 100644 Linphone/Ressources/linphonerc-default create mode 100644 Linphone/Ressources/linphonerc-factory create mode 100644 Linphone/Utils/Avatar.swift diff --git a/Linphone.xcodeproj/project.pbxproj b/Linphone.xcodeproj/project.pbxproj index 64f20df16..ee1e35284 100644 --- a/Linphone.xcodeproj/project.pbxproj +++ b/Linphone.xcodeproj/project.pbxproj @@ -29,6 +29,8 @@ D72343362AD037AF009AA24E /* ToastView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D72343352AD037AF009AA24E /* ToastView.swift */; }; D72992392ADD7F68003AF125 /* HistoryContactFragment.swift in Sources */ = {isa = PBXBuildFile; fileRef = D72992382ADD7F68003AF125 /* HistoryContactFragment.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 */; }; 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 */; }; @@ -45,6 +47,7 @@ D7A03FC02ACC2E390081A588 /* HistoryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7A03FBF2ACC2E390081A588 /* HistoryView.swift */; }; D7A03FC62ACC458A0081A588 /* SplashScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7A03FC52ACC458A0081A588 /* SplashScreen.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 */; }; D7C365082AEFAB7F00FE6142 /* ContactListBottomSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7C365072AEFAB7F00FE6142 /* ContactListBottomSheet.swift */; }; D7C3650A2AF001C300FE6142 /* EditContactFragment.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7C365092AF001C300FE6142 /* EditContactFragment.swift */; }; @@ -95,6 +98,8 @@ D72343352AD037AF009AA24E /* ToastView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToastView.swift; sourceTree = ""; }; D72992382ADD7F68003AF125 /* HistoryContactFragment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HistoryContactFragment.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 = ""; }; 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 = ""; }; @@ -112,6 +117,7 @@ D7A03FC52ACC458A0081A588 /* SplashScreen.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SplashScreen.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 = ""; }; D7C365072AEFAB7F00FE6142 /* ContactListBottomSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactListBottomSheet.swift; sourceTree = ""; }; D7C365092AF001C300FE6142 /* EditContactFragment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditContactFragment.swift; sourceTree = ""; }; @@ -165,6 +171,7 @@ D7C3650D2AF15BF200FE6142 /* PhotoPicker.swift */, D7C48DF32AFA66F900D938CB /* EditContactController.swift */, D732A9082AFD235500DB42BA /* ShareSheetController.swift */, + D7ADF5FF2AFE356400212231 /* Avatar.swift */, ); path = Utils; sourceTree = ""; @@ -201,6 +208,7 @@ D70C93DD2AC2D0F60063CA3B /* Localizable.xcstrings */, D719ABBD2ABC67BF00B41C10 /* Preview Content */, D7D24D0C2AC1B4C700C6F35B /* Fonts */, + D7ADF6012AFE5C7C00212231 /* Ressources */, ); path = Linphone; sourceTree = ""; @@ -375,6 +383,15 @@ path = Viewmodel; sourceTree = ""; }; + D7ADF6012AFE5C7C00212231 /* Ressources */ = { + isa = PBXGroup; + children = ( + D732A90A2B0376F500DB42BA /* linphonerc-default */, + D732A90B2B0376F500DB42BA /* linphonerc-factory */, + ); + path = Ressources; + sourceTree = ""; + }; D7D24D0C2AC1B4C700C6F35B /* Fonts */ = { isa = PBXGroup; children = ( @@ -469,6 +486,8 @@ 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 */, D70C93DE2AC2D0F60063CA3B /* Localizable.xcstrings in Resources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -503,6 +522,7 @@ buildActionMask = 2147483647; files = ( D7C3650E2AF15BF200FE6142 /* PhotoPicker.swift in Sources */, + D7ADF6002AFE356400212231 /* Avatar.swift in Sources */, D71707202AC5989C0037746F /* TextExtension.swift in Sources */, D719ABB92ABC67BF00B41C10 /* ContentView.swift in Sources */, D71FCA832AE14D6E00D2E43E /* ContactFragment.swift in Sources */, 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/Core/CoreContext.swift b/Linphone/Core/CoreContext.swift index 350fb6a0d..a18811e17 100644 --- a/Linphone/Core/CoreContext.swift +++ b/Linphone/Core/CoreContext.swift @@ -30,6 +30,7 @@ final class CoreContext: ObservableObject { @Published var loggingInProgress: Bool = false @Published var toastMessage: String = "" @Published var defaultAccount: Account? + @Published var coreIsStarted: Bool = false private var mCore: Core! private var mIteratePublisher: AnyCancellable? @@ -53,17 +54,39 @@ final class CoreContext: ObservableObject { coreQueue.async { let configDir = Factory.Instance.getConfigDir(context: nil) - try? self.mCore = Factory.Instance.createCore(configPath: "\(configDir)/MyConfig", factoryConfigPath: "", systemContext: nil) + + let url = NSURL(fileURLWithPath: configDir) + if let pathComponent = url.appendingPathComponent("linphonerc") { + let filePath = pathComponent.path + let fileManager = FileManager.default + if !fileManager.fileExists(atPath: filePath) { + let path = Bundle.main.path(forResource: "linphonerc-default", ofType: nil) + if path != nil { + try? FileManager.default.copyItem(at: NSURL(fileURLWithPath: path!) as URL, to: pathComponent) + } + } + } + + let config: Config! = Config.newForSharedCore( + appGroupId: "group.org.linphone.phone.msgNotification", + configFilename: "linphonerc", + factoryConfigFilename: Bundle.main.path(forResource: "linphonerc-factory", ofType: nil) + ) + + self.mCore = try? Factory.Instance.createCoreWithConfig(config: config, systemContext: nil) + self.mCore.autoIterateEnabled = false self.mCore.friendsDatabasePath = "\(configDir)/friends.db" self.mCore.publisher?.onGlobalStateChanged?.postOnMainQueue { (cbVal: (core: Core, state: GlobalState, message: String)) in if cbVal.state == GlobalState.On { self.defaultAccount = self.mCore.defaultAccount + self.coreIsStarted = true } else if cbVal.state == GlobalState.Off { self.defaultAccount = nil } } + try? self.mCore.start() // Create a Core listener to listen for the callback we need diff --git a/Linphone/Linphone.entitlements b/Linphone/Linphone.entitlements index f2ef3ae02..500e2cecd 100644 --- a/Linphone/Linphone.entitlements +++ b/Linphone/Linphone.entitlements @@ -2,9 +2,15 @@ - com.apple.security.app-sandbox - - com.apple.security.files.user-selected.read-only - + com.apple.security.app-sandbox + + com.apple.security.application-groups + + group.belledonne-communications.linphone + group.org.linphone.phone.msgNotification + group.org.linphone.phone.linphoneExtension + + com.apple.security.files.user-selected.read-only + diff --git a/Linphone/LinphoneApp.swift b/Linphone/LinphoneApp.swift index 85f14b81c..9308e26fe 100644 --- a/Linphone/LinphoneApp.swift +++ b/Linphone/LinphoneApp.swift @@ -29,7 +29,7 @@ struct LinphoneApp: App { var body: some Scene { WindowGroup { - if isActive { + if isActive && coreContext.coreIsStarted { if !sharedMainViewModel.welcomeViewDisplayed { WelcomeView(sharedMainViewModel: sharedMainViewModel) } else if coreContext.defaultAccount == nil || sharedMainViewModel.displayProfileMode { diff --git a/Linphone/Ressources/linphonerc-default b/Linphone/Ressources/linphonerc-default new file mode 100644 index 000000000..ea5356429 --- /dev/null +++ b/Linphone/Ressources/linphonerc-default @@ -0,0 +1,39 @@ + +## 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 + +[app] +tunnel=disabled +auto_start=1 +record_aware=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 + +## End of default rc diff --git a/Linphone/Ressources/linphonerc-factory b/Linphone/Ressources/linphonerc-factory new file mode 100644 index 000000000..85b543074 --- /dev/null +++ b/Linphone/Ressources/linphonerc-factory @@ -0,0 +1,65 @@ + +## 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=1 +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] +displaytype=MSAndroidTextureDisplay +auto_resize_preview_to_keep_ratio=1 +max_conference_size=vga + +[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] +activation_code_length=4 +prefer_basic_chat_room=1 +record_aware=1 + +[account_creator] +backend=1 +# 1 means FlexiAPI, 0 is XMLRPC +url=https://subscribe.linphone.org/api/ +# replace above URL by https://staging-subscribe.linphone.org/api/ for testing + +[lime] +lime_update_threshold=86400 + +[alerts] +alerts_enabled=1 + +[assistant] +algorithm=SHA-256 +password_min_length=6 +username_regex=^[a-z0-9+_.\-]*$ + +## End of factory rc diff --git a/Linphone/UI/Main/Contacts/Fragments/ContactInnerFragment.swift b/Linphone/UI/Main/Contacts/Fragments/ContactInnerFragment.swift index ac6a8e5cd..dfefb33b6 100644 --- a/Linphone/UI/Main/Contacts/Fragments/ContactInnerFragment.swift +++ b/Linphone/UI/Main/Contacts/Fragments/ContactInnerFragment.swift @@ -113,28 +113,9 @@ struct ContactInnerFragment: View { && magicSearch.lastSearch[contactViewModel.indexDisplayedFriend!].friend != nil && magicSearch.lastSearch[contactViewModel.indexDisplayedFriend!].friend!.photo != nil && !magicSearch.lastSearch[contactViewModel.indexDisplayedFriend!].friend!.photo!.isEmpty { - AsyncImage( - url: ContactsManager.shared.getImagePath( - friendPhotoPath: magicSearch.lastSearch[contactViewModel.indexDisplayedFriend!].friend!.photo!)) { image in - switch image { - case .empty: - ProgressView() - .frame(width: 100, height: 100) - case .success(let image): - image - .resizable() - .aspectRatio(contentMode: .fill) - .frame(width: 100, height: 100) - .clipShape(Circle()) - case .failure: - Image("profil-picture-default") - .resizable() - .frame(width: 100, height: 100) - .clipShape(Circle()) - @unknown default: - EmptyView() - } - } + + Avatar(friend: magicSearch.lastSearch[contactViewModel.indexDisplayedFriend!].friend!, avatarSize: 100) + } else if contactViewModel.indexDisplayedFriend != nil && magicSearch.lastSearch[contactViewModel.indexDisplayedFriend!].friend != nil { Image("profil-picture-default") .resizable() diff --git a/Linphone/UI/Main/Contacts/Fragments/ContactsListFragment.swift b/Linphone/UI/Main/Contacts/Fragments/ContactsListFragment.swift index 7b274e646..67562f06f 100644 --- a/Linphone/UI/Main/Contacts/Fragments/ContactsListFragment.swift +++ b/Linphone/UI/Main/Contacts/Fragments/ContactsListFragment.swift @@ -64,26 +64,9 @@ struct ContactsListFragment: View { } if magicSearch.lastSearch[index].friend!.photo != nil && !magicSearch.lastSearch[index].friend!.photo!.isEmpty { - AsyncImage(url: ContactsManager.shared.getImagePath(friendPhotoPath: magicSearch.lastSearch[index].friend!.photo!)) { image in - switch image { - case .empty: - ProgressView() - .frame(width: 45, height: 45) - case .success(let image): - image - .resizable() - .aspectRatio(contentMode: .fill) - .frame(width: 45, height: 45) - .clipShape(Circle()) - case .failure: - Image("profil-picture-default") - .resizable() - .frame(width: 45, height: 45) - .clipShape(Circle()) - @unknown default: - EmptyView() - } - } + + Avatar(friend: magicSearch.lastSearch[index].friend!, avatarSize: 45) + } else { Image("profil-picture-default") .resizable() diff --git a/Linphone/UI/Main/Contacts/Fragments/EditContactFragment.swift b/Linphone/UI/Main/Contacts/Fragments/EditContactFragment.swift index e7de05556..9e1b2b09c 100644 --- a/Linphone/UI/Main/Contacts/Fragments/EditContactFragment.swift +++ b/Linphone/UI/Main/Contacts/Fragments/EditContactFragment.swift @@ -129,26 +129,9 @@ struct EditContactFragment: View { if editContactViewModel.selectedEditFriend != nil && editContactViewModel.selectedEditFriend!.photo != nil && !editContactViewModel.selectedEditFriend!.photo!.isEmpty && selectedImage == nil && !removedImage { - AsyncImage(url: ContactsManager.shared.getImagePath(friendPhotoPath: editContactViewModel.selectedEditFriend!.photo!)) { image in - switch image { - case .empty: - ProgressView() - .frame(width: 100, height: 100) - case .success(let image): - image - .resizable() - .aspectRatio(contentMode: .fill) - .frame(width: 100, height: 100) - .clipShape(Circle()) - case .failure: - Image("profil-picture-default") - .resizable() - .frame(width: 100, height: 100) - .clipShape(Circle()) - @unknown default: - EmptyView() - } - } + + Avatar(friend: editContactViewModel.selectedEditFriend!, avatarSize: 100) + } else if selectedImage == nil { Image("profil-picture-default") .resizable() diff --git a/Linphone/UI/Main/Contacts/Fragments/FavoriteContactsListFragment.swift b/Linphone/UI/Main/Contacts/Fragments/FavoriteContactsListFragment.swift index 0eb2fa566..1942ece86 100644 --- a/Linphone/UI/Main/Contacts/Fragments/FavoriteContactsListFragment.swift +++ b/Linphone/UI/Main/Contacts/Fragments/FavoriteContactsListFragment.swift @@ -39,27 +39,9 @@ struct FavoriteContactsListFragment: View { VStack { if magicSearch.lastSearch[index].friend!.photo != nil && !magicSearch.lastSearch[index].friend!.photo!.isEmpty { - AsyncImage(url: ContactsManager.shared.getImagePath(friendPhotoPath: magicSearch.lastSearch[index].friend!.photo!) - ) { image in - switch image { - case .empty: - ProgressView() - .frame(width: 45, height: 45) - case .success(let image): - image - .resizable() - .aspectRatio(contentMode: .fill) - .frame(width: 45, height: 45) - .clipShape(Circle()) - case .failure: - Image("profil-picture-default") - .resizable() - .frame(width: 45, height: 45) - .clipShape(Circle()) - @unknown default: - EmptyView() - } - } + + Avatar(friend: magicSearch.lastSearch[index].friend!, avatarSize: 45) + } else { Image("profil-picture-default") .resizable() diff --git a/Linphone/UI/Main/ContentView.swift b/Linphone/UI/Main/ContentView.swift index d9a8307c1..f3dfce4c8 100644 --- a/Linphone/UI/Main/ContentView.swift +++ b/Linphone/UI/Main/ContentView.swift @@ -136,6 +136,7 @@ struct ContentView: View { Menu { Button { isMenuOpen = false + contactViewModel.indexDisplayedFriend = nil magicSearch.allContact = true magicSearch.searchForContacts( sourceFlags: MagicSearch.Source.Friends.rawValue | MagicSearch.Source.LdapServers.rawValue) @@ -153,6 +154,7 @@ struct ContentView: View { Button { isMenuOpen = false + contactViewModel.indexDisplayedFriend = nil magicSearch.allContact = false magicSearch.searchForContacts( sourceFlags: MagicSearch.Source.Friends.rawValue | MagicSearch.Source.LdapServers.rawValue) diff --git a/Linphone/Utils/Avatar.swift b/Linphone/Utils/Avatar.swift new file mode 100644 index 000000000..72847b760 --- /dev/null +++ b/Linphone/Utils/Avatar.swift @@ -0,0 +1,118 @@ +/* + * 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 { + var friend: Friend + let avatarSize: CGFloat + + @State private var friendDelegate: FriendDelegate? + @State private var presenceImage = "" + + var body: some View { + AsyncImage(url: ContactsManager.shared.getImagePath(friendPhotoPath: 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 !friend.addresses.isEmpty { + if presenceImage.isEmpty && (friend.consolidatedPresence == ConsolidatedPresence.Online || friend.consolidatedPresence == ConsolidatedPresence.Busy) { + Image(friend.consolidatedPresence == ConsolidatedPresence.Online ? "presence-online" : "presence-busy") + .resizable() + .frame(width: avatarSize/4, height: avatarSize/4) + .padding(.trailing, avatarSize == 45 ? 1 : 3) + .padding(.bottom, avatarSize == 45 ? 1 : 3) + } else if !presenceImage.isEmpty && (friend.consolidatedPresence != ConsolidatedPresence.DoNotDisturb || friend.consolidatedPresence != ConsolidatedPresence.Offline) { + Image(presenceImage) + .resizable() + .frame(width: avatarSize/4, height: avatarSize/4) + .padding(.trailing, avatarSize == 45 ? 1 : 3) + .padding(.bottom, avatarSize == 45 ? 1 : 3) + } + } + } + } + .frame(width: avatarSize, height: avatarSize) + } + case .failure: + Image("profil-picture-default") + .resizable() + .frame(width: avatarSize, height: avatarSize) + .clipShape(Circle()) + @unknown default: + EmptyView() + } + } + .onAppear { + addDelegate() + } + .onDisappear { + removeAllDelegate() + } + } + + func addDelegate() { + if friend.address != nil { + print("Presence received for first \(friend.name!) \(friend.consolidatedPresence)") + //self.presenceImage = friend.consolidatedPresence == ConsolidatedPresence.Online ? "presence-online" : "presence-busy" + } + + let newFriendDelegate = FriendDelegateStub( + onPresenceReceived: { (linphoneFriend: Friend) -> Void in + print("Presence received for second \(linphoneFriend.name) \(linphoneFriend.consolidatedPresence)") + + /* + if linphoneFriend.address != nil && friend.address != nil + && linphoneFriend.address!.asStringUriOnly() == friend.address!.asStringUriOnly() { + let presenceModel = linphoneFriend.getPresenceModelForUriOrTel(uriOrTel: (linphoneFriend.address!.asStringUriOnly())) + if presenceModel != nil { + presenceImage = presenceModel!.consolidatedPresence == ConsolidatedPresence.Online ? "presence-online" : "presence-busy" + } + } + */ + } + ) + + friendDelegate = newFriendDelegate + if friendDelegate != nil { + friend.addDelegate(delegate: friendDelegate!) + } + } + + func removeAllDelegate(){ + if friendDelegate != nil { + presenceImage = "" + friend.removeDelegate(delegate: friendDelegate!) + friendDelegate = nil + } + } +} From ce9f6c454c9f4cafdc69364cbe811a3cf14bc334 Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Wed, 15 Nov 2023 16:18:06 +0100 Subject: [PATCH 039/486] Add history call list --- Linphone.xcodeproj/project.pbxproj | 16 ++ .../trash-simple-red.imageset/Contents.json | 21 ++ .../trash-simple-red.svg | 3 + Linphone/Contacts/ContactsManager.swift | 14 +- Linphone/LinphoneApp.swift | 9 +- Linphone/Localizable.xcstrings | 20 +- .../ContactInnerActionsFragment.swift | 24 +- .../Contacts/Fragments/ContactsFragment.swift | 4 +- .../Fragments/ContactsInnerFragment.swift | 15 +- Linphone/UI/Main/ContentView.swift | 189 +++++++++----- .../History/Fragments/HistoryFragment.swift | 92 +++++++ .../Fragments/HistoryListBottomSheet.swift | 240 ++++++++++++++++++ .../Fragments/HistoryListFragment.swift | 232 +++++++++++++++++ Linphone/UI/Main/History/HistoryView.swift | 50 +++- .../ViewModel/HistoryListViewModel.swift | 142 +++++++++++ .../History/ViewModel/HistoryViewModel.swift | 5 +- Linphone/Utils/EditContactController.swift | 2 +- 17 files changed, 978 insertions(+), 100 deletions(-) create mode 100644 Linphone/Assets.xcassets/trash-simple-red.imageset/Contents.json create mode 100644 Linphone/Assets.xcassets/trash-simple-red.imageset/trash-simple-red.svg create mode 100644 Linphone/UI/Main/History/Fragments/HistoryFragment.swift create mode 100644 Linphone/UI/Main/History/Fragments/HistoryListBottomSheet.swift create mode 100644 Linphone/UI/Main/History/Fragments/HistoryListFragment.swift create mode 100644 Linphone/UI/Main/History/ViewModel/HistoryListViewModel.swift diff --git a/Linphone.xcodeproj/project.pbxproj b/Linphone.xcodeproj/project.pbxproj index 64f20df16..1279fcc32 100644 --- a/Linphone.xcodeproj/project.pbxproj +++ b/Linphone.xcodeproj/project.pbxproj @@ -29,6 +29,10 @@ D72343362AD037AF009AA24E /* ToastView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D72343352AD037AF009AA24E /* ToastView.swift */; }; D72992392ADD7F68003AF125 /* HistoryContactFragment.swift in Sources */ = {isa = PBXBuildFile; fileRef = D72992382ADD7F68003AF125 /* HistoryContactFragment.swift */; }; D732A9092AFD235500DB42BA /* ShareSheetController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D732A9082AFD235500DB42BA /* ShareSheetController.swift */; }; + 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 */; }; 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 */; }; @@ -95,6 +99,10 @@ D72343352AD037AF009AA24E /* ToastView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToastView.swift; sourceTree = ""; }; D72992382ADD7F68003AF125 /* HistoryContactFragment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HistoryContactFragment.swift; sourceTree = ""; }; D732A9082AFD235500DB42BA /* ShareSheetController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareSheetController.swift; 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 = ""; }; 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 = ""; }; @@ -267,6 +275,7 @@ isa = PBXGroup; children = ( D72250622ADE9615008FB426 /* HistoryViewModel.swift */, + D732A9142B04C7FE00DB42BA /* HistoryListViewModel.swift */, ); path = ViewModel; sourceTree = ""; @@ -275,6 +284,9 @@ isa = PBXGroup; children = ( D72992382ADD7F68003AF125 /* HistoryContactFragment.swift */, + D732A90E2B04C3B400DB42BA /* HistoryFragment.swift */, + D732A9122B04C7A300DB42BA /* HistoryListFragment.swift */, + D732A91A2B061BD900DB42BA /* HistoryListBottomSheet.swift */, ); path = Fragments; sourceTree = ""; @@ -510,12 +522,14 @@ D750D3392AD3E6EE00EC99C5 /* PopupLoadingView.swift in Sources */, D7E6D0492AE933AD00A57AAF /* FavoriteContactsListFragment.swift in Sources */, D706BA822ADD72D100278F45 /* DeviceRotationViewModifier.swift in Sources */, + D732A9132B04C7A300DB42BA /* HistoryListFragment.swift in Sources */, D719ABC92ABC6FD700B41C10 /* CoreContext.swift in Sources */, D7EAACCF2AD6ED8000AA6A8A /* PermissionsFragment.swift in Sources */, D777DBB32AE12C5900565A99 /* ContactsManager.swift in Sources */, D7C3650A2AF001C300FE6142 /* EditContactFragment.swift in Sources */, D7A03FBD2ACC2DB60081A588 /* ContactsView.swift in Sources */, D719ABCF2ABC779A00B41C10 /* AccountLoginViewModel.swift in Sources */, + D732A90F2B04C3B400DB42BA /* HistoryFragment.swift in Sources */, D78290BB2ADD40B2004AA85C /* ContactViewModel.swift in Sources */, D72992392ADD7F68003AF125 /* HistoryContactFragment.swift in Sources */, D7B5066D2AEFA9B900CEB4E9 /* ContactInnerFragment.swift in Sources */, @@ -523,9 +537,11 @@ D7C48DF42AFA66F900D938CB /* EditContactController.swift in Sources */, D74C9D012ACB098C0021626A /* PermissionManager.swift in Sources */, D7702EF22AC7205000557C00 /* WelcomeView.swift in Sources */, + D732A9152B04C7FE00DB42BA /* HistoryListViewModel.swift in Sources */, D71FCA7F2AE1397200D2E43E /* ContactsListViewModel.swift in Sources */, D71FCA812AE14CFC00D2E43E /* ContactsListFragment.swift in Sources */, D719ABB72ABC67BF00B41C10 /* LinphoneApp.swift in Sources */, + D732A91B2B061BD900DB42BA /* HistoryListBottomSheet.swift in Sources */, D72250632ADE9615008FB426 /* HistoryViewModel.swift in Sources */, D7E6D0512AEBDBD500A57AAF /* ContactsListBottomSheet.swift in Sources */, D7A2EDD62AC18115005D90FC /* SharedMainViewModel.swift in Sources */, 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/Contacts/ContactsManager.swift b/Linphone/Contacts/ContactsManager.swift index fa9843527..9db3527c0 100644 --- a/Linphone/Contacts/ContactsManager.swift +++ b/Linphone/Contacts/ContactsManager.swift @@ -271,7 +271,7 @@ final class ContactsManager { } } - func getFriend(contact: Contact) -> Friend? { + func getFriendWithContact(contact: Contact) -> Friend? { if friendList != nil { let friend = friendList!.friends.first(where: {$0.nativeUri == contact.identifier}) return friend @@ -279,6 +279,18 @@ final class ContactsManager { return nil } } + + func getFriendWithAddress(address: Address) -> Friend? { + if friendList != nil { + var friend = friendList!.friends.first(where: {$0.addresses.contains(where: {$0.asStringUriOnly() == address.asStringUriOnly()})}) + if friend == nil { + friend = linphoneFriendList!.friends.first(where: {$0.addresses.contains(where: {$0.asStringUriOnly() == address.asStringUriOnly()})}) + } + return friend + } else { + return nil + } + } } struct PhoneNumber { diff --git a/Linphone/LinphoneApp.swift b/Linphone/LinphoneApp.swift index 85f14b81c..35b3a538e 100644 --- a/Linphone/LinphoneApp.swift +++ b/Linphone/LinphoneApp.swift @@ -36,8 +36,13 @@ struct LinphoneApp: App { AssistantView(sharedMainViewModel: sharedMainViewModel) .toast(isShowing: $coreContext.toastMessage) } else if coreContext.defaultAccount != nil { - ContentView(contactViewModel: ContactViewModel(), editContactViewModel: EditContactViewModel(), historyViewModel: HistoryViewModel()) - .toast(isShowing: $coreContext.toastMessage) + ContentView( + contactViewModel: ContactViewModel(), + editContactViewModel: EditContactViewModel(), + historyViewModel: HistoryViewModel(), + historyListViewModel: HistoryListViewModel() + ) + .toast(isShowing: $coreContext.toastMessage) } } else { SplashScreen(isActive: $isActive) diff --git a/Linphone/Localizable.xcstrings b/Linphone/Localizable.xcstrings index 0062a8ce1..695ac7218 100644 --- a/Linphone/Localizable.xcstrings +++ b/Linphone/Localizable.xcstrings @@ -104,9 +104,15 @@ }, "Add a picture" : { + }, + "Add the contact" : { + }, "Add to favourites" : { + }, + "All calls will be removed from the history." : { + }, "All contacts" : { @@ -172,6 +178,9 @@ }, "Copy number" : { + }, + "Copy SIP address" : { + }, "D'accord" : { @@ -187,6 +196,9 @@ }, "Delete %@?" : { + }, + "Delete all history" : { + }, "Delete this contact" : { @@ -199,6 +211,9 @@ }, "Display Name" : { + }, + "Do you really want to delete all calls history?" : { + }, "Domain" : { @@ -293,7 +308,7 @@ "Next" : { }, - "No calls for the moment..." : { + "No call for the moment..." : { }, "No contacts for the moment..." : { @@ -372,6 +387,9 @@ }, "See all" : { + }, + "See contact" : { + }, "See Linphone contact" : { diff --git a/Linphone/UI/Main/Contacts/Fragments/ContactInnerActionsFragment.swift b/Linphone/UI/Main/Contacts/Fragments/ContactInnerActionsFragment.swift index ff8e3e2e7..8714c25e3 100644 --- a/Linphone/UI/Main/Contacts/Fragments/ContactInnerActionsFragment.swift +++ b/Linphone/UI/Main/Contacts/Fragments/ContactInnerActionsFragment.swift @@ -1,9 +1,21 @@ -// -// ContactInnerActionsFragment.swift -// Linphone -// -// Created by Benoît Martins on 09/11/2023. -// +/* + * 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 diff --git a/Linphone/UI/Main/Contacts/Fragments/ContactsFragment.swift b/Linphone/UI/Main/Contacts/Fragments/ContactsFragment.swift index ef48b5e84..8382da399 100644 --- a/Linphone/UI/Main/Contacts/Fragments/ContactsFragment.swift +++ b/Linphone/UI/Main/Contacts/Fragments/ContactsFragment.swift @@ -42,10 +42,10 @@ struct ContactsFragment: View { showingSheet: $showingSheet, showShareSheet: $showShareSheet ) - .presentationDetents([.fraction(0.2)]) + .presentationDetents([.fraction(0.2)]) } .sheet(isPresented: $showShareSheet) { - ShareSheet(friendToShare: contactViewModel.selectedFriendToShare!) + ShareSheet(friendToShare: contactViewModel.selectedFriendToShare!) .presentationDetents([.medium]) .edgesIgnoringSafeArea(.bottom) } diff --git a/Linphone/UI/Main/Contacts/Fragments/ContactsInnerFragment.swift b/Linphone/UI/Main/Contacts/Fragments/ContactsInnerFragment.swift index 8e09e519f..4f779b294 100644 --- a/Linphone/UI/Main/Contacts/Fragments/ContactsInnerFragment.swift +++ b/Linphone/UI/Main/Contacts/Fragments/ContactsInnerFragment.swift @@ -20,10 +20,7 @@ import SwiftUI import linphonesw -struct ContactsInnerFragment: View { - - @Environment(\.scenePhase) var scenePhase - +struct ContactsInnerFragment: View { @ObservedObject var magicSearch = MagicSearchSingleton.shared @ObservedObject var contactViewModel: ContactViewModel @@ -76,16 +73,6 @@ struct ContactsInnerFragment: View { ContactsListFragment(contactViewModel: contactViewModel, contactsListViewModel: ContactsListViewModel(), showingSheet: $showingSheet) } .navigationBarHidden(true) - .onChange(of: scenePhase) { newPhase in - if newPhase == .active { - ContactsManager.shared.fetchContacts() - print("Active") - } else if newPhase == .inactive { - print("Inactive") - } else if newPhase == .background { - print("Background") - } - } } } diff --git a/Linphone/UI/Main/ContentView.swift b/Linphone/UI/Main/ContentView.swift index d9a8307c1..6c677de8a 100644 --- a/Linphone/UI/Main/ContentView.swift +++ b/Linphone/UI/Main/ContentView.swift @@ -17,18 +17,23 @@ * along with this program. If not, see . */ +// swiftlint:disable type_body_length import SwiftUI import linphonesw struct ContentView: View { + @Environment(\.scenePhase) var scenePhase + + @ObservedObject private var coreContext = CoreContext.shared + var contactManager = ContactsManager.shared var magicSearch = MagicSearchSingleton.shared @ObservedObject var contactViewModel: ContactViewModel @ObservedObject var editContactViewModel: EditContactViewModel @ObservedObject var historyViewModel: HistoryViewModel - @ObservedObject private var coreContext = CoreContext.shared + @ObservedObject var historyListViewModel: HistoryListViewModel @State var index = 0 @State private var orientation = UIDevice.current.orientation @@ -38,7 +43,8 @@ struct ContentView: View { @State private var text = "" @FocusState private var focusedField: Bool @State var isMenuOpen = false - @State var isShowDeletePopup = false + @State var isShowDeleteContactPopup = false + @State var isShowDeleteAllHistoryPopup = false @State var isShowEditContactFragment = false @State var isShowDismissPopup = false @@ -134,34 +140,50 @@ struct ContentView: View { } Menu { - Button { - isMenuOpen = false - magicSearch.allContact = true - magicSearch.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) + if index == 0 { + Button { + isMenuOpen = false + magicSearch.allContact = true + magicSearch.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) + } } } - } - - Button { - isMenuOpen = false - magicSearch.allContact = false - magicSearch.searchForContacts( - sourceFlags: MagicSearch.Source.Friends.rawValue | MagicSearch.Source.LdapServers.rawValue) - } label: { - HStack { - Text("See Linphone contact") - Spacer() - if !magicSearch.allContact { - Image("green-check") + + Button { + isMenuOpen = false + magicSearch.allContact = false + magicSearch.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) + } + } + } + } else { + Button(role: .destructive) { + isMenuOpen = false + isShowDeleteAllHistoryPopup.toggle() + //historyListViewModel.removeCallLogs() + } label: { + HStack { + Text("Delete all history") + Spacer() + Image("trash-simple-red") .resizable() .frame(width: 25, height: 25, alignment: .leading) } @@ -193,9 +215,14 @@ struct ContentView: View { } text = "" - magicSearch.currentFilter = "" - magicSearch.searchForContacts( - sourceFlags: MagicSearch.Source.Friends.rawValue | MagicSearch.Source.LdapServers.rawValue) + + if index == 0 { + magicSearch.currentFilter = "" + magicSearch.searchForContacts( + sourceFlags: MagicSearch.Source.Friends.rawValue | MagicSearch.Source.LdapServers.rawValue) + } else { + historyListViewModel.resetFilterCallLogs() + } } label: { Image("caret-left") .renderingMode(.template) @@ -226,9 +253,13 @@ struct ContentView: View { self.focusedField = true } .onChange(of: text) { newValue in - magicSearch.currentFilter = newValue - magicSearch.searchForContacts( - sourceFlags: MagicSearch.Source.Friends.rawValue | MagicSearch.Source.LdapServers.rawValue) + if index == 0 { + magicSearch.currentFilter = newValue + magicSearch.searchForContacts( + sourceFlags: MagicSearch.Source.Friends.rawValue | MagicSearch.Source.LdapServers.rawValue) + } else { + historyListViewModel.filterCallLogs(filter: text) + } } } else { TextEditor(text: Binding( @@ -281,10 +312,17 @@ struct ContentView: View { historyViewModel: historyViewModel, editContactViewModel: editContactViewModel, isShowEditContactFragment: $isShowEditContactFragment, - isShowDeletePopup: $isShowDeletePopup + isShowDeletePopup: $isShowDeleteContactPopup ) } else if self.index == 1 { - HistoryView() + HistoryView( + historyListViewModel: historyListViewModel, + historyViewModel: historyViewModel, + contactViewModel: contactViewModel, + editContactViewModel: editContactViewModel, + index: $index, + isShowEditContactFragment: $isShowEditContactFragment + ) } } .frame(maxWidth: @@ -367,7 +405,7 @@ struct ContentView: View { } } - if contactViewModel.indexDisplayedFriend != nil || !historyViewModel.historyTitle.isEmpty { + if contactViewModel.indexDisplayedFriend != nil || historyViewModel.indexDisplayedCall != nil { HStack(spacing: 0) { Spacer() .frame(maxWidth: @@ -381,12 +419,12 @@ struct ContentView: View { ContactFragment( contactViewModel: contactViewModel, editContactViewModel: editContactViewModel, - isShowDeletePopup: $isShowDeletePopup, + isShowDeletePopup: $isShowDeleteContactPopup, isShowDismissPopup: $isShowDismissPopup ) - .frame(maxWidth: .infinity) - .background(Color.gray100) - .ignoresSafeArea(.keyboard) + .frame(maxWidth: .infinity) + .background(Color.gray100) + .ignoresSafeArea(.keyboard) } else if self.index == 1 { HistoryContactFragment() .frame(maxWidth: .infinity) @@ -433,25 +471,25 @@ struct ContentView: View { isShowEditContactFragment: $isShowEditContactFragment, isShowDismissPopup: $isShowDismissPopup ) - .zIndex(3) - .transition(.move(edge: .bottom)) - .onAppear { - contactViewModel.indexDisplayedFriend = nil - } + .zIndex(3) + .transition(.move(edge: .bottom)) + .onAppear { + contactViewModel.indexDisplayedFriend = nil + } } - if isShowDeletePopup { - PopupView(sharedMainViewModel: SharedMainViewModel(), isShowPopup: $isShowDeletePopup, + if isShowDeleteContactPopup { + PopupView(sharedMainViewModel: SharedMainViewModel(), isShowPopup: $isShowDeleteContactPopup, title: Text( - contactViewModel.selectedFriend != nil - ? "Delete \(contactViewModel.selectedFriend!.name!)?" - : (contactViewModel.indexDisplayedFriend != nil - ? "Delete \(magicSearch.lastSearch[contactViewModel.indexDisplayedFriend!].friend!.name!)?" - : "Error Name")), + contactViewModel.selectedFriend != nil + ? "Delete \(contactViewModel.selectedFriend!.name!)?" + : (contactViewModel.indexDisplayedFriend != nil + ? "Delete \(magicSearch.lastSearch[contactViewModel.indexDisplayedFriend!].friend!.name!)?" + : "Error Name")), content: Text("This contact will be deleted definitively."), titleFirstButton: Text("Cancel"), actionFirstButton: { - self.isShowDeletePopup.toggle()}, + self.isShowDeleteContactPopup.toggle()}, titleSecondButton: Text("Ok"), actionSecondButton: { if contactViewModel.selectedFriendToDelete != nil { @@ -470,18 +508,37 @@ struct ContentView: View { } magicSearch.searchForContacts( sourceFlags: MagicSearch.Source.Friends.rawValue | MagicSearch.Source.LdapServers.rawValue) - self.isShowDeletePopup.toggle() + self.isShowDeleteContactPopup.toggle() }) .background(.black.opacity(0.65)) .zIndex(3) .onTapGesture { - self.isShowDeletePopup.toggle() + self.isShowDeleteContactPopup.toggle() } .onAppear { contactViewModel.selectedFriendToDelete = contactViewModel.selectedFriend } } + if isShowDeleteAllHistoryPopup { + PopupView(sharedMainViewModel: SharedMainViewModel(), 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()}, + titleSecondButton: Text("Ok"), + actionSecondButton: { + historyListViewModel.removeCallLogs() + self.isShowDeleteAllHistoryPopup.toggle() + }) + .background(.black.opacity(0.65)) + .zIndex(3) + .onTapGesture { + self.isShowDeleteAllHistoryPopup.toggle() + } + } + if isShowDismissPopup { PopupView(sharedMainViewModel: SharedMainViewModel(), isShowPopup: $isShowDismissPopup, title: Text("Don’t save modifications?"), @@ -524,13 +581,23 @@ struct ContentView: View { } } .onRotate { newOrientation in - if (contactViewModel.indexDisplayedFriend != nil || !historyViewModel.historyTitle.isEmpty) && searchIsActive { + if (contactViewModel.indexDisplayedFriend != nil || historyViewModel.indexDisplayedCall != nil) && searchIsActive { self.focusedField = false } else if searchIsActive { self.focusedField = true } orientation = newOrientation } + .onChange(of: scenePhase) { newPhase in + if newPhase == .active { + ContactsManager.shared.fetchContacts() + print("Active") + } else if newPhase == .inactive { + print("Inactive") + } else if newPhase == .background { + print("Background") + } + } } func openMenu() { @@ -541,5 +608,11 @@ struct ContentView: View { } #Preview { - ContentView(contactViewModel: ContactViewModel(), editContactViewModel: EditContactViewModel(), historyViewModel: HistoryViewModel()) + ContentView( + contactViewModel: ContactViewModel(), + editContactViewModel: EditContactViewModel(), + historyViewModel: HistoryViewModel(), + historyListViewModel: HistoryListViewModel() + ) } +// swiftlint:enable type_body_length diff --git a/Linphone/UI/Main/History/Fragments/HistoryFragment.swift b/Linphone/UI/Main/History/Fragments/HistoryFragment.swift new file mode 100644 index 000000000..36a39e7b6 --- /dev/null +++ b/Linphone/UI/Main/History/Fragments/HistoryFragment.swift @@ -0,0 +1,92 @@ +/* + * Copyright (c) 2010-2023 Belledonne Communications SARL. + * + * This file is part of linphone-iphone + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +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 + + var body: some View { + ZStack { + if #available(iOS 16.0, *) { + if idiom != .pad { + HistoryListFragment(historyListViewModel: historyListViewModel, historyViewModel: historyViewModel, showingSheet: $showingSheet) + .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) + .halfSheet(showSheet: $showingSheet) { + HistoryListBottomSheet( + historyViewModel: historyViewModel, + contactViewModel: contactViewModel, + editContactViewModel: editContactViewModel, + historyListViewModel: historyListViewModel, + showingSheet: $showingSheet, + index: $index, + isShowEditContactFragment: $isShowEditContactFragment + ) + } onDismiss: {} + } + } else { + HistoryListFragment(historyListViewModel: historyListViewModel, historyViewModel: historyViewModel, showingSheet: $showingSheet) + .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) + ) +} diff --git a/Linphone/UI/Main/History/Fragments/HistoryListBottomSheet.swift b/Linphone/UI/Main/History/Fragments/HistoryListBottomSheet.swift new file mode 100644 index 000000000..a9c9092c1 --- /dev/null +++ b/Linphone/UI/Main/History/Fragments/HistoryListBottomSheet.swift @@ -0,0 +1,240 @@ +/* + * 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 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 ContactsManager.shared.getFriendWithAddress( + address: historyViewModel.selectedCall != nil && historyViewModel.selectedCall!.dir == .Outgoing + ? historyViewModel.selectedCall!.toAddress! + : historyViewModel.selectedCall!.fromAddress! + ) != nil { + let addressCall = historyViewModel.selectedCall != nil && historyViewModel.selectedCall!.dir == .Outgoing + ? historyViewModel.selectedCall!.toAddress! + : historyViewModel.selectedCall!.fromAddress! + + let friendIndex = MagicSearchSingleton.shared.lastSearch.firstIndex(where: {$0.friend!.addresses.contains(where: {$0.asStringUriOnly() == addressCall.asStringUriOnly()})}) + if friendIndex != nil { + withAnimation { + contactViewModel.indexDisplayedFriend = friendIndex + } + } + } else { + let addressCall = historyViewModel.selectedCall != nil && historyViewModel.selectedCall!.dir == .Outgoing + ? historyViewModel.selectedCall!.toAddress! + : historyViewModel.selectedCall!.fromAddress! + + withAnimation { + isShowEditContactFragment.toggle() + editContactViewModel.sipAddresses.removeAll() + editContactViewModel.sipAddresses.append(String(addressCall.asStringUriOnly().dropFirst(4))) + editContactViewModel.sipAddresses.append("") + } + } + } label: { + HStack { + if ContactsManager.shared.getFriendWithAddress( + address: historyViewModel.selectedCall != nil && historyViewModel.selectedCall!.dir == .Outgoing + ? historyViewModel.selectedCall!.toAddress! + : historyViewModel.selectedCall!.fromAddress! + ) != nil { + Image("user-circle") + .renderingMode(.template) + .resizable() + .foregroundStyle(Color.grayMain2c500) + .frame(width: 25, height: 25, alignment: .leading) + 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) + 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!.dir == .Outgoing { + UIPasteboard.general.setValue( + historyViewModel.selectedCall!.toAddress!.asStringUriOnly().dropFirst(4), + forPasteboardType: UTType.plainText.identifier + ) + } else { + UIPasteboard.general.setValue( + historyViewModel.selectedCall!.fromAddress!.asStringUriOnly().dropFirst(4), + forPasteboardType: UTType.plainText.identifier + ) + } + + if #available(iOS 16.0, *) { + if idiom != .pad { + showingSheet.toggle() + } else { + showingSheet.toggle() + dismiss() + } + } else { + showingSheet.toggle() + dismiss() + } + } label: { + HStack { + Image("copy") + .renderingMode(.template) + .resizable() + .foregroundStyle(Color.grayMain2c500) + .frame(width: 25, height: 25, alignment: .leading) + 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 { + CoreContext.shared.doOnCoreQueue { core in + if historyViewModel.selectedCall != nil { + core.removeCallLog(callLog: historyViewModel.selectedCall!) + historyListViewModel.removeCallLog(callLog: 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) + 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..f370c3352 --- /dev/null +++ b/Linphone/UI/Main/History/Fragments/HistoryListFragment.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 + +struct HistoryListFragment: View { + + @ObservedObject var historyListViewModel: HistoryListViewModel + @ObservedObject var historyViewModel: HistoryViewModel + + @Binding var showingSheet: Bool + + var body: some View { + VStack { + List { + ForEach(0.. 1 + ? historyListViewModel.callLogs[index].toAddress!.displayName!.components(separatedBy: " ")[1] + : "")) + .resizable() + .frame(width: 45, height: 45) + .clipShape(Circle()) + + } else { + Image(uiImage: ContactsManager.shared.textToImage( + firstName: historyListViewModel.callLogs[index].toAddress!.username ?? "Username Error", + lastName: historyListViewModel.callLogs[index].toAddress!.username!.components(separatedBy: " ").count > 1 + ? historyListViewModel.callLogs[index].toAddress!.username!.components(separatedBy: " ")[1] + : "")) + .resizable() + .frame(width: 45, height: 45) + .clipShape(Circle()) + } + + } else if historyListViewModel.callLogs[index].fromAddress != nil { + if historyListViewModel.callLogs[index].fromAddress!.displayName != nil { + Image(uiImage: ContactsManager.shared.textToImage( + firstName: historyListViewModel.callLogs[index].fromAddress!.displayName!, + lastName: historyListViewModel.callLogs[index].fromAddress!.displayName!.components(separatedBy: " ").count > 1 + ? historyListViewModel.callLogs[index].fromAddress!.displayName!.components(separatedBy: " ")[1] + : "")) + .resizable() + .frame(width: 45, height: 45) + .clipShape(Circle()) + } else { + Image(uiImage: ContactsManager.shared.textToImage( + firstName: historyListViewModel.callLogs[index].fromAddress!.username ?? "Username Error", + lastName: historyListViewModel.callLogs[index].fromAddress!.username!.components(separatedBy: " ").count > 1 + ? historyListViewModel.callLogs[index].fromAddress!.username!.components(separatedBy: " ")[1] + : "")) + .resizable() + .frame(width: 45, height: 45) + .clipShape(Circle()) + } + } + } + + VStack(spacing: 0) { + Spacer() + + let fromAddressFriend = ContactsManager.shared.getFriendWithAddress(address: historyListViewModel.callLogs[index].fromAddress!) + let toAddressFriend = ContactsManager.shared.getFriendWithAddress(address: historyListViewModel.callLogs[index].toAddress!) + + if historyListViewModel.callLogs[index].dir == .Incoming && fromAddressFriend != nil { + Text(fromAddressFriend!.name!) + .default_text_style(styleSize: 14) + .frame(maxWidth: .infinity, alignment: .leading) + .lineLimit(1) + } else if historyListViewModel.callLogs[index].dir == .Outgoing && toAddressFriend != nil { + Text(toAddressFriend!.name!) + .default_text_style(styleSize: 14) + .frame(maxWidth: .infinity, alignment: .leading) + .lineLimit(1) + } else { + if historyListViewModel.callLogs[index].dir == .Outgoing && historyListViewModel.callLogs[index].toAddress != nil { + Text(historyListViewModel.callLogs[index].toAddress!.displayName != nil + ? historyListViewModel.callLogs[index].toAddress!.displayName! + : historyListViewModel.callLogs[index].toAddress!.username!) + .default_text_style(styleSize: 14) + .frame(maxWidth: .infinity, alignment: .leading) + .lineLimit(1) + } else if historyListViewModel.callLogs[index].fromAddress != nil { + Text(historyListViewModel.callLogs[index].fromAddress!.displayName != nil + ? historyListViewModel.callLogs[index].fromAddress!.displayName! + : historyListViewModel.callLogs[index].fromAddress!.username!) + .default_text_style(styleSize: 14) + .frame(maxWidth: .infinity, alignment: .leading) + .lineLimit(1) + } + } + HStack { + Image(historyListViewModel.getCallIconResId(callStatus: historyListViewModel.callLogs[index].status, callDir: historyListViewModel.callLogs[index].dir)) + .resizable() + .frame( + width: historyListViewModel.getCallIconResId(callStatus: historyListViewModel.callLogs[index].status, callDir: historyListViewModel.callLogs[index].dir).contains("rejected") ? 12 : 8, + height: historyListViewModel.getCallIconResId(callStatus: historyListViewModel.callLogs[index].status, callDir: historyListViewModel.callLogs[index].dir).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() + } + + Image("phone") + .resizable() + .frame(width: 25, height: 25) + .padding(.trailing, 5) + } + } + .buttonStyle(.borderless) + .listRowInsets(EdgeInsets(top: 5, leading: 20, bottom: 5, trailing: 20)) + .listRowSeparator(.hidden) + .simultaneousGesture( + LongPressGesture() + .onEnded { _ in + historyViewModel.selectedCall = historyListViewModel.callLogs[index] + showingSheet.toggle() + } + ) + .highPriorityGesture( + TapGesture() + .onEnded { _ in + withAnimation { + //historyViewModel.indexDisplayedCall = index + } + } + ) + } + } + .listStyle(.plain) + .overlay( + VStack { + if historyListViewModel.callLogs.isEmpty { + Spacer() + Image("illus-belledonne") + .resizable() + .scaledToFit() + .clipped() + .padding(.all) + Text("No call for the moment...") + .default_text_style_800(styleSize: 16) + Spacer() + Spacer() + } + } + .padding(.all) + ) + } + } +} + +#Preview { + HistoryListFragment(historyListViewModel: HistoryListViewModel(), historyViewModel: HistoryViewModel(), showingSheet: .constant(false)) +} diff --git a/Linphone/UI/Main/History/HistoryView.swift b/Linphone/UI/Main/History/HistoryView.swift index 25d6b0b1e..a308d4688 100644 --- a/Linphone/UI/Main/History/HistoryView.swift +++ b/Linphone/UI/Main/History/HistoryView.swift @@ -21,22 +21,37 @@ import SwiftUI 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 isShowEditContactFragment: Bool + var body: some View { NavigationView { - VStack(spacing: 0) { - VStack { - Spacer() - Image("illus-belledonne") - .resizable() - .scaledToFit() - .clipped() - .padding(.all) - Text("No calls for the moment...") - .default_text_style_800(styleSize: 16) - Spacer() - Spacer() + ZStack(alignment: .bottomTrailing) { + HistoryFragment( + historyListViewModel: historyListViewModel, + historyViewModel: historyViewModel, + contactViewModel: contactViewModel, + editContactViewModel: editContactViewModel, + index: $index, + isShowEditContactFragment: $isShowEditContactFragment + ) + + Button { + + } label: { + Image("phone-plus") + .padding() + .background(.white) + .clipShape(Circle()) + .shadow(color: .black.opacity(0.2), radius: 4) + } - .padding(.all) + .padding() } } .navigationViewStyle(.stack) @@ -44,5 +59,12 @@ struct HistoryView: View { } #Preview { - HistoryView() + HistoryFragment( + historyListViewModel: HistoryListViewModel(), + historyViewModel: HistoryViewModel(), + contactViewModel: ContactViewModel(), + editContactViewModel: EditContactViewModel(), + index: .constant(1), + isShowEditContactFragment: .constant(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..94d57a8d6 --- /dev/null +++ b/Linphone/UI/Main/History/ViewModel/HistoryListViewModel.swift @@ -0,0 +1,142 @@ +/* + * Copyright (c) 2010-2023 Belledonne Communications SARL. + * + * This file is part of linphone-iphone + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import linphonesw + +class HistoryListViewModel: ObservableObject { + + private var coreContext = CoreContext.shared + + @Published var callLogs: [CallLog] = [] + var callLogsTmp: [CallLog] = [] + + init() { + computeCallLogsList() + } + + func computeCallLogsList() { + coreContext.doOnCoreQueue { core in + let account = core.defaultAccount + let logs = account?.callLogs != nil ? account!.callLogs : core.callLogs + + self.callLogs.removeAll() + self.callLogsTmp.removeAll() + + DispatchQueue.main.async { + logs.forEach { log in + self.callLogs.append(log) + self.callLogsTmp.append(log) + } + } + } + } + + func getCallIconResId(callStatus: Call.Status, callDir: Call.Dir) -> String { + switch callStatus { + case Call.Status.Missed: + if callDir == .Outgoing { + "outgoing-call-missed" + } else { + "incoming-call-missed" + } + + case Call.Status.Success: + if callDir == .Outgoing { + "outgoing-call" + } else { + "incoming-call" + } + + default: + if callDir == .Outgoing { + "outgoing-call-rejected" + } else { + "incoming-call-rejected" + } + } + } + + 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 " + 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.dir == .Outgoing && callLog.toAddress != nil { + if callLog.toAddress!.username != nil && callLog.toAddress!.username!.contains(filter) { + callLogs.append(callLog) + } else if callLog.toAddress!.displayName != nil && callLog.toAddress!.displayName!.contains(filter) { + callLogs.append(callLog) + } + } else if callLog.fromAddress != nil { + if callLog.fromAddress!.username != nil && callLog.fromAddress!.username!.contains(filter) { + callLogs.append(callLog) + } else if callLog.fromAddress!.displayName != nil && callLog.fromAddress!.displayName!.contains(filter) { + callLogs.append(callLog) + } + } + } + } + + func resetFilterCallLogs() { + callLogs = callLogsTmp + } + + func removeCallLogs() { + coreContext.doOnCoreQueue { core in + let account = core.defaultAccount + if account != nil { + account!.clearCallLogs() + } else { + core.clearCallLogs() + } + self.callLogs.removeAll() + self.callLogsTmp.removeAll() + } + } + + func removeCallLog(callLog: CallLog) { + let index = self.callLogs.firstIndex(where: {$0.callId == callLog.callId}) + self.callLogs.remove(at: index!) + + let indexTmp = self.callLogsTmp.firstIndex(where: {$0.callId == callLog.callId}) + self.callLogsTmp.remove(at: index!) + } +} diff --git a/Linphone/UI/Main/History/ViewModel/HistoryViewModel.swift b/Linphone/UI/Main/History/ViewModel/HistoryViewModel.swift index 3222b6a0f..850a2f2ab 100644 --- a/Linphone/UI/Main/History/ViewModel/HistoryViewModel.swift +++ b/Linphone/UI/Main/History/ViewModel/HistoryViewModel.swift @@ -18,10 +18,13 @@ */ import Foundation +import linphonesw class HistoryViewModel: ObservableObject { - @Published var historyTitle: String = "" + @Published var indexDisplayedCall: Int? + + var selectedCall: CallLog? init() {} } diff --git a/Linphone/Utils/EditContactController.swift b/Linphone/Utils/EditContactController.swift index b3a9d250e..b29718a4e 100644 --- a/Linphone/Utils/EditContactController.swift +++ b/Linphone/Utils/EditContactController.swift @@ -53,7 +53,7 @@ struct EditContactView: UIViewControllerRepresentable { name: cnc.givenName + cnc.familyName + String(Int.random(in: 1...1000)) + ((imageThumbnail == nil) ? "-default" : ""), contact: newContact, linphoneFriend: false, - existingFriend: ContactsManager.shared.getFriend(contact: newContact)) + existingFriend: ContactsManager.shared.getFriendWithContact(contact: newContact)) MagicSearchSingleton.shared.searchForContacts(sourceFlags: MagicSearch.Source.Friends.rawValue | MagicSearch.Source.LdapServers.rawValue) } From 375c8b0ce6f0f1d93a55251e6a845c6079b496ed Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Thu, 16 Nov 2023 16:59:10 +0100 Subject: [PATCH 040/486] Add history call detail --- Linphone/Localizable.xcstrings | 6 +- Linphone/UI/Main/ContentView.swift | 9 +- .../Fragments/HistoryContactFragment.swift | 409 +++++++++++++++++- .../Fragments/HistoryListFragment.swift | 40 +- .../History/ViewModel/HistoryViewModel.swift | 2 +- 5 files changed, 418 insertions(+), 48 deletions(-) diff --git a/Linphone/Localizable.xcstrings b/Linphone/Localizable.xcstrings index 695ac7218..dda26ce71 100644 --- a/Linphone/Localizable.xcstrings +++ b/Linphone/Localizable.xcstrings @@ -145,6 +145,9 @@ }, "Block the number" : { + }, + "Call history" : { + }, "Calls" : { @@ -253,9 +256,6 @@ }, "First name*" : { - }, - "History Contact fragment" : { - }, "I prefere create an account" : { diff --git a/Linphone/UI/Main/ContentView.swift b/Linphone/UI/Main/ContentView.swift index 6c677de8a..62f76caca 100644 --- a/Linphone/UI/Main/ContentView.swift +++ b/Linphone/UI/Main/ContentView.swift @@ -61,6 +61,7 @@ struct ContentView: View { Spacer() Button(action: { self.index = 0 + historyViewModel.displayedCall = nil }, label: { VStack { Image("address-book") @@ -178,7 +179,6 @@ struct ContentView: View { Button(role: .destructive) { isMenuOpen = false isShowDeleteAllHistoryPopup.toggle() - //historyListViewModel.removeCallLogs() } label: { HStack { Text("Delete all history") @@ -353,6 +353,7 @@ struct ContentView: View { Spacer() Button(action: { self.index = 0 + historyViewModel.displayedCall = nil }, label: { VStack { Image("address-book") @@ -405,7 +406,7 @@ struct ContentView: View { } } - if contactViewModel.indexDisplayedFriend != nil || historyViewModel.indexDisplayedCall != nil { + if contactViewModel.indexDisplayedFriend != nil || historyViewModel.displayedCall != nil { HStack(spacing: 0) { Spacer() .frame(maxWidth: @@ -426,7 +427,7 @@ struct ContentView: View { .background(Color.gray100) .ignoresSafeArea(.keyboard) } else if self.index == 1 { - HistoryContactFragment() + HistoryContactFragment(historyViewModel: historyViewModel, isShowDeleteAllHistoryPopup: $isShowDeleteAllHistoryPopup) .frame(maxWidth: .infinity) .background(Color.gray100) .ignoresSafeArea(.keyboard) @@ -581,7 +582,7 @@ struct ContentView: View { } } .onRotate { newOrientation in - if (contactViewModel.indexDisplayedFriend != nil || historyViewModel.indexDisplayedCall != nil) && searchIsActive { + if (contactViewModel.indexDisplayedFriend != nil || historyViewModel.displayedCall != nil) && searchIsActive { self.focusedField = false } else if searchIsActive { self.focusedField = true diff --git a/Linphone/UI/Main/History/Fragments/HistoryContactFragment.swift b/Linphone/UI/Main/History/Fragments/HistoryContactFragment.swift index ef7dc3419..97489fb0b 100644 --- a/Linphone/UI/Main/History/Fragments/HistoryContactFragment.swift +++ b/Linphone/UI/Main/History/Fragments/HistoryContactFragment.swift @@ -20,15 +20,410 @@ import SwiftUI struct HistoryContactFragment: View { - var body: some View { - VStack { - Spacer() - Text("History Contact fragment") - Spacer() + + @State private var orientation = UIDevice.current.orientation + + @ObservedObject var sharedMainViewModel = SharedMainViewModel() + @ObservedObject var historyViewModel: HistoryViewModel + + @State var isMenuOpen = false + + @Binding var isShowDeleteAllHistoryPopup: Bool + + var body: some View { + NavigationView { + 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(.top, 2) + .onTapGesture { + withAnimation { + historyViewModel.displayedCall = nil + } + } + } + + Text("Call history") + .default_text_style_orange_800(styleSize: 20) + + Spacer() + + Menu { + Button { + isMenuOpen = false + } label: { + HStack { + Text("See all") + Spacer() + Image("green-check") + .resizable() + .frame(width: 25, height: 25, alignment: .leading) + } + } + + Button { + isMenuOpen = false + } label: { + HStack { + Text("See Linphone contact") + Spacer() + Image("green-check") + .resizable() + .frame(width: 25, height: 25, alignment: .leading) + } + } + + Button(role: .destructive) { + isMenuOpen = false + } label: { + HStack { + Text("Delete all history") + Spacer() + Image("trash-simple-red") + .resizable() + .frame(width: 25, height: 25, alignment: .leading) + } + } + } label: { + Image("dots-three-vertical") + .renderingMode(.template) + .resizable() + .foregroundStyle(Color.orangeMain500) + .frame(width: 25, height: 25, alignment: .leading) + } + .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) { + VStack(spacing: 0) { + + let fromAddressFriend = historyViewModel.displayedCall != nil ? ContactsManager.shared.getFriendWithAddress(address: historyViewModel.displayedCall!.fromAddress!) : nil + let toAddressFriend = historyViewModel.displayedCall != nil ? ContactsManager.shared.getFriendWithAddress(address: historyViewModel.displayedCall!.toAddress!) : nil + let addressFriend = historyViewModel.displayedCall != nil ? (historyViewModel.displayedCall!.dir == .Incoming ? fromAddressFriend : toAddressFriend) : nil + + if historyViewModel.displayedCall != nil + && addressFriend != nil + && addressFriend!.photo != nil + && !addressFriend!.photo!.isEmpty { + AsyncImage( + url: ContactsManager.shared.getImagePath( + friendPhotoPath: addressFriend!.photo!)) { image in + switch image { + case .empty: + ProgressView() + .frame(width: 100, height: 100) + case .success(let image): + image + .resizable() + .aspectRatio(contentMode: .fill) + .frame(width: 100, height: 100) + .clipShape(Circle()) + case .failure: + Image("profil-picture-default") + .resizable() + .frame(width: 100, height: 100) + .clipShape(Circle()) + @unknown default: + EmptyView() + } + } + } else if historyViewModel.displayedCall != nil { + if historyViewModel.displayedCall!.dir == .Outgoing && historyViewModel.displayedCall!.toAddress != nil { + if historyViewModel.displayedCall!.toAddress!.displayName != nil { + Image(uiImage: ContactsManager.shared.textToImage( + firstName: historyViewModel.displayedCall!.toAddress!.displayName!, + lastName: historyViewModel.displayedCall!.toAddress!.displayName!.components(separatedBy: " ").count > 1 + ? historyViewModel.displayedCall!.toAddress!.displayName!.components(separatedBy: " ")[1] + : "")) + .resizable() + .frame(width: 100, height: 100) + .clipShape(Circle()) + + Text(historyViewModel.displayedCall!.toAddress!.displayName!) + .foregroundStyle(Color.grayMain2c700) + .multilineTextAlignment(.center) + .default_text_style(styleSize: 14) + .frame(maxWidth: .infinity) + .padding(.top, 10) + + Text("") + .multilineTextAlignment(.center) + .default_text_style_300(styleSize: 12) + .frame(maxWidth: .infinity) + .frame(height: 20) + } else { + Image(uiImage: ContactsManager.shared.textToImage( + firstName: historyViewModel.displayedCall!.toAddress!.username ?? "Username Error", + lastName: historyViewModel.displayedCall!.toAddress!.username!.components(separatedBy: " ").count > 1 + ? historyViewModel.displayedCall!.toAddress!.username!.components(separatedBy: " ")[1] + : "")) + .resizable() + .frame(width: 100, height: 100) + .clipShape(Circle()) + + Text(historyViewModel.displayedCall!.toAddress!.username ?? "Username Error") + .foregroundStyle(Color.grayMain2c700) + .multilineTextAlignment(.center) + .default_text_style(styleSize: 14) + .frame(maxWidth: .infinity) + .padding(.top, 10) + + Text("") + .multilineTextAlignment(.center) + .default_text_style_300(styleSize: 12) + .frame(maxWidth: .infinity) + .frame(height: 20) + } + + } else if historyViewModel.displayedCall!.fromAddress != nil { + if historyViewModel.displayedCall!.fromAddress!.displayName != nil { + Image(uiImage: ContactsManager.shared.textToImage( + firstName: historyViewModel.displayedCall!.fromAddress!.displayName!, + lastName: historyViewModel.displayedCall!.fromAddress!.displayName!.components(separatedBy: " ").count > 1 + ? historyViewModel.displayedCall!.fromAddress!.displayName!.components(separatedBy: " ")[1] + : "")) + .resizable() + .frame(width: 100, height: 100) + .clipShape(Circle()) + + Text(historyViewModel.displayedCall!.fromAddress!.displayName!) + .foregroundStyle(Color.grayMain2c700) + .multilineTextAlignment(.center) + .default_text_style(styleSize: 14) + .frame(maxWidth: .infinity) + .padding(.top, 10) + + Text("") + .multilineTextAlignment(.center) + .default_text_style_300(styleSize: 12) + .frame(maxWidth: .infinity) + .frame(height: 20) + } else { + Image(uiImage: ContactsManager.shared.textToImage( + firstName: historyViewModel.displayedCall!.fromAddress!.username ?? "Username Error", + lastName: historyViewModel.displayedCall!.fromAddress!.username!.components(separatedBy: " ").count > 1 + ? historyViewModel.displayedCall!.fromAddress!.username!.components(separatedBy: " ")[1] + : "")) + .resizable() + .frame(width: 100, height: 100) + .clipShape(Circle()) + + Text(historyViewModel.displayedCall!.fromAddress!.username ?? "Username Error") + .foregroundStyle(Color.grayMain2c700) + .multilineTextAlignment(.center) + .default_text_style(styleSize: 14) + .frame(maxWidth: .infinity) + .padding(.top, 10) + + Text("") + .multilineTextAlignment(.center) + .default_text_style_300(styleSize: 12) + .frame(maxWidth: .infinity) + .frame(height: 20) + } + } + } + if historyViewModel.displayedCall != nil + && addressFriend != nil + && addressFriend!.name != nil { + Text((addressFriend!.name)!) + .foregroundStyle(Color.grayMain2c700) + .multilineTextAlignment(.center) + .default_text_style(styleSize: 14) + .frame(maxWidth: .infinity) + .padding(.top, 10) + + Text("En ligne") + .foregroundStyle(Color.greenSuccess500) + .multilineTextAlignment(.center) + .default_text_style_300(styleSize: 12) + .frame(maxWidth: .infinity) + .frame(height: 20) + } + } + .frame(minHeight: 150) + .frame(maxWidth: .infinity) + .padding(.top, 10) + .background(Color.gray100) + + HStack { + Spacer() + + Button(action: { + }, label: { + VStack { + HStack(alignment: .center) { + Image("phone") + .renderingMode(.template) + .resizable() + .foregroundStyle(Color.grayMain2c600) + .frame(width: 25, height: 25) + .onTapGesture { + withAnimation { + + } + } + } + .padding(16) + .background(Color.grayMain2c200) + .cornerRadius(40) + + Text("Appel") + .default_text_style(styleSize: 14) + } + }) + + Spacer() + + Button(action: { + + }, label: { + VStack { + HStack(alignment: .center) { + Image("chat-teardrop-text") + .renderingMode(.template) + .resizable() + .foregroundStyle(Color.grayMain2c600) + .frame(width: 25, height: 25) + .onTapGesture { + withAnimation { + + } + } + } + .padding(16) + .background(Color.grayMain2c200) + .cornerRadius(40) + + Text("Message") + .default_text_style(styleSize: 14) + } + }) + + Spacer() + + Button(action: { + + }, label: { + VStack { + HStack(alignment: .center) { + Image("video-camera") + .renderingMode(.template) + .resizable() + .foregroundStyle(Color.grayMain2c600) + .frame(width: 25, height: 25) + .onTapGesture { + withAnimation { + + } + } + } + .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) + + VStack(spacing: 0) { + + let fromAddressFriend = historyViewModel.displayedCall != nil ? ContactsManager.shared.getFriendWithAddress(address: historyViewModel.displayedCall!.fromAddress!) : nil + let toAddressFriend = historyViewModel.displayedCall != nil ? ContactsManager.shared.getFriendWithAddress(address: historyViewModel.displayedCall!.toAddress!) : nil + let addressFriend = historyViewModel.displayedCall != nil ? (historyViewModel.displayedCall!.dir == .Incoming ? fromAddressFriend : toAddressFriend) : nil + + if historyViewModel.displayedCall != nil && addressFriend != nil && addressFriend != nil { + ForEach(0.. Date: Mon, 20 Nov 2023 13:29:22 +0100 Subject: [PATCH 041/486] Converting SharedMainViewModel class into singleton --- Linphone/Core/CoreContext.swift | 8 +++--- Linphone/LinphoneApp.swift | 10 ++++---- Linphone/Localizable.xcstrings | 6 +++++ Linphone/UI/Assistant/AssistantView.swift | 8 +++--- .../Assistant/Fragments/LoginFragment.swift | 10 ++++---- .../Fragments/PermissionsFragment.swift | 4 +-- .../Fragments/ProfileModeFragment.swift | 6 ++--- .../Fragments/QrCodeScannerFragment.swift | 3 ++- .../ThirdPartySipAccountLoginFragment.swift | 4 +-- .../ThirdPartySipAccountWarningFragment.swift | 6 ++--- .../UI/Assistant/Viewmodel/QRScanner.swift | 5 ++-- .../Fragments/ContactInnerFragment.swift | 2 +- .../Fragments/ContactListBottomSheet.swift | 4 +++ .../Fragments/EditContactFragment.swift | 2 +- Linphone/UI/Main/ContentView.swift | 6 ++--- .../UI/Main/Fragments/PopupLoadingView.swift | 4 +-- Linphone/UI/Main/Fragments/PopupView.swift | 4 +-- Linphone/UI/Main/Fragments/ToastView.swift | 25 +++++++++++++++---- .../Fragments/HistoryContactFragment.swift | 4 ++- .../Fragments/HistoryListBottomSheet.swift | 5 ++++ Linphone/UI/Main/History/HistoryView.swift | 2 +- .../ViewModel/HistoryListViewModel.swift | 11 ++++++-- .../Main/Viewmodel/SharedMainViewModel.swift | 6 ++++- Linphone/UI/Welcome/WelcomeView.swift | 8 +++--- 24 files changed, 99 insertions(+), 54 deletions(-) diff --git a/Linphone/Core/CoreContext.swift b/Linphone/Core/CoreContext.swift index 350fb6a0d..386b9f07b 100644 --- a/Linphone/Core/CoreContext.swift +++ b/Linphone/Core/CoreContext.swift @@ -24,11 +24,11 @@ import Combine 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 toastMessage: String = "" @Published var defaultAccount: Account? private var mCore: Core! @@ -71,9 +71,9 @@ final class CoreContext: ObservableObject { self.mCore.publisher?.onConfiguringStatus?.postOnMainQueue { (cbVal: (core: Core, status: Config.ConfiguringState, message: String)) in NSLog("New configuration state is \(cbVal.status) = \(cbVal.message)\n") if cbVal.status == Config.ConfiguringState.Successful { - self.toastMessage = "Successful" + self.sharedMainViewModel.toastMessage = "Successful" } else { - self.toastMessage = "Failed" + self.sharedMainViewModel.toastMessage = "Failed" } } @@ -90,7 +90,7 @@ final class CoreContext: ObservableObject { } else if cbVal.state == .Progress { self.loggingInProgress = true } else { - self.toastMessage = "Registration failed" + self.sharedMainViewModel.toastMessage = "Registration failed" self.loggingInProgress = false self.loggedIn = false } diff --git a/Linphone/LinphoneApp.swift b/Linphone/LinphoneApp.swift index 35b3a538e..52b399973 100644 --- a/Linphone/LinphoneApp.swift +++ b/Linphone/LinphoneApp.swift @@ -23,7 +23,7 @@ import SwiftUI struct LinphoneApp: App { @ObservedObject private var coreContext = CoreContext.shared - @ObservedObject private var sharedMainViewModel = SharedMainViewModel() + @ObservedObject private var sharedMainViewModel = SharedMainViewModel.shared @State private var isActive = false @@ -31,10 +31,10 @@ struct LinphoneApp: App { WindowGroup { if isActive { if !sharedMainViewModel.welcomeViewDisplayed { - WelcomeView(sharedMainViewModel: sharedMainViewModel) + WelcomeView() } else if coreContext.defaultAccount == nil || sharedMainViewModel.displayProfileMode { - AssistantView(sharedMainViewModel: sharedMainViewModel) - .toast(isShowing: $coreContext.toastMessage) + AssistantView() + .toast(isShowing: $sharedMainViewModel.toastMessage) } else if coreContext.defaultAccount != nil { ContentView( contactViewModel: ContactViewModel(), @@ -42,7 +42,7 @@ struct LinphoneApp: App { historyViewModel: HistoryViewModel(), historyListViewModel: HistoryListViewModel() ) - .toast(isShowing: $coreContext.toastMessage) + .toast(isShowing: $sharedMainViewModel.toastMessage) } } else { SplashScreen(isActive: $isActive) diff --git a/Linphone/Localizable.xcstrings b/Linphone/Localizable.xcstrings index 7965caa6a..8a1a3da17 100644 --- a/Linphone/Localizable.xcstrings +++ b/Linphone/Localizable.xcstrings @@ -262,6 +262,9 @@ }, "First name*" : { + }, + "History has been deleted" : { + }, "I prefere create an account" : { @@ -414,6 +417,9 @@ }, "SIP address :" : { + }, + "SIP address copied into clipboard" : { + }, "sip.linphone.org" : { diff --git a/Linphone/UI/Assistant/AssistantView.swift b/Linphone/UI/Assistant/AssistantView.swift index 1819486d8..6c4418bcf 100644 --- a/Linphone/UI/Assistant/AssistantView.swift +++ b/Linphone/UI/Assistant/AssistantView.swift @@ -21,18 +21,18 @@ import SwiftUI struct AssistantView: View { - @ObservedObject var sharedMainViewModel: SharedMainViewModel + @ObservedObject private var sharedMainViewModel = SharedMainViewModel.shared @ObservedObject private var coreContext = CoreContext.shared var body: some View { if sharedMainViewModel.displayProfileMode && coreContext.loggedIn { - ProfileModeFragment(sharedMainViewModel: sharedMainViewModel) + ProfileModeFragment() } else { - LoginFragment(accountLoginViewModel: AccountLoginViewModel(), sharedMainViewModel: sharedMainViewModel) + LoginFragment(accountLoginViewModel: AccountLoginViewModel()) } } } #Preview { - LoginFragment(accountLoginViewModel: AccountLoginViewModel(), sharedMainViewModel: SharedMainViewModel()) + LoginFragment(accountLoginViewModel: AccountLoginViewModel()) } diff --git a/Linphone/UI/Assistant/Fragments/LoginFragment.swift b/Linphone/UI/Assistant/Fragments/LoginFragment.swift index 61677f584..8ae3e46e1 100644 --- a/Linphone/UI/Assistant/Fragments/LoginFragment.swift +++ b/Linphone/UI/Assistant/Fragments/LoginFragment.swift @@ -22,8 +22,8 @@ import SwiftUI struct LoginFragment: View { @ObservedObject private var coreContext = CoreContext.shared + @ObservedObject private var sharedMainViewModel = SharedMainViewModel.shared @ObservedObject var accountLoginViewModel: AccountLoginViewModel - @ObservedObject var sharedMainViewModel: SharedMainViewModel @State private var isSecured: Bool = true @@ -183,7 +183,7 @@ struct LoginFragment: View { .padding(.bottom) NavigationLink(isActive: $isLinkSIPActive, destination: { - ThirdPartySipAccountWarningFragment(sharedMainViewModel: sharedMainViewModel, accountLoginViewModel: accountLoginViewModel) + ThirdPartySipAccountWarningFragment(accountLoginViewModel: accountLoginViewModel) }, label: { Text("Use SIP Account") .default_text_style_orange_600(styleSize: 20) @@ -268,7 +268,7 @@ struct LoginFragment: View { let contentPopup3 = Text(" et ") let contentPopup4 = Text("[nos conditions d’utilisation](https://linphone.org/general-terms)").underline() let contentPopup5 = Text(".") - PopupView(sharedMainViewModel: sharedMainViewModel, isShowPopup: $isShowPopup, + PopupView(isShowPopup: $isShowPopup, title: Text("Conditions de service"), content: contentPopup1 + contentPopup2 + contentPopup3 + contentPopup4 + contentPopup5, titleFirstButton: Text("Deny all"), @@ -283,7 +283,7 @@ struct LoginFragment: View { } if coreContext.loggingInProgress { - PopupLoadingView(sharedMainViewModel: sharedMainViewModel) + PopupLoadingView() .background(.black.opacity(0.65)) } } @@ -306,5 +306,5 @@ struct LoginFragment: View { } #Preview { - LoginFragment(accountLoginViewModel: AccountLoginViewModel(), sharedMainViewModel: SharedMainViewModel()) + LoginFragment(accountLoginViewModel: AccountLoginViewModel()) } diff --git a/Linphone/UI/Assistant/Fragments/PermissionsFragment.swift b/Linphone/UI/Assistant/Fragments/PermissionsFragment.swift index efb732cd4..82f709bac 100644 --- a/Linphone/UI/Assistant/Fragments/PermissionsFragment.swift +++ b/Linphone/UI/Assistant/Fragments/PermissionsFragment.swift @@ -21,7 +21,7 @@ import SwiftUI struct PermissionsFragment: View { - @ObservedObject var sharedMainViewModel: SharedMainViewModel + @ObservedObject private var sharedMainViewModel = SharedMainViewModel.shared var permissionManager = PermissionManager.shared @@ -204,5 +204,5 @@ struct PermissionsFragment: View { } #Preview { - PermissionsFragment(sharedMainViewModel: SharedMainViewModel()) + PermissionsFragment() } diff --git a/Linphone/UI/Assistant/Fragments/ProfileModeFragment.swift b/Linphone/UI/Assistant/Fragments/ProfileModeFragment.swift index 0eec8a5e0..fbe34e463 100644 --- a/Linphone/UI/Assistant/Fragments/ProfileModeFragment.swift +++ b/Linphone/UI/Assistant/Fragments/ProfileModeFragment.swift @@ -21,7 +21,7 @@ import SwiftUI struct ProfileModeFragment: View { - @ObservedObject var sharedMainViewModel: SharedMainViewModel + @ObservedObject private var sharedMainViewModel = SharedMainViewModel.shared @State var options: Int = 1 @State private var isShowPopup = false @@ -142,7 +142,7 @@ struct ProfileModeFragment: View { } if self.isShowPopup { - PopupView(sharedMainViewModel: sharedMainViewModel, isShowPopup: $isShowPopup, + PopupView(isShowPopup: $isShowPopup, title: Text(isShowPopupForDefault ? "Default mode" : "Interoperable mode"), content: Text( isShowPopupForDefault @@ -167,5 +167,5 @@ struct ProfileModeFragment: View { } #Preview { - ProfileModeFragment(sharedMainViewModel: SharedMainViewModel()) + ProfileModeFragment() } diff --git a/Linphone/UI/Assistant/Fragments/QrCodeScannerFragment.swift b/Linphone/UI/Assistant/Fragments/QrCodeScannerFragment.swift index d5c9ad36e..31045592c 100644 --- a/Linphone/UI/Assistant/Fragments/QrCodeScannerFragment.swift +++ b/Linphone/UI/Assistant/Fragments/QrCodeScannerFragment.swift @@ -22,6 +22,7 @@ import SwiftUI struct QrCodeScannerFragment: View { @ObservedObject private var coreContext = CoreContext.shared + @ObservedObject private var sharedMainViewModel = SharedMainViewModel.shared @Environment(\.dismiss) var dismiss @@ -54,7 +55,7 @@ struct QrCodeScannerFragment: View { .edgesIgnoringSafeArea(.all) .navigationBarHidden(true) - if coreContext.toastMessage == "Successful" { + if sharedMainViewModel.toastMessage == "Successful" { ZStack { }.onAppear { diff --git a/Linphone/UI/Assistant/Fragments/ThirdPartySipAccountLoginFragment.swift b/Linphone/UI/Assistant/Fragments/ThirdPartySipAccountLoginFragment.swift index b8be41481..6bb7e3bd0 100644 --- a/Linphone/UI/Assistant/Fragments/ThirdPartySipAccountLoginFragment.swift +++ b/Linphone/UI/Assistant/Fragments/ThirdPartySipAccountLoginFragment.swift @@ -21,7 +21,7 @@ import SwiftUI struct ThirdPartySipAccountLoginFragment: View { - @ObservedObject var sharedMainViewModel: SharedMainViewModel + @ObservedObject private var sharedMainViewModel = SharedMainViewModel.shared @ObservedObject private var coreContext = CoreContext.shared @ObservedObject var accountLoginViewModel: AccountLoginViewModel @@ -233,5 +233,5 @@ struct ThirdPartySipAccountLoginFragment: View { } #Preview { - ThirdPartySipAccountLoginFragment(sharedMainViewModel: SharedMainViewModel(), accountLoginViewModel: AccountLoginViewModel()) + ThirdPartySipAccountLoginFragment(accountLoginViewModel: AccountLoginViewModel()) } diff --git a/Linphone/UI/Assistant/Fragments/ThirdPartySipAccountWarningFragment.swift b/Linphone/UI/Assistant/Fragments/ThirdPartySipAccountWarningFragment.swift index 3d85dcd02..a3aa14ad5 100644 --- a/Linphone/UI/Assistant/Fragments/ThirdPartySipAccountWarningFragment.swift +++ b/Linphone/UI/Assistant/Fragments/ThirdPartySipAccountWarningFragment.swift @@ -21,7 +21,7 @@ import SwiftUI struct ThirdPartySipAccountWarningFragment: View { - @ObservedObject var sharedMainViewModel: SharedMainViewModel + @ObservedObject private var sharedMainViewModel = SharedMainViewModel.shared @ObservedObject private var coreContext = CoreContext.shared @ObservedObject var accountLoginViewModel: AccountLoginViewModel @@ -152,7 +152,7 @@ struct ThirdPartySipAccountWarningFragment: View { .padding(.horizontal) NavigationLink(destination: { - ThirdPartySipAccountLoginFragment(sharedMainViewModel: sharedMainViewModel, accountLoginViewModel: accountLoginViewModel) + ThirdPartySipAccountLoginFragment(accountLoginViewModel: accountLoginViewModel) }, label: { Text("I understand") .default_text_style_white_600(styleSize: 20) @@ -178,5 +178,5 @@ struct ThirdPartySipAccountWarningFragment: View { } #Preview { - ThirdPartySipAccountWarningFragment(sharedMainViewModel: SharedMainViewModel(), accountLoginViewModel: AccountLoginViewModel()) + ThirdPartySipAccountWarningFragment(accountLoginViewModel: AccountLoginViewModel()) } diff --git a/Linphone/UI/Assistant/Viewmodel/QRScanner.swift b/Linphone/UI/Assistant/Viewmodel/QRScanner.swift index 5d546ef96..a7f3d2eeb 100644 --- a/Linphone/UI/Assistant/Viewmodel/QRScanner.swift +++ b/Linphone/UI/Assistant/Viewmodel/QRScanner.swift @@ -43,6 +43,7 @@ struct QRScanner: UIViewControllerRepresentable { class Coordinator: NSObject, AVCaptureMetadataOutputObjectsDelegate { private var coreContext = CoreContext.shared + private var sharedMainViewModel = SharedMainViewModel.shared @Binding var scanResult: String private var lastResult: String = "" @@ -76,10 +77,10 @@ class Coordinator: NSObject, AVCaptureMetadataOutputObjectsDelegate { try? core.start() } } else { - coreContext.toastMessage = "Invalide URI" + sharedMainViewModel.toastMessage = "Invalide URI" } } else { - coreContext.toastMessage = "Invalide URI" + sharedMainViewModel.toastMessage = "Invalide URI" } } } diff --git a/Linphone/UI/Main/Contacts/Fragments/ContactInnerFragment.swift b/Linphone/UI/Main/Contacts/Fragments/ContactInnerFragment.swift index ac6a8e5cd..84f5b996b 100644 --- a/Linphone/UI/Main/Contacts/Fragments/ContactInnerFragment.swift +++ b/Linphone/UI/Main/Contacts/Fragments/ContactInnerFragment.swift @@ -23,7 +23,7 @@ import ContactsUI struct ContactInnerFragment: View { - @ObservedObject private var sharedMainViewModel = SharedMainViewModel() + @ObservedObject private var sharedMainViewModel = SharedMainViewModel.shared @ObservedObject var magicSearch = MagicSearchSingleton.shared diff --git a/Linphone/UI/Main/Contacts/Fragments/ContactListBottomSheet.swift b/Linphone/UI/Main/Contacts/Fragments/ContactListBottomSheet.swift index bbf9ac0cc..a35158ec4 100644 --- a/Linphone/UI/Main/Contacts/Fragments/ContactListBottomSheet.swift +++ b/Linphone/UI/Main/Contacts/Fragments/ContactListBottomSheet.swift @@ -25,6 +25,7 @@ struct ContactListBottomSheet: View { private var idiom: UIUserInterfaceIdiom { UIDevice.current.userInterfaceIdiom } @ObservedObject var magicSearch = MagicSearchSingleton.shared + @ObservedObject private var sharedMainViewModel = SharedMainViewModel.shared @ObservedObject var contactViewModel: ContactViewModel @@ -68,6 +69,9 @@ struct ContactListBottomSheet: View { showingSheet.toggle() dismiss() } + + sharedMainViewModel.toastMessage = "Success_copied_into_clipboard" + } label: { HStack { Image("copy") diff --git a/Linphone/UI/Main/Contacts/Fragments/EditContactFragment.swift b/Linphone/UI/Main/Contacts/Fragments/EditContactFragment.swift index e7de05556..e82feb785 100644 --- a/Linphone/UI/Main/Contacts/Fragments/EditContactFragment.swift +++ b/Linphone/UI/Main/Contacts/Fragments/EditContactFragment.swift @@ -23,7 +23,7 @@ import linphonesw struct EditContactFragment: View { @ObservedObject var editContactViewModel: EditContactViewModel - @ObservedObject private var sharedMainViewModel = SharedMainViewModel() + @ObservedObject private var sharedMainViewModel = SharedMainViewModel.shared @Environment(\.dismiss) var dismiss diff --git a/Linphone/UI/Main/ContentView.swift b/Linphone/UI/Main/ContentView.swift index ad1d37600..8b7ea36b9 100644 --- a/Linphone/UI/Main/ContentView.swift +++ b/Linphone/UI/Main/ContentView.swift @@ -488,7 +488,7 @@ struct ContentView: View { } if isShowDeleteContactPopup { - PopupView(sharedMainViewModel: SharedMainViewModel(), isShowPopup: $isShowDeleteContactPopup, + PopupView(isShowPopup: $isShowDeleteContactPopup, title: Text( contactViewModel.selectedFriend != nil ? "Delete \(contactViewModel.selectedFriend!.name!)?" @@ -530,7 +530,7 @@ struct ContentView: View { } if isShowDeleteAllHistoryPopup { - PopupView(sharedMainViewModel: SharedMainViewModel(), isShowPopup: $isShowDeleteContactPopup, + 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"), @@ -552,7 +552,7 @@ struct ContentView: View { } if isShowDismissPopup { - PopupView(sharedMainViewModel: SharedMainViewModel(), isShowPopup: $isShowDismissPopup, + PopupView(isShowPopup: $isShowDismissPopup, title: Text("Don’t save modifications?"), content: Text("All modifications will be canceled."), titleFirstButton: Text("Cancel"), diff --git a/Linphone/UI/Main/Fragments/PopupLoadingView.swift b/Linphone/UI/Main/Fragments/PopupLoadingView.swift index 99f4f063e..0e1eb2aa6 100644 --- a/Linphone/UI/Main/Fragments/PopupLoadingView.swift +++ b/Linphone/UI/Main/Fragments/PopupLoadingView.swift @@ -21,7 +21,7 @@ import SwiftUI struct PopupLoadingView: View { - @ObservedObject var sharedMainViewModel: SharedMainViewModel + @ObservedObject private var sharedMainViewModel = SharedMainViewModel.shared var body: some View { GeometryReader { geometry in @@ -54,6 +54,6 @@ struct PopupLoadingView: View { } #Preview { - PopupLoadingView(sharedMainViewModel: SharedMainViewModel()) + PopupLoadingView() .background(.black.opacity(0.65)) } diff --git a/Linphone/UI/Main/Fragments/PopupView.swift b/Linphone/UI/Main/Fragments/PopupView.swift index dbfd53afa..ad4bb3dfd 100644 --- a/Linphone/UI/Main/Fragments/PopupView.swift +++ b/Linphone/UI/Main/Fragments/PopupView.swift @@ -22,7 +22,7 @@ import Photos struct PopupView: View { - @ObservedObject var sharedMainViewModel: SharedMainViewModel + @ObservedObject private var sharedMainViewModel = SharedMainViewModel.shared var permissionManager = PermissionManager.shared @@ -100,7 +100,7 @@ struct PopupView: View { } #Preview { - PopupView(sharedMainViewModel: SharedMainViewModel(), isShowPopup: .constant(true), + PopupView(isShowPopup: .constant(true), title: Text("Title"), content: Text("Content"), titleFirstButton: Text("Deny all"), diff --git a/Linphone/UI/Main/Fragments/ToastView.swift b/Linphone/UI/Main/Fragments/ToastView.swift index b24ba953e..4caf6acab 100644 --- a/Linphone/UI/Main/Fragments/ToastView.swift +++ b/Linphone/UI/Main/Fragments/ToastView.swift @@ -21,7 +21,7 @@ import SwiftUI struct ToastView: ViewModifier { - @ObservedObject var sharedMainViewModel: SharedMainViewModel + @ObservedObject private var sharedMainViewModel = SharedMainViewModel.shared @Binding var isShowing: String @@ -29,6 +29,7 @@ struct ToastView: ViewModifier { ZStack { content toastView + .padding(.top, 60) } } @@ -36,11 +37,11 @@ struct ToastView: ViewModifier { VStack { if !isShowing.isEmpty { HStack { - Image(isShowing == "Successful" ? "smiley" : "warning-circle") + Image(isShowing.contains("Success") ? "check" : "warning-circle") .resizable() .renderingMode(.template) .frame(width: 25, height: 25, alignment: .leading) - .foregroundStyle(isShowing == "Successful" ? Color.greenSuccess500 : Color.redDanger500) + .foregroundStyle(isShowing.contains("Success") ? Color.greenSuccess500 : Color.redDanger500) switch isShowing { case "Successful": @@ -50,6 +51,20 @@ struct ToastView: ViewModifier { .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_copied_into_clipboard": + Text("SIP address copied into clipboard") + .multilineTextAlignment(.center) + .foregroundStyle(Color.greenSuccess500) + .default_text_style(styleSize: 15) + .padding(8) + case "Failed": Text("Invalid QR code!") .multilineTextAlignment(.center) @@ -85,7 +100,7 @@ struct ToastView: ViewModifier { .overlay( RoundedRectangle(cornerRadius: 50) .inset(by: 0.5) - .stroke(isShowing == "Successful" ? Color.greenSuccess500 : Color.redDanger500, lineWidth: 1) + .stroke(isShowing.contains("Success") ? Color.greenSuccess500 : Color.redDanger500, lineWidth: 1) ) .onTapGesture { isShowing = "" @@ -108,6 +123,6 @@ struct ToastView: ViewModifier { extension View { func toast(isShowing: Binding) -> some View { - self.modifier(ToastView(sharedMainViewModel: SharedMainViewModel(), isShowing: isShowing)) + self.modifier(ToastView(isShowing: isShowing)) } } diff --git a/Linphone/UI/Main/History/Fragments/HistoryContactFragment.swift b/Linphone/UI/Main/History/Fragments/HistoryContactFragment.swift index 8062d5913..cb4916f6f 100644 --- a/Linphone/UI/Main/History/Fragments/HistoryContactFragment.swift +++ b/Linphone/UI/Main/History/Fragments/HistoryContactFragment.swift @@ -24,7 +24,7 @@ struct HistoryContactFragment: View { @State private var orientation = UIDevice.current.orientation - @ObservedObject var sharedMainViewModel = SharedMainViewModel() + @ObservedObject private var sharedMainViewModel = SharedMainViewModel.shared @ObservedObject var historyViewModel: HistoryViewModel @ObservedObject var historyListViewModel: HistoryListViewModel @ObservedObject var contactViewModel: ContactViewModel @@ -131,6 +131,8 @@ struct HistoryContactFragment: View { forPasteboardType: UTType.plainText.identifier ) } + + sharedMainViewModel.toastMessage = "Success_copied_into_clipboard" } label: { HStack { Text("Copy SIP address") diff --git a/Linphone/UI/Main/History/Fragments/HistoryListBottomSheet.swift b/Linphone/UI/Main/History/Fragments/HistoryListBottomSheet.swift index a9c9092c1..233c18f57 100644 --- a/Linphone/UI/Main/History/Fragments/HistoryListBottomSheet.swift +++ b/Linphone/UI/Main/History/Fragments/HistoryListBottomSheet.swift @@ -26,6 +26,8 @@ struct HistoryListBottomSheet: View { private var idiom: UIUserInterfaceIdiom { UIDevice.current.userInterfaceIdiom } + @ObservedObject private var sharedMainViewModel = SharedMainViewModel.shared + @ObservedObject var historyViewModel: HistoryViewModel @ObservedObject var contactViewModel: ContactViewModel @ObservedObject var editContactViewModel: EditContactViewModel @@ -161,6 +163,9 @@ struct HistoryListBottomSheet: View { showingSheet.toggle() dismiss() } + + sharedMainViewModel.toastMessage = "Success_copied_into_clipboard" + } label: { HStack { Image("copy") diff --git a/Linphone/UI/Main/History/HistoryView.swift b/Linphone/UI/Main/History/HistoryView.swift index a308d4688..8b4977b97 100644 --- a/Linphone/UI/Main/History/HistoryView.swift +++ b/Linphone/UI/Main/History/HistoryView.swift @@ -42,7 +42,7 @@ struct HistoryView: View { ) Button { - + SharedMainViewModel.shared.toastMessage = "Success_remove_call_logs" } label: { Image("phone-plus") .padding() diff --git a/Linphone/UI/Main/History/ViewModel/HistoryListViewModel.swift b/Linphone/UI/Main/History/ViewModel/HistoryListViewModel.swift index 8321523f6..4758564a2 100644 --- a/Linphone/UI/Main/History/ViewModel/HistoryListViewModel.swift +++ b/Linphone/UI/Main/History/ViewModel/HistoryListViewModel.swift @@ -155,8 +155,11 @@ class HistoryListViewModel: ObservableObject { } else { core.clearCallLogs() } - self.callLogs.removeAll() - self.callLogsTmp.removeAll() + + DispatchQueue.main.async { + self.callLogs.removeAll() + self.callLogsTmp.removeAll() + } } } else { removeCallLogsWithAddress() @@ -167,6 +170,10 @@ class HistoryListViewModel: ObservableObject { func removeCallLogsWithAddress() { self.callLogs.filter { $0.toAddress!.asStringUriOnly() == callLogsAddressToDelete || $0.fromAddress!.asStringUriOnly() == callLogsAddressToDelete }.forEach { callLog in removeCallLog(callLog: callLog) + + coreContext.doOnCoreQueue { core in + core.removeCallLog(callLog: callLog) + } } } diff --git a/Linphone/UI/Main/Viewmodel/SharedMainViewModel.swift b/Linphone/UI/Main/Viewmodel/SharedMainViewModel.swift index 6adcc0dcc..189962e19 100644 --- a/Linphone/UI/Main/Viewmodel/SharedMainViewModel.swift +++ b/Linphone/UI/Main/Viewmodel/SharedMainViewModel.swift @@ -21,6 +21,10 @@ import linphonesw class SharedMainViewModel: ObservableObject { + static let shared = SharedMainViewModel() + + @Published var toastMessage: String = "" + @Published var welcomeViewDisplayed = false @Published var generalTermsAccepted = false @Published var displayProfileMode = false @@ -31,7 +35,7 @@ class SharedMainViewModel: ObservableObject { var maxWidth = 400.0 - init() { + private init() { let preferences = UserDefaults.standard if preferences.object(forKey: welcomeViewKey) == nil { diff --git a/Linphone/UI/Welcome/WelcomeView.swift b/Linphone/UI/Welcome/WelcomeView.swift index 5a4d6e700..6e19048a4 100644 --- a/Linphone/UI/Welcome/WelcomeView.swift +++ b/Linphone/UI/Welcome/WelcomeView.swift @@ -21,7 +21,7 @@ import SwiftUI struct WelcomeView: View { - @ObservedObject var sharedMainViewModel: SharedMainViewModel + @ObservedObject private var sharedMainViewModel = SharedMainViewModel.shared @State private var index = 0 @@ -39,7 +39,7 @@ struct WelcomeView: View { VStack(alignment: .trailing) { NavigationLink(destination: { - PermissionsFragment(sharedMainViewModel: sharedMainViewModel) + PermissionsFragment() }, label: { Text("Skip") .underline() @@ -96,7 +96,7 @@ struct WelcomeView: View { if index == 2 { NavigationLink(destination: { - PermissionsFragment(sharedMainViewModel: sharedMainViewModel) + PermissionsFragment() }, label: { Text("Start") .default_text_style_white_600(styleSize: 20) @@ -158,5 +158,5 @@ struct WelcomeView: View { } #Preview { - WelcomeView(sharedMainViewModel: SharedMainViewModel()) + WelcomeView() } From 0142a9146bfdec4595ce154031001f413beaafdf Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Mon, 20 Nov 2023 17:00:19 +0100 Subject: [PATCH 042/486] Display toast when user copy adress or delete call logs in History views --- Linphone.xcodeproj/project.pbxproj | 4 ++ Linphone/Core/CoreContext.swift | 9 ++-- Linphone/LinphoneApp.swift | 2 - .../Fragments/QrCodeScannerFragment.swift | 4 +- .../UI/Assistant/Viewmodel/QRScanner.swift | 6 ++- .../Fragments/ContactListBottomSheet.swift | 3 +- Linphone/UI/Main/ContentView.swift | 9 ++++ Linphone/UI/Main/Fragments/ToastView.swift | 51 ++++++++----------- .../Fragments/HistoryContactFragment.swift | 4 +- .../Fragments/HistoryListBottomSheet.swift | 3 +- Linphone/UI/Main/History/HistoryView.swift | 1 - .../Main/Viewmodel/SharedMainViewModel.swift | 2 - .../UI/Main/Viewmodel/ToastViewModel.swift | 19 +++++++ 13 files changed, 73 insertions(+), 44 deletions(-) create mode 100644 Linphone/UI/Main/Viewmodel/ToastViewModel.swift diff --git a/Linphone.xcodeproj/project.pbxproj b/Linphone.xcodeproj/project.pbxproj index 71f3fce2e..64e88979f 100644 --- a/Linphone.xcodeproj/project.pbxproj +++ b/Linphone.xcodeproj/project.pbxproj @@ -46,6 +46,7 @@ D777DBB32AE12C5900565A99 /* ContactsManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = D777DBB22AE12C5900565A99 /* ContactsManager.swift */; }; D78290B82ADD3910004AA85C /* ContactsFragment.swift in Sources */ = {isa = PBXBuildFile; fileRef = D78290B72ADD3910004AA85C /* ContactsFragment.swift */; }; D78290BB2ADD40B2004AA85C /* ContactViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D78290BA2ADD40B2004AA85C /* ContactViewModel.swift */; }; + D796F2002B0BB61A0041115F /* ToastViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D796F1FF2B0BB61A0041115F /* ToastViewModel.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 */; }; @@ -117,6 +118,7 @@ D777DBB22AE12C5900565A99 /* ContactsManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactsManager.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 = ""; }; + D796F1FF2B0BB61A0041115F /* ToastViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToastViewModel.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 = ""; }; @@ -386,6 +388,7 @@ isa = PBXGroup; children = ( D7A2EDD52AC18115005D90FC /* SharedMainViewModel.swift */, + D796F1FF2B0BB61A0041115F /* ToastViewModel.swift */, ); path = Viewmodel; sourceTree = ""; @@ -529,6 +532,7 @@ D719ABC92ABC6FD700B41C10 /* CoreContext.swift in Sources */, D7EAACCF2AD6ED8000AA6A8A /* PermissionsFragment.swift in Sources */, D777DBB32AE12C5900565A99 /* ContactsManager.swift in Sources */, + D796F2002B0BB61A0041115F /* ToastViewModel.swift in Sources */, D7C3650A2AF001C300FE6142 /* EditContactFragment.swift in Sources */, D7A03FBD2ACC2DB60081A588 /* ContactsView.swift in Sources */, D719ABCF2ABC779A00B41C10 /* AccountLoginViewModel.swift in Sources */, diff --git a/Linphone/Core/CoreContext.swift b/Linphone/Core/CoreContext.swift index 386b9f07b..4a76095bc 100644 --- a/Linphone/Core/CoreContext.swift +++ b/Linphone/Core/CoreContext.swift @@ -71,9 +71,11 @@ final class CoreContext: ObservableObject { self.mCore.publisher?.onConfiguringStatus?.postOnMainQueue { (cbVal: (core: Core, status: Config.ConfiguringState, message: String)) in NSLog("New configuration state is \(cbVal.status) = \(cbVal.message)\n") if cbVal.status == Config.ConfiguringState.Successful { - self.sharedMainViewModel.toastMessage = "Successful" + ToastViewModel.shared.toastMessage = "Successful" + ToastViewModel.shared.displayToast.toggle() } else { - self.sharedMainViewModel.toastMessage = "Failed" + ToastViewModel.shared.toastMessage = "Failed" + ToastViewModel.shared.displayToast.toggle() } } @@ -90,7 +92,8 @@ final class CoreContext: ObservableObject { } else if cbVal.state == .Progress { self.loggingInProgress = true } else { - self.sharedMainViewModel.toastMessage = "Registration failed" + ToastViewModel.shared.toastMessage = "Registration failed" + ToastViewModel.shared.displayToast.toggle() self.loggingInProgress = false self.loggedIn = false } diff --git a/Linphone/LinphoneApp.swift b/Linphone/LinphoneApp.swift index 52b399973..6371e5bee 100644 --- a/Linphone/LinphoneApp.swift +++ b/Linphone/LinphoneApp.swift @@ -34,7 +34,6 @@ struct LinphoneApp: App { WelcomeView() } else if coreContext.defaultAccount == nil || sharedMainViewModel.displayProfileMode { AssistantView() - .toast(isShowing: $sharedMainViewModel.toastMessage) } else if coreContext.defaultAccount != nil { ContentView( contactViewModel: ContactViewModel(), @@ -42,7 +41,6 @@ struct LinphoneApp: App { historyViewModel: HistoryViewModel(), historyListViewModel: HistoryListViewModel() ) - .toast(isShowing: $sharedMainViewModel.toastMessage) } } else { SplashScreen(isActive: $isActive) diff --git a/Linphone/UI/Assistant/Fragments/QrCodeScannerFragment.swift b/Linphone/UI/Assistant/Fragments/QrCodeScannerFragment.swift index 31045592c..a12a98419 100644 --- a/Linphone/UI/Assistant/Fragments/QrCodeScannerFragment.swift +++ b/Linphone/UI/Assistant/Fragments/QrCodeScannerFragment.swift @@ -55,13 +55,15 @@ struct QrCodeScannerFragment: View { .edgesIgnoringSafeArea(.all) .navigationBarHidden(true) - if sharedMainViewModel.toastMessage == "Successful" { + /* + if $isShowToast { ZStack { }.onAppear { dismiss() } } + */ } } diff --git a/Linphone/UI/Assistant/Viewmodel/QRScanner.swift b/Linphone/UI/Assistant/Viewmodel/QRScanner.swift index a7f3d2eeb..014cf08f3 100644 --- a/Linphone/UI/Assistant/Viewmodel/QRScanner.swift +++ b/Linphone/UI/Assistant/Viewmodel/QRScanner.swift @@ -77,10 +77,12 @@ class Coordinator: NSObject, AVCaptureMetadataOutputObjectsDelegate { try? core.start() } } else { - sharedMainViewModel.toastMessage = "Invalide URI" + ToastViewModel.shared.toastMessage = "Invalide URI" + ToastViewModel.shared.displayToast.toggle() } } else { - sharedMainViewModel.toastMessage = "Invalide URI" + ToastViewModel.shared.toastMessage = "Invalide URI" + ToastViewModel.shared.displayToast.toggle() } } } diff --git a/Linphone/UI/Main/Contacts/Fragments/ContactListBottomSheet.swift b/Linphone/UI/Main/Contacts/Fragments/ContactListBottomSheet.swift index a35158ec4..0a883e3de 100644 --- a/Linphone/UI/Main/Contacts/Fragments/ContactListBottomSheet.swift +++ b/Linphone/UI/Main/Contacts/Fragments/ContactListBottomSheet.swift @@ -70,7 +70,8 @@ struct ContactListBottomSheet: View { dismiss() } - sharedMainViewModel.toastMessage = "Success_copied_into_clipboard" + ToastViewModel.shared.toastMessage = "Success_copied_into_clipboard" + ToastViewModel.shared.displayToast.toggle() } label: { HStack { diff --git a/Linphone/UI/Main/ContentView.swift b/Linphone/UI/Main/ContentView.swift index 8b7ea36b9..cdffd8cd0 100644 --- a/Linphone/UI/Main/ContentView.swift +++ b/Linphone/UI/Main/ContentView.swift @@ -26,6 +26,7 @@ struct ContentView: View { @Environment(\.scenePhase) var scenePhase @ObservedObject private var coreContext = CoreContext.shared + @ObservedObject private var sharedMainViewModel = SharedMainViewModel.shared var contactManager = ContactsManager.shared var magicSearch = MagicSearchSingleton.shared @@ -543,6 +544,9 @@ struct ContentView: View { 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) @@ -580,6 +584,11 @@ struct ContentView: View { self.isShowDismissPopup.toggle() } } + + //if sharedMainViewModel.displayToast { + ToastView() + .zIndex(3) + //} } } .overlay { diff --git a/Linphone/UI/Main/Fragments/ToastView.swift b/Linphone/UI/Main/Fragments/ToastView.swift index 4caf6acab..40d5e7e16 100644 --- a/Linphone/UI/Main/Fragments/ToastView.swift +++ b/Linphone/UI/Main/Fragments/ToastView.swift @@ -19,31 +19,21 @@ import SwiftUI -struct ToastView: ViewModifier { +struct ToastView: View { - @ObservedObject private var sharedMainViewModel = SharedMainViewModel.shared + @ObservedObject private var toastViewModel = ToastViewModel.shared - @Binding var isShowing: String - - func body(content: Content) -> some View { - ZStack { - content - toastView - .padding(.top, 60) - } - } - - private var toastView: some View { + var body: some View { VStack { - if !isShowing.isEmpty { + if toastViewModel.displayToast { HStack { - Image(isShowing.contains("Success") ? "check" : "warning-circle") + Image(toastViewModel.toastMessage.contains("Success") ? "check" : "warning-circle") .resizable() .renderingMode(.template) .frame(width: 25, height: 25, alignment: .leading) - .foregroundStyle(isShowing.contains("Success") ? Color.greenSuccess500 : Color.redDanger500) + .foregroundStyle(toastViewModel.toastMessage.contains("Success") ? Color.greenSuccess500 : Color.redDanger500) - switch isShowing { + switch toastViewModel.toastMessage { case "Successful": Text("QR code validated!") .multilineTextAlignment(.center) @@ -100,29 +90,30 @@ struct ToastView: ViewModifier { .overlay( RoundedRectangle(cornerRadius: 50) .inset(by: 0.5) - .stroke(isShowing.contains("Success") ? Color.greenSuccess500 : Color.redDanger500, lineWidth: 1) + .stroke(toastViewModel.toastMessage.contains("Success") ? Color.greenSuccess500 : Color.redDanger500, lineWidth: 1) ) .onTapGesture { - isShowing = "" + withAnimation { + toastViewModel.toastMessage = "" + toastViewModel.displayToast = false + } } .onAppear { DispatchQueue.main.asyncAfter(deadline: .now() + 2) { - isShowing = "" + withAnimation { + toastViewModel.toastMessage = "" + toastViewModel.displayToast = false + } } } + + Spacer() } - Spacer() } - .frame(maxWidth: sharedMainViewModel.maxWidth) + .frame(maxWidth: SharedMainViewModel.shared.maxWidth) .padding(.horizontal, 16) .padding(.bottom, 18) - .animation(.linear(duration: 0.3), value: isShowing) - .transition(.opacity) - } -} - -extension View { - func toast(isShowing: Binding) -> some View { - self.modifier(ToastView(isShowing: isShowing)) + .transition(.move(edge: .top)) + .padding(.top, 60) } } diff --git a/Linphone/UI/Main/History/Fragments/HistoryContactFragment.swift b/Linphone/UI/Main/History/Fragments/HistoryContactFragment.swift index cb4916f6f..07cc6a2a5 100644 --- a/Linphone/UI/Main/History/Fragments/HistoryContactFragment.swift +++ b/Linphone/UI/Main/History/Fragments/HistoryContactFragment.swift @@ -132,7 +132,9 @@ struct HistoryContactFragment: View { ) } - sharedMainViewModel.toastMessage = "Success_copied_into_clipboard" + ToastViewModel.shared.toastMessage = "Success_copied_into_clipboard" + ToastViewModel.shared.displayToast.toggle() + } label: { HStack { Text("Copy SIP address") diff --git a/Linphone/UI/Main/History/Fragments/HistoryListBottomSheet.swift b/Linphone/UI/Main/History/Fragments/HistoryListBottomSheet.swift index 233c18f57..e5a58b79d 100644 --- a/Linphone/UI/Main/History/Fragments/HistoryListBottomSheet.swift +++ b/Linphone/UI/Main/History/Fragments/HistoryListBottomSheet.swift @@ -164,7 +164,8 @@ struct HistoryListBottomSheet: View { dismiss() } - sharedMainViewModel.toastMessage = "Success_copied_into_clipboard" + ToastViewModel.shared.toastMessage = "Success_copied_into_clipboard" + ToastViewModel.shared.displayToast.toggle() } label: { HStack { diff --git a/Linphone/UI/Main/History/HistoryView.swift b/Linphone/UI/Main/History/HistoryView.swift index 8b4977b97..13c857b14 100644 --- a/Linphone/UI/Main/History/HistoryView.swift +++ b/Linphone/UI/Main/History/HistoryView.swift @@ -42,7 +42,6 @@ struct HistoryView: View { ) Button { - SharedMainViewModel.shared.toastMessage = "Success_remove_call_logs" } label: { Image("phone-plus") .padding() diff --git a/Linphone/UI/Main/Viewmodel/SharedMainViewModel.swift b/Linphone/UI/Main/Viewmodel/SharedMainViewModel.swift index 189962e19..abb48824b 100644 --- a/Linphone/UI/Main/Viewmodel/SharedMainViewModel.swift +++ b/Linphone/UI/Main/Viewmodel/SharedMainViewModel.swift @@ -23,8 +23,6 @@ class SharedMainViewModel: ObservableObject { static let shared = SharedMainViewModel() - @Published var toastMessage: String = "" - @Published var welcomeViewDisplayed = false @Published var generalTermsAccepted = false @Published var displayProfileMode = false diff --git a/Linphone/UI/Main/Viewmodel/ToastViewModel.swift b/Linphone/UI/Main/Viewmodel/ToastViewModel.swift new file mode 100644 index 000000000..6fb3287a9 --- /dev/null +++ b/Linphone/UI/Main/Viewmodel/ToastViewModel.swift @@ -0,0 +1,19 @@ +// +// ToastViewModel.swift +// Linphone +// +// Created by Benoît Martins on 20/11/2023. +// + +import Foundation + +class ToastViewModel: ObservableObject { + + static let shared = ToastViewModel() + + var toastMessage: String = "" + @Published var displayToast = false + + private init() { + } +} From d7da763dae5cf2700522b60d84a436327d336ac1 Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Tue, 21 Nov 2023 14:16:06 +0100 Subject: [PATCH 043/486] Refresh calllogs list when receive callback --- Linphone/LinphoneApp.swift | 25 ++++++++++--- .../Fragments/HistoryContactFragment.swift | 22 ++++++------ .../ViewModel/HistoryListViewModel.swift | 36 +++++++++++++++++++ 3 files changed, 68 insertions(+), 15 deletions(-) diff --git a/Linphone/LinphoneApp.swift b/Linphone/LinphoneApp.swift index 6371e5bee..86403a04f 100644 --- a/Linphone/LinphoneApp.swift +++ b/Linphone/LinphoneApp.swift @@ -25,6 +25,11 @@ struct LinphoneApp: App { @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 isActive = false var body: some Scene { @@ -34,16 +39,26 @@ struct LinphoneApp: App { WelcomeView() } else if coreContext.defaultAccount == nil || sharedMainViewModel.displayProfileMode { AssistantView() - } else if coreContext.defaultAccount != nil { + } else if coreContext.defaultAccount != nil + && contactViewModel != nil + && editContactViewModel != nil + && historyViewModel != nil + && historyListViewModel != nil { ContentView( - contactViewModel: ContactViewModel(), - editContactViewModel: EditContactViewModel(), - historyViewModel: HistoryViewModel(), - historyListViewModel: HistoryListViewModel() + contactViewModel: contactViewModel!, + editContactViewModel: editContactViewModel!, + historyViewModel: historyViewModel!, + historyListViewModel: historyListViewModel! ) } } else { SplashScreen(isActive: $isActive) + .onDisappear { + contactViewModel = ContactViewModel() + editContactViewModel = EditContactViewModel() + historyViewModel = HistoryViewModel() + historyListViewModel = HistoryListViewModel() + } } } } diff --git a/Linphone/UI/Main/History/Fragments/HistoryContactFragment.swift b/Linphone/UI/Main/History/Fragments/HistoryContactFragment.swift index 07cc6a2a5..139245706 100644 --- a/Linphone/UI/Main/History/Fragments/HistoryContactFragment.swift +++ b/Linphone/UI/Main/History/Fragments/HistoryContactFragment.swift @@ -73,8 +73,6 @@ struct HistoryContactFragment: View { Button { isMenuOpen = false - indexPage = 0 - if ContactsManager.shared.getFriendWithAddress( address: historyViewModel.displayedCall != nil && historyViewModel.displayedCall!.dir == .Outgoing ? historyViewModel.displayedCall!.toAddress! @@ -87,11 +85,13 @@ struct HistoryContactFragment: View { let friendIndex = MagicSearchSingleton.shared.lastSearch.firstIndex( where: {$0.friend!.addresses.contains(where: {$0.asStringUriOnly() == addressCall.asStringUriOnly()})}) if friendIndex != nil { - - withAnimation { + + withAnimation { historyViewModel.displayedCall = nil - contactViewModel.indexDisplayedFriend = friendIndex - } + indexPage = 0 + + contactViewModel.indexDisplayedFriend = friendIndex + } } } else { let addressCall = historyViewModel.displayedCall != nil && historyViewModel.displayedCall!.dir == .Outgoing @@ -100,10 +100,12 @@ struct HistoryContactFragment: View { withAnimation { historyViewModel.displayedCall = nil - isShowEditContactFragment.toggle() - editContactViewModel.sipAddresses.removeAll() - editContactViewModel.sipAddresses.append(String(addressCall.asStringUriOnly().dropFirst(4))) - editContactViewModel.sipAddresses.append("") + indexPage = 0 + + isShowEditContactFragment.toggle() + editContactViewModel.sipAddresses.removeAll() + editContactViewModel.sipAddresses.append(String(addressCall.asStringUriOnly().dropFirst(4))) + editContactViewModel.sipAddresses.append("") } } diff --git a/Linphone/UI/Main/History/ViewModel/HistoryListViewModel.swift b/Linphone/UI/Main/History/ViewModel/HistoryListViewModel.swift index 4758564a2..3251d2511 100644 --- a/Linphone/UI/Main/History/ViewModel/HistoryListViewModel.swift +++ b/Linphone/UI/Main/History/ViewModel/HistoryListViewModel.swift @@ -26,9 +26,12 @@ class HistoryListViewModel: ObservableObject { @Published var callLogs: [CallLog] = [] var callLogsTmp: [CallLog] = [] + @Published private var coreDelegate: CoreDelegate? + var callLogsAddressToDelete = "" init() { + removeAllDelegate() computeCallLogsList() } @@ -46,6 +49,29 @@ class HistoryListViewModel: ObservableObject { self.callLogsTmp.append(log) } } + + DispatchQueue.main.async { + self.coreDelegate = CoreDelegateStub( + onCallLogUpdated: { (_: Core, _: CallLog) -> Void in + DispatchQueue.main.async { + let account = core.defaultAccount + let logs = account?.callLogs != nil ? account!.callLogs : core.callLogs + + self.callLogs.removeAll() + self.callLogsTmp.removeAll() + + logs.forEach { log in + self.callLogs.append(log) + self.callLogsTmp.append(log) + } + } + } + ) + if self.coreDelegate != nil { + core.addDelegate(delegate: self.coreDelegate!) + } + } + } } @@ -184,4 +210,14 @@ class HistoryListViewModel: ObservableObject { let indexTmp = self.callLogsTmp.firstIndex(where: {$0.callId == callLog.callId}) self.callLogsTmp.remove(at: indexTmp!) } + + func removeAllDelegate() { + coreContext.doOnCoreQueue { core in + if self.coreDelegate != nil { + core.removeDelegate(delegate: self.coreDelegate!) + self.coreDelegate = nil + } + } + } + } From 84da8a367b7a63b4de276c1c9137a5ecca9c222c Mon Sep 17 00:00:00 2001 From: "benoit.martins" Date: Tue, 21 Nov 2023 17:28:36 +0100 Subject: [PATCH 044/486] Fix Presence --- Linphone/Contacts/ContactsManager.swift | 5 +- Linphone/Utils/Avatar.swift | 105 ++++++++++++++++++++++++ 2 files changed, 109 insertions(+), 1 deletion(-) create mode 100644 Linphone/Utils/Avatar.swift diff --git a/Linphone/Contacts/ContactsManager.swift b/Linphone/Contacts/ContactsManager.swift index 9db3527c0..74010a791 100644 --- a/Linphone/Contacts/ContactsManager.swift +++ b/Linphone/Contacts/ContactsManager.swift @@ -188,7 +188,7 @@ final class ContactsManager { 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) @@ -274,6 +274,9 @@ final class ContactsManager { 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 diff --git a/Linphone/Utils/Avatar.swift b/Linphone/Utils/Avatar.swift new file mode 100644 index 000000000..16d3f9fba --- /dev/null +++ b/Linphone/Utils/Avatar.swift @@ -0,0 +1,105 @@ +/* + * 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 { + + var friend: Friend + let avatarSize: CGFloat + + @State private var friendDelegate: FriendDelegate? + @State private var presenceImage = "" + + var body: some View { + AsyncImage(url: ContactsManager.shared.getImagePath(friendPhotoPath: 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 !friend.addresses.isEmpty { + if presenceImage.isEmpty + && (friend.consolidatedPresence == ConsolidatedPresence.Online || friend.consolidatedPresence == ConsolidatedPresence.Busy) { + Image(friend.consolidatedPresence == ConsolidatedPresence.Online ? "presence-online" : "presence-busy") + .resizable() + .frame(width: avatarSize/4, height: avatarSize/4) + .padding(.trailing, avatarSize == 45 ? 1 : 3) + .padding(.bottom, avatarSize == 45 ? 1 : 3) + } else if !presenceImage.isEmpty { + Image(presenceImage) + .resizable() + .frame(width: avatarSize/4, height: avatarSize/4) + .padding(.trailing, avatarSize == 45 ? 1 : 3) + .padding(.bottom, avatarSize == 45 ? 1 : 3) + } + } + } + } + .frame(width: avatarSize, height: avatarSize) + } + .onAppear { + addDelegate() + } + .onDisappear { + removeAllDelegate() + } + case .failure: + Image("profil-picture-default") + .resizable() + .frame(width: avatarSize, height: avatarSize) + .clipShape(Circle()) + @unknown default: + EmptyView() + } + } + } + + func addDelegate() { + let newFriendDelegate = FriendDelegateStub( + onPresenceReceived: { (linphoneFriend: Friend) -> Void in + self.presenceImage = linphoneFriend.consolidatedPresence == ConsolidatedPresence.Online ? "presence-online" : "presence-busy" + } + ) + + friendDelegate = newFriendDelegate + if friendDelegate != nil { + friend.addDelegate(delegate: friendDelegate!) + } + } + + func removeAllDelegate() { + if friendDelegate != nil { + presenceImage = "" + friend.removeDelegate(delegate: friendDelegate!) + friendDelegate = nil + } + } +} From a209349f9501de459579502ba8ec460cde6d9d57 Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Wed, 22 Nov 2023 18:23:16 +0100 Subject: [PATCH 045/486] Fix rebase --- Linphone.xcodeproj/project.pbxproj | 20 ++++++ Linphone/.DS_Store | Bin 6148 -> 8196 bytes .../presence-busy.imageset/Contents.json | 21 ++++++ .../presence-busy.imageset/presence-busy.svg | 5 ++ .../presence-online.imageset/Contents.json | 21 ++++++ .../presence-online.svg | 5 ++ Linphone/Contacts/ContactsManager.swift | 2 +- Linphone/Core/CoreContext.swift | 31 ++++++++- Linphone/Linphone.entitlements | 14 ++-- Linphone/LinphoneApp.swift | 4 +- Linphone/Ressources/linphonerc-default | 39 +++++++++++ Linphone/Ressources/linphonerc-factory | 65 ++++++++++++++++++ Linphone/SplashScreen.swift | 8 --- .../Fragments/ContactInnerFragment.swift | 23 +------ .../Fragments/ContactsListFragment.swift | 21 +----- .../Fragments/EditContactFragment.swift | 21 +----- .../FavoriteContactsListFragment.swift | 22 +----- Linphone/Utils/Avatar.swift | 2 + 18 files changed, 224 insertions(+), 100 deletions(-) create mode 100644 Linphone/Assets.xcassets/presence-busy.imageset/Contents.json create mode 100644 Linphone/Assets.xcassets/presence-busy.imageset/presence-busy.svg create mode 100644 Linphone/Assets.xcassets/presence-online.imageset/Contents.json create mode 100644 Linphone/Assets.xcassets/presence-online.imageset/presence-online.svg create mode 100644 Linphone/Ressources/linphonerc-default create mode 100644 Linphone/Ressources/linphonerc-factory diff --git a/Linphone.xcodeproj/project.pbxproj b/Linphone.xcodeproj/project.pbxproj index 64e88979f..cb827f198 100644 --- a/Linphone.xcodeproj/project.pbxproj +++ b/Linphone.xcodeproj/project.pbxproj @@ -29,6 +29,8 @@ D72343362AD037AF009AA24E /* ToastView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D72343352AD037AF009AA24E /* ToastView.swift */; }; D72992392ADD7F68003AF125 /* HistoryContactFragment.swift in Sources */ = {isa = PBXBuildFile; fileRef = D72992382ADD7F68003AF125 /* HistoryContactFragment.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 */; }; @@ -51,6 +53,7 @@ D7A03FC02ACC2E390081A588 /* HistoryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7A03FBF2ACC2E390081A588 /* HistoryView.swift */; }; D7A03FC62ACC458A0081A588 /* SplashScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7A03FC52ACC458A0081A588 /* SplashScreen.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 */; }; D7C365082AEFAB7F00FE6142 /* ContactListBottomSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7C365072AEFAB7F00FE6142 /* ContactListBottomSheet.swift */; }; D7C3650A2AF001C300FE6142 /* EditContactFragment.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7C365092AF001C300FE6142 /* EditContactFragment.swift */; }; @@ -101,6 +104,8 @@ D72343352AD037AF009AA24E /* ToastView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToastView.swift; sourceTree = ""; }; D72992382ADD7F68003AF125 /* HistoryContactFragment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HistoryContactFragment.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 = ""; }; @@ -124,6 +129,7 @@ D7A03FC52ACC458A0081A588 /* SplashScreen.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SplashScreen.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 = ""; }; D7C365072AEFAB7F00FE6142 /* ContactListBottomSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactListBottomSheet.swift; sourceTree = ""; }; D7C365092AF001C300FE6142 /* EditContactFragment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditContactFragment.swift; sourceTree = ""; }; @@ -178,6 +184,7 @@ D7C3650D2AF15BF200FE6142 /* PhotoPicker.swift */, D7C48DF32AFA66F900D938CB /* EditContactController.swift */, D732A9082AFD235500DB42BA /* ShareSheetController.swift */, + D7ADF5FF2AFE356400212231 /* Avatar.swift */, ); path = Utils; sourceTree = ""; @@ -214,6 +221,7 @@ D70C93DD2AC2D0F60063CA3B /* Localizable.xcstrings */, D719ABBD2ABC67BF00B41C10 /* Preview Content */, D7D24D0C2AC1B4C700C6F35B /* Fonts */, + D7ADF6012AFE5C7C00212231 /* Ressources */, ); path = Linphone; sourceTree = ""; @@ -393,6 +401,15 @@ path = Viewmodel; sourceTree = ""; }; + D7ADF6012AFE5C7C00212231 /* Ressources */ = { + isa = PBXGroup; + children = ( + D732A90A2B0376F500DB42BA /* linphonerc-default */, + D732A90B2B0376F500DB42BA /* linphonerc-factory */, + ); + path = Ressources; + sourceTree = ""; + }; D7D24D0C2AC1B4C700C6F35B /* Fonts */ = { isa = PBXGroup; children = ( @@ -487,6 +504,8 @@ 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 */, D70C93DE2AC2D0F60063CA3B /* Localizable.xcstrings in Resources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -521,6 +540,7 @@ buildActionMask = 2147483647; files = ( D7C3650E2AF15BF200FE6142 /* PhotoPicker.swift in Sources */, + D7ADF6002AFE356400212231 /* Avatar.swift in Sources */, D71707202AC5989C0037746F /* TextExtension.swift in Sources */, D719ABB92ABC67BF00B41C10 /* ContentView.swift in Sources */, D71FCA832AE14D6E00D2E43E /* ContactFragment.swift in Sources */, diff --git a/Linphone/.DS_Store b/Linphone/.DS_Store index ee98363d48998eeedc9d54f56ee7d99d34976794..06ce575e56dfde609b73451038cc47f059aa7fea 100644 GIT binary patch delta 651 zcmcIiO-lkn7=E{PcLx>B$nYSjcUdwC>`-b_un;6kmpX)QX2P;tenfN#1Mjg65p?j< zSt9lr^e>`L{egZ!1c5zkAEmYY92KP;aNS=H-;pjeA=&^#hY6Q)hP8~S0*zCg zLV@ZyH>o;qknUV9{wisjm$txYpj*@eUUzq+nFST--goAHf8cfkhZp1vCB2l0rIL&M zMb0cwR6Z%zO~W#)P!TX@s5q#%g#3qybvUS?oBUCV3SB262ZTk5woytArHvc=fW+UE zARa=(PFqPO(}~zZnm@}~-|$^Td9&L$K@k+Run*Ik``Mi!dlz|NR^yv~#$eF>BZ%+$ MuXs;B|1TrgU%(`xY5)KL delta 120 zcmZp1XfcprU|?W$DortDU=RQ@Ie-{Mvv5r;6q~50$jG%ZU^g=(*JK_6$IZ0@0*sSi z3-(MtC}g*}T!e*jV#%Gw>>M0|%s?GLAixbITtS*Q7Jg@*%rD~!GKzr-Vl2oehRyLj GbC>~z@fK46 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/Contacts/ContactsManager.swift b/Linphone/Contacts/ContactsManager.swift index 74010a791..717bc923e 100644 --- a/Linphone/Contacts/ContactsManager.swift +++ b/Linphone/Contacts/ContactsManager.swift @@ -173,7 +173,7 @@ final class ContactsManager { self.saveFriend(result: result, contact: contact, existingFriend: existingFriend) { resultFriend in if resultFriend != nil { if linphoneFriend && existingFriend == nil { - _ = self.linphoneFriendList?.addLocalFriend(linphoneFriend: resultFriend!) + _ = self.linphoneFriendList?.addFriend(linphoneFriend: resultFriend!) self.linphoneFriendList?.updateSubscriptions() } else if existingFriend == nil { _ = self.friendList?.addLocalFriend(linphoneFriend: resultFriend!) diff --git a/Linphone/Core/CoreContext.swift b/Linphone/Core/CoreContext.swift index 4a76095bc..5d025600b 100644 --- a/Linphone/Core/CoreContext.swift +++ b/Linphone/Core/CoreContext.swift @@ -30,11 +30,18 @@ final class CoreContext: ObservableObject { @Published var loggedIn: Bool = false @Published var loggingInProgress: Bool = false @Published var defaultAccount: Account? + @Published var coreIsStarted: Bool = false private var mCore: Core! private var mIteratePublisher: AnyCancellable? - private init() {} + private init() { + do { + try initialiseCore() + } catch { + + } + } func doOnCoreQueue(synchronous: Bool = false, lambda: @escaping (Core) -> Void) { if synchronous { @@ -53,7 +60,26 @@ final class CoreContext: ObservableObject { coreQueue.async { let configDir = Factory.Instance.getConfigDir(context: nil) - try? self.mCore = Factory.Instance.createCore(configPath: "\(configDir)/MyConfig", factoryConfigPath: "", systemContext: nil) + let url = NSURL(fileURLWithPath: configDir) + if let pathComponent = url.appendingPathComponent("linphonerc") { + let filePath = pathComponent.path + let fileManager = FileManager.default + if !fileManager.fileExists(atPath: filePath) { + let path = Bundle.main.path(forResource: "linphonerc-default", ofType: nil) + if path != nil { + try? FileManager.default.copyItem(at: NSURL(fileURLWithPath: path!) as URL, to: pathComponent) + } + } + } + + let config: Config! = Config.newForSharedCore( + appGroupId: "group.org.linphone.phone.msgNotification", + configFilename: "linphonerc", + factoryConfigFilename: Bundle.main.path(forResource: "linphonerc-factory", ofType: nil) + ) + + self.mCore = try? Factory.Instance.createCoreWithConfig(config: config, systemContext: nil) + self.mCore.autoIterateEnabled = false self.mCore.friendsDatabasePath = "\(configDir)/friends.db" @@ -89,6 +115,7 @@ final class CoreContext: ObservableObject { if cbVal.state == .Ok { self.loggingInProgress = false self.loggedIn = true + self.coreIsStarted = true } else if cbVal.state == .Progress { self.loggingInProgress = true } else { diff --git a/Linphone/Linphone.entitlements b/Linphone/Linphone.entitlements index f2ef3ae02..7fe270b49 100644 --- a/Linphone/Linphone.entitlements +++ b/Linphone/Linphone.entitlements @@ -2,9 +2,15 @@ - com.apple.security.app-sandbox - - com.apple.security.files.user-selected.read-only - + 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 + diff --git a/Linphone/LinphoneApp.swift b/Linphone/LinphoneApp.swift index 86403a04f..1075bea5b 100644 --- a/Linphone/LinphoneApp.swift +++ b/Linphone/LinphoneApp.swift @@ -30,11 +30,11 @@ struct LinphoneApp: App { @State private var historyViewModel: HistoryViewModel? @State private var historyListViewModel: HistoryListViewModel? - @State private var isActive = false + @State private var isActive = true var body: some Scene { WindowGroup { - if isActive { + if isActive && coreContext.coreIsStarted { if !sharedMainViewModel.welcomeViewDisplayed { WelcomeView() } else if coreContext.defaultAccount == nil || sharedMainViewModel.displayProfileMode { diff --git a/Linphone/Ressources/linphonerc-default b/Linphone/Ressources/linphonerc-default new file mode 100644 index 000000000..ea5356429 --- /dev/null +++ b/Linphone/Ressources/linphonerc-default @@ -0,0 +1,39 @@ + +## 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 + +[app] +tunnel=disabled +auto_start=1 +record_aware=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 + +## End of default rc diff --git a/Linphone/Ressources/linphonerc-factory b/Linphone/Ressources/linphonerc-factory new file mode 100644 index 000000000..85b543074 --- /dev/null +++ b/Linphone/Ressources/linphonerc-factory @@ -0,0 +1,65 @@ + +## 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=1 +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] +displaytype=MSAndroidTextureDisplay +auto_resize_preview_to_keep_ratio=1 +max_conference_size=vga + +[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] +activation_code_length=4 +prefer_basic_chat_room=1 +record_aware=1 + +[account_creator] +backend=1 +# 1 means FlexiAPI, 0 is XMLRPC +url=https://subscribe.linphone.org/api/ +# replace above URL by https://staging-subscribe.linphone.org/api/ for testing + +[lime] +lime_update_threshold=86400 + +[alerts] +alerts_enabled=1 + +[assistant] +algorithm=SHA-256 +password_min_length=6 +username_regex=^[a-z0-9+_.\-]*$ + +## End of factory rc diff --git a/Linphone/SplashScreen.swift b/Linphone/SplashScreen.swift index 350fc93ad..e8c11ef7c 100644 --- a/Linphone/SplashScreen.swift +++ b/Linphone/SplashScreen.swift @@ -38,14 +38,6 @@ struct SplashScreen: View { } .ignoresSafeArea(.all) - .onAppear { - Task { - try coreContext.initialiseCore() - withAnimation { - self.isActive = true - } - } - } } } diff --git a/Linphone/UI/Main/Contacts/Fragments/ContactInnerFragment.swift b/Linphone/UI/Main/Contacts/Fragments/ContactInnerFragment.swift index 84f5b996b..426205a74 100644 --- a/Linphone/UI/Main/Contacts/Fragments/ContactInnerFragment.swift +++ b/Linphone/UI/Main/Contacts/Fragments/ContactInnerFragment.swift @@ -113,28 +113,7 @@ struct ContactInnerFragment: View { && magicSearch.lastSearch[contactViewModel.indexDisplayedFriend!].friend != nil && magicSearch.lastSearch[contactViewModel.indexDisplayedFriend!].friend!.photo != nil && !magicSearch.lastSearch[contactViewModel.indexDisplayedFriend!].friend!.photo!.isEmpty { - AsyncImage( - url: ContactsManager.shared.getImagePath( - friendPhotoPath: magicSearch.lastSearch[contactViewModel.indexDisplayedFriend!].friend!.photo!)) { image in - switch image { - case .empty: - ProgressView() - .frame(width: 100, height: 100) - case .success(let image): - image - .resizable() - .aspectRatio(contentMode: .fill) - .frame(width: 100, height: 100) - .clipShape(Circle()) - case .failure: - Image("profil-picture-default") - .resizable() - .frame(width: 100, height: 100) - .clipShape(Circle()) - @unknown default: - EmptyView() - } - } + Avatar(friend: magicSearch.lastSearch[contactViewModel.indexDisplayedFriend!].friend!, avatarSize: 100) } else if contactViewModel.indexDisplayedFriend != nil && magicSearch.lastSearch[contactViewModel.indexDisplayedFriend!].friend != nil { Image("profil-picture-default") .resizable() diff --git a/Linphone/UI/Main/Contacts/Fragments/ContactsListFragment.swift b/Linphone/UI/Main/Contacts/Fragments/ContactsListFragment.swift index 7b274e646..32e4f2ebe 100644 --- a/Linphone/UI/Main/Contacts/Fragments/ContactsListFragment.swift +++ b/Linphone/UI/Main/Contacts/Fragments/ContactsListFragment.swift @@ -64,26 +64,7 @@ struct ContactsListFragment: View { } if magicSearch.lastSearch[index].friend!.photo != nil && !magicSearch.lastSearch[index].friend!.photo!.isEmpty { - AsyncImage(url: ContactsManager.shared.getImagePath(friendPhotoPath: magicSearch.lastSearch[index].friend!.photo!)) { image in - switch image { - case .empty: - ProgressView() - .frame(width: 45, height: 45) - case .success(let image): - image - .resizable() - .aspectRatio(contentMode: .fill) - .frame(width: 45, height: 45) - .clipShape(Circle()) - case .failure: - Image("profil-picture-default") - .resizable() - .frame(width: 45, height: 45) - .clipShape(Circle()) - @unknown default: - EmptyView() - } - } + Avatar(friend: magicSearch.lastSearch[index].friend!, avatarSize: 45) } else { Image("profil-picture-default") .resizable() diff --git a/Linphone/UI/Main/Contacts/Fragments/EditContactFragment.swift b/Linphone/UI/Main/Contacts/Fragments/EditContactFragment.swift index e82feb785..8209eb469 100644 --- a/Linphone/UI/Main/Contacts/Fragments/EditContactFragment.swift +++ b/Linphone/UI/Main/Contacts/Fragments/EditContactFragment.swift @@ -129,26 +129,7 @@ struct EditContactFragment: View { if editContactViewModel.selectedEditFriend != nil && editContactViewModel.selectedEditFriend!.photo != nil && !editContactViewModel.selectedEditFriend!.photo!.isEmpty && selectedImage == nil && !removedImage { - AsyncImage(url: ContactsManager.shared.getImagePath(friendPhotoPath: editContactViewModel.selectedEditFriend!.photo!)) { image in - switch image { - case .empty: - ProgressView() - .frame(width: 100, height: 100) - case .success(let image): - image - .resizable() - .aspectRatio(contentMode: .fill) - .frame(width: 100, height: 100) - .clipShape(Circle()) - case .failure: - Image("profil-picture-default") - .resizable() - .frame(width: 100, height: 100) - .clipShape(Circle()) - @unknown default: - EmptyView() - } - } + Avatar(friend: editContactViewModel.selectedEditFriend!, avatarSize: 100) } else if selectedImage == nil { Image("profil-picture-default") .resizable() diff --git a/Linphone/UI/Main/Contacts/Fragments/FavoriteContactsListFragment.swift b/Linphone/UI/Main/Contacts/Fragments/FavoriteContactsListFragment.swift index 0eb2fa566..3c9a61bf1 100644 --- a/Linphone/UI/Main/Contacts/Fragments/FavoriteContactsListFragment.swift +++ b/Linphone/UI/Main/Contacts/Fragments/FavoriteContactsListFragment.swift @@ -39,27 +39,7 @@ struct FavoriteContactsListFragment: View { VStack { if magicSearch.lastSearch[index].friend!.photo != nil && !magicSearch.lastSearch[index].friend!.photo!.isEmpty { - AsyncImage(url: ContactsManager.shared.getImagePath(friendPhotoPath: magicSearch.lastSearch[index].friend!.photo!) - ) { image in - switch image { - case .empty: - ProgressView() - .frame(width: 45, height: 45) - case .success(let image): - image - .resizable() - .aspectRatio(contentMode: .fill) - .frame(width: 45, height: 45) - .clipShape(Circle()) - case .failure: - Image("profil-picture-default") - .resizable() - .frame(width: 45, height: 45) - .clipShape(Circle()) - @unknown default: - EmptyView() - } - } + Avatar(friend: magicSearch.lastSearch[index].friend!, avatarSize: 45) } else { Image("profil-picture-default") .resizable() diff --git a/Linphone/Utils/Avatar.swift b/Linphone/Utils/Avatar.swift index 16d3f9fba..5597ba38a 100644 --- a/Linphone/Utils/Avatar.swift +++ b/Linphone/Utils/Avatar.swift @@ -83,8 +83,10 @@ struct Avatar: View { } func addDelegate() { + print("onPresenceReceivedonPresenceReceived \(friend.name) \(friend.consolidatedPresence)") let newFriendDelegate = FriendDelegateStub( onPresenceReceived: { (linphoneFriend: Friend) -> Void in + print("onPresenceReceivedonPresenceReceived delegate \(friend.name) \(friend.consolidatedPresence) \(linphoneFriend.consolidatedPresence)") self.presenceImage = linphoneFriend.consolidatedPresence == ConsolidatedPresence.Online ? "presence-online" : "presence-busy" } ) From b576785399bb86c5340ef8b1378af6569457074d Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Thu, 23 Nov 2023 17:21:49 +0100 Subject: [PATCH 046/486] Send and clear logs --- Linphone/Contacts/ContactsManager.swift | 3 ++ Linphone/Core/CoreContext.swift | 28 ++++++++---- Linphone/LinphoneApp.swift | 6 +-- Linphone/Localizable.xcstrings | 9 ++-- Linphone/Ressources/linphonerc-default | 1 + Linphone/SplashScreen.swift | 5 +-- Linphone/UI/Main/Fragments/SideMenu.swift | 53 ++++++++++++++++++++++- 7 files changed, 83 insertions(+), 22 deletions(-) diff --git a/Linphone/Contacts/ContactsManager.swift b/Linphone/Contacts/ContactsManager.swift index 717bc923e..44149bb49 100644 --- a/Linphone/Contacts/ContactsManager.swift +++ b/Linphone/Contacts/ContactsManager.swift @@ -67,6 +67,9 @@ final class ContactsManager { do { self.linphoneFriendList = try core.getFriendListByName(name: self.linphoneAddressBookFriendList) ?? core.createFriendList() + + //self.linphoneFriendList?.updateSubscriptions() + print("friendListfriendListfriendListfriendList \(self.linphoneFriendList!.rlsAddress)") } catch let error { print("\(#function) - Failed to enumerate contacts: \(error)") } diff --git a/Linphone/Core/CoreContext.swift b/Linphone/Core/CoreContext.swift index 5d025600b..ecb07e916 100644 --- a/Linphone/Core/CoreContext.swift +++ b/Linphone/Core/CoreContext.swift @@ -60,6 +60,10 @@ final class CoreContext: ObservableObject { coreQueue.async { let configDir = Factory.Instance.getConfigDir(context: nil) + + Factory.Instance.logCollectionPath = configDir + Factory.Instance.enableLogCollection(state: LogCollectionState.Enabled) + let url = NSURL(fileURLWithPath: configDir) if let pathComponent = url.appendingPathComponent("linphonerc") { let filePath = pathComponent.path @@ -71,25 +75,32 @@ final class CoreContext: ObservableObject { } } } - - let config: Config! = Config.newForSharedCore( - appGroupId: "group.org.linphone.phone.msgNotification", - configFilename: "linphonerc", - factoryConfigFilename: Bundle.main.path(forResource: "linphonerc-factory", ofType: nil) - ) - - self.mCore = try? Factory.Instance.createCoreWithConfig(config: config, systemContext: nil) + + let config = try? Factory.Instance.createConfigWithFactory( + path: "\(configDir)/linphonerc", + factoryPath: Bundle.main.path(forResource: "linphonerc-factory", ofType: nil) + ) + if config != nil { + self.mCore = try? Factory.Instance.createCoreWithConfig(config: config!, systemContext: nil) + } self.mCore.autoIterateEnabled = false self.mCore.friendsDatabasePath = "\(configDir)/friends.db" + //self.mCore.logCollectionUploadServerUrl = "https://www.linphone.org:444/lft.php" + //self.mCore.friendListSubscriptionEnabled = true + + print("configDirconfigDir \(configDir)") + self.mCore.publisher?.onGlobalStateChanged?.postOnMainQueue { (cbVal: (core: Core, state: GlobalState, message: String)) in if cbVal.state == GlobalState.On { self.defaultAccount = self.mCore.defaultAccount } else if cbVal.state == GlobalState.Off { self.defaultAccount = nil } + self.coreIsStarted = true } + try? self.mCore.start() // Create a Core listener to listen for the callback we need @@ -115,7 +126,6 @@ final class CoreContext: ObservableObject { if cbVal.state == .Ok { self.loggingInProgress = false self.loggedIn = true - self.coreIsStarted = true } else if cbVal.state == .Progress { self.loggingInProgress = true } else { diff --git a/Linphone/LinphoneApp.swift b/Linphone/LinphoneApp.swift index 1075bea5b..159abb97d 100644 --- a/Linphone/LinphoneApp.swift +++ b/Linphone/LinphoneApp.swift @@ -30,11 +30,9 @@ struct LinphoneApp: App { @State private var historyViewModel: HistoryViewModel? @State private var historyListViewModel: HistoryListViewModel? - @State private var isActive = true - var body: some Scene { WindowGroup { - if isActive && coreContext.coreIsStarted { + if coreContext.coreIsStarted { if !sharedMainViewModel.welcomeViewDisplayed { WelcomeView() } else if coreContext.defaultAccount == nil || sharedMainViewModel.displayProfileMode { @@ -52,7 +50,7 @@ struct LinphoneApp: App { ) } } else { - SplashScreen(isActive: $isActive) + SplashScreen() .onDisappear { contactViewModel = ContactViewModel() editContactViewModel = EditContactViewModel() diff --git a/Linphone/Localizable.xcstrings b/Linphone/Localizable.xcstrings index 8a1a3da17..d95a2a884 100644 --- a/Linphone/Localizable.xcstrings +++ b/Linphone/Localizable.xcstrings @@ -163,6 +163,9 @@ }, "Chiffrement de bout en bout de tous vos échanges, grâce au mode default vos communications sont à l’abri des regards." : { + }, + "Clear logs" : { + }, "Close" : { @@ -375,9 +378,6 @@ }, "Plus tard" : { - }, - "Posts" : { - }, "Pour vous permettre de vous profitez pleinement de Linphone nous avons besoin des autorisations suivantes :" : { @@ -408,6 +408,9 @@ }, "See Linphone contact" : { + }, + "Send logs" : { + }, "Share" : { diff --git a/Linphone/Ressources/linphonerc-default b/Linphone/Ressources/linphonerc-default index ea5356429..46ca19ac0 100644 --- a/Linphone/Ressources/linphonerc-default +++ b/Linphone/Ressources/linphonerc-default @@ -16,6 +16,7 @@ update_presence_model_timestamp_before_publish_expires_refresh=1 #Because dynamic bitrate adaption can increase bitrate, we must allow "no limit" download_bw=0 upload_bw=0 +friendlist_subscription_enabled=1 [video] size=vga diff --git a/Linphone/SplashScreen.swift b/Linphone/SplashScreen.swift index e8c11ef7c..e15c4aaac 100644 --- a/Linphone/SplashScreen.swift +++ b/Linphone/SplashScreen.swift @@ -21,9 +21,6 @@ import SwiftUI struct SplashScreen: View { - @ObservedObject private var coreContext = CoreContext.shared - @Binding var isActive: Bool - var body: some View { GeometryReader { _ in VStack { @@ -42,5 +39,5 @@ struct SplashScreen: View { } #Preview { - SplashScreen(isActive: .constant(true)) + SplashScreen() } diff --git a/Linphone/UI/Main/Fragments/SideMenu.swift b/Linphone/UI/Main/Fragments/SideMenu.swift index 317490553..8da7a0086 100644 --- a/Linphone/UI/Main/Fragments/SideMenu.swift +++ b/Linphone/UI/Main/Fragments/SideMenu.swift @@ -18,8 +18,15 @@ */ import SwiftUI +import linphonesw +import UniformTypeIdentifiers struct SideMenu: View { + + @ObservedObject private var coreContext = CoreContext.shared + + @State private var coreDelegate: CoreDelegate? + let width: CGFloat let isOpen: Bool let menuClose: () -> Void @@ -41,9 +48,13 @@ struct SideMenu: View { Text("My Profile").onTapGesture { print("My Profile") } - Text("Posts").onTapGesture { - print("Posts") + Text("Send logs").onTapGesture { + sendLogs() } + Text("Clear logs").onTapGesture { + print("Clear logs") + Core.resetLogCollection() + } Text("Logout").onTapGesture { print("Logout") } @@ -60,4 +71,42 @@ struct SideMenu: View { } .frame(maxWidth: .infinity, maxHeight: .infinity) } + + func sendLogs() { + coreContext.doOnCoreQueue { core in + core.uploadLogCollection() + + let newCoreDelegate = CoreDelegateStub( + onLogCollectionUploadStateChanged: { core, logCollectionUploadState, logString in + print("newCoreDelegatenewCoreDelegate \(logString)") + + if logString.starts(with: "https") { + UIPasteboard.general.setValue( + logString, + forPasteboardType: UTType.plainText.identifier + ) + + removeAllDelegate() + + ToastViewModel.shared.toastMessage = "Success_copied_into_clipboard" + ToastViewModel.shared.displayToast.toggle() + } + } + ) + + coreDelegate = newCoreDelegate + if coreDelegate != nil { + core.addDelegate(delegate: coreDelegate!) + } + } + } + + func removeAllDelegate() { + coreContext.doOnCoreQueue { core in + if coreDelegate != nil { + core.removeDelegate(delegate: coreDelegate!) + coreDelegate = nil + } + } + } } From 07b2c1e04ec5edc02d8455be6eb9b37fee21b5a4 Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Fri, 24 Nov 2023 16:48:04 +0100 Subject: [PATCH 047/486] Add assistant config files (linphone and third party) Fix presence --- Linphone.xcodeproj/project.pbxproj | 8 +++++ Linphone/Contacts/ContactsManager.swift | 7 ++-- Linphone/Core/CoreContext.swift | 5 +-- .../assistant_linphone_default_values | 36 +++++++++++++++++++ .../assistant_third_party_default_values | 25 +++++++++++++ Linphone/Ressources/linphonerc-default | 1 - .../Viewmodel/AccountLoginViewModel.swift | 11 ++++++ Linphone/UI/Main/Fragments/SideMenu.swift | 1 - Linphone/Utils/Avatar.swift | 2 -- 9 files changed, 85 insertions(+), 11 deletions(-) create mode 100644 Linphone/Ressources/assistant_linphone_default_values create mode 100644 Linphone/Ressources/assistant_third_party_default_values diff --git a/Linphone.xcodeproj/project.pbxproj b/Linphone.xcodeproj/project.pbxproj index cb827f198..f9374e42c 100644 --- a/Linphone.xcodeproj/project.pbxproj +++ b/Linphone.xcodeproj/project.pbxproj @@ -48,6 +48,8 @@ D777DBB32AE12C5900565A99 /* ContactsManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = D777DBB22AE12C5900565A99 /* ContactsManager.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 */; }; D796F2002B0BB61A0041115F /* ToastViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D796F1FF2B0BB61A0041115F /* ToastViewModel.swift */; }; D7A03FBD2ACC2DB60081A588 /* ContactsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7A03FBC2ACC2DB60081A588 /* ContactsView.swift */; }; D7A03FC02ACC2E390081A588 /* HistoryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7A03FBF2ACC2E390081A588 /* HistoryView.swift */; }; @@ -123,6 +125,8 @@ D777DBB22AE12C5900565A99 /* ContactsManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactsManager.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 = ""; }; D796F1FF2B0BB61A0041115F /* ToastViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToastViewModel.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 = ""; }; @@ -404,6 +408,8 @@ D7ADF6012AFE5C7C00212231 /* Ressources */ = { isa = PBXGroup; children = ( + D783C77A2B1089B200622CC2 /* assistant_linphone_default_values */, + D783C77B2B1089B200622CC2 /* assistant_third_party_default_values */, D732A90A2B0376F500DB42BA /* linphonerc-default */, D732A90B2B0376F500DB42BA /* linphonerc-factory */, ); @@ -499,6 +505,7 @@ 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 */, @@ -506,6 +513,7 @@ 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 */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/Linphone/Contacts/ContactsManager.swift b/Linphone/Contacts/ContactsManager.swift index 44149bb49..eafa75152 100644 --- a/Linphone/Contacts/ContactsManager.swift +++ b/Linphone/Contacts/ContactsManager.swift @@ -67,9 +67,6 @@ final class ContactsManager { do { self.linphoneFriendList = try core.getFriendListByName(name: self.linphoneAddressBookFriendList) ?? core.createFriendList() - - //self.linphoneFriendList?.updateSubscriptions() - print("friendListfriendListfriendListfriendList \(self.linphoneFriendList!.rlsAddress)") } catch let error { print("\(#function) - Failed to enumerate contacts: \(error)") } @@ -239,7 +236,11 @@ final class ContactsManager { 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) diff --git a/Linphone/Core/CoreContext.swift b/Linphone/Core/CoreContext.swift index ecb07e916..9f8e1d5bf 100644 --- a/Linphone/Core/CoreContext.swift +++ b/Linphone/Core/CoreContext.swift @@ -87,10 +87,7 @@ final class CoreContext: ObservableObject { self.mCore.autoIterateEnabled = false self.mCore.friendsDatabasePath = "\(configDir)/friends.db" - //self.mCore.logCollectionUploadServerUrl = "https://www.linphone.org:444/lft.php" - //self.mCore.friendListSubscriptionEnabled = true - - print("configDirconfigDir \(configDir)") + self.mCore.friendListSubscriptionEnabled = true self.mCore.publisher?.onGlobalStateChanged?.postOnMainQueue { (cbVal: (core: Core, state: GlobalState, message: String)) in if cbVal.state == GlobalState.On { diff --git a/Linphone/Ressources/assistant_linphone_default_values b/Linphone/Ressources/assistant_linphone_default_values new file mode 100644 index 000000000..f3723d7a9 --- /dev/null +++ b/Linphone/Ressources/assistant_linphone_default_values @@ -0,0 +1,36 @@ + + +
+ 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 + 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..78927cf4e --- /dev/null +++ b/Linphone/Ressources/assistant_third_party_default_values @@ -0,0 +1,25 @@ + + +
+ 0 + 0 + 0 + -1 + + 0 + 0 + 3600 + + + + 1 + + + + + 0 + 0 + 0 + +
+
diff --git a/Linphone/Ressources/linphonerc-default b/Linphone/Ressources/linphonerc-default index 46ca19ac0..ea5356429 100644 --- a/Linphone/Ressources/linphonerc-default +++ b/Linphone/Ressources/linphonerc-default @@ -16,7 +16,6 @@ update_presence_model_timestamp_before_publish_expires_refresh=1 #Because dynamic bitrate adaption can increase bitrate, we must allow "no limit" download_bw=0 upload_bw=0 -friendlist_subscription_enabled=1 [video] size=vga diff --git a/Linphone/UI/Assistant/Viewmodel/AccountLoginViewModel.swift b/Linphone/UI/Assistant/Viewmodel/AccountLoginViewModel.swift index 7462e16dd..e9150c82c 100644 --- a/Linphone/UI/Assistant/Viewmodel/AccountLoginViewModel.swift +++ b/Linphone/UI/Assistant/Viewmodel/AccountLoginViewModel.swift @@ -35,6 +35,17 @@ class AccountLoginViewModel: ObservableObject { func login() { coreContext.doOnCoreQueue { core in do { + + 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 diff --git a/Linphone/UI/Main/Fragments/SideMenu.swift b/Linphone/UI/Main/Fragments/SideMenu.swift index 8da7a0086..60f3c9606 100644 --- a/Linphone/UI/Main/Fragments/SideMenu.swift +++ b/Linphone/UI/Main/Fragments/SideMenu.swift @@ -78,7 +78,6 @@ struct SideMenu: View { let newCoreDelegate = CoreDelegateStub( onLogCollectionUploadStateChanged: { core, logCollectionUploadState, logString in - print("newCoreDelegatenewCoreDelegate \(logString)") if logString.starts(with: "https") { UIPasteboard.general.setValue( diff --git a/Linphone/Utils/Avatar.swift b/Linphone/Utils/Avatar.swift index 5597ba38a..16d3f9fba 100644 --- a/Linphone/Utils/Avatar.swift +++ b/Linphone/Utils/Avatar.swift @@ -83,10 +83,8 @@ struct Avatar: View { } func addDelegate() { - print("onPresenceReceivedonPresenceReceived \(friend.name) \(friend.consolidatedPresence)") let newFriendDelegate = FriendDelegateStub( onPresenceReceived: { (linphoneFriend: Friend) -> Void in - print("onPresenceReceivedonPresenceReceived delegate \(friend.name) \(friend.consolidatedPresence) \(linphoneFriend.consolidatedPresence)") self.presenceImage = linphoneFriend.consolidatedPresence == ConsolidatedPresence.Online ? "presence-online" : "presence-busy" } ) From 7b476904cb9b9e13b1f39e8704be99ae6e3e27ea Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Tue, 28 Nov 2023 17:28:46 +0100 Subject: [PATCH 048/486] Fix presence --- Linphone.xcodeproj/project.pbxproj | 12 ++ Linphone/Contacts/ContactsManager.swift | 9 +- Linphone/Localizable.xcstrings | 3 - .../Contacts/Fragments/ContactFragment.swift | 10 +- .../ContactInnerActionsFragment.swift | 84 ++++++------ .../Fragments/ContactInnerFragment.swift | 42 +++--- .../Fragments/ContactListBottomSheet.swift | 1 - .../Fragments/ContactsInnerFragment.swift | 8 +- .../Fragments/ContactsListBottomSheet.swift | 4 +- .../Fragments/ContactsListFragment.swift | 22 ++-- .../Fragments/EditContactFragment.swift | 27 +++- .../FavoriteContactsListFragment.swift | 20 +-- .../Contacts/Model/ContactAvatarModel.swift | 121 ++++++++++++++++++ .../Contacts/ViewModel/ContactViewModel.swift | 6 +- .../ViewModel/ContactsListViewModel.swift | 2 +- Linphone/UI/Main/ContentView.swift | 34 +++-- .../Fragments/HistoryContactFragment.swift | 63 ++++----- .../Fragments/HistoryListBottomSheet.swift | 7 +- .../Fragments/HistoryListFragment.swift | 49 +++---- .../ViewModel/HistoryListViewModel.swift | 5 +- .../UI/Main/Viewmodel/ToastViewModel.swift | 24 +++- Linphone/Utils/Avatar.swift | 115 ++++++----------- Linphone/Utils/IntExtension.swift | 12 +- Linphone/Utils/MagicSearchSingleton.swift | 45 ++++++- 24 files changed, 445 insertions(+), 280 deletions(-) create mode 100644 Linphone/UI/Main/Contacts/Model/ContactAvatarModel.swift diff --git a/Linphone.xcodeproj/project.pbxproj b/Linphone.xcodeproj/project.pbxproj index f9374e42c..60a8812a8 100644 --- a/Linphone.xcodeproj/project.pbxproj +++ b/Linphone.xcodeproj/project.pbxproj @@ -27,6 +27,7 @@ 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 */; }; D72992392ADD7F68003AF125 /* HistoryContactFragment.swift in Sources */ = {isa = PBXBuildFile; fileRef = D72992382ADD7F68003AF125 /* HistoryContactFragment.swift */; }; D732A9092AFD235500DB42BA /* ShareSheetController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D732A9082AFD235500DB42BA /* ShareSheetController.swift */; }; D732A90C2B0376F500DB42BA /* linphonerc-default in Resources */ = {isa = PBXBuildFile; fileRef = D732A90A2B0376F500DB42BA /* linphonerc-default */; }; @@ -104,6 +105,7 @@ 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 = ""; }; D72992382ADD7F68003AF125 /* HistoryContactFragment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HistoryContactFragment.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 = ""; }; @@ -297,6 +299,14 @@ path = ViewModel; sourceTree = ""; }; + D726E4372B1643FF0083C415 /* Model */ = { + isa = PBXGroup; + children = ( + D726E4382B16440C0083C415 /* ContactAvatarModel.swift */, + ); + path = Model; + sourceTree = ""; + }; D72992372ADD7F1C003AF125 /* Fragments */ = { isa = PBXGroup; children = ( @@ -380,6 +390,7 @@ isa = PBXGroup; children = ( D78290B62ADD38F9004AA85C /* Fragments */, + D726E4372B1643FF0083C415 /* Model */, D78290B92ADD409D004AA85C /* ViewModel */, D7A03FBC2ACC2DB60081A588 /* ContactsView.swift */, ); @@ -578,6 +589,7 @@ D719ABB72ABC67BF00B41C10 /* LinphoneApp.swift in Sources */, D732A91B2B061BD900DB42BA /* HistoryListBottomSheet.swift in Sources */, D72250632ADE9615008FB426 /* HistoryViewModel.swift in Sources */, + D726E4392B16440C0083C415 /* ContactAvatarModel.swift in Sources */, D76005F62B0798B00054B79A /* IntExtension.swift in Sources */, D7E6D0512AEBDBD500A57AAF /* ContactsListBottomSheet.swift in Sources */, D7A2EDD62AC18115005D90FC /* SharedMainViewModel.swift in Sources */, diff --git a/Linphone/Contacts/ContactsManager.swift b/Linphone/Contacts/ContactsManager.swift index eafa75152..6b80d8131 100644 --- a/Linphone/Contacts/ContactsManager.swift +++ b/Linphone/Contacts/ContactsManager.swift @@ -22,12 +22,11 @@ import Contacts import SwiftUI import ContactsUI -final class ContactsManager { +final class ContactsManager: ObservableObject { static let shared = ContactsManager() private var coreContext = CoreContext.shared - private var magicSearch = MagicSearchSingleton.shared private let nativeAddressBookFriendList = "Native address-book" let linphoneAddressBookFriendList = "Linphone address-book" @@ -35,6 +34,9 @@ final class ContactsManager { var friendList: FriendList? var linphoneFriendList: FriendList? + @Published var lastSearch: [SearchResult] = [] + @Published var avatarListModel: [ContactAvatarModel] = [] + private init() { fetchContacts() } @@ -132,7 +134,8 @@ final class ContactsManager { print("\(#function) - access denied") } } - self.magicSearch.searchForContacts(sourceFlags: MagicSearch.Source.Friends.rawValue | MagicSearch.Source.LdapServers.rawValue) + + MagicSearchSingleton.shared.searchForContacts(sourceFlags: MagicSearch.Source.Friends.rawValue | MagicSearch.Source.LdapServers.rawValue) } } diff --git a/Linphone/Localizable.xcstrings b/Linphone/Localizable.xcstrings index d95a2a884..394b1e7f0 100644 --- a/Linphone/Localizable.xcstrings +++ b/Linphone/Localizable.xcstrings @@ -247,9 +247,6 @@ }, "En continuant, vous acceptez ces conditions, " : { - }, - "En ligne" : { - }, "Error" : { diff --git a/Linphone/UI/Main/Contacts/Fragments/ContactFragment.swift b/Linphone/UI/Main/Contacts/Fragments/ContactFragment.swift index f1f427e2f..e29473d97 100644 --- a/Linphone/UI/Main/Contacts/Fragments/ContactFragment.swift +++ b/Linphone/UI/Main/Contacts/Fragments/ContactFragment.swift @@ -34,9 +34,11 @@ struct ContactFragment: View { @State private var showShareSheet = false var body: some View { + let indexDisplayed = contactViewModel.indexDisplayedFriend != nil ? contactViewModel.indexDisplayedFriend! : 0 if #available(iOS 16.0, *) { if idiom != .pad { ContactInnerFragment( + contactAvatarModel: ContactsManager.shared.avatarListModel[indexDisplayed], contactViewModel: contactViewModel, editContactViewModel: editContactViewModel, cnContact: CNContact(), @@ -50,12 +52,13 @@ struct ContactFragment: View { .presentationDetents([.fraction(0.2)]) } .sheet(isPresented: $showShareSheet) { - ShareSheet(friendToShare: MagicSearchSingleton.shared.lastSearch[contactViewModel.indexDisplayedFriend!].friend!) + ShareSheet(friendToShare: ContactsManager.shared.lastSearch[contactViewModel.indexDisplayedFriend!].friend!) .presentationDetents([.medium]) .edgesIgnoringSafeArea(.bottom) } } else { ContactInnerFragment( + contactAvatarModel: ContactsManager.shared.avatarListModel[indexDisplayed], contactViewModel: contactViewModel, editContactViewModel: editContactViewModel, cnContact: CNContact(), @@ -68,12 +71,13 @@ struct ContactFragment: View { ContactListBottomSheet(contactViewModel: contactViewModel, showingSheet: $showingSheet) } onDismiss: {} .sheet(isPresented: $showShareSheet) { - ShareSheet(friendToShare: MagicSearchSingleton.shared.lastSearch[contactViewModel.indexDisplayedFriend!].friend!) + ShareSheet(friendToShare: ContactsManager.shared.lastSearch[contactViewModel.indexDisplayedFriend!].friend!) .edgesIgnoringSafeArea(.bottom) } } } else { ContactInnerFragment( + contactAvatarModel: ContactsManager.shared.avatarListModel[indexDisplayed], contactViewModel: contactViewModel, editContactViewModel: editContactViewModel, cnContact: CNContact(), @@ -86,7 +90,7 @@ struct ContactFragment: View { ContactListBottomSheet(contactViewModel: contactViewModel, showingSheet: $showingSheet) } onDismiss: {} .sheet(isPresented: $showShareSheet) { - ShareSheet(friendToShare: MagicSearchSingleton.shared.lastSearch[contactViewModel.indexDisplayedFriend!].friend!) + ShareSheet(friendToShare: ContactsManager.shared.lastSearch[contactViewModel.indexDisplayedFriend!].friend!) .edgesIgnoringSafeArea(.bottom) } } diff --git a/Linphone/UI/Main/Contacts/Fragments/ContactInnerActionsFragment.swift b/Linphone/UI/Main/Contacts/Fragments/ContactInnerActionsFragment.swift index 8714c25e3..99333cd53 100644 --- a/Linphone/UI/Main/Contacts/Fragments/ContactInnerActionsFragment.swift +++ b/Linphone/UI/Main/Contacts/Fragments/ContactInnerActionsFragment.swift @@ -21,7 +21,8 @@ import SwiftUI struct ContactInnerActionsFragment: View { - @ObservedObject var magicSearch = MagicSearchSingleton.shared + @ObservedObject var contactsManager = ContactsManager.shared + @ObservedObject var contactViewModel: ContactViewModel @ObservedObject var editContactViewModel: EditContactViewModel @@ -59,8 +60,8 @@ struct ContactInnerActionsFragment: View { if informationIsOpen { VStack(spacing: 0) { - if contactViewModel.indexDisplayedFriend != nil && magicSearch.lastSearch[contactViewModel.indexDisplayedFriend!].friend != nil { - ForEach(0... + */ + +import Foundation +import linphonesw + +class ContactAvatarModel: ObservableObject { + + let friend: Friend? + + let withPresence: Bool? + + @Published var lastPresenceInfo: String + + @Published var presenceStatus: ConsolidatedPresence + + private var friendDelegate: FriendDelegate? + + init(friend: Friend?, withPresence: Bool?) { + self.friend = friend + 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 self.friendDelegate != nil { + self.friend!.removeDelegate(delegate: self.friendDelegate!) + self.friendDelegate = nil + } + + addDelegate() + } else { + self.lastPresenceInfo = "" + self.presenceStatus = .Offline + } + } + + func addDelegate() { + let newFriendDelegate = FriendDelegateStub( + onPresenceReceived: { (linphoneFriend: Friend) -> Void in + DispatchQueue.main.sync { + self.presenceStatus = linphoneFriend.consolidatedPresence + if linphoneFriend.consolidatedPresence == .Online || linphoneFriend.consolidatedPresence == .Busy { + if linphoneFriend.consolidatedPresence == .Online || linphoneFriend.presenceModel!.latestActivityTimestamp != -1 { + self.lastPresenceInfo = linphoneFriend.consolidatedPresence == .Online ? "Online" : self.getCallTime(startDate: linphoneFriend.presenceModel!.latestActivityTimestamp) + } else { + self.lastPresenceInfo = "Away" + } + } else { + self.lastPresenceInfo = "" + } + } + } + ) + + friendDelegate = newFriendDelegate + if friendDelegate != nil { + friend!.addDelegate(delegate: friendDelegate!) + } + } + + func removeAllDelegate() { + if friendDelegate != nil { + presenceStatus = .Offline + friend!.removeDelegate(delegate: friendDelegate!) + 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) + } + } +} diff --git a/Linphone/UI/Main/Contacts/ViewModel/ContactViewModel.swift b/Linphone/UI/Main/Contacts/ViewModel/ContactViewModel.swift index a3f8443c7..1c17f540c 100644 --- a/Linphone/UI/Main/Contacts/ViewModel/ContactViewModel.swift +++ b/Linphone/UI/Main/Contacts/ViewModel/ContactViewModel.swift @@ -29,9 +29,5 @@ class ContactViewModel: ObservableObject { var selectedFriendToShare: Friend? var selectedFriendToDelete: Friend? - private var magicSearch = MagicSearchSingleton.shared - - init() { - magicSearch.searchForContacts( - sourceFlags: MagicSearch.Source.Friends.rawValue | MagicSearch.Source.LdapServers.rawValue)} + init() {} } diff --git a/Linphone/UI/Main/Contacts/ViewModel/ContactsListViewModel.swift b/Linphone/UI/Main/Contacts/ViewModel/ContactsListViewModel.swift index a126f5975..3c59661d2 100644 --- a/Linphone/UI/Main/Contacts/ViewModel/ContactsListViewModel.swift +++ b/Linphone/UI/Main/Contacts/ViewModel/ContactsListViewModel.swift @@ -20,6 +20,6 @@ import linphonesw class ContactsListViewModel: ObservableObject { - + init() {} } diff --git a/Linphone/UI/Main/ContentView.swift b/Linphone/UI/Main/ContentView.swift index cdffd8cd0..186b5fd3f 100644 --- a/Linphone/UI/Main/ContentView.swift +++ b/Linphone/UI/Main/ContentView.swift @@ -28,7 +28,7 @@ struct ContentView: View { @ObservedObject private var coreContext = CoreContext.shared @ObservedObject private var sharedMainViewModel = SharedMainViewModel.shared - var contactManager = ContactsManager.shared + @ObservedObject var contactsManager = ContactsManager.shared var magicSearch = MagicSearchSingleton.shared @ObservedObject var contactViewModel: ContactViewModel @@ -146,7 +146,7 @@ struct ContentView: View { Button { isMenuOpen = false magicSearch.allContact = true - magicSearch.searchForContacts( + MagicSearchSingleton.shared.searchForContacts( sourceFlags: MagicSearch.Source.Friends.rawValue | MagicSearch.Source.LdapServers.rawValue) } label: { HStack { @@ -163,7 +163,7 @@ struct ContentView: View { Button { isMenuOpen = false magicSearch.allContact = false - magicSearch.searchForContacts( + MagicSearchSingleton.shared.searchForContacts( sourceFlags: MagicSearch.Source.Friends.rawValue | MagicSearch.Source.LdapServers.rawValue) } label: { HStack { @@ -219,7 +219,7 @@ struct ContentView: View { if index == 0 { magicSearch.currentFilter = "" - magicSearch.searchForContacts( + MagicSearchSingleton.shared.searchForContacts( sourceFlags: MagicSearch.Source.Friends.rawValue | MagicSearch.Source.LdapServers.rawValue) } else { historyListViewModel.resetFilterCallLogs() @@ -256,7 +256,7 @@ struct ContentView: View { .onChange(of: text) { newValue in if index == 0 { magicSearch.currentFilter = newValue - magicSearch.searchForContacts( + MagicSearchSingleton.shared.searchForContacts( sourceFlags: MagicSearch.Source.Friends.rawValue | MagicSearch.Source.LdapServers.rawValue) } else { historyListViewModel.filterCallLogs(filter: text) @@ -284,7 +284,7 @@ struct ContentView: View { } .onChange(of: text) { newValue in magicSearch.currentFilter = newValue - magicSearch.searchForContacts( + MagicSearchSingleton.shared.searchForContacts( sourceFlags: MagicSearch.Source.Friends.rawValue | MagicSearch.Source.LdapServers.rawValue) } } @@ -428,7 +428,20 @@ struct ContentView: View { .background(Color.gray100) .ignoresSafeArea(.keyboard) } else if self.index == 1 { + let fromAddressFriend = historyViewModel.displayedCall != nil ? contactsManager.getFriendWithAddress(address: historyViewModel.displayedCall!.fromAddress!) : nil + let toAddressFriend = historyViewModel.displayedCall != nil ? contactsManager.getFriendWithAddress(address: historyViewModel.displayedCall!.toAddress!) : nil + let addressFriend = historyViewModel.displayedCall != nil ? (historyViewModel.displayedCall!.dir == .Incoming ? fromAddressFriend : toAddressFriend) : nil + + let contactAvatarModel = addressFriend != nil + ? ContactsManager.shared.avatarListModel.first(where: { + ($0.friend!.consolidatedPresence == .Online || $0.friend!.consolidatedPresence == .Busy) + && $0.friend!.name == addressFriend!.name + && $0.friend!.address!.asStringUriOnly() == addressFriend!.address!.asStringUriOnly() + }) + : ContactAvatarModel(friend: nil, withPresence: false) + HistoryContactFragment( + contactAvatarModel: contactAvatarModel!, historyViewModel: historyViewModel, historyListViewModel: historyListViewModel, contactViewModel: contactViewModel, @@ -478,6 +491,7 @@ struct ContentView: View { if isShowEditContactFragment { EditContactFragment( editContactViewModel: editContactViewModel, + contactViewModel: contactViewModel, isShowEditContactFragment: $isShowEditContactFragment, isShowDismissPopup: $isShowDismissPopup ) @@ -494,7 +508,7 @@ struct ContentView: View { contactViewModel.selectedFriend != nil ? "Delete \(contactViewModel.selectedFriend!.name!)?" : (contactViewModel.indexDisplayedFriend != nil - ? "Delete \(magicSearch.lastSearch[contactViewModel.indexDisplayedFriend!].friend!.name!)?" + ? "Delete \(contactsManager.lastSearch[contactViewModel.indexDisplayedFriend!].friend!.name!)?" : "Error Name")), content: Text("This contact will be deleted definitively."), titleFirstButton: Text("Cancel"), @@ -514,9 +528,9 @@ struct ContentView: View { withAnimation { contactViewModel.indexDisplayedFriend = nil } - magicSearch.lastSearch[tmpIndex!].friend!.remove() + contactsManager.lastSearch[tmpIndex!].friend!.remove() } - magicSearch.searchForContacts( + MagicSearchSingleton.shared.searchForContacts( sourceFlags: MagicSearch.Source.Friends.rawValue | MagicSearch.Source.LdapServers.rawValue) self.isShowDeleteContactPopup.toggle() }) @@ -611,7 +625,7 @@ struct ContentView: View { } .onChange(of: scenePhase) { newPhase in if newPhase == .active { - ContactsManager.shared.fetchContacts() + contactsManager.fetchContacts() print("Active") } else if newPhase == .inactive { print("Inactive") diff --git a/Linphone/UI/Main/History/Fragments/HistoryContactFragment.swift b/Linphone/UI/Main/History/Fragments/HistoryContactFragment.swift index 139245706..dbb5c4a4a 100644 --- a/Linphone/UI/Main/History/Fragments/HistoryContactFragment.swift +++ b/Linphone/UI/Main/History/Fragments/HistoryContactFragment.swift @@ -25,6 +25,9 @@ struct HistoryContactFragment: View { @State private var orientation = UIDevice.current.orientation @ObservedObject private var sharedMainViewModel = SharedMainViewModel.shared + @ObservedObject var contactsManager = ContactsManager.shared + + @ObservedObject var contactAvatarModel: ContactAvatarModel @ObservedObject var historyViewModel: HistoryViewModel @ObservedObject var historyListViewModel: HistoryListViewModel @ObservedObject var contactViewModel: ContactViewModel @@ -66,14 +69,14 @@ struct HistoryContactFragment: View { Spacer() Menu { - let fromAddressFriend = historyViewModel.displayedCall != nil ? ContactsManager.shared.getFriendWithAddress(address: historyViewModel.displayedCall!.fromAddress!) : nil - let toAddressFriend = historyViewModel.displayedCall != nil ? ContactsManager.shared.getFriendWithAddress(address: historyViewModel.displayedCall!.toAddress!) : nil + let fromAddressFriend = historyViewModel.displayedCall != nil ? contactsManager.getFriendWithAddress(address: historyViewModel.displayedCall!.fromAddress!) : nil + let toAddressFriend = historyViewModel.displayedCall != nil ? contactsManager.getFriendWithAddress(address: historyViewModel.displayedCall!.toAddress!) : nil let addressFriend = historyViewModel.displayedCall != nil ? (historyViewModel.displayedCall!.dir == .Incoming ? fromAddressFriend : toAddressFriend) : nil Button { isMenuOpen = false - if ContactsManager.shared.getFriendWithAddress( + if contactsManager.getFriendWithAddress( address: historyViewModel.displayedCall != nil && historyViewModel.displayedCall!.dir == .Outgoing ? historyViewModel.displayedCall!.toAddress! : historyViewModel.displayedCall!.fromAddress! @@ -82,7 +85,7 @@ struct HistoryContactFragment: View { ? historyViewModel.displayedCall!.toAddress! : historyViewModel.displayedCall!.fromAddress! - let friendIndex = MagicSearchSingleton.shared.lastSearch.firstIndex( + let friendIndex = contactsManager.lastSearch.firstIndex( where: {$0.friend!.addresses.contains(where: {$0.asStringUriOnly() == addressCall.asStringUriOnly()})}) if friendIndex != nil { @@ -190,40 +193,19 @@ struct HistoryContactFragment: View { VStack(spacing: 0) { VStack(spacing: 0) { - let fromAddressFriend = historyViewModel.displayedCall != nil ? ContactsManager.shared.getFriendWithAddress(address: historyViewModel.displayedCall!.fromAddress!) : nil - let toAddressFriend = historyViewModel.displayedCall != nil ? ContactsManager.shared.getFriendWithAddress(address: historyViewModel.displayedCall!.toAddress!) : nil + let fromAddressFriend = historyViewModel.displayedCall != nil ? contactsManager.getFriendWithAddress(address: historyViewModel.displayedCall!.fromAddress!) : nil + let toAddressFriend = historyViewModel.displayedCall != nil ? contactsManager.getFriendWithAddress(address: historyViewModel.displayedCall!.toAddress!) : nil let addressFriend = historyViewModel.displayedCall != nil ? (historyViewModel.displayedCall!.dir == .Incoming ? fromAddressFriend : toAddressFriend) : nil if historyViewModel.displayedCall != nil && addressFriend != nil && addressFriend!.photo != nil && !addressFriend!.photo!.isEmpty { - AsyncImage( - url: ContactsManager.shared.getImagePath( - friendPhotoPath: addressFriend!.photo!)) { image in - switch image { - case .empty: - ProgressView() - .frame(width: 100, height: 100) - case .success(let image): - image - .resizable() - .aspectRatio(contentMode: .fill) - .frame(width: 100, height: 100) - .clipShape(Circle()) - case .failure: - Image("profil-picture-default") - .resizable() - .frame(width: 100, height: 100) - .clipShape(Circle()) - @unknown default: - EmptyView() - } - } + Avatar(contactAvatarModel: contactAvatarModel, avatarSize: 100) } else if historyViewModel.displayedCall != nil { if historyViewModel.displayedCall!.dir == .Outgoing && historyViewModel.displayedCall!.toAddress != nil { if historyViewModel.displayedCall!.toAddress!.displayName != nil { - Image(uiImage: ContactsManager.shared.textToImage( + Image(uiImage: contactsManager.textToImage( firstName: historyViewModel.displayedCall!.toAddress!.displayName!, lastName: historyViewModel.displayedCall!.toAddress!.displayName!.components(separatedBy: " ").count > 1 ? historyViewModel.displayedCall!.toAddress!.displayName!.components(separatedBy: " ")[1] @@ -252,7 +234,7 @@ struct HistoryContactFragment: View { .frame(maxWidth: .infinity) .frame(height: 20) } else { - Image(uiImage: ContactsManager.shared.textToImage( + Image(uiImage: contactsManager.textToImage( firstName: historyViewModel.displayedCall!.toAddress!.username ?? "Username Error", lastName: historyViewModel.displayedCall!.toAddress!.username!.components(separatedBy: " ").count > 1 ? historyViewModel.displayedCall!.toAddress!.username!.components(separatedBy: " ")[1] @@ -284,7 +266,7 @@ struct HistoryContactFragment: View { } else if historyViewModel.displayedCall!.fromAddress != nil { if historyViewModel.displayedCall!.fromAddress!.displayName != nil { - Image(uiImage: ContactsManager.shared.textToImage( + Image(uiImage: contactsManager.textToImage( firstName: historyViewModel.displayedCall!.fromAddress!.displayName!, lastName: historyViewModel.displayedCall!.fromAddress!.displayName!.components(separatedBy: " ").count > 1 ? historyViewModel.displayedCall!.fromAddress!.displayName!.components(separatedBy: " ")[1] @@ -313,7 +295,7 @@ struct HistoryContactFragment: View { .frame(maxWidth: .infinity) .frame(height: 20) } else { - Image(uiImage: ContactsManager.shared.textToImage( + Image(uiImage: contactsManager.textToImage( firstName: historyViewModel.displayedCall!.fromAddress!.username ?? "Username Error", lastName: historyViewModel.displayedCall!.fromAddress!.username!.components(separatedBy: " ").count > 1 ? historyViewModel.displayedCall!.fromAddress!.username!.components(separatedBy: " ")[1] @@ -370,13 +352,15 @@ struct HistoryContactFragment: View { .padding(.top, 5) } - Text("En ligne") - .foregroundStyle(Color.greenSuccess500) + 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) + .padding(.top, 5) } } .frame(minHeight: 150) @@ -508,7 +492,11 @@ struct HistoryContactFragment: View { .frame(maxWidth: .infinity, alignment: .leading) Text(historyListViewModel.getCallTime(startDate: callLogsFilter[index].startDate)) - .foregroundStyle(callLogsFilter[index].status != .Success ? Color.redDanger500 : Color.grayMain2c600) + .foregroundStyle( + callLogsFilter[index].status != .Success + ? Color.redDanger500 + : Color.grayMain2c600 + ) .default_text_style_300(styleSize: 12) .frame(maxWidth: .infinity, alignment: .leading) } @@ -546,7 +534,8 @@ struct HistoryContactFragment: View { #Preview { HistoryContactFragment( - historyViewModel: HistoryViewModel(), + contactAvatarModel: ContactAvatarModel(friend: nil, withPresence: false), + historyViewModel: HistoryViewModel(), historyListViewModel: HistoryListViewModel(), contactViewModel: ContactViewModel(), editContactViewModel: EditContactViewModel(), diff --git a/Linphone/UI/Main/History/Fragments/HistoryListBottomSheet.swift b/Linphone/UI/Main/History/Fragments/HistoryListBottomSheet.swift index e5a58b79d..fb8dfd731 100644 --- a/Linphone/UI/Main/History/Fragments/HistoryListBottomSheet.swift +++ b/Linphone/UI/Main/History/Fragments/HistoryListBottomSheet.swift @@ -27,6 +27,7 @@ struct HistoryListBottomSheet: View { 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 @@ -76,7 +77,7 @@ struct HistoryListBottomSheet: View { index = 0 - if ContactsManager.shared.getFriendWithAddress( + if contactsManager.getFriendWithAddress( address: historyViewModel.selectedCall != nil && historyViewModel.selectedCall!.dir == .Outgoing ? historyViewModel.selectedCall!.toAddress! : historyViewModel.selectedCall!.fromAddress! @@ -85,7 +86,7 @@ struct HistoryListBottomSheet: View { ? historyViewModel.selectedCall!.toAddress! : historyViewModel.selectedCall!.fromAddress! - let friendIndex = MagicSearchSingleton.shared.lastSearch.firstIndex(where: {$0.friend!.addresses.contains(where: {$0.asStringUriOnly() == addressCall.asStringUriOnly()})}) + let friendIndex = contactsManager.lastSearch.firstIndex(where: {$0.friend!.addresses.contains(where: {$0.asStringUriOnly() == addressCall.asStringUriOnly()})}) if friendIndex != nil { withAnimation { contactViewModel.indexDisplayedFriend = friendIndex @@ -105,7 +106,7 @@ struct HistoryListBottomSheet: View { } } label: { HStack { - if ContactsManager.shared.getFriendWithAddress( + if contactsManager.getFriendWithAddress( address: historyViewModel.selectedCall != nil && historyViewModel.selectedCall!.dir == .Outgoing ? historyViewModel.selectedCall!.toAddress! : historyViewModel.selectedCall!.fromAddress! diff --git a/Linphone/UI/Main/History/Fragments/HistoryListFragment.swift b/Linphone/UI/Main/History/Fragments/HistoryListFragment.swift index 677623806..50fd2513a 100644 --- a/Linphone/UI/Main/History/Fragments/HistoryListFragment.swift +++ b/Linphone/UI/Main/History/Fragments/HistoryListFragment.swift @@ -22,6 +22,8 @@ import linphonesw struct HistoryListFragment: View { + @ObservedObject var contactsManager = ContactsManager.shared + @ObservedObject var historyListViewModel: HistoryListViewModel @ObservedObject var historyViewModel: HistoryViewModel @@ -34,37 +36,24 @@ struct HistoryListFragment: View { Button { } label: { HStack { - let fromAddressFriend = ContactsManager.shared.getFriendWithAddress(address: historyListViewModel.callLogs[index].fromAddress!) - let toAddressFriend = ContactsManager.shared.getFriendWithAddress(address: historyListViewModel.callLogs[index].toAddress!) + let fromAddressFriend = contactsManager.getFriendWithAddress(address: historyListViewModel.callLogs[index].fromAddress!) + let toAddressFriend = contactsManager.getFriendWithAddress(address: historyListViewModel.callLogs[index].toAddress!) let addressFriend = historyListViewModel.callLogs[index].dir == .Incoming ? fromAddressFriend : toAddressFriend + let contactAvatarModel = addressFriend != nil + ? ContactsManager.shared.avatarListModel.first(where: { + ($0.friend!.consolidatedPresence == .Online || $0.friend!.consolidatedPresence == .Busy) + && $0.friend!.name == addressFriend!.name + && $0.friend!.address!.asStringUriOnly() == addressFriend!.address!.asStringUriOnly() + }) + : ContactAvatarModel(friend: nil, withPresence: false) + if addressFriend != nil && addressFriend!.photo != nil && !addressFriend!.photo!.isEmpty { - AsyncImage(url: - ContactsManager.shared.getImagePath( - friendPhotoPath: addressFriend!.photo!)) { image in - switch image { - case .empty: - ProgressView() - .frame(width: 45, height: 45) - case .success(let image): - image - .resizable() - .aspectRatio(contentMode: .fill) - .frame(width: 45, height: 45) - .clipShape(Circle()) - case .failure: - Image("profil-picture-default") - .resizable() - .frame(width: 45, height: 45) - .clipShape(Circle()) - @unknown default: - EmptyView() - } - } + Avatar(contactAvatarModel: contactAvatarModel!, avatarSize: 45) } else { if historyListViewModel.callLogs[index].dir == .Outgoing && historyListViewModel.callLogs[index].toAddress != nil { if historyListViewModel.callLogs[index].toAddress!.displayName != nil { - Image(uiImage: ContactsManager.shared.textToImage( + Image(uiImage: contactsManager.textToImage( firstName: historyListViewModel.callLogs[index].toAddress!.displayName!, lastName: historyListViewModel.callLogs[index].toAddress!.displayName!.components(separatedBy: " ").count > 1 ? historyListViewModel.callLogs[index].toAddress!.displayName!.components(separatedBy: " ")[1] @@ -74,7 +63,7 @@ struct HistoryListFragment: View { .clipShape(Circle()) } else { - Image(uiImage: ContactsManager.shared.textToImage( + Image(uiImage: contactsManager.textToImage( firstName: historyListViewModel.callLogs[index].toAddress!.username ?? "Username Error", lastName: historyListViewModel.callLogs[index].toAddress!.username!.components(separatedBy: " ").count > 1 ? historyListViewModel.callLogs[index].toAddress!.username!.components(separatedBy: " ")[1] @@ -86,7 +75,7 @@ struct HistoryListFragment: View { } else if historyListViewModel.callLogs[index].fromAddress != nil { if historyListViewModel.callLogs[index].fromAddress!.displayName != nil { - Image(uiImage: ContactsManager.shared.textToImage( + Image(uiImage: contactsManager.textToImage( firstName: historyListViewModel.callLogs[index].fromAddress!.displayName!, lastName: historyListViewModel.callLogs[index].fromAddress!.displayName!.components(separatedBy: " ").count > 1 ? historyListViewModel.callLogs[index].fromAddress!.displayName!.components(separatedBy: " ")[1] @@ -95,7 +84,7 @@ struct HistoryListFragment: View { .frame(width: 45, height: 45) .clipShape(Circle()) } else { - Image(uiImage: ContactsManager.shared.textToImage( + Image(uiImage: contactsManager.textToImage( firstName: historyListViewModel.callLogs[index].fromAddress!.username ?? "Username Error", lastName: historyListViewModel.callLogs[index].fromAddress!.username!.components(separatedBy: " ").count > 1 ? historyListViewModel.callLogs[index].fromAddress!.username!.components(separatedBy: " ")[1] @@ -110,8 +99,8 @@ struct HistoryListFragment: View { VStack(spacing: 0) { Spacer() - let fromAddressFriend = ContactsManager.shared.getFriendWithAddress(address: historyListViewModel.callLogs[index].fromAddress!) - let toAddressFriend = ContactsManager.shared.getFriendWithAddress(address: historyListViewModel.callLogs[index].toAddress!) + let fromAddressFriend = contactsManager.getFriendWithAddress(address: historyListViewModel.callLogs[index].fromAddress!) + let toAddressFriend = contactsManager.getFriendWithAddress(address: historyListViewModel.callLogs[index].toAddress!) let addressFriend = historyListViewModel.callLogs[index].dir == .Incoming ? fromAddressFriend : toAddressFriend if addressFriend != nil { diff --git a/Linphone/UI/Main/History/ViewModel/HistoryListViewModel.swift b/Linphone/UI/Main/History/ViewModel/HistoryListViewModel.swift index 3251d2511..19e7c9668 100644 --- a/Linphone/UI/Main/History/ViewModel/HistoryListViewModel.swift +++ b/Linphone/UI/Main/History/ViewModel/HistoryListViewModel.swift @@ -53,9 +53,9 @@ class HistoryListViewModel: ObservableObject { DispatchQueue.main.async { self.coreDelegate = CoreDelegateStub( onCallLogUpdated: { (_: Core, _: CallLog) -> Void in - DispatchQueue.main.async { + DispatchQueue.main.sync { let account = core.defaultAccount - let logs = account?.callLogs != nil ? account!.callLogs : core.callLogs + let logs = account != nil ? account!.callLogs : core.callLogs self.callLogs.removeAll() self.callLogsTmp.removeAll() @@ -71,7 +71,6 @@ class HistoryListViewModel: ObservableObject { core.addDelegate(delegate: self.coreDelegate!) } } - } } diff --git a/Linphone/UI/Main/Viewmodel/ToastViewModel.swift b/Linphone/UI/Main/Viewmodel/ToastViewModel.swift index 6fb3287a9..b046c5a06 100644 --- a/Linphone/UI/Main/Viewmodel/ToastViewModel.swift +++ b/Linphone/UI/Main/Viewmodel/ToastViewModel.swift @@ -1,9 +1,21 @@ -// -// ToastViewModel.swift -// Linphone -// -// Created by Benoît Martins on 20/11/2023. -// +/* + * 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 diff --git a/Linphone/Utils/Avatar.swift b/Linphone/Utils/Avatar.swift index 16d3f9fba..609b13885 100644 --- a/Linphone/Utils/Avatar.swift +++ b/Linphone/Utils/Avatar.swift @@ -22,84 +22,45 @@ import linphonesw struct Avatar: View { - var friend: Friend - let avatarSize: CGFloat - - @State private var friendDelegate: FriendDelegate? - @State private var presenceImage = "" - - var body: some View { - AsyncImage(url: ContactsManager.shared.getImagePath(friendPhotoPath: 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 !friend.addresses.isEmpty { - if presenceImage.isEmpty - && (friend.consolidatedPresence == ConsolidatedPresence.Online || friend.consolidatedPresence == ConsolidatedPresence.Busy) { - Image(friend.consolidatedPresence == ConsolidatedPresence.Online ? "presence-online" : "presence-busy") - .resizable() - .frame(width: avatarSize/4, height: avatarSize/4) - .padding(.trailing, avatarSize == 45 ? 1 : 3) - .padding(.bottom, avatarSize == 45 ? 1 : 3) - } else if !presenceImage.isEmpty { - Image(presenceImage) - .resizable() - .frame(width: avatarSize/4, height: avatarSize/4) - .padding(.trailing, avatarSize == 45 ? 1 : 3) - .padding(.bottom, avatarSize == 45 ? 1 : 3) - } - } - } - } - .frame(width: avatarSize, height: avatarSize) - } - .onAppear { - addDelegate() + @ObservedObject var contactAvatarModel: ContactAvatarModel + let avatarSize: CGFloat + + var body: some View { + 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 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 == 45 ? 1 : 3) + .padding(.bottom, avatarSize == 45 ? 1 : 3) + } + } + } + .frame(width: avatarSize, height: avatarSize) } - .onDisappear { - removeAllDelegate() - } - case .failure: - Image("profil-picture-default") - .resizable() - .frame(width: avatarSize, height: avatarSize) - .clipShape(Circle()) - @unknown default: - EmptyView() - } - } - } - - func addDelegate() { - let newFriendDelegate = FriendDelegateStub( - onPresenceReceived: { (linphoneFriend: Friend) -> Void in - self.presenceImage = linphoneFriend.consolidatedPresence == ConsolidatedPresence.Online ? "presence-online" : "presence-busy" - } - ) - - friendDelegate = newFriendDelegate - if friendDelegate != nil { - friend.addDelegate(delegate: friendDelegate!) - } - } - - func removeAllDelegate() { - if friendDelegate != nil { - presenceImage = "" - friend.removeDelegate(delegate: friendDelegate!) - friendDelegate = nil + case .failure: + Image("profil-picture-default") + .resizable() + .frame(width: avatarSize, height: avatarSize) + .clipShape(Circle()) + @unknown default: + EmptyView() + } } } } diff --git a/Linphone/Utils/IntExtension.swift b/Linphone/Utils/IntExtension.swift index fca8ba9d1..0f457700a 100644 --- a/Linphone/Utils/IntExtension.swift +++ b/Linphone/Utils/IntExtension.swift @@ -28,7 +28,7 @@ extension Int { public func convertDurationToString() -> String { var duration = "" let (hour, minute, second) = self.hmsFrom() - if (hour > 0) { + if hour > 0 { duration = self.getHour(hour: hour) } return "\(duration)\(self.getMinute(minute: minute))\(self.getSecond(second: second))" @@ -36,18 +36,18 @@ extension Int { private func getHour(hour: Int) -> String { var duration = "\(hour):" - if (hour < 10) { + if hour < 10 { duration = "0\(hour):" } return duration } private func getMinute(minute: Int) -> String { - if (minute == 0) { + if minute == 0 { return "00:" } - if (minute < 10) { + if minute < 10 { return "0\(minute):" } @@ -55,11 +55,11 @@ extension Int { } private func getSecond(second: Int) -> String { - if (second == 0){ + if second == 0 { return "00" } - if (second < 10) { + if second < 10 { return "0\(second)" } return "\(second)" diff --git a/Linphone/Utils/MagicSearchSingleton.swift b/Linphone/Utils/MagicSearchSingleton.swift index 7ce369b0a..488eb5d4b 100644 --- a/Linphone/Utils/MagicSearchSingleton.swift +++ b/Linphone/Utils/MagicSearchSingleton.swift @@ -23,6 +23,7 @@ final class MagicSearchSingleton: ObservableObject { static let shared = MagicSearchSingleton() private var coreContext = CoreContext.shared + private var contactsManager = ContactsManager.shared private var magicSearch: MagicSearch! @@ -31,8 +32,6 @@ final class MagicSearchSingleton: ObservableObject { var needUpdateLastSearchContacts = false - @Published var lastSearch: [SearchResult] = [] - private var limitSearchToLinphoneAccounts = true @Published var allContact = false @@ -47,7 +46,23 @@ final class MagicSearchSingleton: ObservableObject { self.magicSearch.publisher?.onSearchResultsReceived?.postOnMainQueue { (magicSearch: MagicSearch) in self.needUpdateLastSearchContacts = true - self.lastSearch = magicSearch.lastSearch + self.contactsManager.lastSearch = magicSearch.lastSearch.sorted(by: { + $0.friend!.name!.lowercased().folding(options: .diacriticInsensitive, locale: .current) + < + $1.friend!.name!.lowercased().folding(options: .diacriticInsensitive, locale: .current) + }) + + self.contactsManager.avatarListModel.forEach { contactAvatarModel in + contactAvatarModel.removeAllDelegate() + } + + self.contactsManager.avatarListModel.removeAll() + + self.contactsManager.lastSearch.forEach { searchResult in + if searchResult.friend != nil { + self.contactsManager.avatarListModel.append(ContactAvatarModel(friend: searchResult.friend!, withPresence: true)) + } + } } } } @@ -75,4 +90,28 @@ final class MagicSearchSingleton: ObservableObject { aggregation: MagicSearch.Aggregation.Friend) } } + + func searchForContactsWithResult(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) + } + } } From 8b14538fcdc8b7595bfee9ea6a7731a1cde73790 Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Fri, 1 Dec 2023 10:20:31 +0100 Subject: [PATCH 049/486] Send own presence --- Linphone/Core/CoreContext.swift | 27 +++++++++++++++++++ .../Fragments/EditContactFragment.swift | 1 - Linphone/UI/Main/ContentView.swift | 2 ++ 3 files changed, 29 insertions(+), 1 deletion(-) diff --git a/Linphone/Core/CoreContext.swift b/Linphone/Core/CoreContext.swift index 9f8e1d5bf..b6699bb8c 100644 --- a/Linphone/Core/CoreContext.swift +++ b/Linphone/Core/CoreContext.swift @@ -123,6 +123,9 @@ final class CoreContext: ObservableObject { if cbVal.state == .Ok { self.loggingInProgress = false self.loggedIn = true + if self.mCore.consolidatedPresence != ConsolidatedPresence.Online { + self.onForeground() + } } else if cbVal.state == .Progress { self.loggingInProgress = true } else { @@ -154,6 +157,30 @@ final class CoreContext: ObservableObject { } } + + func onForeground() { + coreQueue.async { + // We can't rely on defaultAccount?.params?.isPublishEnabled + // as it will be modified by the SDK when changing the presence status + if self.mCore.config!.getBool(section: "app", key: "publish_presence", defaultValue: true) { + NSLog("App is in foreground, PUBLISHING presence as Online") + self.mCore.consolidatedPresence = ConsolidatedPresence.Online + } + } + } + + func onBackground() { + coreQueue.async { + // We can't rely on defaultAccount?.params?.isPublishEnabled + // as it will be modified by the SDK when changing the presence status + if self.mCore.config!.getBool(section: "app", key: "publish_presence", defaultValue: true) { + NSLog("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.mCore.consolidatedPresence = ConsolidatedPresence.Offline + } + } + } } // swiftlint:enable large_tuple diff --git a/Linphone/UI/Main/Contacts/Fragments/EditContactFragment.swift b/Linphone/UI/Main/Contacts/Fragments/EditContactFragment.swift index abb0fda21..2e5f702f1 100644 --- a/Linphone/UI/Main/Contacts/Fragments/EditContactFragment.swift +++ b/Linphone/UI/Main/Contacts/Fragments/EditContactFragment.swift @@ -504,7 +504,6 @@ struct EditContactFragment: View { let result = ContactsManager.shared.lastSearch.firstIndex(where: { $0.friend!.name == newContact.firstName + " " + newContact.lastName }) - print("getFriendIndexWithFriendgetFriendIndexWithFriend \(newContact.firstName) \(newContact.lastName) \(result)") contactViewModel.indexDisplayedFriend = result } } diff --git a/Linphone/UI/Main/ContentView.swift b/Linphone/UI/Main/ContentView.swift index 186b5fd3f..9096f3117 100644 --- a/Linphone/UI/Main/ContentView.swift +++ b/Linphone/UI/Main/ContentView.swift @@ -625,11 +625,13 @@ struct ContentView: View { } .onChange(of: scenePhase) { newPhase in if newPhase == .active { + coreContext.onForeground() contactsManager.fetchContacts() print("Active") } else if newPhase == .inactive { print("Inactive") } else if newPhase == .background { + coreContext.onBackground() print("Background") } } From e47a04c5d9e0065bfdc7d3823d60dffcb2b02c01 Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Fri, 1 Dec 2023 16:36:11 +0100 Subject: [PATCH 050/486] Start new call view --- Linphone.xcodeproj/project.pbxproj | 12 + .../dialer.imageset/Contents.json | 21 ++ .../dialer.imageset/dialer.svg | 3 + Linphone/Contacts/ContactsManager.swift | 13 +- Linphone/Core/CoreContext.swift | 5 +- Linphone/LinphoneApp.swift | 12 +- Linphone/Localizable.xcstrings | 51 +++ .../Fragments/PermissionsFragment.swift | 5 +- .../Fragments/ContactsInnerFragment.swift | 24 +- .../Fragments/ContactsListFragment.swift | 174 +++++----- .../Fragments/EditContactFragment.swift | 4 +- Linphone/UI/Main/ContentView.swift | 32 +- .../History/Fragments/DialerBottomSheet.swift | 320 ++++++++++++++++++ .../Fragments/HistoryListFragment.swift | 4 +- .../History/Fragments/StartCallFragment.swift | 245 ++++++++++++++ Linphone/UI/Main/History/HistoryView.swift | 6 + .../ViewModel/StartCallViewModel.swift | 27 ++ Linphone/Utils/EditContactController.swift | 3 +- Linphone/Utils/MagicSearchSingleton.swift | 35 +- Linphone/Utils/PermissionManager.swift | 7 + 20 files changed, 876 insertions(+), 127 deletions(-) create mode 100644 Linphone/Assets.xcassets/dialer.imageset/Contents.json create mode 100644 Linphone/Assets.xcassets/dialer.imageset/dialer.svg create mode 100644 Linphone/UI/Main/History/Fragments/DialerBottomSheet.swift create mode 100644 Linphone/UI/Main/History/Fragments/StartCallFragment.swift create mode 100644 Linphone/UI/Main/History/ViewModel/StartCallViewModel.swift diff --git a/Linphone.xcodeproj/project.pbxproj b/Linphone.xcodeproj/project.pbxproj index 60a8812a8..3636ae410 100644 --- a/Linphone.xcodeproj/project.pbxproj +++ b/Linphone.xcodeproj/project.pbxproj @@ -28,6 +28,8 @@ 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 */; }; D732A9092AFD235500DB42BA /* ShareSheetController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D732A9082AFD235500DB42BA /* ShareSheetController.swift */; }; D732A90C2B0376F500DB42BA /* linphonerc-default in Resources */ = {isa = PBXBuildFile; fileRef = D732A90A2B0376F500DB42BA /* linphonerc-default */; }; @@ -51,6 +53,7 @@ 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 */; }; + D79622342B1DFE600037EACD /* DialerBottomSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = D79622332B1DFE600037EACD /* DialerBottomSheet.swift */; }; D796F2002B0BB61A0041115F /* ToastViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D796F1FF2B0BB61A0041115F /* ToastViewModel.swift */; }; D7A03FBD2ACC2DB60081A588 /* ContactsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7A03FBC2ACC2DB60081A588 /* ContactsView.swift */; }; D7A03FC02ACC2E390081A588 /* HistoryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7A03FBF2ACC2E390081A588 /* HistoryView.swift */; }; @@ -106,6 +109,8 @@ 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 = ""; }; 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 = ""; }; @@ -129,6 +134,7 @@ 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 = ""; }; + D79622332B1DFE600037EACD /* DialerBottomSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DialerBottomSheet.swift; sourceTree = ""; }; D796F1FF2B0BB61A0041115F /* ToastViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToastViewModel.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 = ""; }; @@ -295,6 +301,7 @@ children = ( D72250622ADE9615008FB426 /* HistoryViewModel.swift */, D732A9142B04C7FE00DB42BA /* HistoryListViewModel.swift */, + D726E43E2B19E56F0083C415 /* StartCallViewModel.swift */, ); path = ViewModel; sourceTree = ""; @@ -314,6 +321,8 @@ D732A90E2B04C3B400DB42BA /* HistoryFragment.swift */, D732A9122B04C7A300DB42BA /* HistoryListFragment.swift */, D732A91A2B061BD900DB42BA /* HistoryListBottomSheet.swift */, + D726E43C2B19E4FE0083C415 /* StartCallFragment.swift */, + D79622332B1DFE600037EACD /* DialerBottomSheet.swift */, ); path = Fragments; sourceTree = ""; @@ -576,6 +585,7 @@ D7A03FBD2ACC2DB60081A588 /* ContactsView.swift in Sources */, D719ABCF2ABC779A00B41C10 /* AccountLoginViewModel.swift in Sources */, D732A90F2B04C3B400DB42BA /* HistoryFragment.swift in Sources */, + D79622342B1DFE600037EACD /* DialerBottomSheet.swift in Sources */, D78290BB2ADD40B2004AA85C /* ContactViewModel.swift in Sources */, D72992392ADD7F68003AF125 /* HistoryContactFragment.swift in Sources */, D7B5066D2AEFA9B900CEB4E9 /* ContactInnerFragment.swift in Sources */, @@ -605,7 +615,9 @@ D7C48DF62AFCDF4700D938CB /* ContactInnerActionsFragment.swift in Sources */, D72343322ACEFF58009AA24E /* QRScannerController.swift in Sources */, D72343342ACEFFC3009AA24E /* QRScanner.swift in Sources */, + D726E43D2B19E4FE0083C415 /* StartCallFragment.swift in Sources */, D72343302ACEFEF8009AA24E /* QrCodeScannerFragment.swift in Sources */, + D726E43F2B19E56F0083C415 /* StartCallViewModel.swift in Sources */, D7D1698C2AE66FA500109A5C /* MagicSearchSingleton.swift in Sources */, D72250692ADFBF2D008FB426 /* SideMenu.swift in Sources */, D717071E2AC5922E0037746F /* ColorExtension.swift in Sources */, 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/Contacts/ContactsManager.swift b/Linphone/Contacts/ContactsManager.swift index 6b80d8131..139e62338 100644 --- a/Linphone/Contacts/ContactsManager.swift +++ b/Linphone/Contacts/ContactsManager.swift @@ -35,6 +35,7 @@ final class ContactsManager: ObservableObject { var linphoneFriendList: FriendList? @Published var lastSearch: [SearchResult] = [] + @Published var lastSearchSuggestions: [SearchResult] = [] @Published var avatarListModel: [ContactAvatarModel] = [] private init() { @@ -121,7 +122,8 @@ final class ContactsManager: ObservableObject { && contact.phoneNumbers.first?.value.stringValue != nil ? contact.phoneNumbers.first!.value.stringValue : contact.givenName, lastName: contact.familyName), - name: contact.givenName + contact.familyName + String(Int.random(in: 1...1000)) + ((imageThumbnail == nil) ? "-default" : ""), + name: contact.givenName + contact.familyName, + prefix: String(Int.random(in: 1...1000)) + ((imageThumbnail == nil) ? "-default" : ""), contact: newContact, linphoneFriend: false, existingFriend: nil) } }) @@ -167,12 +169,12 @@ final class ContactsManager: ObservableObject { return IBImgViewUserProfile } - func saveImage(image: UIImage, name: String, contact: Contact, linphoneFriend: Bool, existingFriend: Friend?) { + func saveImage(image: UIImage, name: String, prefix: String, contact: Contact, linphoneFriend: Bool, existingFriend: Friend?) { guard let data = image.jpegData(compressionQuality: 1) ?? image.pngData() else { return } - awaitDataWrite(data: data, name: name) { _, result in + 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 { @@ -260,15 +262,16 @@ final class ContactsManager: ObservableObject { return imagePath } - func awaitDataWrite(data: Data, name: String, completion: @escaping ((), String) -> Void) { + 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) + let urlName = URL(string: name + prefix) let imagePath = urlName != nil ? urlName!.absoluteString.replacingOccurrences(of: "%", with: "") : String(Int.random(in: 1...1000)) let decodedData: () = try data.write(to: directory!.appendingPathComponent(imagePath + ".png")) + completion(decodedData, imagePath + ".png") } catch { print("Error: ", error) diff --git a/Linphone/Core/CoreContext.swift b/Linphone/Core/CoreContext.swift index b6699bb8c..ce88facec 100644 --- a/Linphone/Core/CoreContext.swift +++ b/Linphone/Core/CoreContext.swift @@ -87,15 +87,18 @@ final class CoreContext: ObservableObject { self.mCore.autoIterateEnabled = false self.mCore.friendsDatabasePath = "\(configDir)/friends.db" + print("configDirconfigDirconfigDir \(configDir)") + self.mCore.friendListSubscriptionEnabled = true self.mCore.publisher?.onGlobalStateChanged?.postOnMainQueue { (cbVal: (core: Core, state: GlobalState, message: String)) in if cbVal.state == GlobalState.On { self.defaultAccount = self.mCore.defaultAccount + self.coreIsStarted = true } else if cbVal.state == GlobalState.Off { self.defaultAccount = nil + self.coreIsStarted = true } - self.coreIsStarted = true } try? self.mCore.start() diff --git a/Linphone/LinphoneApp.swift b/Linphone/LinphoneApp.swift index 159abb97d..61e22bdf3 100644 --- a/Linphone/LinphoneApp.swift +++ b/Linphone/LinphoneApp.swift @@ -29,6 +29,7 @@ struct LinphoneApp: App { @State private var editContactViewModel: EditContactViewModel? @State private var historyViewModel: HistoryViewModel? @State private var historyListViewModel: HistoryListViewModel? + @State private var startCallViewModel: StartCallViewModel? var body: some Scene { WindowGroup { @@ -37,17 +38,21 @@ struct LinphoneApp: App { WelcomeView() } else if coreContext.defaultAccount == nil || sharedMainViewModel.displayProfileMode { AssistantView() - } else if coreContext.defaultAccount != nil + } else if coreContext.defaultAccount != nil && contactViewModel != nil && editContactViewModel != nil && historyViewModel != nil - && historyListViewModel != nil { + && historyListViewModel != nil + && startCallViewModel != nil { ContentView( contactViewModel: contactViewModel!, editContactViewModel: editContactViewModel!, historyViewModel: historyViewModel!, - historyListViewModel: historyListViewModel! + historyListViewModel: historyListViewModel!, + startCallViewModel: startCallViewModel! ) + } else { + SplashScreen() } } else { SplashScreen() @@ -56,6 +61,7 @@ struct LinphoneApp: App { editContactViewModel = EditContactViewModel() historyViewModel = HistoryViewModel() historyListViewModel = HistoryListViewModel() + startCallViewModel = StartCallViewModel() } } } diff --git a/Linphone/Localizable.xcstrings b/Linphone/Localizable.xcstrings index 394b1e7f0..569f38f71 100644 --- a/Linphone/Localizable.xcstrings +++ b/Linphone/Localizable.xcstrings @@ -27,6 +27,9 @@ }, "[notre politique de confidentialité](https://linphone.org/privacy-policy)" : { + }, + "*" : { + }, "**Camera** : Pour capturer votre vidéo lors des appels vidéo et conférence." : { @@ -45,6 +48,9 @@ }, "**Notifications** : Pour vous informé quand vous recevez un message ou un appel." : { + }, + "#" : { + }, "%lld Book (Example)" : { "extractionState" : "manual", @@ -98,6 +104,39 @@ } } } + }, + "+" : { + + }, + "0" : { + + }, + "1" : { + + }, + "2" : { + + }, + "3" : { + + }, + "4" : { + + }, + "5" : { + + }, + "6" : { + + }, + "7" : { + + }, + "8" : { + + }, + "9" : { + }, "Accept all" : { @@ -313,6 +352,9 @@ }, "My Profile" : { + }, + "New call" : { + }, "New contact" : { @@ -393,6 +435,9 @@ }, "Scan QR code" : { + }, + "Search contact or history call" : { + }, "Sécurisé" : { @@ -429,6 +474,9 @@ }, "Start" : { + }, + "Suggestions" : { + }, "TCP" : { @@ -479,6 +527,9 @@ } } } + }, + "Username error" : { + }, "Video Call" : { diff --git a/Linphone/UI/Assistant/Fragments/PermissionsFragment.swift b/Linphone/UI/Assistant/Fragments/PermissionsFragment.swift index 82f709bac..fb7eed70a 100644 --- a/Linphone/UI/Assistant/Fragments/PermissionsFragment.swift +++ b/Linphone/UI/Assistant/Fragments/PermissionsFragment.swift @@ -172,8 +172,7 @@ struct PermissionsFragment: View { .padding(.horizontal) Button { - permissionManager.contactsRequestPermission() - permissionManager.cameraRequestPermission() + permissionManager.getPermissions() } label: { Text("D'accord") .default_text_style_white_600(styleSize: 20) @@ -193,7 +192,7 @@ struct PermissionsFragment: View { } .navigationViewStyle(StackNavigationViewStyle()) .navigationBarHidden(true) - .onReceive(permissionManager.$cameraPermissionGranted, perform: { (granted) in + .onReceive(permissionManager.$contactsPermissionGranted, perform: { (granted) in if granted { withAnimation { sharedMainViewModel.changeWelcomeView() diff --git a/Linphone/UI/Main/Contacts/Fragments/ContactsInnerFragment.swift b/Linphone/UI/Main/Contacts/Fragments/ContactsInnerFragment.swift index d1cb1215f..0168bb88f 100644 --- a/Linphone/UI/Main/Contacts/Fragments/ContactsInnerFragment.swift +++ b/Linphone/UI/Main/Contacts/Fragments/ContactsInnerFragment.swift @@ -72,7 +72,29 @@ struct ContactsInnerFragment: View { .padding(.top, 10) .padding(.horizontal, 16) } - ContactsListFragment(contactViewModel: contactViewModel, contactsListViewModel: ContactsListViewModel(), showingSheet: $showingSheet) + + VStack { + List { + ContactsListFragment(contactViewModel: contactViewModel, contactsListViewModel: ContactsListViewModel(), showingSheet: $showingSheet)} + .listStyle(.plain) + .overlay( + VStack { + if contactsManager.lastSearch.isEmpty { + Spacer() + Image("illus-belledonne") + .resizable() + .scaledToFit() + .clipped() + .padding(.all) + Text("No contacts for the moment...") + .default_text_style_800(styleSize: 16) + Spacer() + Spacer() + } + } + .padding(.all) + ) + } } .navigationBarHidden(true) } diff --git a/Linphone/UI/Main/Contacts/Fragments/ContactsListFragment.swift b/Linphone/UI/Main/Contacts/Fragments/ContactsListFragment.swift index 0857c53aa..f48664a24 100644 --- a/Linphone/UI/Main/Contacts/Fragments/ContactsListFragment.swift +++ b/Linphone/UI/Main/Contacts/Fragments/ContactsListFragment.swift @@ -21,105 +21,83 @@ 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 body: some View { - VStack { - List { - ForEach(0... + */ + +import SwiftUI +import UniformTypeIdentifiers + +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 var startCallViewModel: StartCallViewModel + + @State private var orientation = UIDevice.current.orientation + + @Binding var showingDialer: Bool + + 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(Color.grayMain2c300) + .frame(width: 75, height: 5) + .padding(15) + } + + Spacer() + + HStack { + Button { + startCallViewModel.searchField += "1" + } label: { + Text("1") + .default_text_style(styleSize: 32) + .multilineTextAlignment(.center) + .frame(width: 60, height: 60) + .background(.white) + .clipShape(Circle()) + .shadow(color: .black.opacity(0.2), radius: 4) + } + + Spacer() + + Button { + startCallViewModel.searchField += "2" + } label: { + Text("2") + .default_text_style(styleSize: 32) + .multilineTextAlignment(.center) + .frame(width: 60, height: 60) + .background(.white) + .clipShape(Circle()) + .shadow(color: .black.opacity(0.2), radius: 4) + } + + Spacer() + + Button { + startCallViewModel.searchField += "3" + } label: { + Text("3") + .default_text_style(styleSize: 32) + .multilineTextAlignment(.center) + .frame(width: 60, height: 60) + .background(.white) + .clipShape(Circle()) + .shadow(color: .black.opacity(0.2), radius: 4) + } + } + .padding(.horizontal, 60) + .frame(maxWidth: sharedMainViewModel.maxWidth) + + HStack { + Button { + startCallViewModel.searchField += "4" + } label: { + Text("4") + .default_text_style(styleSize: 32) + .multilineTextAlignment(.center) + .frame(width: 60, height: 60) + .background(.white) + .clipShape(Circle()) + .shadow(color: .black.opacity(0.2), radius: 4) + } + + Spacer() + + Button { + startCallViewModel.searchField += "5" + } label: { + Text("5") + .default_text_style(styleSize: 32) + .multilineTextAlignment(.center) + .frame(width: 60, height: 60) + .background(.white) + .clipShape(Circle()) + .shadow(color: .black.opacity(0.2), radius: 4) + } + + Spacer() + + Button { + startCallViewModel.searchField += "6" + } label: { + Text("6") + .default_text_style(styleSize: 32) + .multilineTextAlignment(.center) + .frame(width: 60, height: 60) + .background(.white) + .clipShape(Circle()) + .shadow(color: .black.opacity(0.2), radius: 4) + } + } + .padding(.horizontal, 60) + .padding(.top, 10) + .frame(maxWidth: sharedMainViewModel.maxWidth) + + HStack { + Button { + startCallViewModel.searchField += "7" + } label: { + Text("7") + .default_text_style(styleSize: 32) + .multilineTextAlignment(.center) + .frame(width: 60, height: 60) + .background(.white) + .clipShape(Circle()) + .shadow(color: .black.opacity(0.2), radius: 4) + } + + Spacer() + + Button { + startCallViewModel.searchField += "8" + } label: { + Text("8") + .default_text_style(styleSize: 32) + .multilineTextAlignment(.center) + .frame(width: 60, height: 60) + .background(.white) + .clipShape(Circle()) + .shadow(color: .black.opacity(0.2), radius: 4) + } + + Spacer() + + Button { + startCallViewModel.searchField += "9" + } label: { + Text("9") + .default_text_style(styleSize: 32) + .multilineTextAlignment(.center) + .frame(width: 60, height: 60) + .background(.white) + .clipShape(Circle()) + .shadow(color: .black.opacity(0.2), radius: 4) + } + } + .padding(.horizontal, 60) + .padding(.top, 10) + .frame(maxWidth: sharedMainViewModel.maxWidth) + + HStack { + Button { + startCallViewModel.searchField += "*" + } label: { + Text("*") + .default_text_style(styleSize: 32) + .multilineTextAlignment(.center) + .frame(width: 60, height: 60) + .background(.white) + .clipShape(Circle()) + .shadow(color: .black.opacity(0.2), radius: 4) + } + + Spacer() + + Button { + } label: { + ZStack { + Text("0") + .default_text_style(styleSize: 32) + .multilineTextAlignment(.center) + .frame(width: 60, height: 75) + .padding(.top, -15) + .background(.white) + .clipShape(Circle()) + .shadow(color: .black.opacity(0.2), radius: 4) + Text("+") + .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" + } + ) + + Spacer() + + Button { + startCallViewModel.searchField += "#" + } label: { + Text("#") + .default_text_style(styleSize: 32) + .multilineTextAlignment(.center) + .frame(width: 60, height: 60) + .background(.white) + .clipShape(Circle()) + .shadow(color: .black.opacity(0.2), radius: 4) + } + } + .padding(.horizontal, 60) + .padding(.top, 10) + .frame(maxWidth: sharedMainViewModel.maxWidth) + + HStack { + + HStack { + + } + .frame(width: 60, height: 60) + + Spacer() + + Button { + } 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(Color.gray100) + .frame(maxWidth: .infinity) + .frame(maxHeight: .infinity) + .onRotate { newOrientation in + orientation = newOrientation + } + } +} + +#Preview { + DialerBottomSheet( + startCallViewModel: StartCallViewModel(), showingDialer: .constant(false) + ) +} diff --git a/Linphone/UI/Main/History/Fragments/HistoryListFragment.swift b/Linphone/UI/Main/History/Fragments/HistoryListFragment.swift index 50fd2513a..a2fcac515 100644 --- a/Linphone/UI/Main/History/Fragments/HistoryListFragment.swift +++ b/Linphone/UI/Main/History/Fragments/HistoryListFragment.swift @@ -49,7 +49,9 @@ struct HistoryListFragment: View { : ContactAvatarModel(friend: nil, withPresence: false) if addressFriend != nil && addressFriend!.photo != nil && !addressFriend!.photo!.isEmpty { - Avatar(contactAvatarModel: contactAvatarModel!, avatarSize: 45) + if contactAvatarModel != nil { + Avatar(contactAvatarModel: contactAvatarModel!, avatarSize: 45) + } } else { if historyListViewModel.callLogs[index].dir == .Outgoing && historyListViewModel.callLogs[index].toAddress != nil { if historyListViewModel.callLogs[index].toAddress!.displayName != nil { diff --git a/Linphone/UI/Main/History/Fragments/StartCallFragment.swift b/Linphone/UI/Main/History/Fragments/StartCallFragment.swift new file mode 100644 index 000000000..b03dde24e --- /dev/null +++ b/Linphone/UI/Main/History/Fragments/StartCallFragment.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 +import linphonesw + +struct StartCallFragment: View { + + @ObservedObject var contactsManager = ContactsManager.shared + @ObservedObject var magicSearch = MagicSearchSingleton.shared + + @ObservedObject var startCallViewModel: StartCallViewModel + + @Binding var isShowStartCallFragment: Bool + @Binding var showingDialer: Bool + + @FocusState var isSearchFieldFocused: Bool + @State private var hasTimeElapsed = false + @State private var delayedColor = Color.white + + 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(.top, 2) + .onTapGesture { + DispatchQueue.global().asyncAfter(deadline: .now() + 0.2) { + magicSearch.searchForContacts( + sourceFlags: MagicSearch.Source.Friends.rawValue | MagicSearch.Source.LdapServers.rawValue) + } + + startCallViewModel.searchField = "" + magicSearch.currentFilterSuggestions = "" + delayColorDismiss() + withAnimation { + isShowStartCallFragment.toggle() + } + } + + Text("New call") + .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("Search contact or history call", 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() + } + + 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: { + isSearchFieldFocused = false + + DispatchQueue.global().asyncAfter(deadline: .now() + 0.2) { + showingDialer.toggle() + } + }, 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) + + 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)) + .padding(.horizontal, 16) + + HStack(alignment: .center) { + Text("Suggestions") + .default_text_style_800(styleSize: 16) + + Spacer() + } + .padding(.vertical, 10) + .padding(.horizontal, 16) + + suggestionsList + } + } + .frame(maxWidth: .infinity) + } + .background(.white) + } + .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 linphonesw + +class StartCallViewModel: ObservableObject { + + @Published var searchField: String = "" + + init() {} +} diff --git a/Linphone/Utils/EditContactController.swift b/Linphone/Utils/EditContactController.swift index b29718a4e..df8e0c67f 100644 --- a/Linphone/Utils/EditContactController.swift +++ b/Linphone/Utils/EditContactController.swift @@ -50,7 +50,8 @@ struct EditContactView: UIViewControllerRepresentable { && cnc.phoneNumbers.first?.value.stringValue != nil ? cnc.phoneNumbers.first!.value.stringValue : cnc.givenName, lastName: cnc.familyName), - name: cnc.givenName + cnc.familyName + String(Int.random(in: 1...1000)) + ((imageThumbnail == nil) ? "-default" : ""), + name: cnc.givenName + cnc.familyName, + prefix: String(Int.random(in: 1...1000)) + ((imageThumbnail == nil) ? "-default" : ""), contact: newContact, linphoneFriend: false, existingFriend: ContactsManager.shared.getFriendWithContact(contact: newContact)) diff --git a/Linphone/Utils/MagicSearchSingleton.swift b/Linphone/Utils/MagicSearchSingleton.swift index 488eb5d4b..0cd349bb8 100644 --- a/Linphone/Utils/MagicSearchSingleton.swift +++ b/Linphone/Utils/MagicSearchSingleton.swift @@ -30,6 +30,9 @@ final class MagicSearchSingleton: ObservableObject { var currentFilter: String = "" var previousFilter: String? + var currentFilterSuggestions: String = "" + var previousFilterSuggestions: String? + var needUpdateLastSearchContacts = false private var limitSearchToLinphoneAccounts = true @@ -46,11 +49,27 @@ final class MagicSearchSingleton: ObservableObject { self.magicSearch.publisher?.onSearchResultsReceived?.postOnMainQueue { (magicSearch: MagicSearch) in self.needUpdateLastSearchContacts = true - self.contactsManager.lastSearch = magicSearch.lastSearch.sorted(by: { + + var lastSearchFriend: [SearchResult] = [] + var lastSearchSuggestions: [SearchResult] = [] + + magicSearch.lastSearch.forEach { searchResult in + if searchResult.friend != nil { + lastSearchFriend.append(searchResult) + } else { + lastSearchSuggestions.append(searchResult) + } + } + + self.contactsManager.lastSearch = lastSearchFriend.sorted(by: { $0.friend!.name!.lowercased().folding(options: .diacriticInsensitive, locale: .current) < $1.friend!.name!.lowercased().folding(options: .diacriticInsensitive, locale: .current) }) + + self.contactsManager.lastSearchSuggestions = lastSearchSuggestions.sorted(by: { + $0.address!.asStringUriOnly() < $1.address!.asStringUriOnly() + }) self.contactsManager.avatarListModel.forEach { contactAvatarModel in contactAvatarModel.removeAllDelegate() @@ -91,26 +110,26 @@ final class MagicSearchSingleton: ObservableObject { } } - func searchForContactsWithResult(sourceFlags: Int) { + func searchForSuggestions() { coreContext.doOnCoreQueue { _ in var needResetCache = false DispatchQueue.main.sync { - if let oldFilter = self.previousFilter { - if oldFilter.count > self.currentFilter.count || oldFilter != self.currentFilter { + if let oldFilter = self.previousFilterSuggestions { + if oldFilter.count > self.currentFilterSuggestions.count || oldFilter != self.currentFilterSuggestions { needResetCache = true } } - self.previousFilter = self.currentFilter + self.previousFilterSuggestions = self.currentFilterSuggestions } if needResetCache { self.magicSearch.resetSearchCache() } self.magicSearch.getContactsListAsync( - filter: self.currentFilter, - domain: self.allContact ? "" : self.domainDefaultAccount, - sourceFlags: sourceFlags, + 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 index f1d741f7e..e19833012 100644 --- a/Linphone/Utils/PermissionManager.swift +++ b/Linphone/Utils/PermissionManager.swift @@ -31,6 +31,13 @@ class PermissionManager: ObservableObject { private init() {} + + func getPermissions(){ + photoLibraryRequestPermission() + cameraRequestPermission() + contactsRequestPermission() + } + func photoLibraryRequestPermission() { PHPhotoLibrary.requestAuthorization(for: .readWrite, handler: {status in DispatchQueue.main.async { From 77951adaa1bea61f7f0c55b78806c7e1899936eb Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Tue, 5 Dec 2023 16:59:46 +0100 Subject: [PATCH 051/486] Fix contact image --- Linphone/Contacts/ContactsManager.swift | 5 +++-- Linphone/Core/CoreContext.swift | 2 -- .../UI/Main/Contacts/Fragments/EditContactFragment.swift | 3 +-- Linphone/Utils/EditContactController.swift | 2 +- 4 files changed, 5 insertions(+), 7 deletions(-) diff --git a/Linphone/Contacts/ContactsManager.swift b/Linphone/Contacts/ContactsManager.swift index 139e62338..0b00df121 100644 --- a/Linphone/Contacts/ContactsManager.swift +++ b/Linphone/Contacts/ContactsManager.swift @@ -123,7 +123,7 @@ final class ContactsManager: ObservableObject { ? contact.phoneNumbers.first!.value.stringValue : contact.givenName, lastName: contact.familyName), name: contact.givenName + contact.familyName, - prefix: String(Int.random(in: 1...1000)) + ((imageThumbnail == nil) ? "-default" : ""), + prefix: ((imageThumbnail == nil) ? "-default" : ""), contact: newContact, linphoneFriend: false, existingFriend: nil) } }) @@ -269,7 +269,8 @@ final class ContactsManager: ObservableObject { DispatchQueue.main.async { do { let urlName = URL(string: name + prefix) - let imagePath = urlName != nil ? urlName!.absoluteString.replacingOccurrences(of: "%", with: "") : String(Int.random(in: 1...1000)) + let imagePath = urlName != nil ? urlName!.absoluteString.replacingOccurrences(of: "%", with: "") : "ImageError" + let decodedData: () = try data.write(to: directory!.appendingPathComponent(imagePath + ".png")) completion(decodedData, imagePath + ".png") diff --git a/Linphone/Core/CoreContext.swift b/Linphone/Core/CoreContext.swift index ce88facec..7d9bb5e6e 100644 --- a/Linphone/Core/CoreContext.swift +++ b/Linphone/Core/CoreContext.swift @@ -87,8 +87,6 @@ final class CoreContext: ObservableObject { self.mCore.autoIterateEnabled = false self.mCore.friendsDatabasePath = "\(configDir)/friends.db" - print("configDirconfigDirconfigDir \(configDir)") - self.mCore.friendListSubscriptionEnabled = true self.mCore.publisher?.onGlobalStateChanged?.postOnMainQueue { (cbVal: (core: Core, state: GlobalState, message: String)) in diff --git a/Linphone/UI/Main/Contacts/Fragments/EditContactFragment.swift b/Linphone/UI/Main/Contacts/Fragments/EditContactFragment.swift index 0798aa34d..1f1c86112 100644 --- a/Linphone/UI/Main/Contacts/Fragments/EditContactFragment.swift +++ b/Linphone/UI/Main/Contacts/Fragments/EditContactFragment.swift @@ -492,8 +492,7 @@ struct EditContactFragment: View { firstName: editContactViewModel.firstName, lastName: editContactViewModel.lastName), name: editContactViewModel.firstName + editContactViewModel.lastName, - prefix: String(Int.random(in: 1...1000)) - + ((selectedImage == nil) ? "-default" : ""), + prefix: ((selectedImage == nil) ? "-default" : ""), contact: newContact, linphoneFriend: true, existingFriend: editContactViewModel.selectedEditFriend) } diff --git a/Linphone/Utils/EditContactController.swift b/Linphone/Utils/EditContactController.swift index df8e0c67f..4734b5562 100644 --- a/Linphone/Utils/EditContactController.swift +++ b/Linphone/Utils/EditContactController.swift @@ -51,7 +51,7 @@ struct EditContactView: UIViewControllerRepresentable { ? cnc.phoneNumbers.first!.value.stringValue : cnc.givenName, lastName: cnc.familyName), name: cnc.givenName + cnc.familyName, - prefix: String(Int.random(in: 1...1000)) + ((imageThumbnail == nil) ? "-default" : ""), + prefix: ((imageThumbnail == nil) ? "-default" : ""), contact: newContact, linphoneFriend: false, existingFriend: ContactsManager.shared.getFriendWithContact(contact: newContact)) From d3cc7091ac8d27abed73fbd4dab67b474ba20d4c Mon Sep 17 00:00:00 2001 From: QuentinArguillere Date: Sat, 9 Dec 2023 17:33:40 +0100 Subject: [PATCH 052/486] Alphabetically sort utility files --- Linphone.xcodeproj/project.pbxproj | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/Linphone.xcodeproj/project.pbxproj b/Linphone.xcodeproj/project.pbxproj index 3636ae410..13b8a495f 100644 --- a/Linphone.xcodeproj/project.pbxproj +++ b/Linphone.xcodeproj/project.pbxproj @@ -188,15 +188,15 @@ D717071C2AC591EF0037746F /* Utils */ = { isa = PBXGroup; children = ( - D717071D2AC5922E0037746F /* ColorExtension.swift */, - D717071F2AC5989C0037746F /* TextExtension.swift */, - D76005F52B0798B00054B79A /* IntExtension.swift */, - D74C9D002ACB098C0021626A /* PermissionManager.swift */, - D7D1698B2AE66FA500109A5C /* MagicSearchSingleton.swift */, - D7C3650D2AF15BF200FE6142 /* PhotoPicker.swift */, - D7C48DF32AFA66F900D938CB /* EditContactController.swift */, - D732A9082AFD235500DB42BA /* ShareSheetController.swift */, D7ADF5FF2AFE356400212231 /* Avatar.swift */, + D717071D2AC5922E0037746F /* ColorExtension.swift */, + D7C48DF32AFA66F900D938CB /* EditContactController.swift */, + D76005F52B0798B00054B79A /* IntExtension.swift */, + D7D1698B2AE66FA500109A5C /* MagicSearchSingleton.swift */, + D74C9D002ACB098C0021626A /* PermissionManager.swift */, + D7C3650D2AF15BF200FE6142 /* PhotoPicker.swift */, + D732A9082AFD235500DB42BA /* ShareSheetController.swift */, + D717071F2AC5989C0037746F /* TextExtension.swift */, ); path = Utils; sourceTree = ""; From 50f85649818e258d00db49292f4eb7fd8c739c0d Mon Sep 17 00:00:00 2001 From: QuentinArguillere Date: Sat, 9 Dec 2023 17:52:45 +0100 Subject: [PATCH 053/486] Add Extensions folder to Utils. Move 'Color', 'Int' and 'Text' extensions inside. Add ConfigExtension and new 'group.org.linphone.phone.logs' app group --- Linphone.xcodeproj/project.pbxproj | 18 +++++- .../{ => Extensions}/ColorExtension.swift | 0 .../Utils/Extensions/ConfigExtension.swift | 64 +++++++++++++++++++ .../Utils/{ => Extensions}/IntExtension.swift | 0 .../{ => Extensions}/TextExtension.swift | 0 5 files changed, 79 insertions(+), 3 deletions(-) rename Linphone/Utils/{ => Extensions}/ColorExtension.swift (100%) create mode 100644 Linphone/Utils/Extensions/ConfigExtension.swift rename Linphone/Utils/{ => Extensions}/IntExtension.swift (100%) rename Linphone/Utils/{ => Extensions}/TextExtension.swift (100%) diff --git a/Linphone.xcodeproj/project.pbxproj b/Linphone.xcodeproj/project.pbxproj index 13b8a495f..6b2d2faa9 100644 --- a/Linphone.xcodeproj/project.pbxproj +++ b/Linphone.xcodeproj/project.pbxproj @@ -7,6 +7,7 @@ objects = { /* Begin PBXBuildFile section */ + 66C491F92B24D25B00CEA16D /* ConfigExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 66C491F82B24D25A00CEA16D /* ConfigExtension.swift */; }; D706BA822ADD72D100278F45 /* DeviceRotationViewModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = D706BA812ADD72D100278F45 /* DeviceRotationViewModifier.swift */; }; D70C93DE2AC2D0F60063CA3B /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = D70C93DD2AC2D0F60063CA3B /* Localizable.xcstrings */; }; D717071E2AC5922E0037746F /* ColorExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = D717071D2AC5922E0037746F /* ColorExtension.swift */; }; @@ -86,6 +87,7 @@ /* End PBXBuildFile section */ /* Begin PBXFileReference section */ + 66C491F82B24D25A00CEA16D /* ConfigExtension.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ConfigExtension.swift; sourceTree = ""; }; D706BA812ADD72D100278F45 /* DeviceRotationViewModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceRotationViewModifier.swift; sourceTree = ""; }; D70C93DD2AC2D0F60063CA3B /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; sourceTree = ""; }; D717071D2AC5922E0037746F /* ColorExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ColorExtension.swift; sourceTree = ""; }; @@ -178,6 +180,17 @@ /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + 66C491F72B24D25A00CEA16D /* Extensions */ = { + isa = PBXGroup; + children = ( + D717071D2AC5922E0037746F /* ColorExtension.swift */, + 66C491F82B24D25A00CEA16D /* ConfigExtension.swift */, + D76005F52B0798B00054B79A /* IntExtension.swift */, + D717071F2AC5989C0037746F /* TextExtension.swift */, + ); + path = Extensions; + sourceTree = ""; + }; A31AF2AB8C6A3D7B7EA3B424 /* Pods */ = { isa = PBXGroup; children = ( @@ -188,15 +201,13 @@ D717071C2AC591EF0037746F /* Utils */ = { isa = PBXGroup; children = ( + 66C491F72B24D25A00CEA16D /* Extensions */, D7ADF5FF2AFE356400212231 /* Avatar.swift */, - D717071D2AC5922E0037746F /* ColorExtension.swift */, D7C48DF32AFA66F900D938CB /* EditContactController.swift */, - D76005F52B0798B00054B79A /* IntExtension.swift */, D7D1698B2AE66FA500109A5C /* MagicSearchSingleton.swift */, D74C9D002ACB098C0021626A /* PermissionManager.swift */, D7C3650D2AF15BF200FE6142 /* PhotoPicker.swift */, D732A9082AFD235500DB42BA /* ShareSheetController.swift */, - D717071F2AC5989C0037746F /* TextExtension.swift */, ); path = Utils; sourceTree = ""; @@ -570,6 +581,7 @@ D7C3650E2AF15BF200FE6142 /* PhotoPicker.swift in Sources */, D7ADF6002AFE356400212231 /* Avatar.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 */, diff --git a/Linphone/Utils/ColorExtension.swift b/Linphone/Utils/Extensions/ColorExtension.swift similarity index 100% rename from Linphone/Utils/ColorExtension.swift rename to Linphone/Utils/Extensions/ColorExtension.swift diff --git a/Linphone/Utils/Extensions/ConfigExtension.swift b/Linphone/Utils/Extensions/ConfigExtension.swift new file mode 100644 index 000000000..6576798f3 --- /dev/null +++ b/Linphone/Utils/Extensions/ConfigExtension.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 + +// 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(Core.runsInsideExtension() ? "linphonerc-factory-appex" : "linphonerc-factory-app")! + _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 + } + + // Apple related + static let appGroupName = "group.org.linphone.phone.logs" + // 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 earlymediaContentExtensionCagetoryIdentifier = 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) + +} diff --git a/Linphone/Utils/IntExtension.swift b/Linphone/Utils/Extensions/IntExtension.swift similarity index 100% rename from Linphone/Utils/IntExtension.swift rename to Linphone/Utils/Extensions/IntExtension.swift diff --git a/Linphone/Utils/TextExtension.swift b/Linphone/Utils/Extensions/TextExtension.swift similarity index 100% rename from Linphone/Utils/TextExtension.swift rename to Linphone/Utils/Extensions/TextExtension.swift From 8e6df8867a7dc3bfcc54181ec82a8b33c720e728 Mon Sep 17 00:00:00 2001 From: QuentinArguillere Date: Sat, 9 Dec 2023 17:54:27 +0100 Subject: [PATCH 054/486] Add CoreExtension --- Linphone.xcodeproj/project.pbxproj | 4 +++ Linphone/Core/CoreExtension.swift | 48 ++++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+) create mode 100644 Linphone/Core/CoreExtension.swift diff --git a/Linphone.xcodeproj/project.pbxproj b/Linphone.xcodeproj/project.pbxproj index 6b2d2faa9..849a2ef13 100644 --- a/Linphone.xcodeproj/project.pbxproj +++ b/Linphone.xcodeproj/project.pbxproj @@ -8,6 +8,7 @@ /* Begin PBXBuildFile section */ 66C491F92B24D25B00CEA16D /* ConfigExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 66C491F82B24D25A00CEA16D /* ConfigExtension.swift */; }; + 66C491FB2B24D32600CEA16D /* CoreExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 66C491FA2B24D32600CEA16D /* CoreExtension.swift */; }; D706BA822ADD72D100278F45 /* DeviceRotationViewModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = D706BA812ADD72D100278F45 /* DeviceRotationViewModifier.swift */; }; D70C93DE2AC2D0F60063CA3B /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = D70C93DD2AC2D0F60063CA3B /* Localizable.xcstrings */; }; D717071E2AC5922E0037746F /* ColorExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = D717071D2AC5922E0037746F /* ColorExtension.swift */; }; @@ -88,6 +89,7 @@ /* Begin PBXFileReference section */ 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 = ""; }; D706BA812ADD72D100278F45 /* DeviceRotationViewModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceRotationViewModifier.swift; sourceTree = ""; }; D70C93DD2AC2D0F60063CA3B /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; sourceTree = ""; }; D717071D2AC5922E0037746F /* ColorExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ColorExtension.swift; sourceTree = ""; }; @@ -283,6 +285,7 @@ isa = PBXGroup; children = ( D719ABC82ABC6FD700B41C10 /* CoreContext.swift */, + 66C491FA2B24D32600CEA16D /* CoreExtension.swift */, ); path = Core; sourceTree = ""; @@ -627,6 +630,7 @@ D7C48DF62AFCDF4700D938CB /* ContactInnerActionsFragment.swift in Sources */, D72343322ACEFF58009AA24E /* QRScannerController.swift in Sources */, D72343342ACEFFC3009AA24E /* QRScanner.swift in Sources */, + 66C491FB2B24D32600CEA16D /* CoreExtension.swift in Sources */, D726E43D2B19E4FE0083C415 /* StartCallFragment.swift in Sources */, D72343302ACEFEF8009AA24E /* QrCodeScannerFragment.swift in Sources */, D726E43F2B19E56F0083C415 /* StartCallViewModel.swift in Sources */, diff --git a/Linphone/Core/CoreExtension.swift b/Linphone/Core/CoreExtension.swift new file mode 100644 index 000000000..0b0b9f94c --- /dev/null +++ b/Linphone/Core/CoreExtension.swift @@ -0,0 +1,48 @@ +/* + * 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" + } + +} + From 5b3176c0314641c24b72a6bc06bbcc5f72753a8c Mon Sep 17 00:00:00 2001 From: QuentinArguillere Date: Sat, 9 Dec 2023 17:55:39 +0100 Subject: [PATCH 055/486] Add AudioRouteUtils --- Linphone.xcodeproj/project.pbxproj | 4 + Linphone/Utils/AudioRouteUtils.swift | 197 +++++++++++++++++++++++++++ 2 files changed, 201 insertions(+) create mode 100644 Linphone/Utils/AudioRouteUtils.swift diff --git a/Linphone.xcodeproj/project.pbxproj b/Linphone.xcodeproj/project.pbxproj index 849a2ef13..2c20f5320 100644 --- a/Linphone.xcodeproj/project.pbxproj +++ b/Linphone.xcodeproj/project.pbxproj @@ -9,6 +9,7 @@ /* Begin PBXBuildFile section */ 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 */; }; D706BA822ADD72D100278F45 /* DeviceRotationViewModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = D706BA812ADD72D100278F45 /* DeviceRotationViewModifier.swift */; }; D70C93DE2AC2D0F60063CA3B /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = D70C93DD2AC2D0F60063CA3B /* Localizable.xcstrings */; }; D717071E2AC5922E0037746F /* ColorExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = D717071D2AC5922E0037746F /* ColorExtension.swift */; }; @@ -90,6 +91,7 @@ /* Begin PBXFileReference section */ 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 = ""; }; D706BA812ADD72D100278F45 /* DeviceRotationViewModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceRotationViewModifier.swift; sourceTree = ""; }; D70C93DD2AC2D0F60063CA3B /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; sourceTree = ""; }; D717071D2AC5922E0037746F /* ColorExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ColorExtension.swift; sourceTree = ""; }; @@ -204,6 +206,7 @@ isa = PBXGroup; children = ( 66C491F72B24D25A00CEA16D /* Extensions */, + 66C491FC2B24D36500CEA16D /* AudioRouteUtils.swift */, D7ADF5FF2AFE356400212231 /* Avatar.swift */, D7C48DF32AFA66F900D938CB /* EditContactController.swift */, D7D1698B2AE66FA500109A5C /* MagicSearchSingleton.swift */, @@ -593,6 +596,7 @@ D706BA822ADD72D100278F45 /* DeviceRotationViewModifier.swift in Sources */, D732A9132B04C7A300DB42BA /* HistoryListFragment.swift in Sources */, D719ABC92ABC6FD700B41C10 /* CoreContext.swift in Sources */, + 66C491FD2B24D36500CEA16D /* AudioRouteUtils.swift in Sources */, D7EAACCF2AD6ED8000AA6A8A /* PermissionsFragment.swift in Sources */, D777DBB32AE12C5900565A99 /* ContactsManager.swift in Sources */, D796F2002B0BB61A0041115F /* ToastViewModel.swift in Sources */, diff --git a/Linphone/Utils/AudioRouteUtils.swift b/Linphone/Utils/AudioRouteUtils.swift new file mode 100644 index 000000000..b5812a0e8 --- /dev/null +++ b/Linphone/Utils/AudioRouteUtils.swift @@ -0,0 +1,197 @@ +/* + * 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 conference = core.conference + let audioDevice = conference != nil && conference?.isIn == true ? conference!.outputAudioDevice : 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 conference = core.conference + + let audioDevice = conference != nil && conference?.isIn == true ? conference!.outputAudioDevice : currentCall?.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 From 52b4bd9f56b35e5d3f0bafdcc3f1fe1242a06b3f Mon Sep 17 00:00:00 2001 From: QuentinArguillere Date: Sat, 9 Dec 2023 18:16:45 +0100 Subject: [PATCH 056/486] Add FileUtils --- Linphone.xcodeproj/project.pbxproj | 4 + Linphone/Utils/FileUtils.swift | 125 +++++++++++++++++++++++++++++ 2 files changed, 129 insertions(+) create mode 100644 Linphone/Utils/FileUtils.swift diff --git a/Linphone.xcodeproj/project.pbxproj b/Linphone.xcodeproj/project.pbxproj index 2c20f5320..fca904b83 100644 --- a/Linphone.xcodeproj/project.pbxproj +++ b/Linphone.xcodeproj/project.pbxproj @@ -10,6 +10,7 @@ 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 */; }; D706BA822ADD72D100278F45 /* DeviceRotationViewModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = D706BA812ADD72D100278F45 /* DeviceRotationViewModifier.swift */; }; D70C93DE2AC2D0F60063CA3B /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = D70C93DD2AC2D0F60063CA3B /* Localizable.xcstrings */; }; D717071E2AC5922E0037746F /* ColorExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = D717071D2AC5922E0037746F /* ColorExtension.swift */; }; @@ -92,6 +93,7 @@ 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 = ""; }; D706BA812ADD72D100278F45 /* DeviceRotationViewModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceRotationViewModifier.swift; sourceTree = ""; }; D70C93DD2AC2D0F60063CA3B /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; sourceTree = ""; }; D717071D2AC5922E0037746F /* ColorExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ColorExtension.swift; sourceTree = ""; }; @@ -209,6 +211,7 @@ 66C491FC2B24D36500CEA16D /* AudioRouteUtils.swift */, D7ADF5FF2AFE356400212231 /* Avatar.swift */, D7C48DF32AFA66F900D938CB /* EditContactController.swift */, + 66C491FE2B24D4AC00CEA16D /* FileUtils.swift */, D7D1698B2AE66FA500109A5C /* MagicSearchSingleton.swift */, D74C9D002ACB098C0021626A /* PermissionManager.swift */, D7C3650D2AF15BF200FE6142 /* PhotoPicker.swift */, @@ -610,6 +613,7 @@ D7B5066D2AEFA9B900CEB4E9 /* ContactInnerFragment.swift in Sources */, D7E6D04D2AEBD77600A57AAF /* CustomBottomSheet.swift in Sources */, D7C48DF42AFA66F900D938CB /* EditContactController.swift in Sources */, + 66C491FF2B24D4AC00CEA16D /* FileUtils.swift in Sources */, D74C9D012ACB098C0021626A /* PermissionManager.swift in Sources */, D7702EF22AC7205000557C00 /* WelcomeView.swift in Sources */, D732A9152B04C7FE00DB42BA /* HistoryListViewModel.swift in Sources */, diff --git a/Linphone/Utils/FileUtils.swift b/Linphone/Utils/FileUtils.swift new file mode 100644 index 000000000..d923462ed --- /dev/null +++ b/Linphone/Utils/FileUtils.swift @@ -0,0 +1,125 @@ +/* +* Copyright (c) 2010-2023 Belledonne Communications SARL. +* +* This file is part of linhome +* +* This program is free software: you can redistribute it and/or modify +* it under the terms of the GNU General Public License as published by +* the Free Software Foundation, either version 3 of the License, or +* (at your option) any later version. +* +* This program is distributed in the hope that it will be useful, +* but WITHOUT ANY WARRANTY; without even the implied warranty of +* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +* GNU General 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 + +class FileUtil: NSObject { + 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)") + } + } + +} From 26dd731f84c4ef797a19761417494eeea7b85559 Mon Sep 17 00:00:00 2001 From: QuentinArguillere Date: Sat, 9 Dec 2023 18:34:04 +0100 Subject: [PATCH 057/486] Add Log --- Linphone.xcodeproj/project.pbxproj | 4 + Linphone/Utils/Log.swift | 115 +++++++++++++++++++++++++++++ 2 files changed, 119 insertions(+) create mode 100644 Linphone/Utils/Log.swift diff --git a/Linphone.xcodeproj/project.pbxproj b/Linphone.xcodeproj/project.pbxproj index fca904b83..e7976994a 100644 --- a/Linphone.xcodeproj/project.pbxproj +++ b/Linphone.xcodeproj/project.pbxproj @@ -11,6 +11,7 @@ 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 */; }; D706BA822ADD72D100278F45 /* DeviceRotationViewModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = D706BA812ADD72D100278F45 /* DeviceRotationViewModifier.swift */; }; D70C93DE2AC2D0F60063CA3B /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = D70C93DD2AC2D0F60063CA3B /* Localizable.xcstrings */; }; D717071E2AC5922E0037746F /* ColorExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = D717071D2AC5922E0037746F /* ColorExtension.swift */; }; @@ -94,6 +95,7 @@ 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 = ""; }; D706BA812ADD72D100278F45 /* DeviceRotationViewModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceRotationViewModifier.swift; sourceTree = ""; }; D70C93DD2AC2D0F60063CA3B /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; sourceTree = ""; }; D717071D2AC5922E0037746F /* ColorExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ColorExtension.swift; sourceTree = ""; }; @@ -212,6 +214,7 @@ D7ADF5FF2AFE356400212231 /* Avatar.swift */, D7C48DF32AFA66F900D938CB /* EditContactController.swift */, 66C491FE2B24D4AC00CEA16D /* FileUtils.swift */, + 66C492002B24DB6900CEA16D /* Log.swift */, D7D1698B2AE66FA500109A5C /* MagicSearchSingleton.swift */, D74C9D002ACB098C0021626A /* PermissionManager.swift */, D7C3650D2AF15BF200FE6142 /* PhotoPicker.swift */, @@ -629,6 +632,7 @@ D7A03FC62ACC458A0081A588 /* SplashScreen.swift in Sources */, D7A03FC02ACC2E390081A588 /* HistoryView.swift in Sources */, D748BF2E2ACD82E7004844EB /* ThirdPartySipAccountWarningFragment.swift in Sources */, + 66C492012B24DB6900CEA16D /* Log.swift in Sources */, D748BF2C2ACD82D2004844EB /* ThirdPartySipAccountLoginFragment.swift in Sources */, D74C9CF82ACACECE0021626A /* WelcomePage1Fragment.swift in Sources */, D7E6D0552AEBFCCE00A57AAF /* ContactsInnerFragment.swift in Sources */, diff --git a/Linphone/Utils/Log.swift b/Linphone/Utils/Log.swift new file mode 100644 index 000000000..25aecc8e5 --- /dev/null +++ b/Linphone/Utils/Log.swift @@ -0,0 +1,115 @@ +/* +* 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 + +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) + } + } + + + 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 From 8523f110d7f72117ef04f5faa52b6d51be5cd70b Mon Sep 17 00:00:00 2001 From: QuentinArguillere Date: Sat, 9 Dec 2023 19:27:34 +0100 Subject: [PATCH 058/486] Disable log file collection because of crash, to be fixed --- Linphone/Core/CoreExtension.swift | 4 ---- Linphone/Utils/Extensions/ConfigExtension.swift | 1 - Linphone/Utils/Log.swift | 3 ++- 3 files changed, 2 insertions(+), 6 deletions(-) diff --git a/Linphone/Core/CoreExtension.swift b/Linphone/Core/CoreExtension.swift index 0b0b9f94c..2a448aef4 100644 --- a/Linphone/Core/CoreExtension.swift +++ b/Linphone/Core/CoreExtension.swift @@ -17,9 +17,6 @@ * 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. @@ -45,4 +42,3 @@ extension Core { } } - diff --git a/Linphone/Utils/Extensions/ConfigExtension.swift b/Linphone/Utils/Extensions/ConfigExtension.swift index 6576798f3..3659c3eae 100644 --- a/Linphone/Utils/Extensions/ConfigExtension.swift +++ b/Linphone/Utils/Extensions/ConfigExtension.swift @@ -48,7 +48,6 @@ extension Config { return hasEntry(section: section, key: key) == 1 ? getString(section: section, key: key, defaultString: "") : nil } - // Apple related static let appGroupName = "group.org.linphone.phone.logs" // 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: "") diff --git a/Linphone/Utils/Log.swift b/Linphone/Utils/Log.swift index 25aecc8e5..37a088142 100644 --- a/Linphone/Utils/Log.swift +++ b/Linphone/Utils/Log.swift @@ -34,7 +34,8 @@ class Log: LoggingServiceDelegate { private init() { service.domain = Bundle.main.bundleIdentifier! - Core.setLogCollectionPath(path: Factory.Instance.getDownloadDir(context: UnsafeMutablePointer(mutating: (Config.appGroupName as NSString).utf8String))) + // CRASH TO BE FIXED ?? + //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) From 76b7681bf45f6ea74eb3990628b82c0288e1d439 Mon Sep 17 00:00:00 2001 From: QuentinArguillere Date: Sat, 9 Dec 2023 23:16:44 +0100 Subject: [PATCH 059/486] Add logs app group entitlements which fixes the log collection crash --- Linphone/Linphone.entitlements | 1 + Linphone/Utils/Log.swift | 3 +-- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Linphone/Linphone.entitlements b/Linphone/Linphone.entitlements index 7fe270b49..5e9048f46 100644 --- a/Linphone/Linphone.entitlements +++ b/Linphone/Linphone.entitlements @@ -9,6 +9,7 @@ group.belledonne-communications.linphone group.org.linphone.phone.linphoneExtension group.org.linphone.phone.msgNotification + group.org.linphone.phone.logs com.apple.security.files.user-selected.read-only diff --git a/Linphone/Utils/Log.swift b/Linphone/Utils/Log.swift index 37a088142..25aecc8e5 100644 --- a/Linphone/Utils/Log.swift +++ b/Linphone/Utils/Log.swift @@ -34,8 +34,7 @@ class Log: LoggingServiceDelegate { private init() { service.domain = Bundle.main.bundleIdentifier! - // CRASH TO BE FIXED ?? - //Core.setLogCollectionPath(path: Factory.Instance.getDownloadDir(context: UnsafeMutablePointer(mutating: (Config.appGroupName as NSString).utf8String))) + 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) From 2a1bd88741fed04ee9a9cc2b78c44b921684346a Mon Sep 17 00:00:00 2001 From: QuentinArguillere Date: Sun, 10 Dec 2023 11:08:21 +0100 Subject: [PATCH 060/486] Allow push notifications in the app, and update account and core settings to allow VOIP push parameters generation for register --- Linphone/Core/CoreContext.swift | 2 ++ Linphone/Info.plist | 5 +++++ Linphone/Linphone.entitlements | 4 ++++ Linphone/UI/Assistant/Viewmodel/AccountLoginViewModel.swift | 3 +++ 4 files changed, 14 insertions(+) diff --git a/Linphone/Core/CoreContext.swift b/Linphone/Core/CoreContext.swift index 7d9bb5e6e..ce607a4da 100644 --- a/Linphone/Core/CoreContext.swift +++ b/Linphone/Core/CoreContext.swift @@ -86,6 +86,8 @@ final class CoreContext: ObservableObject { self.mCore.autoIterateEnabled = false self.mCore.friendsDatabasePath = "\(configDir)/friends.db" + self.mCore.callkitEnabled = true + self.mCore.pushNotificationEnabled = true self.mCore.friendListSubscriptionEnabled = true diff --git a/Linphone/Info.plist b/Linphone/Info.plist index c3b52b04f..5aaaf7fac 100644 --- a/Linphone/Info.plist +++ b/Linphone/Info.plist @@ -11,6 +11,11 @@ NotoSans-Bold.ttf NotoSans-ExtraBold.ttf + UIBackgroundModes + + remote-notification + voip + UILaunchScreen UIImageName diff --git a/Linphone/Linphone.entitlements b/Linphone/Linphone.entitlements index 5e9048f46..f88b0a974 100644 --- a/Linphone/Linphone.entitlements +++ b/Linphone/Linphone.entitlements @@ -2,6 +2,10 @@ + aps-environment + development + com.apple.developer.aps-environment + development com.apple.security.app-sandbox com.apple.security.application-groups diff --git a/Linphone/UI/Assistant/Viewmodel/AccountLoginViewModel.swift b/Linphone/UI/Assistant/Viewmodel/AccountLoginViewModel.swift index e9150c82c..dd9149693 100644 --- a/Linphone/UI/Assistant/Viewmodel/AccountLoginViewModel.swift +++ b/Linphone/UI/Assistant/Viewmodel/AccountLoginViewModel.swift @@ -89,6 +89,9 @@ class AccountLoginViewModel: ObservableObject { try accountParams.setServeraddress(newValue: address) // And we ensure the account will start the registration process accountParams.registerEnabled = true + accountParams.pushNotificationAllowed = true + accountParams.remotePushNotificationAllowed = false + accountParams.pushNotificationConfig?.provider = "apns.dev" // Now that our AccountParams is configured, we can create the Account object let account = try core.createAccount(params: accountParams) From 3bb0d06787a5fc6baf0b5e89fa0bdb3e84e96060 Mon Sep 17 00:00:00 2001 From: QuentinArguillere Date: Tue, 12 Dec 2023 11:42:56 +0100 Subject: [PATCH 061/486] Add ProviderDelegate and TelecomManager for Callkit integration --- Linphone.xcodeproj/project.pbxproj | 16 + Linphone/Core/CoreContext.swift | 5 + Linphone/Info.plist | 4 + .../TelecomManager/ProviderDelegate.swift | 386 +++++++++++++++ Linphone/TelecomManager/TelecomManager.swift | 456 ++++++++++++++++++ .../ViewModel/HistoryListViewModel.swift | 3 +- 6 files changed, 869 insertions(+), 1 deletion(-) create mode 100644 Linphone/TelecomManager/ProviderDelegate.swift create mode 100644 Linphone/TelecomManager/TelecomManager.swift diff --git a/Linphone.xcodeproj/project.pbxproj b/Linphone.xcodeproj/project.pbxproj index e7976994a..901406bbe 100644 --- a/Linphone.xcodeproj/project.pbxproj +++ b/Linphone.xcodeproj/project.pbxproj @@ -7,6 +7,8 @@ objects = { /* Begin PBXBuildFile section */ + 662B69D92B25DE18007118BF /* TelecomManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 662B69D82B25DE18007118BF /* TelecomManager.swift */; }; + 662B69DB2B25DE25007118BF /* ProviderDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 662B69DA2B25DE25007118BF /* ProviderDelegate.swift */; }; 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 */; }; @@ -91,6 +93,8 @@ /* End PBXBuildFile section */ /* Begin PBXFileReference section */ + 662B69D82B25DE18007118BF /* TelecomManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TelecomManager.swift; sourceTree = ""; }; + 662B69DA2B25DE25007118BF /* ProviderDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProviderDelegate.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 = ""; }; @@ -188,6 +192,15 @@ /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + 662B69D72B25DDF6007118BF /* TelecomManager */ = { + isa = PBXGroup; + children = ( + 662B69D82B25DE18007118BF /* TelecomManager.swift */, + 662B69DA2B25DE25007118BF /* ProviderDelegate.swift */, + ); + path = TelecomManager; + sourceTree = ""; + }; 66C491F72B24D25A00CEA16D /* Extensions */ = { isa = PBXGroup; children = ( @@ -247,6 +260,7 @@ D719ABB62ABC67BF00B41C10 /* LinphoneApp.swift */, D777DBB12AE12C4000565A99 /* Contacts */, D719ABC72ABC6FB200B41C10 /* Core */, + 662B69D72B25DDF6007118BF /* TelecomManager */, D719ABC52ABC6EE800B41C10 /* UI */, D717071C2AC591EF0037746F /* Utils */, D719ABBA2ABC67BF00B41C10 /* Assets.xcassets */, @@ -599,6 +613,7 @@ D7C3650C2AF0084000FE6142 /* EditContactViewModel.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 */, D719ABC92ABC6FD700B41C10 /* CoreContext.swift in Sources */, @@ -641,6 +656,7 @@ D7FB55112AD447FD00A5AB15 /* RegisterFragment.swift in Sources */, D7C48DF62AFCDF4700D938CB /* ContactInnerActionsFragment.swift in Sources */, D72343322ACEFF58009AA24E /* QRScannerController.swift in Sources */, + 662B69D92B25DE18007118BF /* TelecomManager.swift in Sources */, D72343342ACEFFC3009AA24E /* QRScanner.swift in Sources */, 66C491FB2B24D32600CEA16D /* CoreExtension.swift in Sources */, D726E43D2B19E4FE0083C415 /* StartCallFragment.swift in Sources */, diff --git a/Linphone/Core/CoreContext.swift b/Linphone/Core/CoreContext.swift index ce607a4da..f2592ae60 100644 --- a/Linphone/Core/CoreContext.swift +++ b/Linphone/Core/CoreContext.swift @@ -149,6 +149,11 @@ final class CoreContext: ObservableObject { cbVal.core.clearAccounts() cbVal.core.clearAllAuthInfo() } + TelecomManager.shared.onAccountRegistrationStateChanged(core: cbVal.core, account: cbVal.account, state: cbVal.state, message: cbVal.message) + } + + self.mCore.publisher?.onCallStateChanged?.postOnCoreQueue { (cbVal: (core: Core, call: Call, state: Call.State, message: String)) in + TelecomManager.shared.onCallStateChanged(core: cbVal.core, call: cbVal.call, state: cbVal.state, message: cbVal.message) } self.mIteratePublisher = Timer.publish(every: 0.02, on: .main, in: .common) diff --git a/Linphone/Info.plist b/Linphone/Info.plist index 5aaaf7fac..340c36eb2 100644 --- a/Linphone/Info.plist +++ b/Linphone/Info.plist @@ -2,6 +2,10 @@ + NSCameraUsageDescription + Camera usage is required for video VOIP calls + NSMicrophoneUsageDescription + Microphone usage is required for VOIP calls UIAppFonts NotoSans-Light.ttf diff --git a/Linphone/TelecomManager/ProviderDelegate.swift b/Linphone/TelecomManager/ProviderDelegate.swift new file mode 100644 index 000000000..348512a04 --- /dev/null +++ b/Linphone/TelecomManager/ProviderDelegate.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 . +*/ +// swiftlint:disable line_length + +import Foundation +import CallKit +import UIKit +import linphonesw +import AVFoundation +import os + +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 { + get { + 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: "callkit_logo")?.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 ?? "" + 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) + + if UIApplication.shared.applicationState != .active { + TelecomManager.shared.backgroundContextCall = call + TelecomManager.shared.backgroundContextCameraIsEnabled = call?.params?.videoEnabled == true || call?.callLog?.wasConference() == 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() + 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?.conference != nil && core.callsNb ?? 0 > 1 {/* + try TelecomManager.shared.lc?.enterConference() + action.fulfill() + NotificationCenter.default.post(name: Notification.Name("LinphoneCallUpdate"), object: self) + */} 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(actived: 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(actived: 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(actived: 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..b92ae1eca --- /dev/null +++ b/Linphone/TelecomManager/TelecomManager.swift @@ -0,0 +1,456 @@ +/* + * 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 + +import Foundation +import linphonesw +import UserNotifications +import os +import CallKit +import AVFoundation + +class CallAppData: NSObject { + var batteryWarningShown = false + var videoRequested = false /*set when user has requested for video*/ + var isConference = false + +} + +class TelecomManager { + static let shared = TelecomManager() + static var uuidReplacedCall: String? + + let providerDelegate: ProviderDelegate // to support callkit + let callController: CXCallController // to support callkit + + 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 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 + if isSas { + lcallParams.mediaEncryption = .ZRTP + } + if isConference { + /* 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 = isVideo + } + + 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*/ + } + } + } + } + + func acceptCall(core: Core, call: Call, hasVideo: Bool) { + do { + let callParams = try core.createCallParams(call: call) + 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) + } 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) -> String { + // TODO + return "IncomingDisplayName" + } + + static func callKitEnabled(core: Core) -> Bool { +#if !targetEnvironment(simulator) + return core.callkitEnabled +#endif + return false + } + + 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 onCallStateChanged(core: Core, call: Call, state cstate: Call.State, message: String) { + let callLog = call.callLog + let callId = callLog?.callId ?? "" + if cstate == .PushIncomingReceived { + displayIncomingCall(call: call, handle: "Calling", hasVideo: false, callId: callId, displayName: "Calling") + } else { + let video = (core.videoActivationPolicy?.automaticallyAccept ?? false) && (call.remoteParams?.videoEnabled ?? false) + + if call.userData == nil { + let appData = CallAppData() + TelecomManager.setAppData(sCall: call, appData: appData) + } + /* + if let conference = call.conference, ConferenceViewModel.shared.conference.value == nil { + Log.info("[Call] Found conference attached to call and no conference in dedicated view model, init & configure it") + ConferenceViewModel.shared.initConference(conference) + ConferenceViewModel.shared.configureConference(conference) + } + */ + switch cstate { + case .IncomingReceived: + let addr = call.remoteAddress + let displayName = incomingDisplayName(call: call) + + if call.replacedCall != nil { + endCallKitReplacedCall = false + + let uuid = providerDelegate.uuids["\(TelecomManager.uuidReplacedCall ?? "")"] + let callInfo = providerDelegate.callInfos[uuid!] + callInfo!.callId = referedToCall ?? "" + if callInfo != nil && uuid != nil && addr != nil { + providerDelegate.callInfos.updateValue(callInfo!, forKey: uuid!) + providerDelegate.uuids.removeValue(forKey: callId) + providerDelegate.uuids.updateValue(uuid!, forKey: callInfo!.callId) + providerDelegate.updateCall(uuid: uuid!, handle: addr!.asStringUriOnly(), hasVideo: video, displayName: displayName) + } + } else if TelecomManager.callKitEnabled(core: core) { + /* + let isConference = isConferenceCall(call: call) + let isEarlyConference = isConference && CallsViewModel.shared.currentCallData.value??.isConferenceCall.value != true // Conference info not be received yet. + if (isEarlyConference) { + CallsViewModel.shared.currentCallData.readCurrentAndObserve { _ in + let uuid = providerDelegate.uuids["\(callId)"] + if (uuid != nil) { + displayName = "\(VoipTexts.conference_incoming_title): \(CallsViewModel.shared.currentCallData.value??.remoteConferenceSubject.value ?? "") (\(CallsViewModel.shared.currentCallData.value??.conferenceParticipantsCountLabel.value ?? ""))" + providerDelegate.updateCall(uuid: uuid!, handle: addr!.asStringUriOnly(), hasVideo: video, displayName: displayName) + } + } + } + */ + let uuid = providerDelegate.uuids["\(callId)"] + if call.replacedCall == nil { + TelecomManager.uuidReplacedCall = callId + } + + if uuid != nil { + // Tha app is now registered, updated the call already existed. + providerDelegate.updateCall(uuid: uuid!, handle: addr!.asStringUriOnly(), hasVideo: video, displayName: displayName) + } else { + displayIncomingCall(call: call, handle: addr!.asStringUriOnly(), hasVideo: video, callId: callId, displayName: displayName) + } + } /* else if UIApplication.shared.applicationState != .active { + // not support callkit , use notif + let content = UNMutableNotificationContent() + content.title = NSLocalizedString("Incoming call", comment: "") + content.body = displayName + content.sound = UNNotificationSound.init(named: UNNotificationSoundName.init("notes_of_the_optimistic.caf")) + content.categoryIdentifier = "call_cat" + content.userInfo = ["CallId": callId] + let req = UNNotificationRequest.init(identifier: "call_request", content: content, trigger: nil) + UNUserNotificationCenter.current().add(req, withCompletionHandler: nil) + } */ + case .StreamsRunning: + if TelecomManager.callKitEnabled(core: core) { + 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!) + } + } + } + + if speakerBeforePause { + speakerBeforePause = false + AudioRouteUtils.routeAudioToSpeaker(core: core) + } + 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 && callId.isEmpty { + 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 { + if false { /* isConferenceCall(call: call) { + let uuid = UUID() + let callInfo = CallInfo.newOutgoingCallInfo(addr: call.remoteAddress!, isSas: call.params?.mediaEncryption == .ZRTP, displayName: VoipTexts.conference_default_title, isVideo: call.params?.videoEnabled == true, isConference:true) + providerDelegate.callInfos.updateValue(callInfo, forKey: uuid) + providerDelegate.uuids.updateValue(uuid, forKey: "") + providerDelegate.reportOutgoingCallStartedConnecting(uuid: uuid) + Core.get().activateAudioSession(actived: true) */ + } else { + referedToCall = callId + } + } + } + case .End, + .Error: + var displayName = "Unknown" + if call.dir == .Incoming { + displayName = incomingDisplayName(call: call) + } else { // if let addr = call.remoteAddress, let contactName = FastAddressBook.displayName(for: addr.getCobject) { + displayName = "TODOContactName" + } + + UIDevice.current.isProximityMonitoringEnabled = false + if core.callsNb == 0 { + core.outputAudioDevice = core.defaultOutputAudioDevice + // disable this because I don't find anygood reason for it: _bluetoothAvailable = FALSE; + // furthermore it introduces a bug when calling multiple times since route may not be + // reconfigured between cause leading to bluetooth being disabled while it should not + // bluetoothEnabled = 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 + } + + let readyForRoutechange = callkitAudioSessionActivated == nil || (callkitAudioSessionActivated == true) + if readyForRoutechange && (cstate == .IncomingReceived || cstate == .OutgoingInit || cstate == .Connected || cstate == .StreamsRunning) { + if (call.currentParams?.videoEnabled ?? false) && AudioRouteUtils.isReceiverEnabled(core: core) && call.conference == nil { + AudioRouteUtils.routeAudioToSpeaker(core: core, call: call) + } else if AudioRouteUtils.isBluetoothAvailable(core: core) { + // Use bluetooth device by default if one is available + AudioRouteUtils.routeAudioToBluetooth(core: core, call: call) + } + } + } + // 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 cyclomatic_complexity diff --git a/Linphone/UI/Main/History/ViewModel/HistoryListViewModel.swift b/Linphone/UI/Main/History/ViewModel/HistoryListViewModel.swift index 19e7c9668..a16ea380e 100644 --- a/Linphone/UI/Main/History/ViewModel/HistoryListViewModel.swift +++ b/Linphone/UI/Main/History/ViewModel/HistoryListViewModel.swift @@ -49,7 +49,7 @@ class HistoryListViewModel: ObservableObject { self.callLogsTmp.append(log) } } - + /* DispatchQueue.main.async { self.coreDelegate = CoreDelegateStub( onCallLogUpdated: { (_: Core, _: CallLog) -> Void in @@ -71,6 +71,7 @@ class HistoryListViewModel: ObservableObject { core.addDelegate(delegate: self.coreDelegate!) } } + */ } } From 035149bd47074869e75edd5636fbf286f16c6b72 Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Mon, 11 Dec 2023 17:00:01 +0100 Subject: [PATCH 062/486] Fix callbacks --- Linphone/Core/CoreContext.swift | 15 ++++++ Linphone/Localizable.xcstrings | 6 +++ .../Contacts/Model/ContactAvatarModel.swift | 19 ++++++++ Linphone/UI/Main/Fragments/SideMenu.swift | 33 ------------- .../ViewModel/HistoryListViewModel.swift | 46 +++++-------------- 5 files changed, 52 insertions(+), 67 deletions(-) diff --git a/Linphone/Core/CoreContext.swift b/Linphone/Core/CoreContext.swift index f2592ae60..10e723c1e 100644 --- a/Linphone/Core/CoreContext.swift +++ b/Linphone/Core/CoreContext.swift @@ -20,6 +20,7 @@ // swiftlint:disable large_tuple import linphonesw import Combine +import UniformTypeIdentifiers final class CoreContext: ObservableObject { @@ -156,6 +157,20 @@ final class CoreContext: ObservableObject { TelecomManager.shared.onCallStateChanged(core: cbVal.core, call: cbVal.call, state: cbVal.state, message: cbVal.message) } + self.mCore.publisher?.onLogCollectionUploadStateChanged?.postOnMainQueue { (cbValue: (_: Core, _: Core.LogCollectionUploadState, info: String)) in + print("publisherpublisher onLogCollectionUploadStateChanged") + + if cbValue.info.starts(with: "https") { + UIPasteboard.general.setValue( + cbValue.info, + forPasteboardType: UTType.plainText.identifier + ) + + ToastViewModel.shared.toastMessage = "Success_copied_into_clipboard" + ToastViewModel.shared.displayToast.toggle() + } + } + self.mIteratePublisher = Timer.publish(every: 0.02, on: .main, in: .common) .autoconnect() .receive(on: coreQueue) diff --git a/Linphone/Localizable.xcstrings b/Linphone/Localizable.xcstrings index 569f38f71..b3982d479 100644 --- a/Linphone/Localizable.xcstrings +++ b/Linphone/Localizable.xcstrings @@ -217,6 +217,9 @@ }, "Contacts" : { + }, + "Content" : { + }, "Continue" : { @@ -486,6 +489,9 @@ }, "This contact will be deleted definitively." : { + }, + "Title" : { + }, "TLS" : { diff --git a/Linphone/UI/Main/Contacts/Model/ContactAvatarModel.swift b/Linphone/UI/Main/Contacts/Model/ContactAvatarModel.swift index 777b12e8f..3c6bc2d26 100644 --- a/Linphone/UI/Main/Contacts/Model/ContactAvatarModel.swift +++ b/Linphone/UI/Main/Contacts/Model/ContactAvatarModel.swift @@ -64,6 +64,24 @@ class ContactAvatarModel: ObservableObject { } func addDelegate() { + + /* + self.friend?.publisher?.onPresenceReceived?.postOnMainQueue { (cbValue: (Friend)) in + print("publisherpublisher onLogCollectionUploadStateChanged \(cbValue.address?.asStringUriOnly())") + + self.presenceStatus = cbValue.consolidatedPresence + if cbValue.consolidatedPresence == .Online || cbValue.consolidatedPresence == .Busy { + if cbValue.consolidatedPresence == .Online || cbValue.presenceModel!.latestActivityTimestamp != -1 { + self.lastPresenceInfo = cbValue.consolidatedPresence == .Online ? "Online" : self.getCallTime(startDate: cbValue.presenceModel!.latestActivityTimestamp) + } else { + self.lastPresenceInfo = "Away" + } + } else { + self.lastPresenceInfo = "" + } + } + */ + let newFriendDelegate = FriendDelegateStub( onPresenceReceived: { (linphoneFriend: Friend) -> Void in DispatchQueue.main.sync { @@ -80,6 +98,7 @@ class ContactAvatarModel: ObservableObject { } } ) + friendDelegate = newFriendDelegate if friendDelegate != nil { diff --git a/Linphone/UI/Main/Fragments/SideMenu.swift b/Linphone/UI/Main/Fragments/SideMenu.swift index 60f3c9606..3c65be7d5 100644 --- a/Linphone/UI/Main/Fragments/SideMenu.swift +++ b/Linphone/UI/Main/Fragments/SideMenu.swift @@ -25,8 +25,6 @@ struct SideMenu: View { @ObservedObject private var coreContext = CoreContext.shared - @State private var coreDelegate: CoreDelegate? - let width: CGFloat let isOpen: Bool let menuClose: () -> Void @@ -75,37 +73,6 @@ struct SideMenu: View { func sendLogs() { coreContext.doOnCoreQueue { core in core.uploadLogCollection() - - let newCoreDelegate = CoreDelegateStub( - onLogCollectionUploadStateChanged: { core, logCollectionUploadState, logString in - - if logString.starts(with: "https") { - UIPasteboard.general.setValue( - logString, - forPasteboardType: UTType.plainText.identifier - ) - - removeAllDelegate() - - ToastViewModel.shared.toastMessage = "Success_copied_into_clipboard" - ToastViewModel.shared.displayToast.toggle() - } - } - ) - - coreDelegate = newCoreDelegate - if coreDelegate != nil { - core.addDelegate(delegate: coreDelegate!) - } - } - } - - func removeAllDelegate() { - coreContext.doOnCoreQueue { core in - if coreDelegate != nil { - core.removeDelegate(delegate: coreDelegate!) - coreDelegate = nil - } } } } diff --git a/Linphone/UI/Main/History/ViewModel/HistoryListViewModel.swift b/Linphone/UI/Main/History/ViewModel/HistoryListViewModel.swift index a16ea380e..974710f9a 100644 --- a/Linphone/UI/Main/History/ViewModel/HistoryListViewModel.swift +++ b/Linphone/UI/Main/History/ViewModel/HistoryListViewModel.swift @@ -26,12 +26,9 @@ class HistoryListViewModel: ObservableObject { @Published var callLogs: [CallLog] = [] var callLogsTmp: [CallLog] = [] - @Published private var coreDelegate: CoreDelegate? - var callLogsAddressToDelete = "" init() { - removeAllDelegate() computeCallLogsList() } @@ -49,29 +46,20 @@ class HistoryListViewModel: ObservableObject { self.callLogsTmp.append(log) } } - /* - DispatchQueue.main.async { - self.coreDelegate = CoreDelegateStub( - onCallLogUpdated: { (_: Core, _: CallLog) -> Void in - DispatchQueue.main.sync { - let account = core.defaultAccount - let logs = account != nil ? account!.callLogs : core.callLogs - - self.callLogs.removeAll() - self.callLogsTmp.removeAll() - - logs.forEach { log in - self.callLogs.append(log) - self.callLogsTmp.append(log) - } - } - } - ) - if self.coreDelegate != nil { - core.addDelegate(delegate: self.coreDelegate!) + + core.publisher?.onCallLogUpdated?.postOnMainQueue { (_: (_: Core, _: CallLog)) in + print("publisherpublisher onCallLogUpdated") + let account = core.defaultAccount + let logs = account != nil ? account!.callLogs : core.callLogs + + self.callLogs.removeAll() + self.callLogsTmp.removeAll() + + logs.forEach { log in + self.callLogs.append(log) + self.callLogsTmp.append(log) } } - */ } } @@ -210,14 +198,4 @@ class HistoryListViewModel: ObservableObject { let indexTmp = self.callLogsTmp.firstIndex(where: {$0.callId == callLog.callId}) self.callLogsTmp.remove(at: indexTmp!) } - - func removeAllDelegate() { - coreContext.doOnCoreQueue { core in - if self.coreDelegate != nil { - core.removeDelegate(delegate: self.coreDelegate!) - self.coreDelegate = nil - } - } - } - } From f7f9ee32b6caa9ea7816158cb48e9b1c11067d66 Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Tue, 12 Dec 2023 16:26:52 +0100 Subject: [PATCH 063/486] Init outgoing call --- Linphone.xcodeproj/project.pbxproj | 28 ++ Linphone/Localizable.xcstrings | 9 + .../TelecomManager/ProviderDelegate.swift | 13 +- Linphone/TelecomManager/TelecomManager.swift | 49 +++- Linphone/UI/Call/CallView.swift | 276 ++++++++++++++++++ .../UI/Call/ViewModel/CallViewModel.swift | 55 ++++ .../ContactInnerActionsFragment.swift | 72 ++--- .../Fragments/ContactInnerFragment.swift | 7 +- .../Fragments/ContactsInnerFragment.swift | 2 +- .../Fragments/ContactsListFragment.swift | 35 ++- .../FavoriteContactsListFragment.swift | 28 +- Linphone/UI/Main/ContentView.swift | 7 + Linphone/UI/Main/Fragments/SideMenu.swift | 2 +- .../History/Fragments/DialerBottomSheet.swift | 28 +- .../Fragments/HistoryContactFragment.swift | 17 +- .../Fragments/HistoryListFragment.swift | 46 +-- .../History/Fragments/StartCallFragment.swift | 48 ++- .../ViewModel/HistoryListViewModel.swift | 16 +- .../ViewModel/StartCallViewModel.swift | 8 +- Linphone/Utils/ActivityIndicator.swift | 33 +++ Linphone/Utils/Avatar.swift | 9 +- 21 files changed, 634 insertions(+), 154 deletions(-) create mode 100644 Linphone/UI/Call/CallView.swift create mode 100644 Linphone/UI/Call/ViewModel/CallViewModel.swift create mode 100644 Linphone/Utils/ActivityIndicator.swift diff --git a/Linphone.xcodeproj/project.pbxproj b/Linphone.xcodeproj/project.pbxproj index 901406bbe..ecccf8226 100644 --- a/Linphone.xcodeproj/project.pbxproj +++ b/Linphone.xcodeproj/project.pbxproj @@ -68,6 +68,9 @@ 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 */; }; 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 */; }; @@ -157,6 +160,9 @@ 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 = ""; }; 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 = ""; }; @@ -232,6 +238,7 @@ D74C9D002ACB098C0021626A /* PermissionManager.swift */, D7C3650D2AF15BF200FE6142 /* PhotoPicker.swift */, D732A9082AFD235500DB42BA /* ShareSheetController.swift */, + D7B99E9A2B29F7C200BE7BF2 /* ActivityIndicator.swift */, ); path = Utils; sourceTree = ""; @@ -286,6 +293,7 @@ isa = PBXGroup; children = ( D719ABCA2ABC761800B41C10 /* Assistant */, + D7B5678C2B28883700DE63EB /* Call */, D719ABC62ABC6F0200B41C10 /* Main */, D7702EF02AC7200600557C00 /* Welcome */, ); @@ -473,6 +481,23 @@ path = Ressources; sourceTree = ""; }; + D7B5678C2B28883700DE63EB /* Call */ = { + isa = PBXGroup; + children = ( + D7B99E972B29B37F00BE7BF2 /* ViewModel */, + D7B5678D2B28888F00DE63EB /* CallView.swift */, + ); + path = Call; + sourceTree = ""; + }; + D7B99E972B29B37F00BE7BF2 /* ViewModel */ = { + isa = PBXGroup; + children = ( + D7B99E982B29B39000BE7BF2 /* CallViewModel.swift */, + ); + path = ViewModel; + sourceTree = ""; + }; D7D24D0C2AC1B4C700C6F35B /* Fonts */ = { isa = PBXGroup; children = ( @@ -611,6 +636,7 @@ D719ABB92ABC67BF00B41C10 /* ContentView.swift in Sources */, D71FCA832AE14D6E00D2E43E /* ContactFragment.swift in Sources */, D7C3650C2AF0084000FE6142 /* EditContactViewModel.swift in Sources */, + D7B5678E2B28888F00DE63EB /* CallView.swift in Sources */, D750D3392AD3E6EE00EC99C5 /* PopupLoadingView.swift in Sources */, D7E6D0492AE933AD00A57AAF /* FavoriteContactsListFragment.swift in Sources */, 662B69DB2B25DE25007118BF /* ProviderDelegate.swift in Sources */, @@ -633,6 +659,7 @@ D7C48DF42AFA66F900D938CB /* EditContactController.swift in Sources */, 66C491FF2B24D4AC00CEA16D /* FileUtils.swift in Sources */, D74C9D012ACB098C0021626A /* PermissionManager.swift in Sources */, + D7B99E992B29B39000BE7BF2 /* CallViewModel.swift in Sources */, D7702EF22AC7205000557C00 /* WelcomeView.swift in Sources */, D732A9152B04C7FE00DB42BA /* HistoryListViewModel.swift in Sources */, D71FCA7F2AE1397200D2E43E /* ContactsListViewModel.swift in Sources */, @@ -660,6 +687,7 @@ D72343342ACEFFC3009AA24E /* QRScanner.swift in Sources */, 66C491FB2B24D32600CEA16D /* CoreExtension.swift in Sources */, D726E43D2B19E4FE0083C415 /* StartCallFragment.swift in Sources */, + D7B99E9B2B29F7C300BE7BF2 /* ActivityIndicator.swift in Sources */, D72343302ACEFEF8009AA24E /* QrCodeScannerFragment.swift in Sources */, D726E43F2B19E56F0083C415 /* StartCallViewModel.swift in Sources */, D7D1698C2AE66FA500109A5C /* MagicSearchSingleton.swift in Sources */, diff --git a/Linphone/Localizable.xcstrings b/Linphone/Localizable.xcstrings index b3982d479..aadf76990 100644 --- a/Linphone/Localizable.xcstrings +++ b/Linphone/Localizable.xcstrings @@ -313,6 +313,9 @@ }, "I understand" : { + }, + "Incoming call" : { + }, "Incoming Call" : { @@ -352,6 +355,9 @@ }, "Message" : { + }, + "Missed call" : { + }, "My Profile" : { @@ -385,6 +391,9 @@ }, "Other actions" : { + }, + "Outgoing call" : { + }, "Outgoing Call" : { diff --git a/Linphone/TelecomManager/ProviderDelegate.swift b/Linphone/TelecomManager/ProviderDelegate.swift index 348512a04..61443c185 100644 --- a/Linphone/TelecomManager/ProviderDelegate.swift +++ b/Linphone/TelecomManager/ProviderDelegate.swift @@ -24,6 +24,7 @@ import UIKit import linphonesw import AVFoundation import os +import SwiftUI class CallInfo { var callId: String = "" @@ -207,6 +208,12 @@ extension ProviderDelegate: CXProviderDelegate { let uuid = action.callUUID let callInfo = callInfos[uuid] let callId = callInfo?.callId ?? "" + + DispatchQueue.main.async { + withAnimation { + TelecomManager.shared.callInProgress = true + } + } CoreContext.shared.doOnCoreQueue { core in Log.info("CallKit: answer call with call-id: \(String(describing: callId)) and UUID: \(uuid.description).") @@ -225,7 +232,11 @@ extension ProviderDelegate: CXProviderDelegate { } TelecomManager.shared.callkitAudioSessionActivated = false core.configureAudioSession() - TelecomManager.shared.acceptCall(core: core, call: call!, hasVideo: call!.params?.videoEnabled ?? false) + + if call != nil { + TelecomManager.shared.acceptCall(core: core, call: call!, hasVideo: call!.params?.videoEnabled ?? false) + } + action.fulfill() } } diff --git a/Linphone/TelecomManager/TelecomManager.swift b/Linphone/TelecomManager/TelecomManager.swift index b92ae1eca..db1a80851 100644 --- a/Linphone/TelecomManager/TelecomManager.swift +++ b/Linphone/TelecomManager/TelecomManager.swift @@ -24,6 +24,7 @@ import UserNotifications import os import CallKit import AVFoundation +import SwiftUI class CallAppData: NSObject { var batteryWarningShown = false @@ -32,13 +33,16 @@ class CallAppData: NSObject { } -class TelecomManager { +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 callStarted: Bool = false + var actionToFulFill: CXCallAction? var callkitAudioSessionActivated: Bool? var nextCallIsTransfer: Bool = false @@ -78,7 +82,17 @@ class TelecomManager { sCall.userData = UnsafeMutableRawPointer(Unmanaged.passRetained(appData!).toOpaque()) } } - + + func doCallWithCore(addr: Address) { + CoreContext.shared.doOnCoreQueue { core in + do { + try self.doCall(core: core, addr: addr, isSas: false, isVideo: false) + } catch { + + } + } + } + func doCall(core: Core, addr: Address, isSas: Bool, isVideo: Bool, isConference: Bool = false) throws { // let displayName = FastAddressBook.displayName(for: addr.getCobject) @@ -135,6 +149,13 @@ class TelecomManager { /* will be used later to notify user if video was not activated because of the linphone core*/ } } + + DispatchQueue.main.async { + self.callStarted = true + withAnimation { + self.callInProgress = true + } + } } } @@ -171,6 +192,10 @@ class TelecomManager { } try call.acceptWithParams(params: callParams) + + DispatchQueue.main.async { + self.callStarted = true + } } catch { Log.error("accept call failed \(error)") } @@ -195,7 +220,18 @@ class TelecomManager { } func incomingDisplayName(call: Call) -> String { - // TODO + if call.remoteAddress != nil { + let friend = ContactsManager.shared.getFriendWithAddress(address: call.remoteAddress!) + if friend != nil && friend!.address != nil && friend!.address!.displayName != nil { + return friend!.address!.displayName! + } else { + if call.remoteAddress!.displayName != nil { + return call.remoteAddress!.displayName! + } else if call.remoteAddress!.username != nil { + return call.remoteAddress!.username! + } + } + } return "IncomingDisplayName" } @@ -361,6 +397,13 @@ class TelecomManager { } case .End, .Error: + + DispatchQueue.main.async { + withAnimation { + self.callInProgress = false + self.callStarted = false + } + } var displayName = "Unknown" if call.dir == .Incoming { displayName = incomingDisplayName(call: call) diff --git a/Linphone/UI/Call/CallView.swift b/Linphone/UI/Call/CallView.swift new file mode 100644 index 000000000..bc3baf8fa --- /dev/null +++ b/Linphone/UI/Call/CallView.swift @@ -0,0 +1,276 @@ +/* + * 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 CallView: View { + + @ObservedObject private var coreContext = CoreContext.shared + @ObservedObject private var telecomManager = TelecomManager.shared + @ObservedObject private var contactsManager = ContactsManager.shared + + @ObservedObject var callViewModel: CallViewModel + + @State var startDate = Date.now + @State var timeElapsed: Int = 0 + + let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect() + + var body: some View { + VStack { + Rectangle() + .foregroundColor(Color.orangeMain500) + .edgesIgnoringSafeArea(.top) + .frame(height: 0) + + HStack { + if callViewModel.direction == .Outgoing { + Image("outgoing-call") + .resizable() + .frame(width: 15, height: 15) + .padding(.horizontal) + + Text("Outgoing call") + .foregroundStyle(.white) + } else { + Image("incoming-call") + .resizable() + .frame(width: 15, height: 15) + .padding(.horizontal) + + Text("Incoming call") + .foregroundStyle(.white) + } + + Spacer() + } + .frame(height: 40) + + ZStack { + VStack { + Spacer() + + if callViewModel.remoteAddress != nil { + let addressFriend = contactsManager.getFriendWithAddress(address: callViewModel.remoteAddress!) + + let contactAvatarModel = addressFriend != nil + ? ContactsManager.shared.avatarListModel.first(where: { + ($0.friend!.consolidatedPresence == .Online || $0.friend!.consolidatedPresence == .Busy) + && $0.friend!.name == addressFriend!.name + && $0.friend!.address!.asStringUriOnly() == addressFriend!.address!.asStringUriOnly() + }) + : ContactAvatarModel(friend: nil, withPresence: false) + + if addressFriend != nil && addressFriend!.photo != nil && !addressFriend!.photo!.isEmpty { + if contactAvatarModel != nil { + Avatar(contactAvatarModel: contactAvatarModel!, avatarSize: 100, hidePresence: true) + } + } else { + if callViewModel.remoteAddress!.displayName != nil { + Image(uiImage: contactsManager.textToImage( + firstName: callViewModel.remoteAddress!.displayName!, + lastName: callViewModel.remoteAddress!.displayName!.components(separatedBy: " ").count > 1 + ? callViewModel.remoteAddress!.displayName!.components(separatedBy: " ")[1] + : "")) + .resizable() + .frame(width: 100, height: 100) + .clipShape(Circle()) + + } else { + Image(uiImage: contactsManager.textToImage( + firstName: callViewModel.remoteAddress!.username ?? "Username Error", + lastName: callViewModel.remoteAddress!.username!.components(separatedBy: " ").count > 1 + ? callViewModel.remoteAddress!.username!.components(separatedBy: " ")[1] + : "")) + .resizable() + .frame(width: 100, height: 100) + .clipShape(Circle()) + } + + } + } else { + Image("profil-picture-default") + .resizable() + .frame(width: 100, height: 100) + .clipShape(Circle()) + } + + Text(callViewModel.displayName) + .padding(.top) + .foregroundStyle(.white) + + Text(callViewModel.remoteAddressString) + .foregroundStyle(.white) + + Spacer() + } + + if !telecomManager.callStarted { + VStack { + ActivityIndicator() + .frame(width: 20, height: 20) + .padding(.top, 100) + + Text(counterToMinutes()) + .onReceive(timer) { firedDate in + timeElapsed = Int(firedDate.timeIntervalSince(startDate)) + + } + .padding(.top) + .foregroundStyle(.white) + + Spacer() + } + .background(.clear) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color.gray600) + .cornerRadius(20) + .padding(.horizontal, 4) + + if telecomManager.callStarted { + HStack(spacing: 12) { + Button { + terminateCall() + } label: { + Image("phone-disconnect") + .renderingMode(.template) + .resizable() + .foregroundStyle(.white) + .frame(width: 32, height: 32) + + } + .frame(width: 90, height: 60) + .background(Color.redDanger500) + .cornerRadius(40) + + Spacer() + + Button { + } label: { + Image("video-camera") + .renderingMode(.template) + .resizable() + .foregroundStyle(.white) + .frame(width: 32, height: 32) + + } + .frame(width: 60, height: 60) + .background(Color.gray500) + .cornerRadius(40) + + Button { + } label: { + Image("microphone") + .renderingMode(.template) + .resizable() + .foregroundStyle(.white) + .frame(width: 32, height: 32) + + } + .frame(width: 60, height: 60) + .background(Color.gray500) + .cornerRadius(40) + + Button { + } label: { + Image("speaker-high") + .renderingMode(.template) + .resizable() + .foregroundStyle(.white) + .frame(width: 32, height: 32) + + } + .frame(width: 60, height: 60) + .background(Color.gray500) + .cornerRadius(40) + } + .padding(.horizontal, 25) + .padding(.top, 20) + } else { + HStack(spacing: 12) { + + Spacer() + + Button { + terminateCall() + } label: { + Image("phone-disconnect") + .renderingMode(.template) + .resizable() + .foregroundStyle(.white) + .frame(width: 32, height: 32) + + } + .frame(width: 90, height: 60) + .background(Color.redDanger500) + .cornerRadius(40) + + Button { + //telecomManager.callStarted.toggle() + } label: { + Image("phone") + .renderingMode(.template) + .resizable() + .foregroundStyle(.white) + .frame(width: 32, height: 32) + + } + .frame(width: 90, height: 60) + .background(Color.greenSuccess500) + .cornerRadius(40) + + Spacer() + } + .padding(.horizontal, 25) + .padding(.top, 20) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color.gray900) + } + + func terminateCall() { + coreContext.doOnCoreQueue { core in + do { + // Terminates the call, whether it is ringing or running + try core.currentCall?.terminate() + } catch { NSLog(error.localizedDescription) } + } + timer.upstream.connect().cancel() + } + + 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)" + } + } +} + +#Preview { + CallView(callViewModel: CallViewModel()) +} diff --git a/Linphone/UI/Call/ViewModel/CallViewModel.swift b/Linphone/UI/Call/ViewModel/CallViewModel.swift new file mode 100644 index 000000000..efb3ab588 --- /dev/null +++ b/Linphone/UI/Call/ViewModel/CallViewModel.swift @@ -0,0 +1,55 @@ +/* + * 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 linphonesw + +class CallViewModel: ObservableObject { + + var coreContext = CoreContext.shared + var telecomManager = TelecomManager.shared + + @Published var displayName: String = "Example Linphone" + @Published var direction: Call.Dir = .Outgoing + @Published var remoteAddressString: String = "example.linphone@sip.linphone.org" + @Published var remoteAddress: Address? + @Published var avatarModel: ContactAvatarModel? + + init() { + coreContext.doOnCoreQueue { core in + if core.currentCall != nil && core.currentCall!.remoteAddress != nil { + DispatchQueue.main.async { + self.direction = .Incoming + self.remoteAddressString = String(core.currentCall!.remoteAddress!.asStringUriOnly().dropFirst(4)) + self.remoteAddress = core.currentCall!.remoteAddress! + + let friend = ContactsManager.shared.getFriendWithAddress(address: core.currentCall!.remoteAddress!) + if friend != nil && friend!.address != nil && friend!.address!.displayName != nil { + self.displayName = friend!.address!.displayName! + } else { + if core.currentCall!.remoteAddress!.displayName != nil { + self.displayName = core.currentCall!.remoteAddress!.displayName! + } else if core.currentCall!.remoteAddress!.username != nil { + self.displayName = core.currentCall!.remoteAddress!.username! + } + } + } + } + } + } +} diff --git a/Linphone/UI/Main/Contacts/Fragments/ContactInnerActionsFragment.swift b/Linphone/UI/Main/Contacts/Fragments/ContactInnerActionsFragment.swift index 99333cd53..0e256c39e 100644 --- a/Linphone/UI/Main/Contacts/Fragments/ContactInnerActionsFragment.swift +++ b/Linphone/UI/Main/Contacts/Fragments/ContactInnerActionsFragment.swift @@ -22,6 +22,7 @@ import SwiftUI struct ContactInnerActionsFragment: View { @ObservedObject var contactsManager = ContactsManager.shared + @ObservedObject private var telecomManager = TelecomManager.shared @ObservedObject var contactViewModel: ContactViewModel @ObservedObject var editContactViewModel: EditContactViewModel @@ -62,8 +63,7 @@ struct ContactInnerActionsFragment: View { VStack(spacing: 0) { if contactViewModel.indexDisplayedFriend != nil && contactsManager.lastSearch[contactViewModel.indexDisplayedFriend!].friend != nil { ForEach(0.. Void + var body: some View { ForEach(0.. Date: Tue, 19 Dec 2023 17:34:59 +0100 Subject: [PATCH 064/486] Add startCall utilities to TelecomManager --- Linphone/TelecomManager/TelecomManager.swift | 39 ++++++++++++++++++-- 1 file changed, 36 insertions(+), 3 deletions(-) diff --git a/Linphone/TelecomManager/TelecomManager.swift b/Linphone/TelecomManager/TelecomManager.swift index db1a80851..76c6d0c2b 100644 --- a/Linphone/TelecomManager/TelecomManager.swift +++ b/Linphone/TelecomManager/TelecomManager.swift @@ -82,13 +82,46 @@ class TelecomManager: ObservableObject { sCall.userData = UnsafeMutableRawPointer(Unmanaged.passRetained(appData!).toOpaque()) } } - + + func startCall(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 = "outgoingTODO" // FastAddressBook.displayName(for: addr) ?? "unknow" + 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") + } else { + try doCall(core: core, addr: addr!, isSas: isSas, isVideo: isVideo, isConference: isConference) + } + } + + func startCall(core: Core, addr: String, isSas: Bool = false, isVideo: Bool, isConference: Bool = false) { + do { + let address = try Factory.Instance.createAddress(addr: addr) + try startCall(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 doCallWithCore(addr: Address) { CoreContext.shared.doOnCoreQueue { core in do { - try self.doCall(core: core, addr: addr, isSas: false, isVideo: false) + try self.startCall(core: core, addr: addr, isSas: false, isVideo: false) } catch { - + Log.error("[TelecomManager] unable to create address for a new outgoing call : \(addr.asStringUriOnly()) \(error) ") } } } From cc6d599ec5f2c6d73c8ce4f03f20e1fd8374660d Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Thu, 21 Dec 2023 14:06:52 +0100 Subject: [PATCH 065/486] Fixes --- Linphone/Contacts/ContactsManager.swift | 2 +- Linphone/Core/CoreContext.swift | 3 - Linphone/TelecomManager/TelecomManager.swift | 24 +- Linphone/UI/Call/CallView.swift | 588 ++++++++++++------ Linphone/UI/Main/ContentView.swift | 31 +- .../History/Fragments/DialerBottomSheet.swift | 18 +- .../Fragments/HistoryListFragment.swift | 12 +- Linphone/Utils/Avatar.swift | 64 +- 8 files changed, 479 insertions(+), 263 deletions(-) diff --git a/Linphone/Contacts/ContactsManager.swift b/Linphone/Contacts/ContactsManager.swift index 0b00df121..9cb3b0768 100644 --- a/Linphone/Contacts/ContactsManager.swift +++ b/Linphone/Contacts/ContactsManager.swift @@ -47,7 +47,6 @@ final class ContactsManager: ObservableObject { 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 { @@ -81,6 +80,7 @@ final class ContactsManager: ObservableObject { linphoneFriendList.displayName = self.linphoneAddressBookFriendList core.addFriendList(list: linphoneFriendList) } + linphoneFriendList.subscriptionsEnabled = true } } diff --git a/Linphone/Core/CoreContext.swift b/Linphone/Core/CoreContext.swift index 10e723c1e..404098fed 100644 --- a/Linphone/Core/CoreContext.swift +++ b/Linphone/Core/CoreContext.swift @@ -86,12 +86,9 @@ final class CoreContext: ObservableObject { } self.mCore.autoIterateEnabled = false - self.mCore.friendsDatabasePath = "\(configDir)/friends.db" self.mCore.callkitEnabled = true self.mCore.pushNotificationEnabled = true - self.mCore.friendListSubscriptionEnabled = true - self.mCore.publisher?.onGlobalStateChanged?.postOnMainQueue { (cbVal: (core: Core, state: GlobalState, message: String)) in if cbVal.state == GlobalState.On { self.defaultAccount = self.mCore.defaultAccount diff --git a/Linphone/TelecomManager/TelecomManager.swift b/Linphone/TelecomManager/TelecomManager.swift index 76c6d0c2b..ce319cde3 100644 --- a/Linphone/TelecomManager/TelecomManager.swift +++ b/Linphone/TelecomManager/TelecomManager.swift @@ -83,13 +83,13 @@ class TelecomManager: ObservableObject { } } - func startCall(core: Core, addr: Address?, isSas: Bool, isVideo: Bool, isConference: Bool = false) throws { + 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 { + if TelecomManager.callKitEnabled(core: core) {//&& !nextCallIsTransfer != true { let uuid = UUID() let name = "outgoingTODO" // FastAddressBook.displayName(for: addr) ?? "unknow" let handle = CXHandle(type: .generic, value: addr?.asStringUriOnly() ?? "") @@ -110,7 +110,7 @@ class TelecomManager: ObservableObject { func startCall(core: Core, addr: String, isSas: Bool = false, isVideo: Bool, isConference: Bool = false) { do { let address = try Factory.Instance.createAddress(addr: addr) - try startCall(core: core, addr: address, isSas: isSas, isVideo: isVideo, isConference: isConference) + 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) ") } @@ -118,11 +118,11 @@ class TelecomManager: ObservableObject { func doCallWithCore(addr: Address) { CoreContext.shared.doOnCoreQueue { core in - do { - try self.startCall(core: core, addr: addr, isSas: false, isVideo: false) - } catch { - Log.error("[TelecomManager] unable to create address for a new outgoing call : \(addr.asStringUriOnly()) \(error) ") - } + do { + try self.startCallCallKit(core: core, addr: addr, isSas: false, isVideo: false, isConference: false) + } catch { + Log.error("[TelecomManager] unable to create address for a new outgoing call : \(addr) \(error) ") + } } } @@ -329,6 +329,14 @@ class TelecomManager: ObservableObject { let addr = call.remoteAddress let displayName = incomingDisplayName(call: call) + #if targetEnvironment(simulator) + DispatchQueue.main.async { + withAnimation { + TelecomManager.shared.callInProgress = true + } + } + #endif + if call.replacedCall != nil { endCallKitReplacedCall = false diff --git a/Linphone/UI/Call/CallView.swift b/Linphone/UI/Call/CallView.swift index bc3baf8fa..5f3bc98bb 100644 --- a/Linphone/UI/Call/CallView.swift +++ b/Linphone/UI/Call/CallView.swift @@ -18,6 +18,7 @@ */ import SwiftUI +import CallKit struct CallView: View { @@ -29,10 +30,213 @@ struct CallView: View { @State var startDate = Date.now @State var timeElapsed: Int = 0 + @State var micMutted: Bool = false let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect() var body: some View { + if #available(iOS 16.4, *) { + innerView() + .background(.black) + .sheet(isPresented: .constant(true)) { + VStack { + HStack(spacing: 12) { + Button { + terminateCall() + } label: { + Image("phone-disconnect") + .renderingMode(.template) + .resizable() + .foregroundStyle(.white) + .frame(width: 32, height: 32) + + } + .frame(width: 90, height: 60) + .background(Color.redDanger500) + .cornerRadius(40) + + Spacer() + + Button { + } label: { + Image("video-camera") + .renderingMode(.template) + .resizable() + .foregroundStyle(.white) + .frame(width: 32, height: 32) + + } + .frame(width: 60, height: 60) + .background(Color.gray500) + .cornerRadius(40) + + Button { + muteCall() + } label: { + Image(micMutted ? "microphone-slash" : "microphone") + .renderingMode(.template) + .resizable() + .foregroundStyle(micMutted ? .black : .white) + .frame(width: 32, height: 32) + + } + .frame(width: 60, height: 60) + .background(micMutted ? .white : Color.gray500) + .cornerRadius(40) + + Button { + } label: { + Image("speaker-high") + .renderingMode(.template) + .resizable() + .foregroundStyle(.white) + .frame(width: 32, height: 32) + + } + .frame(width: 60, height: 60) + .background(Color.gray500) + .cornerRadius(40) + } + .padding(.horizontal, 25) + .padding(.top, 20) + + HStack(spacing: 12) { + Button { + } label: { + Image("video-camera") + .renderingMode(.template) + .resizable() + .foregroundStyle(.white) + .frame(width: 32, height: 32) + + } + .frame(width: 60, height: 60) + .background(Color.gray500) + .cornerRadius(40) + + Spacer() + + Button { + muteCall() + } label: { + Image(micMutted ? "microphone-slash" : "microphone") + .renderingMode(.template) + .resizable() + .foregroundStyle(micMutted ? .black : .white) + .frame(width: 32, height: 32) + + } + .frame(width: 60, height: 60) + .background(micMutted ? .white : Color.gray500) + .cornerRadius(40) + + Spacer() + + Button { + } label: { + Image("speaker-high") + .renderingMode(.template) + .resizable() + .foregroundStyle(.white) + .frame(width: 32, height: 32) + + } + .frame(width: 60, height: 60) + .background(Color.gray500) + .cornerRadius(40) + + Spacer() + + Button { + } label: { + Image("speaker-high") + .renderingMode(.template) + .resizable() + .foregroundStyle(.white) + .frame(width: 32, height: 32) + + } + .frame(width: 60, height: 60) + .background(Color.gray500) + .cornerRadius(40) + } + .padding(.horizontal, 25) + .padding(.top, 20) + + HStack(spacing: 12) { + Button { + } label: { + Image("video-camera") + .renderingMode(.template) + .resizable() + .foregroundStyle(.white) + .frame(width: 32, height: 32) + + } + .frame(width: 60, height: 60) + .background(Color.gray500) + .cornerRadius(40) + + Spacer() + + Button { + muteCall() + } label: { + Image(micMutted ? "microphone-slash" : "microphone") + .renderingMode(.template) + .resizable() + .foregroundStyle(micMutted ? .black : .white) + .frame(width: 32, height: 32) + + } + .frame(width: 60, height: 60) + .background(micMutted ? .white : Color.gray500) + .cornerRadius(40) + + Spacer() + + Button { + } label: { + Image("speaker-high") + .renderingMode(.template) + .resizable() + .foregroundStyle(.white) + .frame(width: 32, height: 32) + + } + .frame(width: 60, height: 60) + .background(Color.gray500) + .cornerRadius(40) + + Spacer() + + Button { + } label: { + Image("speaker-high") + .renderingMode(.template) + .resizable() + .foregroundStyle(.white) + .frame(width: 32, height: 32) + + } + .frame(width: 60, height: 60) + .background(Color.gray500) + .cornerRadius(40) + } + .padding(.horizontal, 25) + .padding(.top, 20) + } + .frame(maxHeight: .infinity, alignment: .top) + .background(.black) + .presentationDetents([.fraction(0.1), .medium]) + .interactiveDismissDisabled() + .presentationBackgroundInteraction(.enabled) + } + } + } + + @ViewBuilder + func innerView() -> some View { VStack { Rectangle() .foregroundColor(Color.orangeMain500) @@ -40,222 +244,200 @@ struct CallView: View { .frame(height: 0) HStack { - if callViewModel.direction == .Outgoing { - Image("outgoing-call") - .resizable() - .frame(width: 15, height: 15) - .padding(.horizontal) - - Text("Outgoing call") - .foregroundStyle(.white) - } else { - Image("incoming-call") - .resizable() - .frame(width: 15, height: 15) - .padding(.horizontal) - - Text("Incoming call") - .foregroundStyle(.white) - } - - Spacer() + if callViewModel.direction == .Outgoing { + Image("outgoing-call") + .resizable() + .frame(width: 15, height: 15) + .padding(.horizontal) + + Text("Outgoing call") + .foregroundStyle(.white) + } else { + Image("incoming-call") + .resizable() + .frame(width: 15, height: 15) + .padding(.horizontal) + + Text("Incoming call") + .foregroundStyle(.white) + } + + Spacer() } .frame(height: 40) - - ZStack { - VStack { - Spacer() - - if callViewModel.remoteAddress != nil { - let addressFriend = contactsManager.getFriendWithAddress(address: callViewModel.remoteAddress!) - - let contactAvatarModel = addressFriend != nil - ? ContactsManager.shared.avatarListModel.first(where: { - ($0.friend!.consolidatedPresence == .Online || $0.friend!.consolidatedPresence == .Busy) - && $0.friend!.name == addressFriend!.name - && $0.friend!.address!.asStringUriOnly() == addressFriend!.address!.asStringUriOnly() - }) - : ContactAvatarModel(friend: nil, withPresence: false) - - if addressFriend != nil && addressFriend!.photo != nil && !addressFriend!.photo!.isEmpty { - if contactAvatarModel != nil { - Avatar(contactAvatarModel: contactAvatarModel!, avatarSize: 100, hidePresence: true) - } - } else { - if callViewModel.remoteAddress!.displayName != nil { - Image(uiImage: contactsManager.textToImage( - firstName: callViewModel.remoteAddress!.displayName!, - lastName: callViewModel.remoteAddress!.displayName!.components(separatedBy: " ").count > 1 - ? callViewModel.remoteAddress!.displayName!.components(separatedBy: " ")[1] - : "")) - .resizable() - .frame(width: 100, height: 100) - .clipShape(Circle()) - - } else { - Image(uiImage: contactsManager.textToImage( - firstName: callViewModel.remoteAddress!.username ?? "Username Error", - lastName: callViewModel.remoteAddress!.username!.components(separatedBy: " ").count > 1 - ? callViewModel.remoteAddress!.username!.components(separatedBy: " ")[1] - : "")) - .resizable() - .frame(width: 100, height: 100) - .clipShape(Circle()) - } - - } - } else { - Image("profil-picture-default") - .resizable() - .frame(width: 100, height: 100) - .clipShape(Circle()) - } - - Text(callViewModel.displayName) - .padding(.top) - .foregroundStyle(.white) - - Text(callViewModel.remoteAddressString) - .foregroundStyle(.white) - - Spacer() - } - - if !telecomManager.callStarted { - VStack { - ActivityIndicator() - .frame(width: 20, height: 20) - .padding(.top, 100) - - Text(counterToMinutes()) - .onReceive(timer) { firedDate in - timeElapsed = Int(firedDate.timeIntervalSince(startDate)) - - } - .padding(.top) - .foregroundStyle(.white) - - Spacer() - } - .background(.clear) - } - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - .background(Color.gray600) - .cornerRadius(20) - .padding(.horizontal, 4) - if telecomManager.callStarted { - HStack(spacing: 12) { - Button { - terminateCall() - } label: { - Image("phone-disconnect") - .renderingMode(.template) - .resizable() - .foregroundStyle(.white) - .frame(width: 32, height: 32) - - } - .frame(width: 90, height: 60) - .background(Color.redDanger500) - .cornerRadius(40) - - Spacer() - - Button { - } label: { - Image("video-camera") - .renderingMode(.template) - .resizable() - .foregroundStyle(.white) - .frame(width: 32, height: 32) - - } - .frame(width: 60, height: 60) - .background(Color.gray500) - .cornerRadius(40) - - Button { - } label: { - Image("microphone") - .renderingMode(.template) - .resizable() - .foregroundStyle(.white) - .frame(width: 32, height: 32) - - } - .frame(width: 60, height: 60) - .background(Color.gray500) - .cornerRadius(40) - - Button { - } label: { - Image("speaker-high") - .renderingMode(.template) - .resizable() - .foregroundStyle(.white) - .frame(width: 32, height: 32) - - } - .frame(width: 60, height: 60) - .background(Color.gray500) - .cornerRadius(40) - } - .padding(.horizontal, 25) - .padding(.top, 20) - } else { - HStack(spacing: 12) { - - Spacer() - - Button { - terminateCall() - } label: { - Image("phone-disconnect") - .renderingMode(.template) - .resizable() - .foregroundStyle(.white) - .frame(width: 32, height: 32) - - } - .frame(width: 90, height: 60) - .background(Color.redDanger500) - .cornerRadius(40) + ZStack { + VStack { + Spacer() + + if callViewModel.remoteAddress != nil { + let addressFriend = contactsManager.getFriendWithAddress(address: callViewModel.remoteAddress!) + + let contactAvatarModel = addressFriend != nil + ? ContactsManager.shared.avatarListModel.first(where: { + ($0.friend!.consolidatedPresence == .Online || $0.friend!.consolidatedPresence == .Busy) + && $0.friend!.name == addressFriend!.name + && $0.friend!.address!.asStringUriOnly() == addressFriend!.address!.asStringUriOnly() + }) + : ContactAvatarModel(friend: nil, withPresence: false) + + if addressFriend != nil && addressFriend!.photo != nil && !addressFriend!.photo!.isEmpty { + if contactAvatarModel != nil { + Avatar(contactAvatarModel: contactAvatarModel!, avatarSize: 100, hidePresence: true) + } + } else { + if callViewModel.remoteAddress!.displayName != nil { + Image(uiImage: contactsManager.textToImage( + firstName: callViewModel.remoteAddress!.displayName!, + lastName: callViewModel.remoteAddress!.displayName!.components(separatedBy: " ").count > 1 + ? callViewModel.remoteAddress!.displayName!.components(separatedBy: " ")[1] + : "")) + .resizable() + .frame(width: 100, height: 100) + .clipShape(Circle()) + + } else { + Image(uiImage: contactsManager.textToImage( + firstName: callViewModel.remoteAddress!.username ?? "Username Error", + lastName: callViewModel.remoteAddress!.username!.components(separatedBy: " ").count > 1 + ? callViewModel.remoteAddress!.username!.components(separatedBy: " ")[1] + : "")) + .resizable() + .frame(width: 100, height: 100) + .clipShape(Circle()) + } + + } + } else { + Image("profil-picture-default") + .resizable() + .frame(width: 100, height: 100) + .clipShape(Circle()) + } + + Text(callViewModel.displayName) + .padding(.top) + .foregroundStyle(.white) + + Text(callViewModel.remoteAddressString) + .foregroundStyle(.white) + + Spacer() + } + + if !telecomManager.callStarted { + VStack { + ActivityIndicator() + .frame(width: 20, height: 20) + .padding(.top, 100) + + Text(counterToMinutes()) + .onReceive(timer) { firedDate in + timeElapsed = Int(firedDate.timeIntervalSince(startDate)) - Button { - //telecomManager.callStarted.toggle() - } label: { - Image("phone") - .renderingMode(.template) - .resizable() - .foregroundStyle(.white) - .frame(width: 32, height: 32) - - } - .frame(width: 90, height: 60) - .background(Color.greenSuccess500) - .cornerRadius(40) - - Spacer() - } - .padding(.horizontal, 25) - .padding(.top, 20) - } + } + .padding(.top) + .foregroundStyle(.white) + + Spacer() + } + .background(.clear) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color.gray600) + .cornerRadius(20) + .padding(.horizontal, 4) + + if telecomManager.callStarted { + HStack(spacing: 12) { + HStack { + } + .frame(width: 60, height: 60) + } + .padding(.horizontal, 25) + .padding(.top, 20) + } else { + HStack(spacing: 12) { + + Spacer() + + Button { + terminateCall() + } label: { + Image("phone-disconnect") + .renderingMode(.template) + .resizable() + .foregroundStyle(.white) + .frame(width: 32, height: 32) + + } + .frame(width: 90, height: 60) + .background(Color.redDanger500) + .cornerRadius(40) + + Button { + acceptCall() + } label: { + Image("phone") + .renderingMode(.template) + .resizable() + .foregroundStyle(.white) + .frame(width: 32, height: 32) + + } + .frame(width: 90, height: 60) + .background(Color.greenSuccess500) + .cornerRadius(40) + + Spacer() + } + .padding(.horizontal, 25) + .padding(.top, 20) + } } .frame(maxWidth: .infinity, maxHeight: .infinity) .background(Color.gray900) - } + } func terminateCall() { + withAnimation { + telecomManager.callInProgress = false + telecomManager.callStarted = false + } + coreContext.doOnCoreQueue { core in - do { - // Terminates the call, whether it is ringing or running - try core.currentCall?.terminate() - } catch { NSLog(error.localizedDescription) } + if core.currentCall != nil { + telecomManager.terminateCall(call: core.currentCall!) + } } + timer.upstream.connect().cancel() } + + func acceptCall() { + withAnimation { + telecomManager.callInProgress = true + telecomManager.callStarted = true + } + + coreContext.doOnCoreQueue { core in + if core.currentCall != nil { + telecomManager.acceptCall(core: core, call: core.currentCall!, hasVideo: false) + } + } + + timer.upstream.connect().cancel() + } + + func muteCall() { + coreContext.doOnCoreQueue { core in + if core.currentCall != nil { + micMutted = !micMutted + core.currentCall!.microphoneMuted = micMutted + } + } + } func counterToMinutes() -> String { let currentTime = timeElapsed diff --git a/Linphone/UI/Main/ContentView.swift b/Linphone/UI/Main/ContentView.swift index 905ef9799..cdd0ca2a4 100644 --- a/Linphone/UI/Main/ContentView.swift +++ b/Linphone/UI/Main/ContentView.swift @@ -446,19 +446,21 @@ struct ContentView: View { }) : ContactAvatarModel(friend: nil, withPresence: false) - HistoryContactFragment( - contactAvatarModel: contactAvatarModel!, - historyViewModel: historyViewModel, - historyListViewModel: historyListViewModel, - contactViewModel: contactViewModel, - editContactViewModel: editContactViewModel, - isShowDeleteAllHistoryPopup: $isShowDeleteAllHistoryPopup, - isShowEditContactFragment: $isShowEditContactFragment, - indexPage: $index - ) - .frame(maxWidth: .infinity) - .background(Color.gray100) - .ignoresSafeArea(.keyboard) + if contactAvatarModel != nil { + HistoryContactFragment( + contactAvatarModel: contactAvatarModel!, + historyViewModel: historyViewModel, + historyListViewModel: historyListViewModel, + contactViewModel: contactViewModel, + editContactViewModel: editContactViewModel, + isShowDeleteAllHistoryPopup: $isShowDeleteAllHistoryPopup, + isShowEditContactFragment: $isShowEditContactFragment, + indexPage: $index + ) + .frame(maxWidth: .infinity) + .background(Color.gray100) + .ignoresSafeArea(.keyboard) + } } } .onAppear { @@ -656,6 +658,9 @@ struct ContentView: View { coreContext.onForeground() if !isShowStartCallFragment { contactsManager.fetchContacts() + DispatchQueue.global().asyncAfter(deadline: .now() + 0.5) { + historyListViewModel.computeCallLogsList() + } } print("Active") } else if newPhase == .inactive { diff --git a/Linphone/UI/Main/History/Fragments/DialerBottomSheet.swift b/Linphone/UI/Main/History/Fragments/DialerBottomSheet.swift index 92f932c73..dc3915be6 100644 --- a/Linphone/UI/Main/History/Fragments/DialerBottomSheet.swift +++ b/Linphone/UI/Main/History/Fragments/DialerBottomSheet.swift @@ -231,12 +231,18 @@ struct DialerBottomSheet: View { .clipShape(Circle()) } } - .onTapGesture { - startCallViewModel.searchField += "0" - } - .onLongPressGesture(minimumDuration: 0.2) { - startCallViewModel.searchField += "+" - } + .simultaneousGesture( + LongPressGesture() + .onEnded { _ in + startCallViewModel.searchField += "+" + } + ) + .highPriorityGesture( + TapGesture() + .onEnded { _ in + startCallViewModel.searchField += "0" + } + ) Spacer() diff --git a/Linphone/UI/Main/History/Fragments/HistoryListFragment.swift b/Linphone/UI/Main/History/Fragments/HistoryListFragment.swift index ccdfd4820..ceafcb884 100644 --- a/Linphone/UI/Main/History/Fragments/HistoryListFragment.swift +++ b/Linphone/UI/Main/History/Fragments/HistoryListFragment.swift @@ -51,6 +51,11 @@ struct HistoryListFragment: View { if addressFriend != nil && addressFriend!.photo != nil && !addressFriend!.photo!.isEmpty { if contactAvatarModel != nil { Avatar(contactAvatarModel: contactAvatarModel!, avatarSize: 45) + } else { + Image("profil-picture-default") + .resizable() + .frame(width: 45, height: 45) + .clipShape(Circle()) } } else { if historyListViewModel.callLogs[index].dir == .Outgoing && historyListViewModel.callLogs[index].toAddress != nil { @@ -95,7 +100,12 @@ struct HistoryListFragment: View { .frame(width: 45, height: 45) .clipShape(Circle()) } - } + } else { + Image("profil-picture-default") + .resizable() + .frame(width: 45, height: 45) + .clipShape(Circle()) + } } VStack(spacing: 0) { diff --git a/Linphone/Utils/Avatar.swift b/Linphone/Utils/Avatar.swift index 780eccc9d..8d44fa061 100644 --- a/Linphone/Utils/Avatar.swift +++ b/Linphone/Utils/Avatar.swift @@ -23,6 +23,7 @@ import linphonesw struct Avatar: View { @ObservedObject var contactAvatarModel: ContactAvatarModel + let avatarSize: CGFloat let hidePresence: Bool @@ -33,41 +34,48 @@ struct Avatar: View { } var body: some View { - 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) + if contactAvatarModel.friend != nil { + AsyncImage(url: ContactsManager.shared.getImagePath(friendPhotoPath: contactAvatarModel.friend!.photo!)) { image in + switch image { + case .empty: + ProgressView() .frame(width: avatarSize, height: avatarSize) - .clipShape(Circle()) - HStack { - Spacer() - VStack { + case .success(let image): + ZStack { + image + .resizable() + .aspectRatio(contentMode: .fill) + .frame(width: avatarSize, height: avatarSize) + .clipShape(Circle()) + HStack { 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 == 45 ? 1 : 3) - .padding(.bottom, avatarSize == 45 ? 1 : 3) + 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 == 45 ? 1 : 3) + .padding(.bottom, avatarSize == 45 ? 1 : 3) + } } } + .frame(width: avatarSize, height: avatarSize) } - .frame(width: avatarSize, height: avatarSize) + case .failure: + Image("profil-picture-default") + .resizable() + .frame(width: avatarSize, height: avatarSize) + .clipShape(Circle()) + @unknown default: + EmptyView() } - case .failure: - Image("profil-picture-default") - .resizable() - .frame(width: avatarSize, height: avatarSize) - .clipShape(Circle()) - @unknown default: - EmptyView() } + } else { + Image("profil-picture-default") + .resizable() + .frame(width: avatarSize, height: avatarSize) + .clipShape(Circle()) } } } From 63d83b13f69d7bdf17f50a5f8e8312d29c0689ce Mon Sep 17 00:00:00 2001 From: "benoit.martins" Date: Wed, 27 Dec 2023 18:10:28 +0100 Subject: [PATCH 066/486] Fix bottom sheet in call view --- .../notebook.imageset/Contents.json | 21 + .../notebook.imageset/notebook.svg | 1 + .../screencast.imageset/Contents.json | 21 + .../screencast.imageset/screencast.svg | 1 + Linphone/Localizable.xcstrings | 21 + Linphone/UI/Call/CallView.swift | 850 +++++++++--------- 6 files changed, 510 insertions(+), 405 deletions(-) create mode 100644 Linphone/Assets.xcassets/notebook.imageset/Contents.json create mode 100644 Linphone/Assets.xcassets/notebook.imageset/notebook.svg create mode 100644 Linphone/Assets.xcassets/screencast.imageset/Contents.json create mode 100644 Linphone/Assets.xcassets/screencast.imageset/screencast.svg 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/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/Localizable.xcstrings b/Linphone/Localizable.xcstrings index aadf76990..d6a578052 100644 --- a/Linphone/Localizable.xcstrings +++ b/Linphone/Localizable.xcstrings @@ -190,6 +190,9 @@ }, "Call history" : { + }, + "Call list" : { + }, "Calls" : { @@ -265,6 +268,9 @@ }, "Display Name" : { + }, + "Disposition" : { + }, "Do you really want to delete all calls history?" : { @@ -355,6 +361,9 @@ }, "Message" : { + }, + "Messages" : { + }, "Missed call" : { @@ -397,6 +406,9 @@ }, "Outgoing Call" : { + }, + "Participants" : { + }, "password" : { "extractionState" : "manual", @@ -414,6 +426,9 @@ } } } + }, + "Pause" : { + }, "Personnalize your profil mode" : { @@ -435,6 +450,9 @@ }, "QR code validated!" : { + }, + "Record" : { + }, "Register" : { @@ -447,6 +465,9 @@ }, "Scan QR code" : { + }, + "Screen share" : { + }, "Search contact or history call" : { diff --git a/Linphone/UI/Call/CallView.swift b/Linphone/UI/Call/CallView.swift index 5f3bc98bb..821a66cbb 100644 --- a/Linphone/UI/Call/CallView.swift +++ b/Linphone/UI/Call/CallView.swift @@ -23,428 +23,468 @@ import CallKit struct CallView: View { @ObservedObject private var coreContext = CoreContext.shared - @ObservedObject private var telecomManager = TelecomManager.shared + @ObservedObject private var telecomManager = TelecomManager.shared @ObservedObject private var contactsManager = ContactsManager.shared @ObservedObject var callViewModel: CallViewModel @State var startDate = Date.now @State var timeElapsed: Int = 0 - @State var micMutted: Bool = false + @State var micMutted: Bool = false let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect() - + var body: some View { - if #available(iOS 16.4, *) { - innerView() - .background(.black) - .sheet(isPresented: .constant(true)) { - VStack { - HStack(spacing: 12) { - Button { - terminateCall() - } label: { - Image("phone-disconnect") - .renderingMode(.template) - .resizable() - .foregroundStyle(.white) - .frame(width: 32, height: 32) - - } - .frame(width: 90, height: 60) - .background(Color.redDanger500) - .cornerRadius(40) - - Spacer() - - Button { - } label: { - Image("video-camera") - .renderingMode(.template) - .resizable() - .foregroundStyle(.white) - .frame(width: 32, height: 32) - - } - .frame(width: 60, height: 60) - .background(Color.gray500) - .cornerRadius(40) - - Button { - muteCall() - } label: { - Image(micMutted ? "microphone-slash" : "microphone") - .renderingMode(.template) - .resizable() - .foregroundStyle(micMutted ? .black : .white) - .frame(width: 32, height: 32) - - } - .frame(width: 60, height: 60) - .background(micMutted ? .white : Color.gray500) - .cornerRadius(40) - - Button { - } label: { - Image("speaker-high") - .renderingMode(.template) - .resizable() - .foregroundStyle(.white) - .frame(width: 32, height: 32) - - } - .frame(width: 60, height: 60) - .background(Color.gray500) - .cornerRadius(40) - } - .padding(.horizontal, 25) - .padding(.top, 20) - - HStack(spacing: 12) { - Button { - } label: { - Image("video-camera") - .renderingMode(.template) - .resizable() - .foregroundStyle(.white) - .frame(width: 32, height: 32) - - } - .frame(width: 60, height: 60) - .background(Color.gray500) - .cornerRadius(40) - - Spacer() - - Button { - muteCall() - } label: { - Image(micMutted ? "microphone-slash" : "microphone") - .renderingMode(.template) - .resizable() - .foregroundStyle(micMutted ? .black : .white) - .frame(width: 32, height: 32) - - } - .frame(width: 60, height: 60) - .background(micMutted ? .white : Color.gray500) - .cornerRadius(40) - - Spacer() - - Button { - } label: { - Image("speaker-high") - .renderingMode(.template) - .resizable() - .foregroundStyle(.white) - .frame(width: 32, height: 32) - - } - .frame(width: 60, height: 60) - .background(Color.gray500) - .cornerRadius(40) - - Spacer() - - Button { - } label: { - Image("speaker-high") - .renderingMode(.template) - .resizable() - .foregroundStyle(.white) - .frame(width: 32, height: 32) - - } - .frame(width: 60, height: 60) - .background(Color.gray500) - .cornerRadius(40) - } - .padding(.horizontal, 25) - .padding(.top, 20) - - HStack(spacing: 12) { - Button { - } label: { - Image("video-camera") - .renderingMode(.template) - .resizable() - .foregroundStyle(.white) - .frame(width: 32, height: 32) - - } - .frame(width: 60, height: 60) - .background(Color.gray500) - .cornerRadius(40) - - Spacer() - - Button { - muteCall() - } label: { - Image(micMutted ? "microphone-slash" : "microphone") - .renderingMode(.template) - .resizable() - .foregroundStyle(micMutted ? .black : .white) - .frame(width: 32, height: 32) - - } - .frame(width: 60, height: 60) - .background(micMutted ? .white : Color.gray500) - .cornerRadius(40) - - Spacer() - - Button { - } label: { - Image("speaker-high") - .renderingMode(.template) - .resizable() - .foregroundStyle(.white) - .frame(width: 32, height: 32) - - } - .frame(width: 60, height: 60) - .background(Color.gray500) - .cornerRadius(40) - - Spacer() - - Button { - } label: { - Image("speaker-high") - .renderingMode(.template) - .resizable() - .foregroundStyle(.white) - .frame(width: 32, height: 32) - - } - .frame(width: 60, height: 60) - .background(Color.gray500) - .cornerRadius(40) - } - .padding(.horizontal, 25) - .padding(.top, 20) - } - .frame(maxHeight: .infinity, alignment: .top) - .background(.black) - .presentationDetents([.fraction(0.1), .medium]) - .interactiveDismissDisabled() - .presentationBackgroundInteraction(.enabled) - } - } + GeometryReader { geo in + if #available(iOS 16.4, *) { + innerView() + .sheet(isPresented: $telecomManager.callStarted) { + GeometryReader { _ in + VStack(spacing: 0) { + HStack(spacing: 12) { + Button { + terminateCall() + } label: { + Image("phone-disconnect") + .renderingMode(.template) + .resizable() + .foregroundStyle(.white) + .frame(width: 32, height: 32) + + } + .frame(width: 90, height: 60) + .background(Color.redDanger500) + .cornerRadius(40) + + Spacer() + + Button { + } label: { + Image("video-camera") + .renderingMode(.template) + .resizable() + .foregroundStyle(.white) + .frame(width: 32, height: 32) + + } + .frame(width: 60, height: 60) + .background(Color.gray500) + .cornerRadius(40) + + Button { + muteCall() + } label: { + Image(micMutted ? "microphone-slash" : "microphone") + .renderingMode(.template) + .resizable() + .foregroundStyle(micMutted ? .black : .white) + .frame(width: 32, height: 32) + + } + .frame(width: 60, height: 60) + .background(micMutted ? .white : Color.gray500) + .cornerRadius(40) + + Button { + } label: { + Image("speaker-high") + .renderingMode(.template) + .resizable() + .foregroundStyle(.white) + .frame(width: 32, height: 32) + + } + .frame(width: 60, height: 60) + .background(Color.gray500) + .cornerRadius(40) + } + .frame(height: geo.size.height * 0.15) + .padding(.horizontal, 20) + + HStack(spacing: 0) { + VStack { + Button { + } label: { + Image("screencast") + .renderingMode(.template) + .resizable() + .foregroundStyle(.white) + .frame(width: 32, height: 32) + } + .frame(width: 60, height: 60) + .background(Color.gray500) + .cornerRadius(40) + + Text("Screen share") + .foregroundStyle(.white) + .default_text_style(styleSize: 15) + } + .frame(width: geo.size.width * 0.25, height: geo.size.width * 0.25) + + VStack { + Button { + } label: { + Image("users") + .renderingMode(.template) + .resizable() + .foregroundStyle(.white) + .frame(width: 32, height: 32) + } + .frame(width: 60, height: 60) + .background(Color.gray500) + .cornerRadius(40) + + Text("Participants") + .foregroundStyle(.white) + .default_text_style(styleSize: 15) + } + .frame(width: geo.size.width * 0.25, height: geo.size.width * 0.25) + + VStack { + Button { + } label: { + Image("chat-teardrop-text") + .renderingMode(.template) + .resizable() + .foregroundStyle(.white) + .frame(width: 32, height: 32) + } + .frame(width: 60, height: 60) + .background(Color.gray500) + .cornerRadius(40) + + Text("Messages") + .foregroundStyle(.white) + .default_text_style(styleSize: 15) + } + .frame(width: geo.size.width * 0.25, height: geo.size.width * 0.25) + + VStack { + Button { + } label: { + Image("notebook") + .renderingMode(.template) + .resizable() + .foregroundStyle(.white) + .frame(width: 32, height: 32) + } + .frame(width: 60, height: 60) + .background(Color.gray500) + .cornerRadius(40) + + Text("Disposition") + .foregroundStyle(.white) + .default_text_style(styleSize: 15) + } + .frame(width: geo.size.width * 0.25, height: geo.size.width * 0.25) + } + .frame(height: geo.size.height * 0.15) + + HStack(spacing: 0) { + VStack { + Button { + } label: { + Image("phone-call") + .renderingMode(.template) + .resizable() + .foregroundStyle(.white) + .frame(width: 32, height: 32) + } + .frame(width: 60, height: 60) + .background(Color.gray500) + .cornerRadius(40) + + Text("Call list") + .foregroundStyle(.white) + .default_text_style(styleSize: 15) + } + .frame(width: geo.size.width * 0.25, height: geo.size.width * 0.25) + + VStack { + Button { + } label: { + Image("pause") + .renderingMode(.template) + .resizable() + .foregroundStyle(.white) + .frame(width: 32, height: 32) + } + .frame(width: 60, height: 60) + .background(Color.gray500) + .cornerRadius(40) + + Text("Pause") + .foregroundStyle(.white) + .default_text_style(styleSize: 15) + } + .frame(width: geo.size.width * 0.25, height: geo.size.width * 0.25) + + VStack { + Button { + } label: { + Image("record-fill") + .renderingMode(.template) + .resizable() + .foregroundStyle(.white) + .frame(width: 32, height: 32) + } + .frame(width: 60, height: 60) + .background(Color.gray500) + .cornerRadius(40) + + Text("Record") + .foregroundStyle(.white) + .default_text_style(styleSize: 15) + } + .frame(width: geo.size.width * 0.25, height: geo.size.width * 0.25) + + VStack { + Button { + } label: { + Image("video-camera") + .renderingMode(.template) + .resizable() + .foregroundStyle(.white) + .frame(width: 32, height: 32) + } + .frame(width: 60, height: 60) + .background(Color.gray500) + .cornerRadius(40) + + Text("Disposition") + .foregroundStyle(.white) + .default_text_style(styleSize: 15) + } + .frame(width: geo.size.width * 0.25, height: geo.size.width * 0.25) + .hidden() + } + .frame(height: geo.size.height * 0.15) + + Spacer() + } + .frame(maxHeight: .infinity, alignment: .top) + .background(.black) + .presentationDetents([.fraction(0.1), .medium]) + .interactiveDismissDisabled() + .presentationBackgroundInteraction(.enabled) + } + } + } + } + } + + @ViewBuilder + func innerView() -> some View { + VStack { + Rectangle() + .foregroundColor(Color.orangeMain500) + .edgesIgnoringSafeArea(.top) + .frame(height: 0) + + HStack { + if callViewModel.direction == .Outgoing { + Image("outgoing-call") + .resizable() + .frame(width: 15, height: 15) + .padding(.horizontal) + + Text("Outgoing call") + .foregroundStyle(.white) + } else { + Image("incoming-call") + .resizable() + .frame(width: 15, height: 15) + .padding(.horizontal) + + Text("Incoming call") + .foregroundStyle(.white) + } + + Spacer() + } + .frame(height: 40) + + ZStack { + VStack { + Spacer() + + if callViewModel.remoteAddress != nil { + let addressFriend = contactsManager.getFriendWithAddress(address: callViewModel.remoteAddress!) + + let contactAvatarModel = addressFriend != nil + ? ContactsManager.shared.avatarListModel.first(where: { + ($0.friend!.consolidatedPresence == .Online || $0.friend!.consolidatedPresence == .Busy) + && $0.friend!.name == addressFriend!.name + && $0.friend!.address!.asStringUriOnly() == addressFriend!.address!.asStringUriOnly() + }) + : ContactAvatarModel(friend: nil, withPresence: false) + + if addressFriend != nil && addressFriend!.photo != nil && !addressFriend!.photo!.isEmpty { + if contactAvatarModel != nil { + Avatar(contactAvatarModel: contactAvatarModel!, avatarSize: 100, hidePresence: true) + } + } else { + if callViewModel.remoteAddress!.displayName != nil { + Image(uiImage: contactsManager.textToImage( + firstName: callViewModel.remoteAddress!.displayName!, + lastName: callViewModel.remoteAddress!.displayName!.components(separatedBy: " ").count > 1 + ? callViewModel.remoteAddress!.displayName!.components(separatedBy: " ")[1] + : "")) + .resizable() + .frame(width: 100, height: 100) + .clipShape(Circle()) + + } else { + Image(uiImage: contactsManager.textToImage( + firstName: callViewModel.remoteAddress!.username ?? "Username Error", + lastName: callViewModel.remoteAddress!.username!.components(separatedBy: " ").count > 1 + ? callViewModel.remoteAddress!.username!.components(separatedBy: " ")[1] + : "")) + .resizable() + .frame(width: 100, height: 100) + .clipShape(Circle()) + } + + } + } else { + Image("profil-picture-default") + .resizable() + .frame(width: 100, height: 100) + .clipShape(Circle()) + } + + Text(callViewModel.displayName) + .padding(.top) + .foregroundStyle(.white) + + Text(callViewModel.remoteAddressString) + .foregroundStyle(.white) + + Spacer() + } + + if !telecomManager.callStarted { + VStack { + ActivityIndicator() + .frame(width: 20, height: 20) + .padding(.top, 100) + + Text(counterToMinutes()) + .onReceive(timer) { firedDate in + timeElapsed = Int(firedDate.timeIntervalSince(startDate)) + + } + .padding(.top) + .foregroundStyle(.white) + + Spacer() + } + .background(.clear) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color.gray600) + .cornerRadius(20) + .padding(.horizontal, 4) + + if telecomManager.callStarted { + HStack(spacing: 12) { + HStack { + } + .frame(height: 60) + } + .padding(.horizontal, 25) + .padding(.top, 20) + } else { + HStack(spacing: 12) { + HStack { + Spacer() + + Button { + terminateCall() + } label: { + Image("phone-disconnect") + .renderingMode(.template) + .resizable() + .foregroundStyle(.white) + .frame(width: 32, height: 32) + + } + .frame(width: 90, height: 60) + .background(Color.redDanger500) + .cornerRadius(40) + + Button { + acceptCall() + } label: { + Image("phone") + .renderingMode(.template) + .resizable() + .foregroundStyle(.white) + .frame(width: 32, height: 32) + + } + .frame(width: 90, height: 60) + .background(Color.greenSuccess500) + .cornerRadius(40) + + Spacer() + } + .frame(height: 60) + } + .padding(.horizontal, 25) + .padding(.top, 20) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color.gray900) } - - @ViewBuilder - func innerView() -> some View { - VStack { - Rectangle() - .foregroundColor(Color.orangeMain500) - .edgesIgnoringSafeArea(.top) - .frame(height: 0) - - HStack { - if callViewModel.direction == .Outgoing { - Image("outgoing-call") - .resizable() - .frame(width: 15, height: 15) - .padding(.horizontal) - - Text("Outgoing call") - .foregroundStyle(.white) - } else { - Image("incoming-call") - .resizable() - .frame(width: 15, height: 15) - .padding(.horizontal) - - Text("Incoming call") - .foregroundStyle(.white) - } - - Spacer() - } - .frame(height: 40) - - ZStack { - VStack { - Spacer() - - if callViewModel.remoteAddress != nil { - let addressFriend = contactsManager.getFriendWithAddress(address: callViewModel.remoteAddress!) - - let contactAvatarModel = addressFriend != nil - ? ContactsManager.shared.avatarListModel.first(where: { - ($0.friend!.consolidatedPresence == .Online || $0.friend!.consolidatedPresence == .Busy) - && $0.friend!.name == addressFriend!.name - && $0.friend!.address!.asStringUriOnly() == addressFriend!.address!.asStringUriOnly() - }) - : ContactAvatarModel(friend: nil, withPresence: false) - - if addressFriend != nil && addressFriend!.photo != nil && !addressFriend!.photo!.isEmpty { - if contactAvatarModel != nil { - Avatar(contactAvatarModel: contactAvatarModel!, avatarSize: 100, hidePresence: true) - } - } else { - if callViewModel.remoteAddress!.displayName != nil { - Image(uiImage: contactsManager.textToImage( - firstName: callViewModel.remoteAddress!.displayName!, - lastName: callViewModel.remoteAddress!.displayName!.components(separatedBy: " ").count > 1 - ? callViewModel.remoteAddress!.displayName!.components(separatedBy: " ")[1] - : "")) - .resizable() - .frame(width: 100, height: 100) - .clipShape(Circle()) - - } else { - Image(uiImage: contactsManager.textToImage( - firstName: callViewModel.remoteAddress!.username ?? "Username Error", - lastName: callViewModel.remoteAddress!.username!.components(separatedBy: " ").count > 1 - ? callViewModel.remoteAddress!.username!.components(separatedBy: " ")[1] - : "")) - .resizable() - .frame(width: 100, height: 100) - .clipShape(Circle()) - } - - } - } else { - Image("profil-picture-default") - .resizable() - .frame(width: 100, height: 100) - .clipShape(Circle()) - } - - Text(callViewModel.displayName) - .padding(.top) - .foregroundStyle(.white) - - Text(callViewModel.remoteAddressString) - .foregroundStyle(.white) - - Spacer() - } - - if !telecomManager.callStarted { - VStack { - ActivityIndicator() - .frame(width: 20, height: 20) - .padding(.top, 100) - - Text(counterToMinutes()) - .onReceive(timer) { firedDate in - timeElapsed = Int(firedDate.timeIntervalSince(startDate)) - - } - .padding(.top) - .foregroundStyle(.white) - - Spacer() - } - .background(.clear) - } - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - .background(Color.gray600) - .cornerRadius(20) - .padding(.horizontal, 4) - - if telecomManager.callStarted { - HStack(spacing: 12) { - HStack { - } - .frame(width: 60, height: 60) - } - .padding(.horizontal, 25) - .padding(.top, 20) - } else { - HStack(spacing: 12) { - - Spacer() - - Button { - terminateCall() - } label: { - Image("phone-disconnect") - .renderingMode(.template) - .resizable() - .foregroundStyle(.white) - .frame(width: 32, height: 32) - - } - .frame(width: 90, height: 60) - .background(Color.redDanger500) - .cornerRadius(40) - - Button { - acceptCall() - } label: { - Image("phone") - .renderingMode(.template) - .resizable() - .foregroundStyle(.white) - .frame(width: 32, height: 32) - - } - .frame(width: 90, height: 60) - .background(Color.greenSuccess500) - .cornerRadius(40) - - Spacer() - } - .padding(.horizontal, 25) - .padding(.top, 20) - } - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - .background(Color.gray900) - } func terminateCall() { - withAnimation { - telecomManager.callInProgress = false - telecomManager.callStarted = false - } - - coreContext.doOnCoreQueue { core in - if core.currentCall != nil { - telecomManager.terminateCall(call: core.currentCall!) - } + withAnimation { + telecomManager.callInProgress = false + telecomManager.callStarted = false } - + + coreContext.doOnCoreQueue { core in + if core.currentCall != nil { + telecomManager.terminateCall(call: core.currentCall!) + } + } + timer.upstream.connect().cancel() } - - func acceptCall() { - withAnimation { - telecomManager.callInProgress = true - telecomManager.callStarted = true - } - - coreContext.doOnCoreQueue { core in - if core.currentCall != nil { - telecomManager.acceptCall(core: core, call: core.currentCall!, hasVideo: false) - } - } - - timer.upstream.connect().cancel() - } - - func muteCall() { - coreContext.doOnCoreQueue { core in - if core.currentCall != nil { - micMutted = !micMutted - core.currentCall!.microphoneMuted = micMutted - } - } - } + + func acceptCall() { + withAnimation { + telecomManager.callInProgress = true + telecomManager.callStarted = true + } + + coreContext.doOnCoreQueue { core in + if core.currentCall != nil { + telecomManager.acceptCall(core: core, call: core.currentCall!, hasVideo: false) + } + } + + timer.upstream.connect().cancel() + } + + func muteCall() { + coreContext.doOnCoreQueue { core in + if core.currentCall != nil { + micMutted = !micMutted + core.currentCall!.microphoneMuted = micMutted + } + } + } 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)) - + 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 { From 81448d80065eeb77fe2efd1f90e385a51782330f Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Thu, 28 Dec 2023 16:53:47 +0100 Subject: [PATCH 067/486] Init audio route --- Linphone/Core/CoreContext.swift | 2 - Linphone/Localizable.xcstrings | 9 + Linphone/TelecomManager/TelecomManager.swift | 9 + Linphone/UI/Call/CallView.swift | 340 +++++++++++++----- .../UI/Call/ViewModel/CallViewModel.swift | 127 +++++++ Linphone/UI/Main/ContentView.swift | 59 ++- .../History/Fragments/StartCallFragment.swift | 4 + 7 files changed, 456 insertions(+), 94 deletions(-) diff --git a/Linphone/Core/CoreContext.swift b/Linphone/Core/CoreContext.swift index 404098fed..09270fc31 100644 --- a/Linphone/Core/CoreContext.swift +++ b/Linphone/Core/CoreContext.swift @@ -155,8 +155,6 @@ final class CoreContext: ObservableObject { } self.mCore.publisher?.onLogCollectionUploadStateChanged?.postOnMainQueue { (cbValue: (_: Core, _: Core.LogCollectionUploadState, info: String)) in - print("publisherpublisher onLogCollectionUploadStateChanged") - if cbValue.info.starts(with: "https") { UIPasteboard.general.setValue( cbValue.info, diff --git a/Linphone/Localizable.xcstrings b/Linphone/Localizable.xcstrings index d6a578052..b5524e746 100644 --- a/Linphone/Localizable.xcstrings +++ b/Linphone/Localizable.xcstrings @@ -187,6 +187,9 @@ }, "Block the number" : { + }, + "Bluetooth" : { + }, "Call history" : { @@ -280,6 +283,9 @@ }, "Don’t save modifications?" : { + }, + "Earpiece" : { + }, "Edit" : { @@ -504,6 +510,9 @@ }, "Skip" : { + }, + "Speaker" : { + }, "Start" : { diff --git a/Linphone/TelecomManager/TelecomManager.swift b/Linphone/TelecomManager/TelecomManager.swift index ce319cde3..0fb2979d4 100644 --- a/Linphone/TelecomManager/TelecomManager.swift +++ b/Linphone/TelecomManager/TelecomManager.swift @@ -399,10 +399,13 @@ class TelecomManager: ObservableObject { } } + /* if speakerBeforePause { speakerBeforePause = false AudioRouteUtils.routeAudioToSpeaker(core: core) } + */ + actionToFulFill?.fulfill() actionToFulFill = nil case .Paused: @@ -518,6 +521,11 @@ class TelecomManager: ObservableObject { break } + //AudioRouteUtils.isBluetoothAvailable(core: core) + //AudioRouteUtils.isHeadsetAudioRouteAvailable(core: core) + //AudioRouteUtils.isBluetoothAudioRouteAvailable(core: core) + + /* let readyForRoutechange = callkitAudioSessionActivated == nil || (callkitAudioSessionActivated == true) if readyForRoutechange && (cstate == .IncomingReceived || cstate == .OutgoingInit || cstate == .Connected || cstate == .StreamsRunning) { if (call.currentParams?.videoEnabled ?? false) && AudioRouteUtils.isReceiverEnabled(core: core) && call.conference == nil { @@ -527,6 +535,7 @@ class TelecomManager: ObservableObject { AudioRouteUtils.routeAudioToBluetooth(core: core, call: call) } } + */ } // post Notification kLinphoneCallUpdate NotificationCenter.default.post(name: Notification.Name("LinphoneCallUpdate"), object: self, userInfo: [ diff --git a/Linphone/UI/Call/CallView.swift b/Linphone/UI/Call/CallView.swift index 821a66cbb..6b2f1e48f 100644 --- a/Linphone/UI/Call/CallView.swift +++ b/Linphone/UI/Call/CallView.swift @@ -17,33 +17,43 @@ * along with this program. If not, see . */ +// swiftlint:disable type_body_length import SwiftUI import CallKit +import AVFAudio 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 callViewModel: CallViewModel + + private var idiom: UIUserInterfaceIdiom { UIDevice.current.userInterfaceIdiom } + @State private var orientation = UIDevice.current.orientation + + let pub = NotificationCenter.default.publisher(for: AVAudioSession.routeChangeNotification) @State var startDate = Date.now - @State var timeElapsed: Int = 0 - @State var micMutted: Bool = false - - let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect() + @State var audioRouteIsSpeaker: Bool = false + @State var audioRouteSheet: Bool = false + @State var hideButtonsSheet: Bool = false + @State var options: Int = 1 + + @State var imageAudioRoute: String = "" var body: some View { GeometryReader { geo in if #available(iOS 16.4, *) { - innerView() - .sheet(isPresented: $telecomManager.callStarted) { + innerView(geoHeight: geo.size.height) + .sheet(isPresented: .constant(telecomManager.callStarted && !hideButtonsSheet && idiom != .pad && !(orientation == .landscapeLeft || orientation == .landscapeRight + || UIScreen.main.bounds.size.width > UIScreen.main.bounds.size.height))) { GeometryReader { _ in VStack(spacing: 0) { HStack(spacing: 12) { Button { - terminateCall() + callViewModel.terminateCall() } label: { Image("phone-disconnect") .renderingMode(.template) @@ -72,26 +82,55 @@ struct CallView: View { .cornerRadius(40) Button { - muteCall() + callViewModel.muteCall() } label: { - Image(micMutted ? "microphone-slash" : "microphone") + Image(callViewModel.micMutted ? "microphone-slash" : "microphone") .renderingMode(.template) .resizable() - .foregroundStyle(micMutted ? .black : .white) + .foregroundStyle(callViewModel.micMutted ? .black : .white) .frame(width: 32, height: 32) } .frame(width: 60, height: 60) - .background(micMutted ? .white : Color.gray500) + .background(callViewModel.micMutted ? .white : Color.gray500) .cornerRadius(40) Button { + options = callViewModel.getAudioRoute() + print("audioRouteIsSpeakeraudioRouteIsSpeaker output \(AVAudioSession.sharedInstance().currentRoute.outputs)") + + print("audioRouteIsSpeakeraudioRouteIsSpeaker inputs \(AVAudioSession.sharedInstance().availableInputs?.count)") + + + if AVAudioSession.sharedInstance().availableInputs != nil + && !AVAudioSession.sharedInstance().availableInputs!.filter({ $0.portType.rawValue.contains("Bluetooth") }).isEmpty { + + hideButtonsSheet = true + + DispatchQueue.global().asyncAfter(deadline: .now() + 0.5) { + audioRouteSheet = true + } + + } else { + audioRouteIsSpeaker = !audioRouteIsSpeaker + + do { + try AVAudioSession.sharedInstance().overrideOutputAudioPort(audioRouteIsSpeaker ? .speaker : .none) + } catch _ { + + } + } + } label: { - Image("speaker-high") + Image(imageAudioRoute) .renderingMode(.template) .resizable() .foregroundStyle(.white) .frame(width: 32, height: 32) + .onAppear(perform: getAudioRouteImage) + .onReceive(pub) { (output) in + self.getAudioRouteImage() + } } .frame(width: 60, height: 60) @@ -263,18 +302,129 @@ struct CallView: View { Spacer() } .frame(maxHeight: .infinity, alignment: .top) - .background(.black) + .presentationBackground(.black) .presentationDetents([.fraction(0.1), .medium]) .interactiveDismissDisabled() .presentationBackgroundInteraction(.enabled) } } + .sheet(isPresented: $audioRouteSheet, onDismiss: { + audioRouteSheet = false + hideButtonsSheet = false + }) { + + VStack(spacing: 0) { + Button(action: { + options = 1 + + audioRouteIsSpeaker = false + + do { + try AVAudioSession.sharedInstance().overrideOutputAudioPort(audioRouteIsSpeaker ? .speaker : .none) + try AVAudioSession.sharedInstance().setCategory(.playAndRecord, mode: .voiceChat, options: .defaultToSpeaker) + try AVAudioSession.sharedInstance().setActive(true) + } catch _ { + + } + }, label: { + HStack { + Image(options == 1 ? "radio-button-fill" : "radio-button") + .renderingMode(.template) + .resizable() + .foregroundStyle(.white) + .frame(width: 25, height: 25, alignment: .leading) + + Text("Earpiece") + .default_text_style_white(styleSize: 15) + + Spacer() + + Image("ear") + .renderingMode(.template) + .resizable() + .foregroundStyle(.white) + .frame(width: 25, height: 25, alignment: .leading) + } + }) + .frame(maxHeight: .infinity) + + Button(action: { + options = 2 + + audioRouteIsSpeaker = true + + do { + try AVAudioSession.sharedInstance().overrideOutputAudioPort(audioRouteIsSpeaker ? .speaker : .none) + } catch _ { + + } + }, label: { + HStack { + Image(options == 2 ? "radio-button-fill" : "radio-button") + .renderingMode(.template) + .resizable() + .foregroundStyle(.white) + .frame(width: 25, height: 25, alignment: .leading) + + Text("Speaker") + .default_text_style_white(styleSize: 15) + + Spacer() + + Image("speaker-high") + .renderingMode(.template) + .resizable() + .foregroundStyle(.white) + .frame(width: 25, height: 25, alignment: .leading) + } + }) + .frame(maxHeight: .infinity) + + Button(action: { + options = 3 + + audioRouteIsSpeaker = false + + do { + try AVAudioSession.sharedInstance().overrideOutputAudioPort(audioRouteIsSpeaker ? .speaker : .none) + try AVAudioSession.sharedInstance().setCategory(.playAndRecord, mode: .voiceChat, options: .allowBluetooth) + try AVAudioSession.sharedInstance().setActive(true) + } catch _ { + + } + }, label: { + HStack { + Image(options == 3 ? "radio-button-fill" : "radio-button") + .renderingMode(.template) + .resizable() + .foregroundStyle(.white) + .frame(width: 25, height: 25, alignment: .leading) + + Text("Bluetooth") + .default_text_style_white(styleSize: 15) + + Spacer() + + Image("bluetooth") + .renderingMode(.template) + .resizable() + .foregroundStyle(.white) + .frame(width: 25, height: 25, alignment: .leading) + } + }) + .frame(maxHeight: .infinity) + } + .padding(.horizontal, 20) + .presentationBackground(Color.gray600) + .presentationDetents([.fraction(0.3)]) + .frame(maxHeight: .infinity) + } } } } @ViewBuilder - func innerView() -> some View { + func innerView(geoHeight: CGFloat) -> some View { VStack { Rectangle() .foregroundColor(Color.orangeMain500) @@ -369,9 +519,9 @@ struct CallView: View { .frame(width: 20, height: 20) .padding(.top, 100) - Text(counterToMinutes()) - .onReceive(timer) { firedDate in - timeElapsed = Int(firedDate.timeIntervalSince(startDate)) + Text(callViewModel.counterToMinutes()) + .onReceive(callViewModel.timer) { firedDate in + callViewModel.timeElapsed = Int(firedDate.timeIntervalSince(startDate)) } .padding(.top) @@ -388,20 +538,84 @@ struct CallView: View { .padding(.horizontal, 4) if telecomManager.callStarted { - HStack(spacing: 12) { - HStack { - } - .frame(height: 60) - } - .padding(.horizontal, 25) - .padding(.top, 20) + if telecomManager.callStarted && idiom != .pad && !(orientation == .landscapeLeft || orientation == .landscapeRight + || UIScreen.main.bounds.size.width > UIScreen.main.bounds.size.height) { + HStack(spacing: 12) { + HStack { + + } + .frame(height: 60) + } + .padding(.horizontal, 25) + .padding(.top, 20) + } else { + HStack(spacing: 12) { + Button { + callViewModel.terminateCall() + } label: { + Image("phone-disconnect") + .renderingMode(.template) + .resizable() + .foregroundStyle(.white) + .frame(width: 32, height: 32) + + } + .frame(width: 90, height: 60) + .background(Color.redDanger500) + .cornerRadius(40) + + Spacer() + + Button { + } label: { + Image("video-camera") + .renderingMode(.template) + .resizable() + .foregroundStyle(.white) + .frame(width: 32, height: 32) + + } + .frame(width: 60, height: 60) + .background(Color.gray500) + .cornerRadius(40) + + Button { + callViewModel.muteCall() + } label: { + Image(callViewModel.micMutted ? "microphone-slash" : "microphone") + .renderingMode(.template) + .resizable() + .foregroundStyle(callViewModel.micMutted ? .black : .white) + .frame(width: 32, height: 32) + + } + .frame(width: 60, height: 60) + .background(callViewModel.micMutted ? .white : Color.gray500) + .cornerRadius(40) + + Button { + } label: { + Image("speaker-high") + .renderingMode(.template) + .resizable() + .foregroundStyle(.white) + .frame(width: 32, height: 32) + + } + .frame(width: 60, height: 60) + .background(Color.gray500) + .cornerRadius(40) + } + .frame(height: geoHeight * 0.15) + .padding(.horizontal, 20) + } } else { HStack(spacing: 12) { HStack { Spacer() Button { - terminateCall() + callViewModel.terminateCall() } label: { Image("phone-disconnect") .renderingMode(.template) @@ -415,7 +629,7 @@ struct CallView: View { .cornerRadius(40) Button { - acceptCall() + callViewModel.acceptCall() } label: { Image("phone") .renderingMode(.template) @@ -439,60 +653,24 @@ struct CallView: View { .frame(maxWidth: .infinity, maxHeight: .infinity) .background(Color.gray900) } - - func terminateCall() { - withAnimation { - telecomManager.callInProgress = false - telecomManager.callStarted = false - } - - coreContext.doOnCoreQueue { core in - if core.currentCall != nil { - telecomManager.terminateCall(call: core.currentCall!) - } - } - - timer.upstream.connect().cancel() - } - - func acceptCall() { - withAnimation { - telecomManager.callInProgress = true - telecomManager.callStarted = true - } - - coreContext.doOnCoreQueue { core in - if core.currentCall != nil { - telecomManager.acceptCall(core: core, call: core.currentCall!, hasVideo: false) - } - } - - timer.upstream.connect().cancel() - } - - func muteCall() { - coreContext.doOnCoreQueue { core in - if core.currentCall != nil { - micMutted = !micMutted - core.currentCall!.microphoneMuted = micMutted - } - } - } - - 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 getAudioRouteImage() { + print("getAudioRouteImagegetAudioRouteImage") + imageAudioRoute = AVAudioSession.sharedInstance().currentRoute.outputs.filter({ $0.portType.rawValue == "Speaker" }).isEmpty + ? ( + AVAudioSession.sharedInstance().currentRoute.outputs.filter({ $0.portType.rawValue.contains("Bluetooth") }).isEmpty + ? ( + AVAudioSession.sharedInstance().currentRoute.outputs.filter({ $0.portType.rawValue == "Receiver" }).isEmpty + ? "headset" + : "speaker-slash" + ) + : "bluetooth" + ) + : "speaker-high" + } } #Preview { - CallView(callViewModel: CallViewModel()) + CallView(callViewModel: CallViewModel()) } +// swiftlint:enable type_body_length diff --git a/Linphone/UI/Call/ViewModel/CallViewModel.swift b/Linphone/UI/Call/ViewModel/CallViewModel.swift index efb3ab588..c7ad9cca7 100644 --- a/Linphone/UI/Call/ViewModel/CallViewModel.swift +++ b/Linphone/UI/Call/ViewModel/CallViewModel.swift @@ -17,7 +17,9 @@ * along with this program. If not, see . */ +import SwiftUI import linphonesw +import AVFAudio class CallViewModel: ObservableObject { @@ -29,8 +31,14 @@ class CallViewModel: ObservableObject { @Published var remoteAddressString: String = "example.linphone@sip.linphone.org" @Published var remoteAddress: Address? @Published var avatarModel: ContactAvatarModel? + @Published var audioSessionImage: String = "" + @State var micMutted: Bool = false + @State var timeElapsed: Int = 0 + + let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect() init() { + setupNotifications() coreContext.doOnCoreQueue { core in if core.currentCall != nil && core.currentCall!.remoteAddress != nil { DispatchQueue.main.async { @@ -52,4 +60,123 @@ class CallViewModel: ObservableObject { } } } + + func terminateCall() { + withAnimation { + telecomManager.callInProgress = false + telecomManager.callStarted = false + } + + coreContext.doOnCoreQueue { core in + if core.currentCall != nil { + self.telecomManager.terminateCall(call: core.currentCall!) + } + } + + timer.upstream.connect().cancel() + } + + func acceptCall() { + withAnimation { + telecomManager.callInProgress = true + telecomManager.callStarted = true + } + + coreContext.doOnCoreQueue { core in + if core.currentCall != nil { + self.telecomManager.acceptCall(core: core, call: core.currentCall!, hasVideo: false) + } + } + + timer.upstream.connect().cancel() + } + + func muteCall() { + coreContext.doOnCoreQueue { core in + if core.currentCall != nil { + self.micMutted = !self.micMutted + core.currentCall!.microphoneMuted = self.micMutted + } + } + } + + 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 setupNotifications() { + /* + notifCenter.addObserver(self, + selector: #selector(handleRouteChange), + name: AVAudioSession.routeChangeNotification, + object: nil) + */ + + //NotificationCenter.default.addObserver(self, selector: Selector(("handleRouteChange")), name: UITextView.textDidChangeNotification, object: nil) + } + + + func handleRouteChange(notification: Notification) { + guard let userInfo = notification.userInfo, + let reasonValue = userInfo[AVAudioSessionRouteChangeReasonKey] as? UInt, + let reason = AVAudioSession.RouteChangeReason(rawValue: reasonValue) else { + return + } + + // Switch over the route change reason. + switch reason { + + + case .newDeviceAvailable, .oldDeviceUnavailable: // New device found. + print("handleRouteChangehandleRouteChange handleRouteChange") + + AVAudioSession.sharedInstance().currentRoute.outputs.filter({ $0.portType.rawValue == "Speaker" }).isEmpty + ? ( + AVAudioSession.sharedInstance().currentRoute.outputs.filter({ $0.portType.rawValue.contains("Bluetooth") }).isEmpty + ? ( + AVAudioSession.sharedInstance().currentRoute.outputs.filter({ $0.portType.rawValue == "Receiver" }).isEmpty + ? "headset" + : "speaker-slash" + ) + : "bluetooth" + ) + : "speaker-high" + + /* + case .oldDeviceUnavailable: // Old device removed. + if let previousRoute = + userInfo[AVAudioSessionRouteChangePreviousRouteKey] as? AVAudioSessionRouteDescription { + + } + */ + default: () + } + } + + + func hasHeadphones(in routeDescription: AVAudioSessionRouteDescription) -> Bool { + // Filter the outputs to only those with a port type of headphones. + return !routeDescription.outputs.filter({$0.portType == .headphones}).isEmpty + } + + func getAudioRoute() -> Int { + if AVAudioSession.sharedInstance().currentRoute.outputs.filter({ $0.portType.rawValue == "Speaker" }).isEmpty { + if AVAudioSession.sharedInstance().currentRoute.outputs.filter({ $0.portType.rawValue.contains("Bluetooth") }).isEmpty { + return 1 + } else { + return 3 + } + } else { + return 2 + } + } } diff --git a/Linphone/UI/Main/ContentView.swift b/Linphone/UI/Main/ContentView.swift index cdd0ca2a4..4c81f0c7c 100644 --- a/Linphone/UI/Main/ContentView.swift +++ b/Linphone/UI/Main/ContentView.swift @@ -511,19 +511,56 @@ struct ContentView: View { } if isShowStartCallFragment { - StartCallFragment( - startCallViewModel: startCallViewModel, - isShowStartCallFragment: $isShowStartCallFragment, - showingDialer: $showingDialer - ) - .zIndex(3) - .transition(.move(edge: .bottom)) - .halfSheet(showSheet: $showingDialer) { - DialerBottomSheet( + + if #available(iOS 16.4, *) { + if idiom != .pad { + StartCallFragment( + startCallViewModel: startCallViewModel, + isShowStartCallFragment: $isShowStartCallFragment, + showingDialer: $showingDialer + ) + .zIndex(3) + .transition(.move(edge: .bottom)) + .sheet(isPresented: $showingDialer) { + DialerBottomSheet( + startCallViewModel: startCallViewModel, + showingDialer: $showingDialer + ) + .presentationDetents([.medium]) + //.interactiveDismissDisabled() + .presentationBackgroundInteraction(.enabled(upThrough: .medium)) + } + } else { + StartCallFragment( + startCallViewModel: startCallViewModel, + isShowStartCallFragment: $isShowStartCallFragment, + showingDialer: $showingDialer + ) + .zIndex(3) + .transition(.move(edge: .bottom)) + .halfSheet(showSheet: $showingDialer) { + DialerBottomSheet( + startCallViewModel: startCallViewModel, + showingDialer: $showingDialer + ) + } onDismiss: {} + } + + } else { + StartCallFragment( startCallViewModel: startCallViewModel, + isShowStartCallFragment: $isShowStartCallFragment, showingDialer: $showingDialer ) - } onDismiss: {} + .zIndex(3) + .transition(.move(edge: .bottom)) + .halfSheet(showSheet: $showingDialer) { + DialerBottomSheet( + startCallViewModel: startCallViewModel, + showingDialer: $showingDialer + ) + } onDismiss: {} + } } if isShowDeleteContactPopup { @@ -624,7 +661,7 @@ struct ContentView: View { } if telecomManager.callInProgress { - CallView(callViewModel: CallViewModel()) + CallView(callViewModel: CallViewModel()) .zIndex(3) .transition(.scale.combined(with: .move(edge: .top))) } diff --git a/Linphone/UI/Main/History/Fragments/StartCallFragment.swift b/Linphone/UI/Main/History/Fragments/StartCallFragment.swift index 08864ab57..60ff5196d 100644 --- a/Linphone/UI/Main/History/Fragments/StartCallFragment.swift +++ b/Linphone/UI/Main/History/Fragments/StartCallFragment.swift @@ -156,6 +156,8 @@ struct StartCallFragment: View { } ContactsListFragment(contactViewModel: ContactViewModel(), contactsListViewModel: ContactsListViewModel(), showingSheet: .constant(false), startCallFunc: { addr in + showingDialer = false + DispatchQueue.global().asyncAfter(deadline: .now() + 0.2) { magicSearch.searchForContacts( sourceFlags: MagicSearch.Source.Friends.rawValue | MagicSearch.Source.LdapServers.rawValue) @@ -236,6 +238,8 @@ struct StartCallFragment: View { } } .onTapGesture { + showingDialer = false + DispatchQueue.global().asyncAfter(deadline: .now() + 0.2) { magicSearch.searchForContacts( sourceFlags: MagicSearch.Source.Friends.rawValue | MagicSearch.Source.LdapServers.rawValue) From d0ae11c880a7c162f7a846c0f4db085eea26fc03 Mon Sep 17 00:00:00 2001 From: QuentinArguillere Date: Tue, 2 Jan 2024 17:27:16 +0100 Subject: [PATCH 068/486] Update publishers to manage the subscriptions manually --- Linphone/Core/CoreContext.swift | 36 ++++++++------- .../Contacts/Model/ContactAvatarModel.swift | 46 ++++--------------- .../ViewModel/HistoryListViewModel.swift | 5 +- Linphone/Utils/MagicSearchSingleton.swift | 7 ++- 4 files changed, 38 insertions(+), 56 deletions(-) diff --git a/Linphone/Core/CoreContext.swift b/Linphone/Core/CoreContext.swift index 09270fc31..d52b91ba7 100644 --- a/Linphone/Core/CoreContext.swift +++ b/Linphone/Core/CoreContext.swift @@ -18,6 +18,8 @@ */ // swiftlint:disable large_tuple +// swiftlint:disable line_length + import linphonesw import Combine import UniformTypeIdentifiers @@ -34,8 +36,9 @@ final class CoreContext: ObservableObject { @Published var coreIsStarted: Bool = false private var mCore: Core! - private var mIteratePublisher: AnyCancellable? - + private var mIterateSuscription: AnyCancellable? + private var mCoreSuscriptions = Set() + private init() { do { try initialiseCore() @@ -89,7 +92,7 @@ final class CoreContext: ObservableObject { self.mCore.callkitEnabled = true self.mCore.pushNotificationEnabled = true - self.mCore.publisher?.onGlobalStateChanged?.postOnMainQueue { (cbVal: (core: Core, state: GlobalState, message: String)) in + self.mCoreSuscriptions.insert(self.mCore.publisher?.onGlobalStateChanged?.postOnMainQueue { (cbVal: (core: Core, state: GlobalState, message: String)) in if cbVal.state == GlobalState.On { self.defaultAccount = self.mCore.defaultAccount self.coreIsStarted = true @@ -97,13 +100,13 @@ final class CoreContext: ObservableObject { self.defaultAccount = nil self.coreIsStarted = true } - } + }) try? self.mCore.start() // Create a Core listener to listen for the callback we need // In this case, we want to know about the account registration status - self.mCore.publisher?.onConfiguringStatus?.postOnMainQueue { (cbVal: (core: Core, status: Config.ConfiguringState, message: String)) in + self.mCoreSuscriptions.insert(self.mCore.publisher?.onConfiguringStatus?.postOnMainQueue { (cbVal: (core: Core, status: Config.ConfiguringState, message: String)) in NSLog("New configuration state is \(cbVal.status) = \(cbVal.message)\n") if cbVal.status == Config.ConfiguringState.Successful { ToastViewModel.shared.toastMessage = "Successful" @@ -112,11 +115,9 @@ final class CoreContext: ObservableObject { ToastViewModel.shared.toastMessage = "Failed" ToastViewModel.shared.displayToast.toggle() } - } + }) - self.mCore.publisher?.onAccountRegistrationStateChanged?.postOnMainQueue {(cbVal: - (core: Core, account: Account, state: RegistrationState, message: String) - ) in + self.mCoreSuscriptions.insert(self.mCore.publisher?.onAccountRegistrationStateChanged?.postOnMainQueue { (cbVal: (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. NSLog("New registration state is \(cbVal.state) for user id " + @@ -135,7 +136,9 @@ final class CoreContext: ObservableObject { self.loggingInProgress = false self.loggedIn = false } - }.postOnCoreQueue { (cbVal: (core: Core, account: Account, state: RegistrationState, message: String)) in + }) + + self.mCoreSuscriptions.insert(self.mCore.publisher?.onAccountRegistrationStateChanged?.postOnCoreQueue { (cbVal: (core: Core, account: Account, state: RegistrationState, message: String)) in // If registration failed, remove account from core if cbVal.state != .Ok && cbVal.state != .Progress { let params = cbVal.account.params @@ -148,13 +151,13 @@ final class CoreContext: ObservableObject { cbVal.core.clearAllAuthInfo() } TelecomManager.shared.onAccountRegistrationStateChanged(core: cbVal.core, account: cbVal.account, state: cbVal.state, message: cbVal.message) - } + }) - self.mCore.publisher?.onCallStateChanged?.postOnCoreQueue { (cbVal: (core: Core, call: Call, state: Call.State, message: String)) in + self.mCoreSuscriptions.insert(self.mCore.publisher?.onCallStateChanged?.postOnCoreQueue { (cbVal: (core: Core, call: Call, state: Call.State, message: String)) in TelecomManager.shared.onCallStateChanged(core: cbVal.core, call: cbVal.call, state: cbVal.state, message: cbVal.message) - } + }) - self.mCore.publisher?.onLogCollectionUploadStateChanged?.postOnMainQueue { (cbValue: (_: Core, _: Core.LogCollectionUploadState, info: String)) in + self.mCoreSuscriptions.insert(self.mCore.publisher?.onLogCollectionUploadStateChanged?.postOnMainQueue { (cbValue: (_: Core, _: Core.LogCollectionUploadState, info: String)) in if cbValue.info.starts(with: "https") { UIPasteboard.general.setValue( cbValue.info, @@ -164,9 +167,9 @@ final class CoreContext: ObservableObject { ToastViewModel.shared.toastMessage = "Success_copied_into_clipboard" ToastViewModel.shared.displayToast.toggle() } - } + }) - self.mIteratePublisher = Timer.publish(every: 0.02, on: .main, in: .common) + self.mIterateSuscription = Timer.publish(every: 0.02, on: .main, in: .common) .autoconnect() .receive(on: coreQueue) .sink { _ in @@ -202,3 +205,4 @@ final class CoreContext: ObservableObject { } // swiftlint:enable large_tuple +// swiftlint:enable line_length diff --git a/Linphone/UI/Main/Contacts/Model/ContactAvatarModel.swift b/Linphone/UI/Main/Contacts/Model/ContactAvatarModel.swift index 3c6bc2d26..bfb3f2ae2 100644 --- a/Linphone/UI/Main/Contacts/Model/ContactAvatarModel.swift +++ b/Linphone/UI/Main/Contacts/Model/ContactAvatarModel.swift @@ -19,6 +19,7 @@ import Foundation import linphonesw +import Combine class ContactAvatarModel: ObservableObject { @@ -30,7 +31,7 @@ class ContactAvatarModel: ObservableObject { @Published var presenceStatus: ConsolidatedPresence - private var friendDelegate: FriendDelegate? + private var friendSuscription: AnyCancellable? init(friend: Friend?, withPresence: Bool?) { self.friend = friend @@ -51,22 +52,20 @@ class ContactAvatarModel: ObservableObject { self.lastPresenceInfo = "" } - if self.friendDelegate != nil { - self.friend!.removeDelegate(delegate: self.friendDelegate!) - self.friendDelegate = nil + if self.friendSuscription != nil { + self.friendSuscription = nil } - addDelegate() + addSubscription() } else { self.lastPresenceInfo = "" self.presenceStatus = .Offline } } - func addDelegate() { + func addSubscription() { - /* - self.friend?.publisher?.onPresenceReceived?.postOnMainQueue { (cbValue: (Friend)) in + friendSuscription = self.friend?.publisher?.onPresenceReceived?.postOnMainQueue { (cbValue: (Friend)) in print("publisherpublisher onLogCollectionUploadStateChanged \(cbValue.address?.asStringUriOnly())") self.presenceStatus = cbValue.consolidatedPresence @@ -80,37 +79,12 @@ class ContactAvatarModel: ObservableObject { self.lastPresenceInfo = "" } } - */ - - let newFriendDelegate = FriendDelegateStub( - onPresenceReceived: { (linphoneFriend: Friend) -> Void in - DispatchQueue.main.sync { - self.presenceStatus = linphoneFriend.consolidatedPresence - if linphoneFriend.consolidatedPresence == .Online || linphoneFriend.consolidatedPresence == .Busy { - if linphoneFriend.consolidatedPresence == .Online || linphoneFriend.presenceModel!.latestActivityTimestamp != -1 { - self.lastPresenceInfo = linphoneFriend.consolidatedPresence == .Online ? "Online" : self.getCallTime(startDate: linphoneFriend.presenceModel!.latestActivityTimestamp) - } else { - self.lastPresenceInfo = "Away" - } - } else { - self.lastPresenceInfo = "" - } - } - } - ) - - - friendDelegate = newFriendDelegate - if friendDelegate != nil { - friend!.addDelegate(delegate: friendDelegate!) - } } - func removeAllDelegate() { - if friendDelegate != nil { + func removeAllSuscription() { + if friendSuscription != nil { presenceStatus = .Offline - friend!.removeDelegate(delegate: friendDelegate!) - friendDelegate = nil + friendSuscription = nil } } diff --git a/Linphone/UI/Main/History/ViewModel/HistoryListViewModel.swift b/Linphone/UI/Main/History/ViewModel/HistoryListViewModel.swift index 6d0c6419a..310ae3cc6 100644 --- a/Linphone/UI/Main/History/ViewModel/HistoryListViewModel.swift +++ b/Linphone/UI/Main/History/ViewModel/HistoryListViewModel.swift @@ -18,6 +18,7 @@ */ import linphonesw +import Combine class HistoryListViewModel: ObservableObject { @@ -27,7 +28,7 @@ class HistoryListViewModel: ObservableObject { var callLogsTmp: [CallLog] = [] var callLogsAddressToDelete = "" - + var callLogSubscription : AnyCancellable? init() { computeCallLogsList() } @@ -47,7 +48,7 @@ class HistoryListViewModel: ObservableObject { } } - core.publisher?.onCallLogUpdated?.postOnCoreQueue { (_: (_: Core, _: CallLog)) in + self.callLogSubscription = core.publisher?.onCallLogUpdated?.postOnCoreQueue { (_: (_: Core, _: CallLog)) in print("publisherpublisher onCallLogUpdated") let account = core.defaultAccount let logs = account != nil ? account!.callLogs : core.callLogs diff --git a/Linphone/Utils/MagicSearchSingleton.swift b/Linphone/Utils/MagicSearchSingleton.swift index 0cd349bb8..518bc4245 100644 --- a/Linphone/Utils/MagicSearchSingleton.swift +++ b/Linphone/Utils/MagicSearchSingleton.swift @@ -18,6 +18,7 @@ */ import linphonesw +import Combine final class MagicSearchSingleton: ObservableObject { @@ -40,6 +41,8 @@ final class MagicSearchSingleton: ObservableObject { @Published var allContact = false private var domainDefaultAccount = "" + var searchSubscription : AnyCancellable? + private init() { coreContext.doOnCoreQueue { core in self.domainDefaultAccount = core.defaultAccount?.params?.domain ?? "" @@ -47,7 +50,7 @@ final class MagicSearchSingleton: ObservableObject { self.magicSearch = try? core.createMagicSearch() self.magicSearch.limitedSearch = false - self.magicSearch.publisher?.onSearchResultsReceived?.postOnMainQueue { (magicSearch: MagicSearch) in + self.searchSubscription = self.magicSearch.publisher?.onSearchResultsReceived?.postOnMainQueue { (magicSearch: MagicSearch) in self.needUpdateLastSearchContacts = true var lastSearchFriend: [SearchResult] = [] @@ -72,7 +75,7 @@ final class MagicSearchSingleton: ObservableObject { }) self.contactsManager.avatarListModel.forEach { contactAvatarModel in - contactAvatarModel.removeAllDelegate() + contactAvatarModel.removeAllSuscription() } self.contactsManager.avatarListModel.removeAll() From bcf4eefe3520529c529a02dd6ba4bbaa11d44322 Mon Sep 17 00:00:00 2001 From: QuentinArguillere Date: Tue, 2 Jan 2024 17:33:54 +0100 Subject: [PATCH 069/486] Fix swiftlint warnings --- Linphone/Contacts/ContactsManager.swift | 2 +- Linphone/TelecomManager/ProviderDelegate.swift | 2 +- Linphone/TelecomManager/TelecomManager.swift | 8 ++++---- Linphone/UI/Call/CallView.swift | 2 -- .../Main/Contacts/Fragments/ContactsListFragment.swift | 2 +- .../UI/Main/Contacts/Model/ContactAvatarModel.swift | 2 +- Linphone/UI/Main/ContentView.swift | 6 +++--- .../Main/History/ViewModel/HistoryListViewModel.swift | 2 +- Linphone/Utils/Extensions/ConfigExtension.swift | 10 ++++------ Linphone/Utils/Log.swift | 5 ++--- Linphone/Utils/MagicSearchSingleton.swift | 2 +- Linphone/Utils/PermissionManager.swift | 3 +-- 12 files changed, 20 insertions(+), 26 deletions(-) diff --git a/Linphone/Contacts/ContactsManager.swift b/Linphone/Contacts/ContactsManager.swift index 9cb3b0768..84efe85fd 100644 --- a/Linphone/Contacts/ContactsManager.swift +++ b/Linphone/Contacts/ContactsManager.swift @@ -262,7 +262,7 @@ final class ContactsManager: ObservableObject { return imagePath } - func awaitDataWrite(data: Data, name: String, prefix: String,completion: @escaping ((), String) -> Void) { + 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 { diff --git a/Linphone/TelecomManager/ProviderDelegate.swift b/Linphone/TelecomManager/ProviderDelegate.swift index 61443c185..c12cbed92 100644 --- a/Linphone/TelecomManager/ProviderDelegate.swift +++ b/Linphone/TelecomManager/ProviderDelegate.swift @@ -272,7 +272,7 @@ extension ProviderDelegate: CXProviderDelegate { // attempt to resume another one. action.fulfill() } else { - if call?.conference != nil && core.callsNb ?? 0 > 1 {/* + if call?.conference != nil && core.callsNb > 1 {/* try TelecomManager.shared.lc?.enterConference() action.fulfill() NotificationCenter.default.post(name: Notification.Name("LinphoneCallUpdate"), object: self) diff --git a/Linphone/TelecomManager/TelecomManager.swift b/Linphone/TelecomManager/TelecomManager.swift index 0fb2979d4..d21d7c525 100644 --- a/Linphone/TelecomManager/TelecomManager.swift +++ b/Linphone/TelecomManager/TelecomManager.swift @@ -89,7 +89,7 @@ class TelecomManager: ObservableObject { return } - if TelecomManager.callKitEnabled(core: core) {//&& !nextCallIsTransfer != true { + if TelecomManager.callKitEnabled(core: core) {// && !nextCallIsTransfer != true { let uuid = UUID() let name = "outgoingTODO" // FastAddressBook.displayName(for: addr) ?? "unknow" let handle = CXHandle(type: .generic, value: addr?.asStringUriOnly() ?? "") @@ -521,9 +521,9 @@ class TelecomManager: ObservableObject { break } - //AudioRouteUtils.isBluetoothAvailable(core: core) - //AudioRouteUtils.isHeadsetAudioRouteAvailable(core: core) - //AudioRouteUtils.isBluetoothAudioRouteAvailable(core: core) + // AudioRouteUtils.isBluetoothAvailable(core: core) + // AudioRouteUtils.isHeadsetAudioRouteAvailable(core: core) + // AudioRouteUtils.isBluetoothAudioRouteAvailable(core: core) /* let readyForRoutechange = callkitAudioSessionActivated == nil || (callkitAudioSessionActivated == true) diff --git a/Linphone/UI/Call/CallView.swift b/Linphone/UI/Call/CallView.swift index 6b2f1e48f..937595fc3 100644 --- a/Linphone/UI/Call/CallView.swift +++ b/Linphone/UI/Call/CallView.swift @@ -98,10 +98,8 @@ struct CallView: View { Button { options = callViewModel.getAudioRoute() print("audioRouteIsSpeakeraudioRouteIsSpeaker output \(AVAudioSession.sharedInstance().currentRoute.outputs)") - print("audioRouteIsSpeakeraudioRouteIsSpeaker inputs \(AVAudioSession.sharedInstance().availableInputs?.count)") - if AVAudioSession.sharedInstance().availableInputs != nil && !AVAudioSession.sharedInstance().availableInputs!.filter({ $0.portType.rawValue.contains("Bluetooth") }).isEmpty { diff --git a/Linphone/UI/Main/Contacts/Fragments/ContactsListFragment.swift b/Linphone/UI/Main/Contacts/Fragments/ContactsListFragment.swift index 9dc7d0604..41bfc686b 100644 --- a/Linphone/UI/Main/Contacts/Fragments/ContactsListFragment.swift +++ b/Linphone/UI/Main/Contacts/Fragments/ContactsListFragment.swift @@ -98,5 +98,5 @@ struct ContactsListFragment: View { } #Preview { - ContactsListFragment(contactViewModel: ContactViewModel(), contactsListViewModel: ContactsListViewModel(), showingSheet: .constant(false), startCallFunc: {addr in }) + ContactsListFragment(contactViewModel: ContactViewModel(), contactsListViewModel: ContactsListViewModel(), showingSheet: .constant(false), startCallFunc: {_ in }) } diff --git a/Linphone/UI/Main/Contacts/Model/ContactAvatarModel.swift b/Linphone/UI/Main/Contacts/Model/ContactAvatarModel.swift index bfb3f2ae2..1346767aa 100644 --- a/Linphone/UI/Main/Contacts/Model/ContactAvatarModel.swift +++ b/Linphone/UI/Main/Contacts/Model/ContactAvatarModel.swift @@ -66,7 +66,7 @@ class ContactAvatarModel: ObservableObject { func addSubscription() { friendSuscription = self.friend?.publisher?.onPresenceReceived?.postOnMainQueue { (cbValue: (Friend)) in - print("publisherpublisher onLogCollectionUploadStateChanged \(cbValue.address?.asStringUriOnly())") + print("publisherpublisher onLogCollectionUploadStateChanged \(cbValue.address?.asStringUriOnly() ?? "")") self.presenceStatus = cbValue.consolidatedPresence if cbValue.consolidatedPresence == .Online || cbValue.consolidatedPresence == .Busy { diff --git a/Linphone/UI/Main/ContentView.swift b/Linphone/UI/Main/ContentView.swift index 4c81f0c7c..64f63dc5b 100644 --- a/Linphone/UI/Main/ContentView.swift +++ b/Linphone/UI/Main/ContentView.swift @@ -527,7 +527,7 @@ struct ContentView: View { showingDialer: $showingDialer ) .presentationDetents([.medium]) - //.interactiveDismissDisabled() + // .interactiveDismissDisabled() .presentationBackgroundInteraction(.enabled(upThrough: .medium)) } } else { @@ -666,10 +666,10 @@ struct ContentView: View { .transition(.scale.combined(with: .move(edge: .top))) } - //if sharedMainViewModel.displayToast { + // if sharedMainViewModel.displayToast { ToastView() .zIndex(3) - //} + // } } } .overlay { diff --git a/Linphone/UI/Main/History/ViewModel/HistoryListViewModel.swift b/Linphone/UI/Main/History/ViewModel/HistoryListViewModel.swift index 310ae3cc6..d08ad626c 100644 --- a/Linphone/UI/Main/History/ViewModel/HistoryListViewModel.swift +++ b/Linphone/UI/Main/History/ViewModel/HistoryListViewModel.swift @@ -28,7 +28,7 @@ class HistoryListViewModel: ObservableObject { var callLogsTmp: [CallLog] = [] var callLogsAddressToDelete = "" - var callLogSubscription : AnyCancellable? + var callLogSubscription: AnyCancellable? init() { computeCallLogsList() } diff --git a/Linphone/Utils/Extensions/ConfigExtension.swift b/Linphone/Utils/Extensions/ConfigExtension.swift index 3659c3eae..9d83cd891 100644 --- a/Linphone/Utils/Extensions/ConfigExtension.swift +++ b/Linphone/Utils/Extensions/ConfigExtension.swift @@ -17,8 +17,6 @@ * along with this program. If not, see . */ - - import Foundation import linphonesw @@ -26,10 +24,10 @@ import linphonesw extension Config { - private static var _instance : Config? + private static var _instance: Config? - public func getDouble(section:String, key:String, defaultValue:Double) -> Double { - if (self.hasEntry(section: section, key: key) != 1) { + 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: "") @@ -51,7 +49,7 @@ extension Config { static let appGroupName = "group.org.linphone.phone.logs" // 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 earlymediaContentExtensionCagetoryIdentifier = Config.get().getString(section: "app", key: "extension_category", 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: "") diff --git a/Linphone/Utils/Log.swift b/Linphone/Utils/Log.swift index 25aecc8e5..bb4e61cb1 100644 --- a/Linphone/Utils/Log.swift +++ b/Linphone/Utils/Log.swift @@ -84,19 +84,18 @@ class Log: LoggingServiceDelegate { 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) + os_log("%{public}@", type: levelToOSleLogLevel[level] ?? .info, log) } else { NSLog(log) } } - 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) } + Thread.callStackSymbols.forEach { print($0) } } // Debug diff --git a/Linphone/Utils/MagicSearchSingleton.swift b/Linphone/Utils/MagicSearchSingleton.swift index 518bc4245..b65c09bf5 100644 --- a/Linphone/Utils/MagicSearchSingleton.swift +++ b/Linphone/Utils/MagicSearchSingleton.swift @@ -41,7 +41,7 @@ final class MagicSearchSingleton: ObservableObject { @Published var allContact = false private var domainDefaultAccount = "" - var searchSubscription : AnyCancellable? + var searchSubscription: AnyCancellable? private init() { coreContext.doOnCoreQueue { core in diff --git a/Linphone/Utils/PermissionManager.swift b/Linphone/Utils/PermissionManager.swift index e19833012..fb3f72bcc 100644 --- a/Linphone/Utils/PermissionManager.swift +++ b/Linphone/Utils/PermissionManager.swift @@ -31,8 +31,7 @@ class PermissionManager: ObservableObject { private init() {} - - func getPermissions(){ + func getPermissions() { photoLibraryRequestPermission() cameraRequestPermission() contactsRequestPermission() From 111fef6603a1b940692a004678d122168bc19ebc Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Wed, 3 Jan 2024 16:34:04 +0100 Subject: [PATCH 070/486] Edit audio route and add an audio reminder --- Linphone/Localizable.xcstrings | 3 + Linphone/UI/Call/CallView.swift | 40 ++++------- .../UI/Call/ViewModel/CallViewModel.swift | 66 ++++--------------- 3 files changed, 30 insertions(+), 79 deletions(-) diff --git a/Linphone/Localizable.xcstrings b/Linphone/Localizable.xcstrings index b5524e746..467b2feee 100644 --- a/Linphone/Localizable.xcstrings +++ b/Linphone/Localizable.xcstrings @@ -316,6 +316,9 @@ }, "First name*" : { + }, + "Headphones" : { + }, "History has been deleted" : { diff --git a/Linphone/UI/Call/CallView.swift b/Linphone/UI/Call/CallView.swift index 937595fc3..b9d952974 100644 --- a/Linphone/UI/Call/CallView.swift +++ b/Linphone/UI/Call/CallView.swift @@ -36,7 +36,6 @@ struct CallView: View { let pub = NotificationCenter.default.publisher(for: AVAudioSession.routeChangeNotification) @State var startDate = Date.now - @State var audioRouteIsSpeaker: Bool = false @State var audioRouteSheet: Bool = false @State var hideButtonsSheet: Bool = false @State var options: Int = 1 @@ -96,10 +95,6 @@ struct CallView: View { .cornerRadius(40) Button { - options = callViewModel.getAudioRoute() - print("audioRouteIsSpeakeraudioRouteIsSpeaker output \(AVAudioSession.sharedInstance().currentRoute.outputs)") - print("audioRouteIsSpeakeraudioRouteIsSpeaker inputs \(AVAudioSession.sharedInstance().availableInputs?.count)") - if AVAudioSession.sharedInstance().availableInputs != nil && !AVAudioSession.sharedInstance().availableInputs!.filter({ $0.portType.rawValue.contains("Bluetooth") }).isEmpty { @@ -108,12 +103,9 @@ struct CallView: View { DispatchQueue.global().asyncAfter(deadline: .now() + 0.5) { audioRouteSheet = true } - } else { - audioRouteIsSpeaker = !audioRouteIsSpeaker - do { - try AVAudioSession.sharedInstance().overrideOutputAudioPort(audioRouteIsSpeaker ? .speaker : .none) + try AVAudioSession.sharedInstance().overrideOutputAudioPort(AVAudioSession.sharedInstance().currentRoute.outputs.filter({ $0.portType.rawValue == "Speaker" }).isEmpty ? .speaker : .none) } catch _ { } @@ -310,17 +302,17 @@ struct CallView: View { audioRouteSheet = false hideButtonsSheet = false }) { - VStack(spacing: 0) { Button(action: { options = 1 - audioRouteIsSpeaker = false - do { - try AVAudioSession.sharedInstance().overrideOutputAudioPort(audioRouteIsSpeaker ? .speaker : .none) - try AVAudioSession.sharedInstance().setCategory(.playAndRecord, mode: .voiceChat, options: .defaultToSpeaker) - try AVAudioSession.sharedInstance().setActive(true) + 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 _ { } @@ -332,12 +324,12 @@ struct CallView: View { .foregroundStyle(.white) .frame(width: 25, height: 25, alignment: .leading) - Text("Earpiece") + Text(!callViewModel.isHeadPhoneAvailable() ? "Earpiece" : "Headphones") .default_text_style_white(styleSize: 15) Spacer() - Image("ear") + Image(!callViewModel.isHeadPhoneAvailable() ? "ear" : "headset") .renderingMode(.template) .resizable() .foregroundStyle(.white) @@ -349,10 +341,8 @@ struct CallView: View { Button(action: { options = 2 - audioRouteIsSpeaker = true - do { - try AVAudioSession.sharedInstance().overrideOutputAudioPort(audioRouteIsSpeaker ? .speaker : .none) + try AVAudioSession.sharedInstance().overrideOutputAudioPort(.speaker) } catch _ { } @@ -381,12 +371,9 @@ struct CallView: View { Button(action: { options = 3 - audioRouteIsSpeaker = false - do { - try AVAudioSession.sharedInstance().overrideOutputAudioPort(audioRouteIsSpeaker ? .speaker : .none) - try AVAudioSession.sharedInstance().setCategory(.playAndRecord, mode: .voiceChat, options: .allowBluetooth) - try AVAudioSession.sharedInstance().setActive(true) + try AVAudioSession.sharedInstance().overrideOutputAudioPort(.none) + try AVAudioSession.sharedInstance().setPreferredInput(AVAudioSession.sharedInstance().availableInputs?.filter({ $0.portType.rawValue.contains("Bluetooth") }).first) } catch _ { } @@ -653,12 +640,11 @@ struct CallView: View { } func getAudioRouteImage() { - print("getAudioRouteImagegetAudioRouteImage") imageAudioRoute = AVAudioSession.sharedInstance().currentRoute.outputs.filter({ $0.portType.rawValue == "Speaker" }).isEmpty ? ( AVAudioSession.sharedInstance().currentRoute.outputs.filter({ $0.portType.rawValue.contains("Bluetooth") }).isEmpty ? ( - AVAudioSession.sharedInstance().currentRoute.outputs.filter({ $0.portType.rawValue == "Receiver" }).isEmpty + callViewModel.isHeadPhoneAvailable() ? "headset" : "speaker-slash" ) diff --git a/Linphone/UI/Call/ViewModel/CallViewModel.swift b/Linphone/UI/Call/ViewModel/CallViewModel.swift index c7ad9cca7..1cf74ad54 100644 --- a/Linphone/UI/Call/ViewModel/CallViewModel.swift +++ b/Linphone/UI/Call/ViewModel/CallViewModel.swift @@ -38,7 +38,14 @@ class CallViewModel: ObservableObject { let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect() init() { - setupNotifications() + + do { + try AVAudioSession.sharedInstance().setCategory(.playAndRecord, mode: .voiceChat, options: .allowBluetooth) + try AVAudioSession.sharedInstance().setActive(true) + } catch _ { + + } + coreContext.doOnCoreQueue { core in if core.currentCall != nil && core.currentCall!.remoteAddress != nil { DispatchQueue.main.async { @@ -113,59 +120,14 @@ class CallViewModel: ObservableObject { } } - func setupNotifications() { - /* - notifCenter.addObserver(self, - selector: #selector(handleRouteChange), - name: AVAudioSession.routeChangeNotification, - object: nil) - */ - - //NotificationCenter.default.addObserver(self, selector: Selector(("handleRouteChange")), name: UITextView.textDidChangeNotification, object: nil) - } - - - func handleRouteChange(notification: Notification) { - guard let userInfo = notification.userInfo, - let reasonValue = userInfo[AVAudioSessionRouteChangeReasonKey] as? UInt, - let reason = AVAudioSession.RouteChangeReason(rawValue: reasonValue) else { - return - } - - // Switch over the route change reason. - switch reason { - - - case .newDeviceAvailable, .oldDeviceUnavailable: // New device found. - print("handleRouteChangehandleRouteChange handleRouteChange") - - AVAudioSession.sharedInstance().currentRoute.outputs.filter({ $0.portType.rawValue == "Speaker" }).isEmpty - ? ( - AVAudioSession.sharedInstance().currentRoute.outputs.filter({ $0.portType.rawValue.contains("Bluetooth") }).isEmpty - ? ( - AVAudioSession.sharedInstance().currentRoute.outputs.filter({ $0.portType.rawValue == "Receiver" }).isEmpty - ? "headset" - : "speaker-slash" - ) - : "bluetooth" - ) - : "speaker-high" - - /* - case .oldDeviceUnavailable: // Old device removed. - if let previousRoute = - userInfo[AVAudioSessionRouteChangePreviousRouteKey] as? AVAudioSessionRouteDescription { - + 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 } - */ - default: () } - } - - - func hasHeadphones(in routeDescription: AVAudioSessionRouteDescription) -> Bool { - // Filter the outputs to only those with a port type of headphones. - return !routeDescription.outputs.filter({$0.portType == .headphones}).isEmpty + return false } func getAudioRoute() -> Int { From 3046336e579ad468136b4c19d61a4a531989ba25 Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Thu, 4 Jan 2024 16:57:05 +0100 Subject: [PATCH 071/486] Init video call --- Linphone.xcodeproj/project.pbxproj | 4 + Linphone/Core/CoreContext.swift | 5 + Linphone/Ressources/linphonerc-factory | 1 - Linphone/UI/Call/CallView.swift | 256 ++++++++++++------ .../UI/Call/ViewModel/CallViewModel.swift | 134 +++++++-- .../Contacts/Model/ContactAvatarModel.swift | 1 - Linphone/Utils/Extensions/ViewExtension.swift | 35 +++ 7 files changed, 334 insertions(+), 102 deletions(-) create mode 100644 Linphone/Utils/Extensions/ViewExtension.swift diff --git a/Linphone.xcodeproj/project.pbxproj b/Linphone.xcodeproj/project.pbxproj index ecccf8226..c49a03650 100644 --- a/Linphone.xcodeproj/project.pbxproj +++ b/Linphone.xcodeproj/project.pbxproj @@ -25,6 +25,7 @@ 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 */; }; @@ -116,6 +117,7 @@ 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 = ""; }; @@ -214,6 +216,7 @@ 66C491F82B24D25A00CEA16D /* ConfigExtension.swift */, D76005F52B0798B00054B79A /* IntExtension.swift */, D717071F2AC5989C0037746F /* TextExtension.swift */, + D71A0E182B485ADF0002C6CD /* ViewExtension.swift */, ); path = Extensions; sourceTree = ""; @@ -637,6 +640,7 @@ D71FCA832AE14D6E00D2E43E /* ContactFragment.swift in Sources */, D7C3650C2AF0084000FE6142 /* EditContactViewModel.swift in Sources */, D7B5678E2B28888F00DE63EB /* CallView.swift in Sources */, + D71A0E192B485ADF0002C6CD /* ViewExtension.swift in Sources */, D750D3392AD3E6EE00EC99C5 /* PopupLoadingView.swift in Sources */, D7E6D0492AE933AD00A57AAF /* FavoriteContactsListFragment.swift in Sources */, 662B69DB2B25DE25007118BF /* ProviderDelegate.swift in Sources */, diff --git a/Linphone/Core/CoreContext.swift b/Linphone/Core/CoreContext.swift index d52b91ba7..e897b6027 100644 --- a/Linphone/Core/CoreContext.swift +++ b/Linphone/Core/CoreContext.swift @@ -102,6 +102,11 @@ final class CoreContext: ObservableObject { } }) + self.mCore.videoCaptureEnabled = true + self.mCore.videoDisplayEnabled = true + + self.mCore.videoActivationPolicy!.automaticallyAccept = true + try? self.mCore.start() // Create a Core listener to listen for the callback we need diff --git a/Linphone/Ressources/linphonerc-factory b/Linphone/Ressources/linphonerc-factory index 85b543074..4074322fe 100644 --- a/Linphone/Ressources/linphonerc-factory +++ b/Linphone/Ressources/linphonerc-factory @@ -29,7 +29,6 @@ rls_uri=sips:rls@sip.linphone.org ec_calibrator_cool_tones=1 [video] -displaytype=MSAndroidTextureDisplay auto_resize_preview_to_keep_ratio=1 max_conference_size=vga diff --git a/Linphone/UI/Call/CallView.swift b/Linphone/UI/Call/CallView.swift index b9d952974..7c90468af 100644 --- a/Linphone/UI/Call/CallView.swift +++ b/Linphone/UI/Call/CallView.swift @@ -21,6 +21,7 @@ import SwiftUI import CallKit import AVFAudio +import linphonesw struct CallView: View { @@ -41,14 +42,24 @@ struct CallView: View { @State var options: Int = 1 @State var imageAudioRoute: String = "" + + @State var angleDegree = 0.0 + @State var fullscreenVideo = false var body: some View { GeometryReader { geo in if #available(iOS 16.4, *) { - innerView(geoHeight: geo.size.height) - .sheet(isPresented: .constant(telecomManager.callStarted && !hideButtonsSheet && idiom != .pad && !(orientation == .landscapeLeft || orientation == .landscapeRight - || UIScreen.main.bounds.size.width > UIScreen.main.bounds.size.height))) { - GeometryReader { _ in + innerView(geoHeight: geo.size.height, geoWidth: geo.size.width) + .sheet(isPresented: + .constant( + telecomManager.callStarted + && !fullscreenVideo + && !hideButtonsSheet + && idiom != .pad + && !(orientation == .landscapeLeft || orientation == .landscapeRight || UIScreen.main.bounds.size.width > UIScreen.main.bounds.size.height) + ) + ) { + GeometryReader { _ in VStack(spacing: 0) { HStack(spacing: 12) { Button { @@ -68,8 +79,9 @@ struct CallView: View { Spacer() Button { + callViewModel.toggleVideo() } label: { - Image("video-camera") + Image(callViewModel.cameraDisplayed ? "video-camera" : "video-camera-slash") .renderingMode(.template) .resizable() .foregroundStyle(.white) @@ -81,7 +93,7 @@ struct CallView: View { .cornerRadius(40) Button { - callViewModel.muteCall() + callViewModel.toggleMuteMicrophone() } label: { Image(callViewModel.micMutted ? "microphone-slash" : "microphone") .renderingMode(.template) @@ -129,6 +141,7 @@ struct CallView: View { } .frame(height: geo.size.height * 0.15) .padding(.horizontal, 20) + .padding(.top, -6) HStack(spacing: 0) { VStack { @@ -231,6 +244,7 @@ struct CallView: View { VStack { Button { + callViewModel.togglePause() } label: { Image("pause") .renderingMode(.template) @@ -250,6 +264,7 @@ struct CallView: View { VStack { Button { + callViewModel.toggleRecording() } label: { Image("record-fill") .renderingMode(.template) @@ -409,35 +424,52 @@ struct CallView: View { } @ViewBuilder - func innerView(geoHeight: CGFloat) -> some View { + func innerView(geoHeight: CGFloat, geoWidth: CGFloat) -> some View { + VStack { - Rectangle() - .foregroundColor(Color.orangeMain500) - .edgesIgnoringSafeArea(.top) - .frame(height: 0) - - HStack { - if callViewModel.direction == .Outgoing { - Image("outgoing-call") - .resizable() - .frame(width: 15, height: 15) - .padding(.horizontal) - - Text("Outgoing call") - .foregroundStyle(.white) - } else { - Image("incoming-call") - .resizable() - .frame(width: 15, height: 15) - .padding(.horizontal) - - Text("Incoming call") - .foregroundStyle(.white) - } - - Spacer() - } - .frame(height: 40) + if !fullscreenVideo { + Rectangle() + .foregroundColor(Color.orangeMain500) + .edgesIgnoringSafeArea(.top) + .frame(height: 0) + + HStack { + if callViewModel.direction == .Outgoing { + Image("outgoing-call") + .resizable() + .frame(width: 15, height: 15) + .padding(.horizontal) + + Text("Outgoing call") + .foregroundStyle(.white) + } else { + Image("incoming-call") + .resizable() + .frame(width: 15, height: 15) + .padding(.horizontal) + + Text("Incoming call") + .foregroundStyle(.white) + } + + Spacer() + + if callViewModel.cameraDisplayed { + Button { + callViewModel.switchCamera() + } label: { + Image("camera-rotate") + .renderingMode(.template) + .resizable() + .foregroundStyle(.white) + .frame(width: 30, height: 30) + .padding(.horizontal) + } + } + } + .frame(height: 40) + .zIndex(1) + } ZStack { VStack { @@ -497,8 +529,40 @@ struct CallView: View { Spacer() } - - if !telecomManager.callStarted { + + LinphoneVideoViewHolder { view in + coreContext.doOnCoreQueue { core in + core.nativeVideoWindow = view + } + } + .frame(width: 120*5, height: 160*5) + .scaledToFill() + .clipped() + .onTapGesture { + fullscreenVideo.toggle() + } + + if callViewModel.cameraDisplayed { + HStack { + Spacer() + VStack { + Spacer() + LinphoneVideoViewHolder { view in + coreContext.doOnCoreQueue { core in + core.nativePreviewWindow = view + } + } + .frame(width: 120*1.2, height: 160*1.2) + .cornerRadius(20) + .padding(10) + .rotationEffect(Angle(degrees: angleDegree)) + .padding(.trailing, abs(angleDegree/2)) + } + } + .frame(maxWidth: fullscreenVideo ? geoWidth : geoWidth - 8, maxHeight: fullscreenVideo ? geoHeight + 140 : geoHeight - 140) + } + + if !telecomManager.callStarted && !fullscreenVideo { VStack { ActivityIndicator() .frame(width: 20, height: 20) @@ -517,14 +581,38 @@ struct CallView: View { .background(.clear) } } - .frame(maxWidth: .infinity, maxHeight: .infinity) + .frame(maxWidth: fullscreenVideo ? geoWidth : geoWidth - 8, maxHeight: fullscreenVideo ? geoHeight + 140 : geoHeight - 140) .background(Color.gray600) .cornerRadius(20) - .padding(.horizontal, 4) + .padding(.horizontal, fullscreenVideo ? 0 : 4) + .onRotate { newOrientation in + orientation = newOrientation + if orientation == .portrait || orientation == .portraitUpsideDown { + angleDegree = 0 + } else { + if orientation == .landscapeLeft { + angleDegree = -90 + } else if orientation == .landscapeRight { + angleDegree = 90 + } + } + } + .onAppear { + if orientation == .portrait && orientation == .portraitUpsideDown { + angleDegree = 0 + } else { + if orientation == .landscapeLeft { + angleDegree = -90 + } else if orientation == .landscapeRight { + angleDegree = 90 + } + } + } - if telecomManager.callStarted { + if !fullscreenVideo { + if telecomManager.callStarted { if telecomManager.callStarted && idiom != .pad && !(orientation == .landscapeLeft || orientation == .landscapeRight - || UIScreen.main.bounds.size.width > UIScreen.main.bounds.size.height) { + || UIScreen.main.bounds.size.width > UIScreen.main.bounds.size.height) { HStack(spacing: 12) { HStack { @@ -552,6 +640,7 @@ struct CallView: View { Spacer() Button { + callViewModel.toggleVideo() } label: { Image("video-camera") .renderingMode(.template) @@ -565,7 +654,7 @@ struct CallView: View { .cornerRadius(40) Button { - callViewModel.muteCall() + callViewModel.toggleMuteMicrophone() } label: { Image(callViewModel.micMutted ? "microphone-slash" : "microphone") .renderingMode(.template) @@ -593,50 +682,55 @@ struct CallView: View { } .frame(height: geoHeight * 0.15) .padding(.horizontal, 20) + .padding(.top, -6) } - } else { - HStack(spacing: 12) { - HStack { - Spacer() - - Button { - callViewModel.terminateCall() - } label: { - Image("phone-disconnect") - .renderingMode(.template) - .resizable() - .foregroundStyle(.white) - .frame(width: 32, height: 32) - - } - .frame(width: 90, height: 60) - .background(Color.redDanger500) - .cornerRadius(40) - - Button { - callViewModel.acceptCall() - } label: { - Image("phone") - .renderingMode(.template) - .resizable() - .foregroundStyle(.white) - .frame(width: 32, height: 32) - - } - .frame(width: 90, height: 60) - .background(Color.greenSuccess500) - .cornerRadius(40) - - Spacer() - } - .frame(height: 60) - } - .padding(.horizontal, 25) - .padding(.top, 20) - } + } else { + HStack(spacing: 12) { + HStack { + Spacer() + + Button { + callViewModel.terminateCall() + } label: { + Image("phone-disconnect") + .renderingMode(.template) + .resizable() + .foregroundStyle(.white) + .frame(width: 32, height: 32) + + } + .frame(width: 90, height: 60) + .background(Color.redDanger500) + .cornerRadius(40) + + Button { + callViewModel.acceptCall() + } label: { + Image("phone") + .renderingMode(.template) + .resizable() + .foregroundStyle(.white) + .frame(width: 32, height: 32) + + } + .frame(width: 90, height: 60) + .background(Color.greenSuccess500) + .cornerRadius(40) + + Spacer() + } + .frame(height: 60) + } + .padding(.horizontal, 25) + .padding(.top, 20) + } + } } .frame(maxWidth: .infinity, maxHeight: .infinity) .background(Color.gray900) + .if(fullscreenVideo) { view in + view.ignoresSafeArea(.all) + } } func getAudioRouteImage() { diff --git a/Linphone/UI/Call/ViewModel/CallViewModel.swift b/Linphone/UI/Call/ViewModel/CallViewModel.swift index 1cf74ad54..3d18060b3 100644 --- a/Linphone/UI/Call/ViewModel/CallViewModel.swift +++ b/Linphone/UI/Call/ViewModel/CallViewModel.swift @@ -31,12 +31,14 @@ class CallViewModel: ObservableObject { @Published var remoteAddressString: String = "example.linphone@sip.linphone.org" @Published var remoteAddress: Address? @Published var avatarModel: ContactAvatarModel? - @Published var audioSessionImage: String = "" - @State var micMutted: Bool = false + @Published var micMutted: Bool = false + @Published var cameraDisplayed: Bool = false @State var timeElapsed: Int = 0 let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect() + var currentCall: Call? + init() { do { @@ -48,21 +50,26 @@ class CallViewModel: ObservableObject { coreContext.doOnCoreQueue { core in if core.currentCall != nil && core.currentCall!.remoteAddress != nil { + self.currentCall = core.currentCall DispatchQueue.main.async { self.direction = .Incoming - self.remoteAddressString = String(core.currentCall!.remoteAddress!.asStringUriOnly().dropFirst(4)) - self.remoteAddress = core.currentCall!.remoteAddress! + self.remoteAddressString = String(self.currentCall!.remoteAddress!.asStringUriOnly().dropFirst(4)) + self.remoteAddress = self.currentCall!.remoteAddress! - let friend = ContactsManager.shared.getFriendWithAddress(address: core.currentCall!.remoteAddress!) + let friend = ContactsManager.shared.getFriendWithAddress(address: self.currentCall!.remoteAddress!) if friend != nil && friend!.address != nil && friend!.address!.displayName != nil { self.displayName = friend!.address!.displayName! } else { - if core.currentCall!.remoteAddress!.displayName != nil { - self.displayName = core.currentCall!.remoteAddress!.displayName! - } else if core.currentCall!.remoteAddress!.username != nil { - self.displayName = core.currentCall!.remoteAddress!.username! + if self.currentCall!.remoteAddress!.displayName != nil { + self.displayName = self.currentCall!.remoteAddress!.displayName! + } else if self.currentCall!.remoteAddress!.username != nil { + self.displayName = self.currentCall!.remoteAddress!.username! } } + + //self.avatarModel = ??? + self.micMutted = self.currentCall!.microphoneMuted + self.cameraDisplayed = self.currentCall!.cameraEnabled == true } } } @@ -74,9 +81,9 @@ class CallViewModel: ObservableObject { telecomManager.callStarted = false } - coreContext.doOnCoreQueue { core in - if core.currentCall != nil { - self.telecomManager.terminateCall(call: core.currentCall!) + coreContext.doOnCoreQueue { _ in + if self.currentCall != nil { + self.telecomManager.terminateCall(call: self.currentCall!) } } @@ -90,23 +97,112 @@ class CallViewModel: ObservableObject { } coreContext.doOnCoreQueue { core in - if core.currentCall != nil { - self.telecomManager.acceptCall(core: core, call: core.currentCall!, hasVideo: false) + if self.currentCall != nil { + self.telecomManager.acceptCall(core: core, call: self.currentCall!, hasVideo: false) } } timer.upstream.connect().cancel() } - func muteCall() { - coreContext.doOnCoreQueue { core in - if core.currentCall != nil { - self.micMutted = !self.micMutted - core.currentCall!.microphoneMuted = self.micMutted + func toggleMuteMicrophone() { + coreContext.doOnCoreQueue { _ in + if self.currentCall != nil { + self.currentCall!.microphoneMuted = !self.currentCall!.microphoneMuted + self.micMutted = self.currentCall!.microphoneMuted + Log.info( + "[CallViewModel] Microphone mute switch \(self.micMutted)" + ) } } } + func toggleVideo() { + coreContext.doOnCoreQueue { core in + if self.currentCall != nil { + do { + let params = try core.createCallParams(call: self.currentCall) + + params.videoEnabled = !params.videoEnabled + Log.info( + "[CallViewModel] Updating call with video enabled set to \(params.videoEnabled)" + ) + try self.currentCall!.update(params: params) + + self.cameraDisplayed = self.currentCall!.cameraEnabled == true + } catch { + + } + } + } + } + + func switchCamera() { + coreContext.doOnCoreQueue { core in + let currentDevice = core.videoDevice + Log.info("[CallViewModel] Current camera device is \(currentDevice)") + + 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() + Log.info("[CallViewModel] Starting call recording \(self.currentCall!.params!.isRecording)") + } + //var recording = self.currentCall!.params!.isRecording + //isRecording.postValue(recording) + } + } + } + + 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() + } else { + Log.info("[CallViewModel] Pausing call \(self.currentCall!.remoteAddress!.asStringUriOnly())") + try self.currentCall!.pause() + } + } catch _ { + + } + } + } + } + + private 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 diff --git a/Linphone/UI/Main/Contacts/Model/ContactAvatarModel.swift b/Linphone/UI/Main/Contacts/Model/ContactAvatarModel.swift index 1346767aa..28016921c 100644 --- a/Linphone/UI/Main/Contacts/Model/ContactAvatarModel.swift +++ b/Linphone/UI/Main/Contacts/Model/ContactAvatarModel.swift @@ -64,7 +64,6 @@ class ContactAvatarModel: ObservableObject { } func addSubscription() { - friendSuscription = self.friend?.publisher?.onPresenceReceived?.postOnMainQueue { (cbValue: (Friend)) in print("publisherpublisher onLogCollectionUploadStateChanged \(cbValue.address?.asStringUriOnly() ?? "")") diff --git a/Linphone/Utils/Extensions/ViewExtension.swift b/Linphone/Utils/Extensions/ViewExtension.swift new file mode 100644 index 000000000..5f9765b89 --- /dev/null +++ b/Linphone/Utils/Extensions/ViewExtension.swift @@ -0,0 +1,35 @@ +/* + * 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 + } + } +} From 341d8171a0d665250ad79137188107180b20fc78 Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Mon, 8 Jan 2024 13:32:18 +0100 Subject: [PATCH 072/486] Add video call, fullscreen mode and automatic video acceptance --- Linphone/Core/CoreContext.swift | 5 ++- Linphone/TelecomManager/TelecomManager.swift | 4 ++ Linphone/UI/Call/CallView.swift | 37 ++++++++++++++----- .../UI/Call/ViewModel/CallViewModel.swift | 33 ++++++++++++++--- .../Contacts/Model/ContactAvatarModel.swift | 2 - .../ViewModel/HistoryListViewModel.swift | 1 - 6 files changed, 63 insertions(+), 19 deletions(-) diff --git a/Linphone/Core/CoreContext.swift b/Linphone/Core/CoreContext.swift index e897b6027..98bdb7127 100644 --- a/Linphone/Core/CoreContext.swift +++ b/Linphone/Core/CoreContext.swift @@ -104,8 +104,11 @@ final class CoreContext: ObservableObject { self.mCore.videoCaptureEnabled = true self.mCore.videoDisplayEnabled = true + self.mCore.recordAwareEnabled = true - self.mCore.videoActivationPolicy!.automaticallyAccept = true + let videoActivationPolicy = self.mCore.videoActivationPolicy! + videoActivationPolicy.automaticallyAccept = true + self.mCore.videoActivationPolicy! = videoActivationPolicy try? self.mCore.start() diff --git a/Linphone/TelecomManager/TelecomManager.swift b/Linphone/TelecomManager/TelecomManager.swift index d21d7c525..855e70305 100644 --- a/Linphone/TelecomManager/TelecomManager.swift +++ b/Linphone/TelecomManager/TelecomManager.swift @@ -313,6 +313,10 @@ class TelecomManager: ObservableObject { } else { let video = (core.videoActivationPolicy?.automaticallyAccept ?? false) && (call.remoteParams?.videoEnabled ?? false) + if video { + Log.info("[Call] Remote video is activated") + } + if call.userData == nil { let appData = CallAppData() TelecomManager.setAppData(sCall: call, appData: appData) diff --git a/Linphone/UI/Call/CallView.swift b/Linphone/UI/Call/CallView.swift index 7c90468af..cd23af51e 100644 --- a/Linphone/UI/Call/CallView.swift +++ b/Linphone/UI/Call/CallView.swift @@ -49,8 +49,8 @@ struct CallView: View { var body: some View { GeometryReader { geo in if #available(iOS 16.4, *) { - innerView(geoHeight: geo.size.height, geoWidth: geo.size.width) - .sheet(isPresented: + innerView(geometry: geo) + .sheet(isPresented: .constant( telecomManager.callStarted && !fullscreenVideo @@ -424,8 +424,7 @@ struct CallView: View { } @ViewBuilder - func innerView(geoHeight: CGFloat, geoWidth: CGFloat) -> some View { - + func innerView(geometry: GeometryProxy) -> some View { VStack { if !fullscreenVideo { Rectangle() @@ -535,7 +534,16 @@ struct CallView: View { core.nativeVideoWindow = view } } - .frame(width: 120*5, height: 160*5) + .frame( + width: + angleDegree == 0 + ? 120 * ((geometry.size.height + geometry.safeAreaInsets.top + geometry.safeAreaInsets.bottom) / 160) + : 120 * ((geometry.size.width + geometry.safeAreaInsets.leading + geometry.safeAreaInsets.trailing) / 120), + height: + angleDegree == 0 + ? 160 * ((geometry.size.height + geometry.safeAreaInsets.top + geometry.safeAreaInsets.bottom) / 160) + : 160 * ((geometry.size.width + geometry.safeAreaInsets.leading + geometry.safeAreaInsets.trailing) / 120) + ) .scaledToFill() .clipped() .onTapGesture { @@ -552,14 +560,16 @@ struct CallView: View { core.nativePreviewWindow = view } } - .frame(width: 120*1.2, height: 160*1.2) + .frame(width: angleDegree == 0 ? 120*1.2 : 160*1.2, height: angleDegree == 0 ? 160*1.2 : 120*1.2) .cornerRadius(20) .padding(10) - .rotationEffect(Angle(degrees: angleDegree)) .padding(.trailing, abs(angleDegree/2)) } } - .frame(maxWidth: fullscreenVideo ? geoWidth : geoWidth - 8, maxHeight: fullscreenVideo ? geoHeight + 140 : geoHeight - 140) + .frame( + maxWidth: fullscreenVideo ? geometry.size.width : geometry.size.width - 8, + maxHeight: fullscreenVideo ? geometry.size.height + geometry.safeAreaInsets.top + geometry.safeAreaInsets.bottom : geometry.size.height - 140 + ) } if !telecomManager.callStarted && !fullscreenVideo { @@ -581,7 +591,10 @@ struct CallView: View { .background(.clear) } } - .frame(maxWidth: fullscreenVideo ? geoWidth : geoWidth - 8, maxHeight: fullscreenVideo ? geoHeight + 140 : geoHeight - 140) + .frame( + maxWidth: fullscreenVideo ? geometry.size.width : geometry.size.width - 8, + maxHeight: fullscreenVideo ? geometry.size.height + geometry.safeAreaInsets.top + geometry.safeAreaInsets.bottom : geometry.size.height - 140 + ) .background(Color.gray600) .cornerRadius(20) .padding(.horizontal, fullscreenVideo ? 0 : 4) @@ -596,6 +609,8 @@ struct CallView: View { angleDegree = 90 } } + + callViewModel.orientationUpdate(orientation: orientation) } .onAppear { if orientation == .portrait && orientation == .portraitUpsideDown { @@ -607,6 +622,8 @@ struct CallView: View { angleDegree = 90 } } + + callViewModel.orientationUpdate(orientation: orientation) } if !fullscreenVideo { @@ -680,7 +697,7 @@ struct CallView: View { .background(Color.gray500) .cornerRadius(40) } - .frame(height: geoHeight * 0.15) + .frame(height: geometry.size.height * 0.15) .padding(.horizontal, 20) .padding(.top, -6) } diff --git a/Linphone/UI/Call/ViewModel/CallViewModel.swift b/Linphone/UI/Call/ViewModel/CallViewModel.swift index 3d18060b3..834b8874b 100644 --- a/Linphone/UI/Call/ViewModel/CallViewModel.swift +++ b/Linphone/UI/Call/ViewModel/CallViewModel.swift @@ -123,10 +123,10 @@ class CallViewModel: ObservableObject { do { let params = try core.createCallParams(call: self.currentCall) - params.videoEnabled = !params.videoEnabled - Log.info( - "[CallViewModel] Updating call with video enabled set to \(params.videoEnabled)" - ) + params.videoEnabled = !params.videoEnabled + Log.info( + "[CallViewModel] Updating call with video enabled set to \(params.videoEnabled)" + ) try self.currentCall!.update(params: params) self.cameraDisplayed = self.currentCall!.cameraEnabled == true @@ -141,7 +141,7 @@ class CallViewModel: ObservableObject { coreContext.doOnCoreQueue { core in let currentDevice = core.videoDevice Log.info("[CallViewModel] Current camera device is \(currentDevice)") - + core.videoDevicesList.forEach { camera in if camera != currentDevice && camera != "StaticImage: Static picture" { Log.info("[CallViewModel] New camera device will be \(camera)") @@ -237,4 +237,27 @@ class CallViewModel: ObservableObject { return 2 } } + + 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 + } + } + } } diff --git a/Linphone/UI/Main/Contacts/Model/ContactAvatarModel.swift b/Linphone/UI/Main/Contacts/Model/ContactAvatarModel.swift index 28016921c..fb469c160 100644 --- a/Linphone/UI/Main/Contacts/Model/ContactAvatarModel.swift +++ b/Linphone/UI/Main/Contacts/Model/ContactAvatarModel.swift @@ -65,8 +65,6 @@ class ContactAvatarModel: ObservableObject { func addSubscription() { friendSuscription = self.friend?.publisher?.onPresenceReceived?.postOnMainQueue { (cbValue: (Friend)) in - print("publisherpublisher onLogCollectionUploadStateChanged \(cbValue.address?.asStringUriOnly() ?? "")") - self.presenceStatus = cbValue.consolidatedPresence if cbValue.consolidatedPresence == .Online || cbValue.consolidatedPresence == .Busy { if cbValue.consolidatedPresence == .Online || cbValue.presenceModel!.latestActivityTimestamp != -1 { diff --git a/Linphone/UI/Main/History/ViewModel/HistoryListViewModel.swift b/Linphone/UI/Main/History/ViewModel/HistoryListViewModel.swift index d08ad626c..9bd9c0a05 100644 --- a/Linphone/UI/Main/History/ViewModel/HistoryListViewModel.swift +++ b/Linphone/UI/Main/History/ViewModel/HistoryListViewModel.swift @@ -49,7 +49,6 @@ class HistoryListViewModel: ObservableObject { } self.callLogSubscription = core.publisher?.onCallLogUpdated?.postOnCoreQueue { (_: (_: Core, _: CallLog)) in - print("publisherpublisher onCallLogUpdated") let account = core.defaultAccount let logs = account != nil ? account!.callLogs : core.callLogs From db7ccff9f58b2756c2d7b2ef741b8b0958458b9f Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Mon, 8 Jan 2024 16:57:26 +0100 Subject: [PATCH 073/486] Record call --- Linphone/Core/CoreContext.swift | 1 - Linphone/LinphoneApp.swift | 8 +++- .../TelecomManager/ProviderDelegate.swift | 8 ++-- Linphone/TelecomManager/TelecomManager.swift | 47 ++++++++++++++++--- Linphone/UI/Call/CallView.swift | 24 +++++++++- .../UI/Call/ViewModel/CallViewModel.swift | 13 +++-- Linphone/UI/Main/ContentView.swift | 9 +++- Linphone/UI/Main/Fragments/ToastView.swift | 7 +++ 8 files changed, 97 insertions(+), 20 deletions(-) diff --git a/Linphone/Core/CoreContext.swift b/Linphone/Core/CoreContext.swift index 98bdb7127..736500641 100644 --- a/Linphone/Core/CoreContext.swift +++ b/Linphone/Core/CoreContext.swift @@ -104,7 +104,6 @@ final class CoreContext: ObservableObject { self.mCore.videoCaptureEnabled = true self.mCore.videoDisplayEnabled = true - self.mCore.recordAwareEnabled = true let videoActivationPolicy = self.mCore.videoActivationPolicy! videoActivationPolicy.automaticallyAccept = true diff --git a/Linphone/LinphoneApp.swift b/Linphone/LinphoneApp.swift index 61e22bdf3..950f6722f 100644 --- a/Linphone/LinphoneApp.swift +++ b/Linphone/LinphoneApp.swift @@ -30,6 +30,7 @@ struct LinphoneApp: App { @State private var historyViewModel: HistoryViewModel? @State private var historyListViewModel: HistoryListViewModel? @State private var startCallViewModel: StartCallViewModel? + @State private var callViewModel: CallViewModel? var body: some Scene { WindowGroup { @@ -43,13 +44,15 @@ struct LinphoneApp: App { && editContactViewModel != nil && historyViewModel != nil && historyListViewModel != nil - && startCallViewModel != nil { + && startCallViewModel != nil + && callViewModel != nil { ContentView( contactViewModel: contactViewModel!, editContactViewModel: editContactViewModel!, historyViewModel: historyViewModel!, historyListViewModel: historyListViewModel!, - startCallViewModel: startCallViewModel! + startCallViewModel: startCallViewModel!, + callViewModel: callViewModel! ) } else { SplashScreen() @@ -62,6 +65,7 @@ struct LinphoneApp: App { historyViewModel = HistoryViewModel() historyListViewModel = HistoryListViewModel() startCallViewModel = StartCallViewModel() + callViewModel = CallViewModel() } } } diff --git a/Linphone/TelecomManager/ProviderDelegate.swift b/Linphone/TelecomManager/ProviderDelegate.swift index c12cbed92..55ff1cf40 100644 --- a/Linphone/TelecomManager/ProviderDelegate.swift +++ b/Linphone/TelecomManager/ProviderDelegate.swift @@ -209,9 +209,11 @@ extension ProviderDelegate: CXProviderDelegate { let callInfo = callInfos[uuid] let callId = callInfo?.callId ?? "" - DispatchQueue.main.async { - withAnimation { - TelecomManager.shared.callInProgress = true + if TelecomManager.shared.callInProgress == false { + DispatchQueue.main.async { + withAnimation { + TelecomManager.shared.callInProgress = true + } } } CoreContext.shared.doOnCoreQueue { core in diff --git a/Linphone/TelecomManager/TelecomManager.swift b/Linphone/TelecomManager/TelecomManager.swift index 855e70305..a5270c0e3 100644 --- a/Linphone/TelecomManager/TelecomManager.swift +++ b/Linphone/TelecomManager/TelecomManager.swift @@ -42,6 +42,8 @@ class TelecomManager: ObservableObject { @Published var callInProgress: Bool = false @Published var callStarted: Bool = false + @Published var remoteVideo: Bool = false + @Published var isRemoteRecording: Bool = false var actionToFulFill: CXCallAction? var callkitAudioSessionActivated: Bool? @@ -125,6 +127,19 @@ class TelecomManager: ObservableObject { } } } + + private func makeRecordFilePath() -> String{ + var filePath = "recording_" + let now = Date() + let dateFormat = DateFormatter() + dateFormat.dateFormat = "E-d-MMM-yyyy-HH-mm-ss" + let date = dateFormat.string(from: now) + filePath = filePath.appending("\(date).mkv") + + 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) @@ -154,6 +169,9 @@ class TelecomManager: ObservableObject { // 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 } @@ -184,9 +202,12 @@ class TelecomManager: ObservableObject { } DispatchQueue.main.async { + self.callStarted = true - withAnimation { - self.callInProgress = true + if self.callInProgress == false { + withAnimation { + self.callInProgress = true + } } } } @@ -195,6 +216,8 @@ class TelecomManager: ObservableObject { 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) @@ -311,12 +334,22 @@ class TelecomManager: ObservableObject { if cstate == .PushIncomingReceived { displayIncomingCall(call: call, handle: "Calling", hasVideo: false, callId: callId, displayName: "Calling") } else { - let video = (core.videoActivationPolicy?.automaticallyAccept ?? false) && (call.remoteParams?.videoEnabled ?? false) + remoteVideo = (core.videoActivationPolicy?.automaticallyAccept ?? false) && (call.remoteParams?.videoEnabled ?? false) - if video { + if remoteVideo { Log.info("[Call] Remote video is activated") } + isRemoteRecording = call.remoteParams?.isRecording ?? false + + if isRemoteRecording && ToastViewModel.shared.toastMessage == "" { + + ToastViewModel.shared.toastMessage = "\(call.remoteAddress) is recording" + ToastViewModel.shared.displayToast.toggle() + + Log.info("[Call] Call is recording by \(call.remoteAddress)") + } + if call.userData == nil { let appData = CallAppData() TelecomManager.setAppData(sCall: call, appData: appData) @@ -351,7 +384,7 @@ class TelecomManager: ObservableObject { providerDelegate.callInfos.updateValue(callInfo!, forKey: uuid!) providerDelegate.uuids.removeValue(forKey: callId) providerDelegate.uuids.updateValue(uuid!, forKey: callInfo!.callId) - providerDelegate.updateCall(uuid: uuid!, handle: addr!.asStringUriOnly(), hasVideo: video, displayName: displayName) + providerDelegate.updateCall(uuid: uuid!, handle: addr!.asStringUriOnly(), hasVideo: remoteVideo, displayName: displayName) } } else if TelecomManager.callKitEnabled(core: core) { /* @@ -374,9 +407,9 @@ class TelecomManager: ObservableObject { if uuid != nil { // Tha app is now registered, updated the call already existed. - providerDelegate.updateCall(uuid: uuid!, handle: addr!.asStringUriOnly(), hasVideo: video, displayName: displayName) + providerDelegate.updateCall(uuid: uuid!, handle: addr!.asStringUriOnly(), hasVideo: remoteVideo, displayName: displayName) } else { - displayIncomingCall(call: call, handle: addr!.asStringUriOnly(), hasVideo: video, callId: callId, displayName: displayName) + displayIncomingCall(call: call, handle: addr!.asStringUriOnly(), hasVideo: remoteVideo, callId: callId, displayName: displayName) } } /* else if UIApplication.shared.applicationState != .active { // not support callkit , use notif diff --git a/Linphone/UI/Call/CallView.swift b/Linphone/UI/Call/CallView.swift index cd23af51e..dc681036d 100644 --- a/Linphone/UI/Call/CallView.swift +++ b/Linphone/UI/Call/CallView.swift @@ -273,7 +273,7 @@ struct CallView: View { .frame(width: 32, height: 32) } .frame(width: 60, height: 60) - .background(Color.gray500) + .background(callViewModel.isRecording ? Color.redDanger500 : Color.gray500) .cornerRadius(40) Text("Record") @@ -572,6 +572,28 @@ struct CallView: View { ) } + if callViewModel.isRecording { + HStack { + VStack { + Image("record-fill") + .renderingMode(.template) + .resizable() + .foregroundStyle(Color.redDanger500) + .frame(width: 32, height: 32) + .padding(10) + .if(fullscreenVideo) { view in + view.padding(.top, 30) + } + Spacer() + } + Spacer() + } + .frame( + maxWidth: fullscreenVideo ? geometry.size.width : geometry.size.width - 8, + maxHeight: fullscreenVideo ? geometry.size.height + geometry.safeAreaInsets.top + geometry.safeAreaInsets.bottom : geometry.size.height - 140 + ) + } + if !telecomManager.callStarted && !fullscreenVideo { VStack { ActivityIndicator() diff --git a/Linphone/UI/Call/ViewModel/CallViewModel.swift b/Linphone/UI/Call/ViewModel/CallViewModel.swift index 834b8874b..382e41e5e 100644 --- a/Linphone/UI/Call/ViewModel/CallViewModel.swift +++ b/Linphone/UI/Call/ViewModel/CallViewModel.swift @@ -33,6 +33,8 @@ class CallViewModel: ObservableObject { @Published var avatarModel: ContactAvatarModel? @Published var micMutted: Bool = false @Published var cameraDisplayed: Bool = false + @Published var isRecording: Bool = false + @Published var isRemoteRecording: Bool = false @State var timeElapsed: Int = 0 let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect() @@ -40,7 +42,6 @@ class CallViewModel: ObservableObject { var currentCall: Call? init() { - do { try AVAudioSession.sharedInstance().setCategory(.playAndRecord, mode: .voiceChat, options: .allowBluetooth) try AVAudioSession.sharedInstance().setActive(true) @@ -48,6 +49,10 @@ class CallViewModel: ObservableObject { } + resetCallView() + } + + func resetCallView() { coreContext.doOnCoreQueue { core in if core.currentCall != nil && core.currentCall!.remoteAddress != nil { self.currentCall = core.currentCall @@ -70,6 +75,7 @@ class CallViewModel: ObservableObject { //self.avatarModel = ??? self.micMutted = self.currentCall!.microphoneMuted self.cameraDisplayed = self.currentCall!.cameraEnabled == true + self.isRecording = self.currentCall!.params!.isRecording } } } @@ -164,10 +170,9 @@ class CallViewModel: ObservableObject { } else { Log.info("[CallViewModel] Starting call recording \(self.currentCall!.params!.isRecording)") self.currentCall!.startRecording() - Log.info("[CallViewModel] Starting call recording \(self.currentCall!.params!.isRecording)") } - //var recording = self.currentCall!.params!.isRecording - //isRecording.postValue(recording) + + self.isRecording = self.currentCall!.params!.isRecording } } } diff --git a/Linphone/UI/Main/ContentView.swift b/Linphone/UI/Main/ContentView.swift index 64f63dc5b..ee403524a 100644 --- a/Linphone/UI/Main/ContentView.swift +++ b/Linphone/UI/Main/ContentView.swift @@ -38,6 +38,7 @@ struct ContentView: View { @ObservedObject var historyViewModel: HistoryViewModel @ObservedObject var historyListViewModel: HistoryListViewModel @ObservedObject var startCallViewModel: StartCallViewModel + @ObservedObject var callViewModel: CallViewModel @State var index = 0 @State private var orientation = UIDevice.current.orientation @@ -661,9 +662,12 @@ struct ContentView: View { } if telecomManager.callInProgress { - CallView(callViewModel: CallViewModel()) + CallView(callViewModel: callViewModel) .zIndex(3) .transition(.scale.combined(with: .move(edge: .top))) + .onAppear { + callViewModel.resetCallView() + } } // if sharedMainViewModel.displayToast { @@ -722,7 +726,8 @@ struct ContentView: View { editContactViewModel: EditContactViewModel(), historyViewModel: HistoryViewModel(), historyListViewModel: HistoryListViewModel(), - startCallViewModel: StartCallViewModel() + startCallViewModel: StartCallViewModel(), + callViewModel: CallViewModel() ) } // swiftlint:enable type_body_length diff --git a/Linphone/UI/Main/Fragments/ToastView.swift b/Linphone/UI/Main/Fragments/ToastView.swift index 40d5e7e16..025479ae0 100644 --- a/Linphone/UI/Main/Fragments/ToastView.swift +++ b/Linphone/UI/Main/Fragments/ToastView.swift @@ -54,6 +54,13 @@ struct ToastView: View { .foregroundStyle(Color.greenSuccess500) .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!") From 1b44d902b1cc8659f075f592d09b143ae82e4ce2 Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Tue, 9 Jan 2024 12:21:07 +0100 Subject: [PATCH 074/486] Toast display when user records call --- Linphone/TelecomManager/TelecomManager.swift | 34 +++++++++++++++++--- Linphone/UI/Main/Fragments/ToastView.swift | 17 ++++++---- 2 files changed, 40 insertions(+), 11 deletions(-) diff --git a/Linphone/TelecomManager/TelecomManager.swift b/Linphone/TelecomManager/TelecomManager.swift index a5270c0e3..22372bc1a 100644 --- a/Linphone/TelecomManager/TelecomManager.swift +++ b/Linphone/TelecomManager/TelecomManager.swift @@ -342,12 +342,38 @@ class TelecomManager: ObservableObject { isRemoteRecording = call.remoteParams?.isRecording ?? false - if isRemoteRecording && ToastViewModel.shared.toastMessage == "" { + if isRemoteRecording && ToastViewModel.shared.toastMessage.isEmpty { - ToastViewModel.shared.toastMessage = "\(call.remoteAddress) is recording" - ToastViewModel.shared.displayToast.toggle() + DispatchQueue.main.async { + 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! + } + } + + ToastViewModel.shared.toastMessage = "\(displayName) is recording" + ToastViewModel.shared.displayToast = true + } - Log.info("[Call] Call is recording by \(call.remoteAddress)") + Log.info("[Call] Call is recording by \(call.remoteAddress!.asStringUriOnly())") + } + + if !isRemoteRecording && ToastViewModel.shared.toastMessage.contains("is recording") { + + DispatchQueue.main.async { + withAnimation { + ToastViewModel.shared.toastMessage = "" + ToastViewModel.shared.displayToast = false + } + } + + Log.info("[Call] Call is recording Stop recording by \(call.remoteAddress!.asStringUriOnly())") } if call.userData == nil { diff --git a/Linphone/UI/Main/Fragments/ToastView.swift b/Linphone/UI/Main/Fragments/ToastView.swift index 025479ae0..9f8dc277e 100644 --- a/Linphone/UI/Main/Fragments/ToastView.swift +++ b/Linphone/UI/Main/Fragments/ToastView.swift @@ -100,19 +100,22 @@ struct ToastView: View { .stroke(toastViewModel.toastMessage.contains("Success") ? Color.greenSuccess500 : Color.redDanger500, lineWidth: 1) ) .onTapGesture { - withAnimation { - toastViewModel.toastMessage = "" - toastViewModel.displayToast = false - } - } - .onAppear { - DispatchQueue.main.asyncAfter(deadline: .now() + 2) { + 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() } From ea1382e801789f8463bfd909799bb6d00bcf1e02 Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Tue, 9 Jan 2024 16:42:11 +0100 Subject: [PATCH 075/486] Refresh view when call is paused --- .../phone-list.imageset/Contents.json | 21 + .../phone-list.imageset/phone-list.svg | 6 + .../phone-transfer.imageset/Contents.json | 21 + .../phone-transfer.svg | 4 + Linphone/Core/CoreContext.swift | 4 - Linphone/Localizable.xcstrings | 21 +- Linphone/Ressources/linphonerc-factory | 2 + .../TelecomManager/ProviderDelegate.swift | 158 ++-- Linphone/TelecomManager/TelecomManager.swift | 282 +++---- Linphone/UI/Call/CallView.swift | 705 ++++++++++-------- .../UI/Call/ViewModel/CallViewModel.swift | 13 +- 11 files changed, 677 insertions(+), 560 deletions(-) create mode 100644 Linphone/Assets.xcassets/phone-list.imageset/Contents.json create mode 100644 Linphone/Assets.xcassets/phone-list.imageset/phone-list.svg create mode 100644 Linphone/Assets.xcassets/phone-transfer.imageset/Contents.json create mode 100644 Linphone/Assets.xcassets/phone-transfer.imageset/phone-transfer.svg 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-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/Core/CoreContext.swift b/Linphone/Core/CoreContext.swift index 736500641..a07c6b926 100644 --- a/Linphone/Core/CoreContext.swift +++ b/Linphone/Core/CoreContext.swift @@ -105,10 +105,6 @@ final class CoreContext: ObservableObject { self.mCore.videoCaptureEnabled = true self.mCore.videoDisplayEnabled = true - let videoActivationPolicy = self.mCore.videoActivationPolicy! - videoActivationPolicy.automaticallyAccept = true - self.mCore.videoActivationPolicy! = videoActivationPolicy - try? self.mCore.start() // Create a Core listener to listen for the callback we need diff --git a/Linphone/Localizable.xcstrings b/Linphone/Localizable.xcstrings index 467b2feee..8dc0dee51 100644 --- a/Linphone/Localizable.xcstrings +++ b/Linphone/Localizable.xcstrings @@ -107,6 +107,9 @@ }, "+" : { + }, + "|" : { + }, "0" : { @@ -268,6 +271,9 @@ }, "Deny all" : { + }, + "Dialer" : { + }, "Display Name" : { @@ -415,9 +421,6 @@ }, "Outgoing Call" : { - }, - "Participants" : { - }, "password" : { "extractionState" : "manual", @@ -438,6 +441,12 @@ }, "Pause" : { + }, + "Paused" : { + + }, + "Paused by remote" : { + }, "Personnalize your profil mode" : { @@ -474,9 +483,6 @@ }, "Scan QR code" : { - }, - "Screen share" : { - }, "Search contact or history call" : { @@ -540,6 +546,9 @@ }, "to Linphone" : { + }, + "Transfer" : { + }, "Transport" : { diff --git a/Linphone/Ressources/linphonerc-factory b/Linphone/Ressources/linphonerc-factory index 4074322fe..0abf8269d 100644 --- a/Linphone/Ressources/linphonerc-factory +++ b/Linphone/Ressources/linphonerc-factory @@ -31,6 +31,8 @@ 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 diff --git a/Linphone/TelecomManager/ProviderDelegate.swift b/Linphone/TelecomManager/ProviderDelegate.swift index 55ff1cf40..44ef6095e 100644 --- a/Linphone/TelecomManager/ProviderDelegate.swift +++ b/Linphone/TelecomManager/ProviderDelegate.swift @@ -1,21 +1,21 @@ /* -* 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 . -*/ + * 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 @@ -56,19 +56,19 @@ class CallInfo { } /* -* A delegate to support callkit. -*/ + * 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 { get { let providerConfiguration = CXProviderConfiguration() @@ -97,18 +97,18 @@ class ProviderDelegate: NSObject { 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 - } - } - */ + 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 @@ -140,7 +140,7 @@ class ProviderDelegate: NSObject { } } } - + func updateCall(uuid: UUID, handle: String, hasVideo: Bool = false, displayName: String) { let update = CXCallUpdate() update.remoteHandle = CXHandle(type: .generic, value: handle) @@ -148,11 +148,11 @@ class ProviderDelegate: NSObject { 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) } @@ -164,7 +164,7 @@ class ProviderDelegate: NSObject { 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 @@ -188,7 +188,7 @@ extension ProviderDelegate: CXProviderDelegate { 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!) @@ -203,7 +203,7 @@ extension ProviderDelegate: CXProviderDelegate { action.fulfill() } } - + func provider(_ provider: CXProvider, perform action: CXAnswerCallAction) { let uuid = action.callUUID let callInfo = callInfos[uuid] @@ -221,15 +221,17 @@ extension ProviderDelegate: CXProviderDelegate { let call = core.getCallByCallid(callId: callId) - if UIApplication.shared.applicationState != .active { - TelecomManager.shared.backgroundContextCall = call - TelecomManager.shared.backgroundContextCameraIsEnabled = call?.params?.videoEnabled == true || call?.callLog?.wasConference() == true - if #available(iOS 16.0, *) { - if call?.cameraEnabled == true { - call?.cameraEnabled = AVCaptureSession().isMultitaskingCameraAccessSupported + DispatchQueue.main.async() { + if UIApplication.shared.applicationState != .active { + TelecomManager.shared.backgroundContextCall = call + TelecomManager.shared.backgroundContextCameraIsEnabled = call?.params?.videoEnabled == true || call?.callLog?.wasConference() == 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 } - } else { - call?.cameraEnabled = false // Disable camera while app is not on foreground } } TelecomManager.shared.callkitAudioSessionActivated = false @@ -242,7 +244,7 @@ extension ProviderDelegate: CXProviderDelegate { action.fulfill() } } - + func provider(_ provider: CXProvider, perform action: CXSetHeldCallAction) { let uuid = action.callUUID let callId = callInfos[uuid]?.callId ?? "" @@ -275,29 +277,29 @@ extension ProviderDelegate: CXProviderDelegate { action.fulfill() } else { if call?.conference != nil && core.callsNb > 1 {/* - try TelecomManager.shared.lc?.enterConference() - action.fulfill() - NotificationCenter.default.post(name: Notification.Name("LinphoneCallUpdate"), object: self) - */} 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(actived: true) - TelecomManager.shared.callkitAudioSessionActivated = true - } + try TelecomManager.shared.lc?.enterConference() + action.fulfill() + NotificationCenter.default.post(name: Notification.Name("LinphoneCallUpdate"), object: self) + */} 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(actived: true) + TelecomManager.shared.callkitAudioSessionActivated = true + } } } } catch { @@ -332,7 +334,7 @@ extension ProviderDelegate: CXProviderDelegate { } } } - + 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)).") @@ -340,7 +342,7 @@ extension ProviderDelegate: CXProviderDelegate { action.fulfill() } } - + func provider(_ provider: CXProvider, perform action: CXSetMutedCallAction) { let uuid = action.callUUID let callId = callInfos[uuid]?.callId @@ -350,7 +352,7 @@ extension ProviderDelegate: CXProviderDelegate { action.fulfill() } } - + func provider(_ provider: CXProvider, perform action: CXPlayDTMFCallAction) { let uuid = action.callUUID let callId = callInfos[uuid]?.callId ?? "" @@ -368,18 +370,18 @@ extension ProviderDelegate: CXProviderDelegate { 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.") @@ -387,7 +389,7 @@ extension ProviderDelegate: CXProviderDelegate { TelecomManager.shared.callkitAudioSessionActivated = true } } - + func provider(_ provider: CXProvider, didDeactivate audioSession: AVAudioSession) { CoreContext.shared.doOnCoreQueue { core in Log.info("CallKit: audio session deactivated.") diff --git a/Linphone/TelecomManager/TelecomManager.swift b/Linphone/TelecomManager/TelecomManager.swift index 22372bc1a..506ba21e4 100644 --- a/Linphone/TelecomManager/TelecomManager.swift +++ b/Linphone/TelecomManager/TelecomManager.swift @@ -41,9 +41,11 @@ class TelecomManager: ObservableObject { let callController: CXCallController // to support callkit @Published var callInProgress: Bool = false - @Published var callStarted: Bool = false + @Published var callStarted: Bool = false + @Published var outgoingCallStarted: Bool = false @Published var remoteVideo: Bool = false - @Published var isRemoteRecording: Bool = false + @Published var isRecordingByRemote: Bool = false + @Published var isPausedByRemote: Bool = false var actionToFulFill: CXCallAction? var callkitAudioSessionActivated: Bool? @@ -118,15 +120,15 @@ class TelecomManager: ObservableObject { } } - func doCallWithCore(addr: Address) { - CoreContext.shared.doOnCoreQueue { core in + func doCallWithCore(addr: Address) { + CoreContext.shared.doOnCoreQueue { core in do { try self.startCallCallKit(core: core, addr: addr, isSas: false, isVideo: false, isConference: false) } catch { Log.error("[TelecomManager] unable to create address for a new outgoing call : \(addr) \(error) ") } - } - } + } + } private func makeRecordFilePath() -> String{ var filePath = "recording_" @@ -140,25 +142,25 @@ class TelecomManager: ObservableObject { 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 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 @@ -176,13 +178,13 @@ class TelecomManager: ObservableObject { lcallParams.mediaEncryption = .ZRTP } if isConference { - /* 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 - }*/ + /* 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 = isVideo } @@ -202,7 +204,7 @@ class TelecomManager: ObservableObject { } DispatchQueue.main.async { - + self.outgoingCallStarted = true self.callStarted = true if self.callInProgress == false { withAnimation { @@ -220,12 +222,12 @@ class TelecomManager: ObservableObject { 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 - }*/ + 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 @@ -234,10 +236,10 @@ class TelecomManager: ObservableObject { // 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 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 @@ -249,9 +251,9 @@ class TelecomManager: ObservableObject { try call.acceptWithParams(params: callParams) - DispatchQueue.main.async { - self.callStarted = true - } + DispatchQueue.main.async { + self.callStarted = true + } } catch { Log.error("accept call failed \(error)") } @@ -334,17 +336,18 @@ class TelecomManager: ObservableObject { if cstate == .PushIncomingReceived { displayIncomingCall(call: call, handle: "Calling", hasVideo: false, callId: callId, displayName: "Calling") } else { - remoteVideo = (core.videoActivationPolicy?.automaticallyAccept ?? false) && (call.remoteParams?.videoEnabled ?? false) - if remoteVideo { - Log.info("[Call] Remote video is activated") - } - - isRemoteRecording = call.remoteParams?.isRecording ?? false - - if isRemoteRecording && ToastViewModel.shared.toastMessage.isEmpty { + DispatchQueue.main.async { + self.remoteVideo = (core.videoActivationPolicy?.automaticallyAccept ?? false) && (call.remoteParams?.videoEnabled ?? false) - DispatchQueue.main.async { + if self.remoteVideo { + Log.info("[Call] Remote video is activated") + } + + self.isRecordingByRemote = call.remoteParams?.isRecording ?? false + + if self.isRecordingByRemote && ToastViewModel.shared.toastMessage.isEmpty { + var displayName = "" let friend = ContactsManager.shared.getFriendWithAddress(address: call.remoteAddress!) if friend != nil && friend!.address != nil && friend!.address!.displayName != nil { @@ -358,22 +361,27 @@ class TelecomManager: ObservableObject { } ToastViewModel.shared.toastMessage = "\(displayName) is recording" - ToastViewModel.shared.displayToast = true + ToastViewModel.shared.displayToast = true + + Log.info("[Call] Call is recording by \(call.remoteAddress!.asStringUriOnly())") } - Log.info("[Call] Call is recording by \(call.remoteAddress!.asStringUriOnly())") - } - - if !isRemoteRecording && ToastViewModel.shared.toastMessage.contains("is recording") { - - DispatchQueue.main.async { + if !self.isRecordingByRemote && ToastViewModel.shared.toastMessage.contains("is recording") { + withAnimation { ToastViewModel.shared.toastMessage = "" ToastViewModel.shared.displayToast = false } + + Log.info("[Call] Recording is stopped by \(call.remoteAddress!.asStringUriOnly())") } - Log.info("[Call] Call is recording Stop recording by \(call.remoteAddress!.asStringUriOnly())") + switch call.state { + case Call.State.PausedByRemote: + self.isPausedByRemote = true + default: + self.isPausedByRemote = false + } } if call.userData == nil { @@ -381,24 +389,28 @@ class TelecomManager: ObservableObject { TelecomManager.setAppData(sCall: call, appData: appData) } /* - if let conference = call.conference, ConferenceViewModel.shared.conference.value == nil { - Log.info("[Call] Found conference attached to call and no conference in dedicated view model, init & configure it") - ConferenceViewModel.shared.initConference(conference) - ConferenceViewModel.shared.configureConference(conference) - } - */ + if let conference = call.conference, ConferenceViewModel.shared.conference.value == nil { + Log.info("[Call] Found conference attached to call and no conference in dedicated view model, init & configure it") + ConferenceViewModel.shared.initConference(conference) + ConferenceViewModel.shared.configureConference(conference) + } + */ switch cstate { case .IncomingReceived: let addr = call.remoteAddress let displayName = incomingDisplayName(call: call) - #if targetEnvironment(simulator) +#if targetEnvironment(simulator) DispatchQueue.main.async { - withAnimation { - TelecomManager.shared.callInProgress = true + self.outgoingCallStarted = false + self.callStarted = true + if self.callInProgress == false { + withAnimation { + self.callInProgress = true + } } } - #endif +#endif if call.replacedCall != nil { endCallKitReplacedCall = false @@ -415,17 +427,17 @@ class TelecomManager: ObservableObject { } else if TelecomManager.callKitEnabled(core: core) { /* let isConference = isConferenceCall(call: call) - let isEarlyConference = isConference && CallsViewModel.shared.currentCallData.value??.isConferenceCall.value != true // Conference info not be received yet. - if (isEarlyConference) { - CallsViewModel.shared.currentCallData.readCurrentAndObserve { _ in - let uuid = providerDelegate.uuids["\(callId)"] - if (uuid != nil) { - displayName = "\(VoipTexts.conference_incoming_title): \(CallsViewModel.shared.currentCallData.value??.remoteConferenceSubject.value ?? "") (\(CallsViewModel.shared.currentCallData.value??.conferenceParticipantsCountLabel.value ?? ""))" - providerDelegate.updateCall(uuid: uuid!, handle: addr!.asStringUriOnly(), hasVideo: video, displayName: displayName) - } - } - } - */ + let isEarlyConference = isConference && CallsViewModel.shared.currentCallData.value??.isConferenceCall.value != true // Conference info not be received yet. + if (isEarlyConference) { + CallsViewModel.shared.currentCallData.readCurrentAndObserve { _ in + let uuid = providerDelegate.uuids["\(callId)"] + if (uuid != nil) { + displayName = "\(VoipTexts.conference_incoming_title): \(CallsViewModel.shared.currentCallData.value??.remoteConferenceSubject.value ?? "") (\(CallsViewModel.shared.currentCallData.value??.conferenceParticipantsCountLabel.value ?? ""))" + providerDelegate.updateCall(uuid: uuid!, handle: addr!.asStringUriOnly(), hasVideo: video, displayName: displayName) + } + } + } + */ let uuid = providerDelegate.uuids["\(callId)"] if call.replacedCall == nil { TelecomManager.uuidReplacedCall = callId @@ -438,18 +450,23 @@ class TelecomManager: ObservableObject { displayIncomingCall(call: call, handle: addr!.asStringUriOnly(), hasVideo: remoteVideo, callId: callId, displayName: displayName) } } /* else if UIApplication.shared.applicationState != .active { - // not support callkit , use notif - let content = UNMutableNotificationContent() - content.title = NSLocalizedString("Incoming call", comment: "") - content.body = displayName - content.sound = UNNotificationSound.init(named: UNNotificationSoundName.init("notes_of_the_optimistic.caf")) - content.categoryIdentifier = "call_cat" - content.userInfo = ["CallId": callId] - let req = UNNotificationRequest.init(identifier: "call_request", content: content, trigger: nil) - UNUserNotificationCenter.current().add(req, withCompletionHandler: nil) - } */ + // not support callkit , use notif + let content = UNMutableNotificationContent() + content.title = NSLocalizedString("Incoming call", comment: "") + content.body = displayName + content.sound = UNNotificationSound.init(named: UNNotificationSoundName.init("notes_of_the_optimistic.caf")) + content.categoryIdentifier = "call_cat" + content.userInfo = ["CallId": callId] + let req = UNNotificationRequest.init(identifier: "call_request", content: content, trigger: nil) + UNUserNotificationCenter.current().add(req, withCompletionHandler: nil) + } */ 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!] @@ -463,10 +480,10 @@ class TelecomManager: ObservableObject { } /* - if speakerBeforePause { - speakerBeforePause = false - AudioRouteUtils.routeAudioToSpeaker(core: core) - } + if speakerBeforePause { + speakerBeforePause = false + AudioRouteUtils.routeAudioToSpeaker(core: core) + } */ actionToFulFill?.fulfill() @@ -491,12 +508,12 @@ class TelecomManager: ObservableObject { providerDelegate.reportOutgoingCallStartedConnecting(uuid: uuid!) } else { if false { /* isConferenceCall(call: call) { - let uuid = UUID() - let callInfo = CallInfo.newOutgoingCallInfo(addr: call.remoteAddress!, isSas: call.params?.mediaEncryption == .ZRTP, displayName: VoipTexts.conference_default_title, isVideo: call.params?.videoEnabled == true, isConference:true) - providerDelegate.callInfos.updateValue(callInfo, forKey: uuid) - providerDelegate.uuids.updateValue(uuid, forKey: "") - providerDelegate.reportOutgoingCallStartedConnecting(uuid: uuid) - Core.get().activateAudioSession(actived: true) */ + let uuid = UUID() + let callInfo = CallInfo.newOutgoingCallInfo(addr: call.remoteAddress!, isSas: call.params?.mediaEncryption == .ZRTP, displayName: VoipTexts.conference_default_title, isVideo: call.params?.videoEnabled == true, isConference:true) + providerDelegate.callInfos.updateValue(callInfo, forKey: uuid) + providerDelegate.uuids.updateValue(uuid, forKey: "") + providerDelegate.reportOutgoingCallStartedConnecting(uuid: uuid) + Core.get().activateAudioSession(actived: true) */ } else { referedToCall = callId } @@ -505,19 +522,6 @@ class TelecomManager: ObservableObject { case .End, .Error: - DispatchQueue.main.async { - withAnimation { - self.callInProgress = false - self.callStarted = false - } - } - var displayName = "Unknown" - if call.dir == .Incoming { - displayName = incomingDisplayName(call: call) - } else { // if let addr = call.remoteAddress, let contactName = FastAddressBook.displayName(for: addr.getCobject) { - displayName = "TODOContactName" - } - UIDevice.current.isProximityMonitoringEnabled = false if core.callsNb == 0 { core.outputAudioDevice = core.defaultOutputAudioDevice @@ -527,18 +531,34 @@ class TelecomManager: ObservableObject { // bluetoothEnabled = 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) + DispatchQueue.main.async { + withAnimation { + self.outgoingCallStarted = false + self.callInProgress = false + self.callStarted = false + } - // 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)") + var displayName = "Unknown" + if call.dir == .Incoming { + displayName = self.incomingDisplayName(call: call) + } else { // if let addr = call.remoteAddress, let contactName = FastAddressBook.displayName(for: addr.getCobject) { + displayName = "TODOContactName" + } + + + 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)") + } } } } @@ -583,22 +603,6 @@ class TelecomManager: ObservableObject { default: break } - - // AudioRouteUtils.isBluetoothAvailable(core: core) - // AudioRouteUtils.isHeadsetAudioRouteAvailable(core: core) - // AudioRouteUtils.isBluetoothAudioRouteAvailable(core: core) - - /* - let readyForRoutechange = callkitAudioSessionActivated == nil || (callkitAudioSessionActivated == true) - if readyForRoutechange && (cstate == .IncomingReceived || cstate == .OutgoingInit || cstate == .Connected || cstate == .StreamsRunning) { - if (call.currentParams?.videoEnabled ?? false) && AudioRouteUtils.isReceiverEnabled(core: core) && call.conference == nil { - AudioRouteUtils.routeAudioToSpeaker(core: core, call: call) - } else if AudioRouteUtils.isBluetoothAvailable(core: core) { - // Use bluetooth device by default if one is available - AudioRouteUtils.routeAudioToBluetooth(core: core, call: call) - } - } - */ } // post Notification kLinphoneCallUpdate NotificationCenter.default.post(name: Notification.Name("LinphoneCallUpdate"), object: self, userInfo: [ diff --git a/Linphone/UI/Call/CallView.swift b/Linphone/UI/Call/CallView.swift index dc681036d..9f1d99f5a 100644 --- a/Linphone/UI/Call/CallView.swift +++ b/Linphone/UI/Call/CallView.swift @@ -24,10 +24,10 @@ import AVFAudio import linphonesw struct CallView: View { - - @ObservedObject private var coreContext = CoreContext.shared - @ObservedObject private var telecomManager = TelecomManager.shared - @ObservedObject private var contactsManager = ContactsManager.shared + + @ObservedObject private var coreContext = CoreContext.shared + @ObservedObject private var telecomManager = TelecomManager.shared + @ObservedObject private var contactsManager = ContactsManager.shared @ObservedObject var callViewModel: CallViewModel @@ -35,8 +35,8 @@ struct CallView: View { @State private var orientation = UIDevice.current.orientation let pub = NotificationCenter.default.publisher(for: AVAudioSession.routeChangeNotification) - - @State var startDate = Date.now + + @State var startDate = Date.now @State var audioRouteSheet: Bool = false @State var hideButtonsSheet: Bool = false @State var options: Int = 1 @@ -45,10 +45,10 @@ struct CallView: View { @State var angleDegree = 0.0 @State var fullscreenVideo = false - - var body: some View { - GeometryReader { geo in - if #available(iOS 16.4, *) { + + var body: some View { + GeometryReader { geo in + if #available(iOS 16.4, *) { innerView(geometry: geo) .sheet(isPresented: .constant( @@ -60,54 +60,55 @@ struct CallView: View { ) ) { GeometryReader { _ in - VStack(spacing: 0) { - HStack(spacing: 12) { - Button { + VStack(spacing: 0) { + HStack(spacing: 12) { + Button { callViewModel.terminateCall() - } label: { - Image("phone-disconnect") - .renderingMode(.template) - .resizable() - .foregroundStyle(.white) - .frame(width: 32, height: 32) - - } - .frame(width: 90, height: 60) - .background(Color.redDanger500) - .cornerRadius(40) - - Spacer() - - Button { + } label: { + Image("phone-disconnect") + .renderingMode(.template) + .resizable() + .foregroundStyle(.white) + .frame(width: 32, height: 32) + + } + .frame(width: 90, height: 60) + .background(Color.redDanger500) + .cornerRadius(40) + + Spacer() + + Button { callViewModel.toggleVideo() - } label: { + } label: { Image(callViewModel.cameraDisplayed ? "video-camera" : "video-camera-slash") - .renderingMode(.template) - .resizable() - .foregroundStyle(.white) - .frame(width: 32, height: 32) - - } - .frame(width: 60, height: 60) - .background(Color.gray500) - .cornerRadius(40) - - Button { + .renderingMode(.template) + .resizable() + .foregroundStyle((callViewModel.isPaused || telecomManager.isPausedByRemote) ? Color.gray500 : .white) + .frame(width: 32, height: 32) + + } + .frame(width: 60, height: 60) + .background((callViewModel.isPaused || telecomManager.isPausedByRemote) ? Color.gray600 : Color.gray500) + .cornerRadius(40) + .disabled(callViewModel.isPaused || telecomManager.isPausedByRemote) + + Button { callViewModel.toggleMuteMicrophone() - } label: { + } label: { Image(callViewModel.micMutted ? "microphone-slash" : "microphone") - .renderingMode(.template) - .resizable() + .renderingMode(.template) + .resizable() .foregroundStyle(callViewModel.micMutted ? .black : .white) - .frame(width: 32, height: 32) - - } - .frame(width: 60, height: 60) + .frame(width: 32, height: 32) + + } + .frame(width: 60, height: 60) .background(callViewModel.micMutted ? .white : Color.gray500) - .cornerRadius(40) - - Button { - if AVAudioSession.sharedInstance().availableInputs != nil + .cornerRadius(40) + + Button { + if AVAudioSession.sharedInstance().availableInputs != nil && !AVAudioSession.sharedInstance().availableInputs!.filter({ $0.portType.rawValue.contains("Bluetooth") }).isEmpty { hideButtonsSheet = true @@ -123,200 +124,206 @@ struct CallView: View { } } - } label: { + } label: { Image(imageAudioRoute) - .renderingMode(.template) - .resizable() - .foregroundStyle(.white) - .frame(width: 32, height: 32) + .renderingMode(.template) + .resizable() + .foregroundStyle(.white) + .frame(width: 32, height: 32) .onAppear(perform: getAudioRouteImage) .onReceive(pub) { (output) in self.getAudioRouteImage() } - - } - .frame(width: 60, height: 60) - .background(Color.gray500) - .cornerRadius(40) - } - .frame(height: geo.size.height * 0.15) - .padding(.horizontal, 20) + + } + .frame(width: 60, height: 60) + .background(Color.gray500) + .cornerRadius(40) + } + .frame(height: geo.size.height * 0.15) + .padding(.horizontal, 20) .padding(.top, -6) - - HStack(spacing: 0) { - VStack { - Button { - } label: { - Image("screencast") - .renderingMode(.template) - .resizable() - .foregroundStyle(.white) - .frame(width: 32, height: 32) - } - .frame(width: 60, height: 60) - .background(Color.gray500) - .cornerRadius(40) - - Text("Screen share") - .foregroundStyle(.white) - .default_text_style(styleSize: 15) - } - .frame(width: geo.size.width * 0.25, height: geo.size.width * 0.25) - - VStack { - Button { - } label: { - Image("users") - .renderingMode(.template) - .resizable() - .foregroundStyle(.white) - .frame(width: 32, height: 32) - } - .frame(width: 60, height: 60) - .background(Color.gray500) - .cornerRadius(40) - - Text("Participants") - .foregroundStyle(.white) - .default_text_style(styleSize: 15) - } - .frame(width: geo.size.width * 0.25, height: geo.size.width * 0.25) - - VStack { - Button { - } label: { - Image("chat-teardrop-text") - .renderingMode(.template) - .resizable() - .foregroundStyle(.white) - .frame(width: 32, height: 32) - } - .frame(width: 60, height: 60) - .background(Color.gray500) - .cornerRadius(40) - - Text("Messages") - .foregroundStyle(.white) - .default_text_style(styleSize: 15) - } - .frame(width: geo.size.width * 0.25, height: geo.size.width * 0.25) - - VStack { - Button { - } label: { - Image("notebook") - .renderingMode(.template) - .resizable() - .foregroundStyle(.white) - .frame(width: 32, height: 32) - } - .frame(width: 60, height: 60) - .background(Color.gray500) - .cornerRadius(40) - - Text("Disposition") - .foregroundStyle(.white) - .default_text_style(styleSize: 15) - } - .frame(width: geo.size.width * 0.25, height: geo.size.width * 0.25) - } - .frame(height: geo.size.height * 0.15) - - HStack(spacing: 0) { - VStack { - Button { - } label: { - Image("phone-call") - .renderingMode(.template) - .resizable() - .foregroundStyle(.white) - .frame(width: 32, height: 32) - } - .frame(width: 60, height: 60) - .background(Color.gray500) - .cornerRadius(40) - - Text("Call list") - .foregroundStyle(.white) - .default_text_style(styleSize: 15) - } - .frame(width: geo.size.width * 0.25, height: geo.size.width * 0.25) - - VStack { - Button { + + HStack(spacing: 0) { + VStack { + Button { + } label: { + Image("phone-transfer") + .renderingMode(.template) + .resizable() + .foregroundStyle(.white) + .frame(width: 32, height: 32) + } + .frame(width: 60, height: 60) + .background(Color.gray500) + .cornerRadius(40) + + Text("Transfer") + .foregroundStyle(.white) + .default_text_style(styleSize: 15) + } + .frame(width: geo.size.width * 0.25, height: geo.size.width * 0.25) + + VStack { + Button { + } label: { + Image("phone-plus") + .renderingMode(.template) + .resizable() + .foregroundStyle(.white) + .frame(width: 32, height: 32) + } + .frame(width: 60, height: 60) + .background(Color.gray500) + .cornerRadius(40) + + Text("New call") + .foregroundStyle(.white) + .default_text_style(styleSize: 15) + } + .frame(width: geo.size.width * 0.25, height: geo.size.width * 0.25) + + VStack { + Button { + } label: { + Image("phone-list") + .renderingMode(.template) + .resizable() + .foregroundStyle(.white) + .frame(width: 32, height: 32) + } + .frame(width: 60, height: 60) + .background(Color.gray500) + .cornerRadius(40) + + Text("Call list") + .foregroundStyle(.white) + .default_text_style(styleSize: 15) + } + .frame(width: geo.size.width * 0.25, height: geo.size.width * 0.25) + + VStack { + Button { + } label: { + Image("dialer") + .renderingMode(.template) + .resizable() + .foregroundStyle(.white) + .frame(width: 32, height: 32) + } + .frame(width: 60, height: 60) + .background(Color.gray500) + .cornerRadius(40) + + Text("Dialer") + .foregroundStyle(.white) + .default_text_style(styleSize: 15) + } + .frame(width: geo.size.width * 0.25, height: geo.size.width * 0.25) + } + .frame(height: geo.size.height * 0.15) + + HStack(spacing: 0) { + VStack { + Button { + } label: { + Image("chat-teardrop-text") + .renderingMode(.template) + .resizable() + //.foregroundStyle((callViewModel.isPaused || telecomManager.isPausedByRemote) ? Color.gray500 : .white) + .foregroundStyle(Color.gray500) + .frame(width: 32, height: 32) + } + .frame(width: 60, height: 60) + //.background((callViewModel.isPaused || telecomManager.isPausedByRemote) ? Color.gray600 : Color.gray500) + .background(Color.gray600) + .cornerRadius(40) + //.disabled(callViewModel.isPaused || telecomManager.isPausedByRemote) + .disabled(true) + + Text("Messages") + .foregroundStyle(.white) + .default_text_style(styleSize: 15) + } + .frame(width: geo.size.width * 0.25, height: geo.size.width * 0.25) + + VStack { + Button { callViewModel.togglePause() - } label: { - Image("pause") - .renderingMode(.template) - .resizable() - .foregroundStyle(.white) - .frame(width: 32, height: 32) - } - .frame(width: 60, height: 60) - .background(Color.gray500) - .cornerRadius(40) - - Text("Pause") - .foregroundStyle(.white) - .default_text_style(styleSize: 15) - } - .frame(width: geo.size.width * 0.25, height: geo.size.width * 0.25) - - VStack { - Button { + } label: { + Image(callViewModel.isPaused ? "play" : "pause") + .renderingMode(.template) + .resizable() + .foregroundStyle(telecomManager.isPausedByRemote ? Color.gray500 : .white) + .frame(width: 32, height: 32) + } + .frame(width: 60, height: 60) + .background(telecomManager.isPausedByRemote ? Color.gray600 : (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.25, height: geo.size.width * 0.25) + + VStack { + Button { callViewModel.toggleRecording() - } label: { - Image("record-fill") - .renderingMode(.template) - .resizable() - .foregroundStyle(.white) - .frame(width: 32, height: 32) - } - .frame(width: 60, height: 60) - .background(callViewModel.isRecording ? Color.redDanger500 : Color.gray500) - .cornerRadius(40) - - Text("Record") - .foregroundStyle(.white) - .default_text_style(styleSize: 15) - } - .frame(width: geo.size.width * 0.25, height: geo.size.width * 0.25) - - VStack { - Button { - } label: { - Image("video-camera") - .renderingMode(.template) - .resizable() - .foregroundStyle(.white) - .frame(width: 32, height: 32) - } - .frame(width: 60, height: 60) - .background(Color.gray500) - .cornerRadius(40) - - Text("Disposition") - .foregroundStyle(.white) - .default_text_style(styleSize: 15) - } - .frame(width: geo.size.width * 0.25, height: geo.size.width * 0.25) - .hidden() - } - .frame(height: geo.size.height * 0.15) - - Spacer() - } - .frame(maxHeight: .infinity, alignment: .top) + } label: { + Image("record-fill") + .renderingMode(.template) + .resizable() + .foregroundStyle((callViewModel.isPaused || telecomManager.isPausedByRemote) ? Color.gray500 : .white) + .frame(width: 32, height: 32) + } + .frame(width: 60, height: 60) + .background((callViewModel.isPaused || telecomManager.isPausedByRemote) ? Color.gray600 : (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.25, height: geo.size.width * 0.25) + + VStack { + Button { + } label: { + Image("video-camera") + .renderingMode(.template) + .resizable() + .foregroundStyle(.white) + .frame(width: 32, height: 32) + } + .frame(width: 60, height: 60) + .background(Color.gray500) + .cornerRadius(40) + + Text("Disposition") + .foregroundStyle(.white) + .default_text_style(styleSize: 15) + } + .frame(width: geo.size.width * 0.25, height: geo.size.width * 0.25) + .hidden() + } + .frame(height: geo.size.height * 0.15) + + Spacer() + } + .frame(maxHeight: .infinity, alignment: .top) .presentationBackground(.black) - .presentationDetents([.fraction(0.1), .medium]) - .interactiveDismissDisabled() - .presentationBackgroundInteraction(.enabled) - } - } - .sheet(isPresented: $audioRouteSheet, onDismiss: { + .presentationDetents([.fraction(0.1), .fraction(0.45)]) + .interactiveDismissDisabled() + .presentationBackgroundInteraction(.enabled) + } + } + .sheet(isPresented: $audioRouteSheet, onDismiss: { audioRouteSheet = false hideButtonsSheet = false - }) { + }) { VStack(spacing: 0) { Button(action: { options = 1 @@ -346,9 +353,9 @@ struct CallView: View { Image(!callViewModel.isHeadPhoneAvailable() ? "ear" : "headset") .renderingMode(.template) - .resizable() + .resizable() .foregroundStyle(.white) - .frame(width: 25, height: 25, alignment: .leading) + .frame(width: 25, height: 25, alignment: .leading) } }) .frame(maxHeight: .infinity) @@ -416,16 +423,16 @@ struct CallView: View { } .padding(.horizontal, 20) .presentationBackground(Color.gray600) - .presentationDetents([.fraction(0.3)]) + .presentationDetents([.fraction(0.3)]) .frame(maxHeight: .infinity) } - } - } - } - - @ViewBuilder + } + } + } + + @ViewBuilder func innerView(geometry: GeometryProxy) -> some View { - VStack { + VStack { if !fullscreenVideo { Rectangle() .foregroundColor(Color.orangeMain500) @@ -451,6 +458,35 @@ struct CallView: View { .foregroundStyle(.white) } + if !telecomManager.outgoingCallStarted && telecomManager.callInProgress { + Text("|") + .foregroundStyle(.white) + + ZStack { + Text(callViewModel.timeElapsed.convertDurationToString()) + .onAppear { + callViewModel.timeElapsed = 0 + startDate = Date.now + } + .onReceive(callViewModel.timer) { firedDate in + callViewModel.timeElapsed = Int(firedDate.timeIntervalSince(startDate)) + + } + .foregroundStyle(.white) + .if(callViewModel.isPaused || telecomManager.isPausedByRemote) { view in + view.hidden() + } + + if callViewModel.isPaused { + Text("Paused") + .foregroundStyle(.white) + } else if telecomManager.isPausedByRemote { + Text("Paused by remote") + .foregroundStyle(.white) + } + } + } + Spacer() if callViewModel.cameraDisplayed { @@ -469,65 +505,65 @@ struct CallView: View { .frame(height: 40) .zIndex(1) } - - ZStack { - VStack { - Spacer() - - if callViewModel.remoteAddress != nil { - let addressFriend = contactsManager.getFriendWithAddress(address: callViewModel.remoteAddress!) - - let contactAvatarModel = addressFriend != nil - ? ContactsManager.shared.avatarListModel.first(where: { - ($0.friend!.consolidatedPresence == .Online || $0.friend!.consolidatedPresence == .Busy) - && $0.friend!.name == addressFriend!.name - && $0.friend!.address!.asStringUriOnly() == addressFriend!.address!.asStringUriOnly() - }) - : ContactAvatarModel(friend: nil, withPresence: false) - - if addressFriend != nil && addressFriend!.photo != nil && !addressFriend!.photo!.isEmpty { - if contactAvatarModel != nil { - Avatar(contactAvatarModel: contactAvatarModel!, avatarSize: 100, hidePresence: true) - } - } else { - if callViewModel.remoteAddress!.displayName != nil { - Image(uiImage: contactsManager.textToImage( - firstName: callViewModel.remoteAddress!.displayName!, - lastName: callViewModel.remoteAddress!.displayName!.components(separatedBy: " ").count > 1 - ? callViewModel.remoteAddress!.displayName!.components(separatedBy: " ")[1] - : "")) - .resizable() - .frame(width: 100, height: 100) - .clipShape(Circle()) - - } else { - Image(uiImage: contactsManager.textToImage( - firstName: callViewModel.remoteAddress!.username ?? "Username Error", - lastName: callViewModel.remoteAddress!.username!.components(separatedBy: " ").count > 1 - ? callViewModel.remoteAddress!.username!.components(separatedBy: " ")[1] - : "")) - .resizable() - .frame(width: 100, height: 100) - .clipShape(Circle()) - } - - } - } else { - Image("profil-picture-default") - .resizable() - .frame(width: 100, height: 100) - .clipShape(Circle()) - } - - Text(callViewModel.displayName) - .padding(.top) - .foregroundStyle(.white) - - Text(callViewModel.remoteAddressString) - .foregroundStyle(.white) - - Spacer() - } + + ZStack { + VStack { + Spacer() + + if callViewModel.remoteAddress != nil { + let addressFriend = contactsManager.getFriendWithAddress(address: callViewModel.remoteAddress!) + + let contactAvatarModel = addressFriend != nil + ? ContactsManager.shared.avatarListModel.first(where: { + ($0.friend!.consolidatedPresence == .Online || $0.friend!.consolidatedPresence == .Busy) + && $0.friend!.name == addressFriend!.name + && $0.friend!.address!.asStringUriOnly() == addressFriend!.address!.asStringUriOnly() + }) + : ContactAvatarModel(friend: nil, withPresence: false) + + if addressFriend != nil && addressFriend!.photo != nil && !addressFriend!.photo!.isEmpty { + if contactAvatarModel != nil { + Avatar(contactAvatarModel: contactAvatarModel!, avatarSize: 100, hidePresence: true) + } + } else { + if callViewModel.remoteAddress!.displayName != nil { + Image(uiImage: contactsManager.textToImage( + firstName: callViewModel.remoteAddress!.displayName!, + lastName: callViewModel.remoteAddress!.displayName!.components(separatedBy: " ").count > 1 + ? callViewModel.remoteAddress!.displayName!.components(separatedBy: " ")[1] + : "")) + .resizable() + .frame(width: 100, height: 100) + .clipShape(Circle()) + + } else { + Image(uiImage: contactsManager.textToImage( + firstName: callViewModel.remoteAddress!.username ?? "Username Error", + lastName: callViewModel.remoteAddress!.username!.components(separatedBy: " ").count > 1 + ? callViewModel.remoteAddress!.username!.components(separatedBy: " ")[1] + : "")) + .resizable() + .frame(width: 100, height: 100) + .clipShape(Circle()) + } + + } + } else { + Image("profil-picture-default") + .resizable() + .frame(width: 100, height: 100) + .clipShape(Circle()) + } + + Text(callViewModel.displayName) + .padding(.top) + .foregroundStyle(.white) + + Text(callViewModel.remoteAddressString) + .foregroundStyle(.white) + + Spacer() + } LinphoneVideoViewHolder { view in coreContext.doOnCoreQueue { core in @@ -535,7 +571,7 @@ struct CallView: View { } } .frame( - width: + width: angleDegree == 0 ? 120 * ((geometry.size.height + geometry.safeAreaInsets.top + geometry.safeAreaInsets.bottom) / 160) : 120 * ((geometry.size.width + geometry.safeAreaInsets.leading + geometry.safeAreaInsets.trailing) / 120), @@ -577,8 +613,8 @@ struct CallView: View { VStack { Image("record-fill") .renderingMode(.template) - .resizable() - .foregroundStyle(Color.redDanger500) + .resizable() + .foregroundStyle(Color.redDanger500) .frame(width: 32, height: 32) .padding(10) .if(fullscreenVideo) { view in @@ -594,31 +630,39 @@ struct CallView: View { ) } - if !telecomManager.callStarted && !fullscreenVideo { - VStack { - ActivityIndicator() - .frame(width: 20, height: 20) - .padding(.top, 100) - + if telecomManager.outgoingCallStarted { + VStack { + ActivityIndicator() + .frame(width: 20, height: 20) + .padding(.top, 100) + Text(callViewModel.counterToMinutes()) + .onAppear { + callViewModel.timeElapsed = 0 + startDate = Date.now + } .onReceive(callViewModel.timer) { firedDate in callViewModel.timeElapsed = Int(firedDate.timeIntervalSince(startDate)) - - } - .padding(.top) - .foregroundStyle(.white) - - Spacer() - } - .background(.clear) - } - } + + } + .padding(.top) + .foregroundStyle(.white) + + Spacer() + } + .frame( + maxWidth: fullscreenVideo ? geometry.size.width : geometry.size.width - 8, + maxHeight: fullscreenVideo ? geometry.size.height + geometry.safeAreaInsets.top + geometry.safeAreaInsets.bottom : geometry.size.height - 140 + ) + .background(.clear) + } + } .frame( maxWidth: fullscreenVideo ? geometry.size.width : geometry.size.width - 8, maxHeight: fullscreenVideo ? geometry.size.height + geometry.safeAreaInsets.top + geometry.safeAreaInsets.bottom : geometry.size.height - 140 ) - .background(Color.gray600) - .cornerRadius(20) + .background(Color.gray600) + .cornerRadius(20) .padding(.horizontal, fullscreenVideo ? 0 : 4) .onRotate { newOrientation in orientation = newOrientation @@ -647,7 +691,7 @@ struct CallView: View { callViewModel.orientationUpdate(orientation: orientation) } - + if !fullscreenVideo { if telecomManager.callStarted { if telecomManager.callStarted && idiom != .pad && !(orientation == .landscapeLeft || orientation == .landscapeRight @@ -684,13 +728,14 @@ struct CallView: View { Image("video-camera") .renderingMode(.template) .resizable() - .foregroundStyle(.white) + .foregroundStyle((callViewModel.isPaused || telecomManager.isPausedByRemote) ? Color.gray500 : .white) .frame(width: 32, height: 32) } .frame(width: 60, height: 60) - .background(Color.gray500) + .background((callViewModel.isPaused || telecomManager.isPausedByRemote) ? Color.gray600 : Color.gray500) .cornerRadius(40) + .disabled(callViewModel.isPaused || telecomManager.isPausedByRemote) Button { callViewModel.toggleMuteMicrophone() @@ -764,13 +809,13 @@ struct CallView: View { .padding(.top, 20) } } - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - .background(Color.gray900) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color.gray900) .if(fullscreenVideo) { view in view.ignoresSafeArea(.all) } - } + } func getAudioRouteImage() { imageAudioRoute = AVAudioSession.sharedInstance().currentRoute.outputs.filter({ $0.portType.rawValue == "Speaker" }).isEmpty diff --git a/Linphone/UI/Call/ViewModel/CallViewModel.swift b/Linphone/UI/Call/ViewModel/CallViewModel.swift index 382e41e5e..26f9dd2ed 100644 --- a/Linphone/UI/Call/ViewModel/CallViewModel.swift +++ b/Linphone/UI/Call/ViewModel/CallViewModel.swift @@ -35,7 +35,8 @@ class CallViewModel: ObservableObject { @Published var cameraDisplayed: Bool = false @Published var isRecording: Bool = false @Published var isRemoteRecording: Bool = false - @State var timeElapsed: Int = 0 + @Published var isPaused: Bool = false + @Published var timeElapsed: Int = 0 let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect() @@ -76,6 +77,8 @@ class CallViewModel: ObservableObject { self.micMutted = self.currentCall!.microphoneMuted self.cameraDisplayed = self.currentCall!.cameraEnabled == true self.isRecording = self.currentCall!.params!.isRecording + self.isPaused = self.isCallPaused() + self.timeElapsed = 0 } } } @@ -83,8 +86,9 @@ class CallViewModel: ObservableObject { func terminateCall() { withAnimation { - telecomManager.callInProgress = false + telecomManager.outgoingCallStarted = false telecomManager.callStarted = false + telecomManager.callInProgress = false } coreContext.doOnCoreQueue { _ in @@ -98,6 +102,7 @@ class CallViewModel: ObservableObject { func acceptCall() { withAnimation { + telecomManager.outgoingCallStarted = false telecomManager.callInProgress = true telecomManager.callStarted = true } @@ -184,9 +189,11 @@ class CallViewModel: ObservableObject { if self.isCallPaused() { Log.info("[CallViewModel] Resuming call \(self.currentCall!.remoteAddress!.asStringUriOnly())") try self.currentCall!.resume() + self.isPaused = false } else { Log.info("[CallViewModel] Pausing call \(self.currentCall!.remoteAddress!.asStringUriOnly())") try self.currentCall!.pause() + self.isPaused = true } } catch _ { @@ -195,7 +202,7 @@ class CallViewModel: ObservableObject { } } - private func isCallPaused() -> Bool { + func isCallPaused() -> Bool { var result = false if self.currentCall != nil { switch self.currentCall!.state { From e2a7ac7f569d7f24484962652687b7c42ac93b23 Mon Sep 17 00:00:00 2001 From: QuentinArguillere Date: Tue, 9 Jan 2024 16:50:40 +0100 Subject: [PATCH 076/486] Fix or disable several swiftlint warnings --- Linphone/UI/Call/CallView.swift | 4 +++- .../UI/Main/Contacts/Fragments/ContactsListFragment.swift | 5 ++++- Linphone/UI/Main/Contacts/Model/ContactAvatarModel.swift | 6 ++++-- Linphone/UI/Main/ContentView.swift | 2 ++ .../UI/Main/History/Fragments/HistoryContactFragment.swift | 4 ++++ .../UI/Main/History/Fragments/HistoryListFragment.swift | 4 ++++ 6 files changed, 21 insertions(+), 4 deletions(-) diff --git a/Linphone/UI/Call/CallView.swift b/Linphone/UI/Call/CallView.swift index 9f1d99f5a..4c0bec411 100644 --- a/Linphone/UI/Call/CallView.swift +++ b/Linphone/UI/Call/CallView.swift @@ -18,6 +18,7 @@ */ // swiftlint:disable type_body_length +// swiftlint:disable line_length import SwiftUI import CallKit import AVFAudio @@ -131,7 +132,7 @@ struct CallView: View { .foregroundStyle(.white) .frame(width: 32, height: 32) .onAppear(perform: getAudioRouteImage) - .onReceive(pub) { (output) in + .onReceive(pub) { _ in self.getAudioRouteImage() } @@ -836,3 +837,4 @@ struct CallView: View { CallView(callViewModel: CallViewModel()) } // swiftlint:enable type_body_length +// swiftlint:enable line_length diff --git a/Linphone/UI/Main/Contacts/Fragments/ContactsListFragment.swift b/Linphone/UI/Main/Contacts/Fragments/ContactsListFragment.swift index 41bfc686b..3f594f977 100644 --- a/Linphone/UI/Main/Contacts/Fragments/ContactsListFragment.swift +++ b/Linphone/UI/Main/Contacts/Fragments/ContactsListFragment.swift @@ -98,5 +98,8 @@ struct ContactsListFragment: View { } #Preview { - ContactsListFragment(contactViewModel: ContactViewModel(), contactsListViewModel: ContactsListViewModel(), showingSheet: .constant(false), startCallFunc: {_ in }) + ContactsListFragment(contactViewModel: ContactViewModel() + , contactsListViewModel: ContactsListViewModel() + , showingSheet: .constant(false) + , startCallFunc: {_ in }) } diff --git a/Linphone/UI/Main/Contacts/Model/ContactAvatarModel.swift b/Linphone/UI/Main/Contacts/Model/ContactAvatarModel.swift index fb469c160..efd2e1426 100644 --- a/Linphone/UI/Main/Contacts/Model/ContactAvatarModel.swift +++ b/Linphone/UI/Main/Contacts/Model/ContactAvatarModel.swift @@ -44,7 +44,8 @@ class ContactAvatarModel: ObservableObject { 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) + self.lastPresenceInfo = (friend!.consolidatedPresence == .Online) ? + "Online" : getCallTime(startDate: friend!.presenceModel!.latestActivityTimestamp) } else { self.lastPresenceInfo = "Away" } @@ -68,7 +69,8 @@ class ContactAvatarModel: ObservableObject { self.presenceStatus = cbValue.consolidatedPresence if cbValue.consolidatedPresence == .Online || cbValue.consolidatedPresence == .Busy { if cbValue.consolidatedPresence == .Online || cbValue.presenceModel!.latestActivityTimestamp != -1 { - self.lastPresenceInfo = cbValue.consolidatedPresence == .Online ? "Online" : self.getCallTime(startDate: cbValue.presenceModel!.latestActivityTimestamp) + self.lastPresenceInfo = cbValue.consolidatedPresence == .Online ? + "Online" : self.getCallTime(startDate: cbValue.presenceModel!.latestActivityTimestamp) } else { self.lastPresenceInfo = "Away" } diff --git a/Linphone/UI/Main/ContentView.swift b/Linphone/UI/Main/ContentView.swift index ee403524a..69886fde9 100644 --- a/Linphone/UI/Main/ContentView.swift +++ b/Linphone/UI/Main/ContentView.swift @@ -18,6 +18,7 @@ */ // swiftlint:disable type_body_length +// swiftlint:disable line_length import SwiftUI import linphonesw @@ -731,3 +732,4 @@ struct ContentView: View { ) } // swiftlint:enable type_body_length +// swiftlint:enable line_length diff --git a/Linphone/UI/Main/History/Fragments/HistoryContactFragment.swift b/Linphone/UI/Main/History/Fragments/HistoryContactFragment.swift index 7ebc04890..370009462 100644 --- a/Linphone/UI/Main/History/Fragments/HistoryContactFragment.swift +++ b/Linphone/UI/Main/History/Fragments/HistoryContactFragment.swift @@ -17,6 +17,8 @@ * along with this program. If not, see . */ +// swiftlint:disable line_length + import SwiftUI import UniformTypeIdentifiers @@ -549,3 +551,5 @@ struct HistoryContactFragment: View { indexPage: .constant(1) ) } + +// swiftlint:enable line_length diff --git a/Linphone/UI/Main/History/Fragments/HistoryListFragment.swift b/Linphone/UI/Main/History/Fragments/HistoryListFragment.swift index ceafcb884..71af38981 100644 --- a/Linphone/UI/Main/History/Fragments/HistoryListFragment.swift +++ b/Linphone/UI/Main/History/Fragments/HistoryListFragment.swift @@ -17,6 +17,8 @@ * along with this program. If not, see . */ +// swiftlint:disable line_length + import SwiftUI import linphonesw @@ -217,3 +219,5 @@ struct HistoryListFragment: View { #Preview { HistoryListFragment(historyListViewModel: HistoryListViewModel(), historyViewModel: HistoryViewModel(), showingSheet: .constant(false)) } + +// swiftlint:enable line_length From 7abacc3caf492798e3eb245ae13559816315d57e Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Tue, 9 Jan 2024 17:29:51 +0100 Subject: [PATCH 077/486] Fixes --- .../AppIcon.appiconset/1024.png | Bin 0 -> 41298 bytes .../AppIcon.appiconset/Contents.json | 1 + Linphone/Core/CoreContext.swift | 45 ++- Linphone/Info.plist | 2 + Linphone/LinphoneApp.swift | 8 +- Linphone/TelecomManager/TelecomManager.swift | 4 +- .../Assistant/Fragments/LoginFragment.swift | 6 + .../Fragments/RegisterFragment.swift | 3 + .../ThirdPartySipAccountLoginFragment.swift | 10 +- .../ThirdPartySipAccountWarningFragment.swift | 3 + .../Viewmodel/AccountLoginViewModel.swift | 5 +- Linphone/UI/Call/CallView.swift | 153 +++++++- Linphone/UI/Main/Contacts/ContactsView.swift | 4 +- .../ContactInnerActionsFragment.swift | 4 +- .../Fragments/ContactInnerFragment.swift | 44 +-- .../Fragments/ContactsListBottomSheet.swift | 2 + Linphone/UI/Main/ContentView.swift | 9 +- .../History/Fragments/DialerBottomSheet.swift | 2 +- .../Fragments/HistoryContactFragment.swift | 364 +++++++++--------- .../Fragments/HistoryListFragment.swift | 6 +- .../History/Fragments/StartCallFragment.swift | 65 ++-- Linphone/UI/Main/History/HistoryView.swift | 4 +- Linphone/UI/Welcome/WelcomeView.swift | 2 + Linphone/Utils/PermissionManager.swift | 10 + 24 files changed, 474 insertions(+), 282 deletions(-) create mode 100644 Linphone/Assets.xcassets/AppIcon.appiconset/1024.png diff --git a/Linphone/Assets.xcassets/AppIcon.appiconset/1024.png b/Linphone/Assets.xcassets/AppIcon.appiconset/1024.png new file mode 100644 index 0000000000000000000000000000000000000000..7b3df457925f29eb1b3ce0ef1dc0b7380a49b381 GIT binary patch literal 41298 zcmeFaS5y?)7dP6pjS>uqBm>e0f|3zr$c+jpS#ppdSrAZikkC5nNEQSnx1fMTiAu&% zf+9%?l0gs}ksKxU-PM3I|8L#L@8RCH=4FPdt~zzj&il9bu6d-cs<4md2n`B_+IQvh zB@Gk`1Ak&rRJ-9{eyrU0;9nHZ8VVOsIZemLQ7A0x$|YGX596PGdkQTrV>qXnv*Lzh z-KdO*OE@PhsvZ{EGJfkHSgved%XXdK=<{jPA8HgGxN~m(qCbiPjiI8$ z;{FI3oyy#DhF|9OS~yuyE8;XkkNpI7)NAOHWm zS4hvpP1l@DnJtK`EA-P7@oF}hFe-CrUA(+LG(ckCoN;t~pFB{(Wc0AtXqd)c;U6Rd zcaRc;l8ets6yknG6qN}|JlFA5!-v(|gv~rxhLN?bDdzKr!$};L4PC)}lb*Yf)Tx*E z;*^ffR1o9l-12AA<`}8N`Qup;`;3Vd>u-%il^)J-mb0r!D;=cG3BL*PZ*OBeEYEEL z9HvfawEqXOk^o#6npEPa)Mdw0f$LFauKOA~wfrYePMcSrAdapWYy69`bdyi#=lXP> z*_dA-W7(#6*4NoIziF}g!1gZTimy%(GTR zNpwEU4gp!0@Hr{RLuA(LNI~!q{^<4b-sM_)-CJT?dF3lf3JU*uS;B5~lLXAIWJ*};d!jsWgq;j8y`Z5&DKa`s;pr)N zJIY8uV5@lbzU-`@XMoV=IG^UES6QBx**-r)&}8DBFk z@rw9b=8??$Qjy+5oNmg*%-YZvbw@y2KCn@s+L>6q7XD@_-B_`8JU6~5aiB^@J}HSA z-A^uj5qgEjC27@`XSH1^kEqM9u%^naGRGHv z5Q#_d->86X=adpGv`kk%lnDMo`b$}-v-q87YyKuaCQIci<|8Aq%xl?Zr9QTG=9x(H zSWtU|QPTz`BT;Cg@KTs-oY88qwc-&1#s|r!-J(Ih>MWKk-GWs+ARE%Omh=kjP#&G+kwuc4Ycy zSsO-tDPcJcXF8^&VP{l@tfMYKL4kX_J0syI_1=D0jlD814ofs~gybcuoPbgOsqnH= z#%tH`n-j~ywQ8!No0;zX4iVxF_i=0eoxIc-akqqsKrhBCWYyg~iACLFqSDeQkREK9 zFZ?>1S+2AgHND!cDY#yLztyvvx4q~6M@-a-7Vjae)|n6Et#3Xr5XC0vgQzTY2=-}# zWe#OUSICH2Mq6Ycb zC(9axM@1@*OIX+CO?_W7E|e3_$+z6wf9G6)*+b(E`1)EzUupva>DdL#yVRPPjZ zi=P5LO=mMO{E@}S*Vu)1k?JVfAgdpQ{m8NKUNAMoGwkwzp&i@V$KUNk<%vx*57{@d^4DS+RMl| zqVedlW0O68+@251(F+3}IN{t1-G#&o2V+BZQT#Ppm*c-~1xfp*aArD5$<+Xr3BGWW z)vg{fL3|CH&-u-6^GB{5{gX84Iadbx#7d6c4$^;^$Yn<1-cokEmz|95?9CoDvJFLC z_t`U8RQmJOA}VEU*lGhRs^&}i+Y_yo8)8kWyD4`&h)KqFc5{0*=}FydKl2EMe?e(* zUYeu3cq#l?+Fj)ND1OkQ3si0DrV9_3dNqZcQIvyd*GAWTgn`lk^6BK zfqwY0%px<|!f8(x;oLmUytp=2MAVe^aVP;xaZ!3MBh$guduAgsc1l%(&m;^|*_iBm z5)#zwx@=tGhZN6^d&y&M&6X)xZ7>5-xaH2Iz@fBJ`MN3HsG^WK#0?Pi5Qa8tii*+A z2>E~5_tJA?SBlTWc@Foy%v2tMg&9vlmo#^#Bq734>Jc2lwa4+&M9?WG^_TSIr@P^4 zTUx`UY6%R}#;Ag^rqSOo_0~h?C(Ews{LRXj5L6Aa9kj{qp7`lS&Iud=i%!@xaQK~C zQ*h43=`)uVy2;|M0qXZ6pd=9&gTA<&aQNbK;?>c!<9+>wwN27X8K3f&P0HL0Z+hHX zi2wCMNY_b%0`18}lwNFS@m*Obe%%O=eErM~k*ue8{ZXbgHV!A$bNr(zlvQrcie_J= zRSRv}aER?jKk65GGqb=_Njmz-E!Q5{z}X@+TyN8}q} z7H!wrvkL7lNh@Z1c_l@}50su#(C(~kd#1o2^sFZ|cca$rG!E;I&hCjnn0=Hr66~ll zVkKWtgA1H@62S*h?d|tb+$%#oHT|*Xr})wbmhy=aqV(!xr|7e78}HOiSnr9lX{09# zU-ZY>W4hfF4mH|MUX5$~c>!5QgqaT27T@~-vw3>Z5)~;yo$#>tP;gT0)e=hQ($Dou z^X7xGQqBey`P6jv;kddi!;~A}dGocx4aj%^ydz zuJzKXn0%EMOKN57XWNK&jJ`H-gab!dMJGJ$V-DuFSKkQ!Np|HT2Vnz$c5z_(d0S*0 zb%Lb5rDD%g6;9r{QkYes#;DWO5pFKycXpNU>uE!|mk&_*3p5^|Gs4W<87R=y5C+g6 zD~&2R1$%E#kh?n2W(%fYZs3KXE_3q_pts(G>TjYZ_UoTgZz3DUroo2e_p5f*(6M+9 z^$6bT>#wolcxP0oEW^`XEFtlZQ8lNqm93X?L-(g}NuZG&Sc0=OE}snORJfb7zU`0V zK6X_7sJ6Wsw)qZ5$ z&TMe>UfQiRL&U`z;+HCNf=An{40=>1}BYCKZ3o$9*sf2Syxx3vN9HPyM#-IvX7jY={ecE>P zTvtsOUQo9BH6Ca0|30TkQ|REXQ>ZFa9sMDfu) zo0XZ^qW%tFM4Mjig?QCm7JCYmX+DK0X1*Atl@e5|{rV9oW6qKH_0FQT)xm4J_CB;= zjInlzT(YQ`tSEMT)Hnb}E}WhYby~!s(F#J1+FfP~B}enGnK>1?vaVSOug@Q0ob+}&5_<{&w&2J1bc{Z@GfA8#+!`C4l}Tnl8y=%&MfTJNqt^}&NfUs zo_&57UlPfTRjDDwSu?C9KL^nYW+&JCaOz@ zy3uZ$6|AB7^=J3on?r{7*eyS}dx&WS4NS7oB3Kp-r`V5vIGnAjk1PifAK{OJwkxt2 zdE;2NU7bJm!yE#7RPU}mZ z&z7~WTPo3m2v?f{`JHjtd+0=T{h)e(%JYz2j-jc|4oe**iPg_{iB(pi>%s&LWEaW= z^NzbV2A)>685;Y6^Z_TZD6ZOLu2%){a#d^k3-PNZcU`rAx^*vCHboq*Ii5EKZ_LBZ zukMjw+dtOsq1yv76yYo4N1Aor#TZ%MeE*BZ)7C*XQ};aLuISQ(aGipo3hy3PX~ooJ zk&T-%8Vbnu8DOO?T!ns|M0D4Hw(#a1mqPnVO()w)2N~E0=ODHJw?-`m%7ZMPOQkGN z-A!dnTX&O}sf^I`h@of)ot&3@f0arYhB2IpD1#aR~M2c&{ zuw7LiD|jq*6x>GfApQO~njlj1w8FH$9ph`+gso8Y43$gsrmXVXnws*E>TAR(*~8u4 z^PN7ml&g-CSox%~6T{kP$uR&`n*zZ#?p%s%%BhKh5+hhT>>}K}|6%G#A*OsUHrsO| z3KRpQc!(%HTk~`Y-~7ZaA``hS9o!6Bhw>J(0U__Decv%^Yrr?9B{<}9t)SD=ja$16J!2Wx^clx;bz(Z^8m@8bHKVUc`a5t9dr@m*2>XGH1iv*2h2 zDOXwWuW9#6-7((V=XsI|VFQTAC4AKQL%AL|>u8VY2H3eeV0tBe#htp^RE`d#}G zWQ$tbJF=*~{OS+5lFtW+Uj4I8!SoD1FG>1{5A)h+%`?PCQ=l~&XTtqoek=(^;ZGu2 zoPeCv=~FGD)@v-@i~Sa>?Oa=jet?@7U%Rx{6s=``!wz%<`voLbIaJ{`_%*N$s+8p0 zxJ(E>(-X;_MXU`b^)$#~9>ZZ2-f5i0*FaE`cn|^;>U#)BK}|IYYfp5|x(9s9KWX|C=H)PJP(Brpn(0FO{Z<+4 zJ><{>e!7q8c9WDb=!nyP*3JVR8~B^xLG#6!wGZm$;HKpa->=w(UPbf-mOjhX;`#nw z>&h&@?kzbbkX@`H9O-7`MG*`Ka-@hWQN14g6aP z)xU-mnjVrh5r{5Jm^~LKKPKzGZ9R5E;~4OaiD<64mMtC*N^J+S(!q4FJPpVr*C#HK z@Z@Tu5^XY|1R6Junzy*ku@h!ga(H+B343m5qC;sJeqF_6Z~@q`0ANFbcJJHI5k&D7RR(0m7qDWqxu!6PrN=oOCpiYEYEvb6PF5uQ zL^s&JewKiEgA*W;_p!Ve35n=a&3$ycM31XC)BjWW;>zOkg|&chJ$yXlex%B zr+0Gj3~CRaEEsJ%1fS5%PJ$i#g@E zv7u#!Va^WzJ4r`Q|C3r_lKuL#SbUbe=(oueOj)XSM=;fA@SI40yBl2OG6L^j8Y2No zb#((uNa6@Nm&T#d(i{T#6LybrO+XwmHo_b=53j)bNH0sgABJID5mnI_{Ken)z!|PU ze8EvOauWUPAec;Fs~3njpyNu#y{qeZlyi!W&&s*yho`#uC;dxtPCY!|+R zSM5=UN>$W7#YhM(@Fr8*SIBQxq1NI){li(xS1?ozS3zFhzx?s2S_9zJ=AhXm>5qt3!n^C!cO1E8p}6HitomtS z%j49S5c-~g!-Q^ZyQCt-abgTqAuAJ*QfoBKK+@eEH?2lWbjMX98y8LvtU!v6pdsV_fhZ=FZ z8Ni`l3Q40wFaktv$h&WoT!hhPWn_UU?s8pax$rOr`UY6s&rb{Zs4q2_oHJ`Z+8nk; zC5#hxW|RAYv-%52Q0wDB^BoUq^5cadJ>6U+##A^R=6(~ob@cQ|e>#HB=cN-nK-&}C z-mS1Y9z&)LHtZ0^37qQg7s_Zc$c08M)IXk9KSSZTTkp)33G%-1KWHIj@P&{eGNbA- z>px!d3NQtX2+J9Xye@gULhpJ-=Puq^(`az zx*JOe>WWaHT^S(_j2sxn{NNN=Z(|hnXc$`Lu5KdBt$+0s$~%&SPsV#MV{gDVKM!}_ z`GIKP28Nh~PaSq0Ani+W@PnEPb7{5Q%m~(i@4FTJ0@N3;MOv@8|XgHN|rCu zGclhXoZzKbz`lkbuNu{4CRVxSviQtKwH;niBb#C;?v~qw_N@zDjn0Nue^b+?XM=PV z_q&}66NAX4zV|Z2CGvfWprlJjTg8X}3ZJ?y2oeK||2e)ahq|0yV4ipbsQta2dP)kfyk?QkZZcE!R!{($-~Oa>32MMAH>Q;*0J9H(^6e@2$glg$g6 z+TW>MSeFxQR?^y8z+srifc@Na4+V+>4B6_%#G66R?Q**?tISa_wiTikMqr56lP_kN zIc|aVg?2%Y`4hkFao@KYVu)6;`+1Dj+gJsWJI6zGs6W81@*JGO_}|;|jsfh08>j#g zwLZG^KY?`g0Ug*dXYfycQeIZM@$-C7w&+Y%u8E@>REAWp&>vOpi4A*GT_5=ReRYvH zdk(>e>`l~_@lE%p{;F-hY4B;(Mb01h0hk>Ap%+K^1HDU>mElzLHN*G^F56bAT!~=$ zWjNWsq00iqmky13=#TpwZM>WW_8o#KU^=yMR-vp5tDDJ|lXV9nh^Ik6qFEV|fhai+ z5vXv+8DG?g5aVG?KN#IV_G0K6ST;5jTK&E^QM=_>lD8F>gB!cPEH|up^f@iT8I38& zfDH|ct*%}_Wj^vj>}6553Hd%8_9Q@N#{n{{xr@SIqLicvBMufN&H1$?ihK)#@coHu+JzSeak$ZB)JaXVS4K>9 z_ZZt_(=h{%Eu#0IX{qdCY%L#kFa;~242t))x+pFcs%M6^D{5?43OtRF2nUYXz+iLD zwTIX{%^{AVIt^jlWt*=CD!SDZoBwQT4pYuh{RE%?f@S6nnJNm}ZZ&^&7 z=>1j~qAxgMP~>m(MR&068OPB3c7n(B=BsNj%*V*94_kKIh{xPR=T z9Hr!{$yL=rFayY{eoSy=1w1Yf7ORO(bATiJd`~1Mm6_j7u#4GxEtC(&-nz>Tr-|k) zo$uhp5#&Mtj>z^rz=>d1M}0*A=`TB?WQagH$h%)%BHaES(<)|1u@AxrEQAl$bVXtR zBknj}%5nxmRQJ+Jbt&M*;>~PjLI(Qc0Uz!MQ{p8V-g@ynYw7Kzzta-PLvx{Mw@WYfiwxs8DAoZit{o4nowG2T@mG>`%)>JJM%{)IJ|Q2+i3C zrtcVNy_2Nmi?lO?!6dT5MuY&zgxSXM#A#oo<5BoCAf&1OqfF>U29Q#z#mTKeoyy~k z|2Z7C3J9^@r1~9HQEH}a_tbiP?COpb zRU5g>n4jAQkO!ay1Vl-K#qKnzv{TarZ7j4B%RH?9VL^Q?ZtNLt%521!0-Y!?fS{3v zx4-D>@wjPr!gqu&v! zG@nYM{f{kl0n$$#g8JMb4Qq;J=)J_iz3~uuf;*h`XYrYO5TyH3a*KReIeco-(qAD) zW6!^qCQ^@RfNg2WJ0s3c;kX|Cxo}aD{O&o>W|-h`?3U9wm(dUK;GLx*pR_c(0w{44L2+b{NA z1+l#fFO}bulJ&o0%ODG4EB7?{c)(dukslU|kxGC)$T0da-wbc)X55xDI?#E~m0hhX zj}}c~DOp%*_rv7p|Ho2SOuH320;W>fcr}!~PN-ACT_s(LM#MK$w!AAS8RFq`k*T{W1cl=t)STQI3KnmyXS1x{eYvT$`2^C>qXc_SuG zh9UKXjeQnw3y=37*yI#-+DG<>_|u4ljC>VI*rBj6W~;K!`c%H4bX&8+nQR7reYP>S zo_@YhSPe@Hq~lSR-FJm%WzsL)ymJfwx~v0yMM8(e5II=buqa+pFeTy(%!!GbUvB87 zA+&ymSH>13t{2&y4+uTK`n&@FznCS z1q4X-ES^rP1}a;)A1p?9uyKNoWl|!(4x$TjNVb1q9DDv(KIr}e3#0i@Em9V#5I@SB z9{5+}07x=ncHZPslaJ$ja4xLC6Ia}8%Tn}kmDd@sW@Aw!RKj-X@Bt)f?GoVQ%n|M= z9N{>S!7IJzoIXV;iBrNWc>69TYLCPI!@oU`h%ElGbxtroZolFyXWs35F#UWj#b0V# z^QS8H5VRLUhuXnnC0$V7-%$t(?+(~q^-HWek_}EaPCg}6SMxrstJl;${W~^-c!tTw z*nPleVBAa+12s>`L@(Ynd;wiQ?zwW!FEdlgAsU(XP(&Xe!5_B|^jze9 zv34o?I7tmcFU~D1+HVM$NBn+t5t5g5^f-W3t}Mg?-f-$|Q_*jST!~vV?v$VZVN!F+ z9~TTX6S-TgQi}FnOPc!WN{%+6j>-dUd=z|57I1L1zv2~xht)!u-P4kS57?wb_iY;t zh>fTUKm8%pT`Uy=g$WTPH9&PSjU%G7>$fbwt@IaD7Dk_@|&Od3d|mZU`F81ZZ$h z-vgz|HmS8W!O3Y|gEh6Y+TxazS*kEQTl8%tG4xxRbA_ooqdmna+kDK(P>DK5tCkVAwS zG$;u)DcpUsjJTW&6^2MV#Na3fzARwK6w9+fxZu z+jx!W?(JPswdoRE8@gIc$8R_ACL341e@gUzy85vz0VfE&sCk;gC&aOnO^W1ZJmHT& z)ojeRYu>88T>j{`Gb&=X8E{n<&Mm5YE}El2)lxi6*_z)^5s}k+A4By7fMKq_PplXY zhV(k-Mh)K;lTrwRr-L`edepu~(2@lTa5`$2Po*NUI{jvf_s7Q9V$O&VIer%>Gr3#5 z`-50Wa`tSbq#p-JN=iIAp0_7Ax5Lvp{ubA^Q>`cI=s6o(vdHr7u zJ6%>8%qD?so6!!*r0xo{g&M<*{^)(%r2RlEoW0pj{5sM7VL%j~fS zXjER#`2+2VKwh_PZJVCsiYif78xKkD`^^{jxXY0kB`lrf1GeyfeP-g<=}{X4*SM_c zU&X6;4m`=cedhd5y({t{vCQ^0i+9Of=;-&!hol!G538Il_xi5A`iL^TPmb0Zt`R0P zndKXzmz(XQ+o>py==Pl#UoJcInuCTjOb%=}Q(AIrFLjWlx6j^BaoVk=^Xu929__y9DQ{6cMFw2 zj=k!u%38fatIt9TWBS=RC5V7d8Jz!vYjteuX(L)&jvUfvoNl>Yz0Tsh{`sz7LelZT zeve|ayK?IUOPGm$pw{#kbg9J5h(j7GAVxt^JraLS4yqu# znG>1bEhlLgtG}!349q!ooXa~wlCQ;k{CsPElXNQ-nLT+-8p)>}G=hegzJo}w2b7s( zo~uq%#?`JgC-IamN2!Dc7V}z1SA?2-TA_>QfV>qtZoJU09vmW3(Bjmyo*usOwTlrPBPOetk{cpCO1&o1O$T zXFBL{T5d>9)#7q<547ZUovroL=x;b$all_wj)3&he$+QOa~1D|_6O953C@<7-aFEB zynOgi8v}dqByfa6x`_RThCZgM2oeCffyMSdZIhSU0xxbLy#}q8)4xcxs8r=KdGs^_ z;V@u6GCZ|KwY-`8M2s&YeCqpB<}-VA&v_pJA;l~s7h*dByo1O=8!5VNE?gviiuhrm za9ngcuHa%&Md>ZY7qHs>`_;{+DZ(e7|voAus~))LZdq+IXF%;H5f z6m;qQ01*wcLv2cf*Ww}uqGH7v-p^lo3@}R6d_6FAwxoTuXgOfXh>!c1v?v{R#sMnn znzDqw_#sZdz_*kN(S;X=$ExTN)A?D7FHz5QB~Qt0=KMgs3eR^M#Ed)*!u9AjJ&M|Q zAO@PA&d3s`Gy2Sae(zCyb1K>Xp>vAm#y!Q-x*7oG3?WyvpyRq|`qNrXe&Bwbs@MJ0 zcDJCAirlz`MZWmh>KSw9x-!=}#iW;AeCbU_P+zY&BOdzwLTa^(=MpJ2-#R0W z7BqaXRzx<;b9-5{4`# zUU9do3+L%#0*MazPJPlRh zv^Zz2uZJTNO;YL07b6Ov%7fK0E;}zLX6=Kdk{! zHfWGh0x7}h$a$WC>U^|+RB-5{;%qf;#lTg)km~B}x=X`~Us9&SdBu>>5^RC^ff5P8 z&3L+BlXN28^Y1W0oGmW)fX!<;ixQ>(G~~z}ANcw;OuLCF-hDTUH;$Ff{+wElq15@D zBplexOYanq$^)P)-+b7vEtgIL(iG?=?Bl%S{^7tn*goiumDUQ&DZCm`J^1$*vz5() zez!mrfkC#DUauHT>j$g=CB72&@RfVH+|_!5iBo=JlvDXS`)h`)NP~OI^%wihpc^~z zYhof;2SDkdJKA7wmtS3fr>><>;Ha7@vGSZbvHx8E+49?wyeaekzkbw#m$!8S9=lt2$*5bwSMSev}j1$xg>St%37|Y-vkB!w-?^~0bIB$JvLSCUA4nC$x zSpF8%THaI%Qnz-kJzEc?PD{gdR$(#t&R7NlE-CWaWutc*Fw6?4eJzFkZgisEGepqs zAk$g{U-tt;NuATR$tINyyk8D*o4Olh+^=3a+AKd^^2IJMQVK{AM!f^dB|I`uM=WeA6!UFTw#LEl@BKVom_^;|;cP=nPAAKghNbN+#-#&25S zsqr+w3rdu_g<1jVN|NI3A6_|7RUmv$?m(KR9zlmi65~Z$pF0+hZt`*BuzQdsJfNeo zGLc+5Md5`(S8jaLkH%1q;06=ecw_E`LrE0`m0{+w{r)|m*!c*q(XXrp>E7SJd(Qr( zEL8Q!_5ZG)dpcg+0JFmN9NxXvsOZVWvNoR5>OT8P@-+dPNa-1gk|T*W>uo`o$~uwu zrF?!^cQZ%}3b=Qnq-V^$Ykt2nIZ+PN#e{^|FWKg?iX~VM| zEk1J%f|GuCgd8n1qrqxj0Td$qSXnAmG$Z`g_SAnVY*WNaQA{r-0s5xw4=5VoGzkeG z;XRiS@ethnqHt(jCl07}g#ePBGV#iLUMt@K5kDY1c6`<@5o!0^+eZp!G4Sv*{;17~ z!2X2}x6KM2d@ms254^SpQdtJ;DDe|G!XN^K&rsNf;+im$t4Rg;GswOUo9`A39U8Ek1wGBhw%w z+dyQs1e~V{slhs(Zt>o%72I5PciJlP$Np|kFfd$&Jndg34>&Eb)IHql-a0C0dxam4 zeu*&uYAqR{J|~TAAtWFlOn{ut8YxnX9fF{_lg0bjQoMHdHE1=%wygY`Om-gdl(UgL z0PMMpps0X4sUXsG=OnJ5Gu;>ci(#gTgp3KmVl&L(5|Lgh{4p8`oQ?K=k`Q}D;Sf|SEI4b#FaBag`Yf!|oFMQA=u%Qu1ql*8%KSM|@l0rPbmv7)LlQc`wew}T`$t(`j#g_qne{`N|#v1gKN+EFJU z;^RRWqKOAQCj@Yv4>uM+a^SFP=K7@MVSrQ9(#2{#y?XiFpI~^N>pe0aNJbnJwBt2H02S4lQbHb}xKUYs>9GZZY zsR4r`_h`msToHt|umU0wI=A)qBDO%8DoE1Ves8afI*095;BXDY4sd67K8+h_aC#eL zfXay8@4Caz%7qR-289lHlv8ar_9BE;R)I!%5m9$=GT1|q;lujhnwmGoXAmfiSgPY&)&M^WxVsC*&P;hfT zEuj6018DgZkk_hXNcQ(Kmf<2oa7`eca$_Sx<@%s%zo~)n7y|}CyJj(|J{H-~9p3s) zB=rfDsB{xLe7|gxRyX%r2Y=XU4_cY51Czz(3T4J zjd-Yo4cc7*6wrt*Hir6NJY2kWs+|u139bo=(o+K2Oh3Zj&wMFC)O``0L!003vsiT( z$O&6Hv!Dlar?UkZ{RdnelFvASj)4_^hZjJ~%HOmn{5-j#5Ib6o?G2 zgCqGkx&~gx;Fv0Y5{ET{Yg>pLB}9GmrKa11iJv8G-2P`7QyK5^cO=D7F_FU_O8H2?W)qLr1+aK% z|72Ai6AaaJgs0&AfTsd*@$VpwQy3xc7J!&iCejsz43qu_eZiT|!EYZ7OTrQ9+adNp z^vBS{9#2c&$v%V)^TYSgiw4YgCN=q;5(h0PfG0nN$X`dusSo57iaCmy1Ejj*X7!97 zEeD_}gKPdOkP}J#12j9&fpI=al=2%!#?S7LE1ZIZs}Y9GCoXk9K46_IKn4eY7xqEQ zej=IvIoinI{Z=skw9`}tQWk`SYwsb&J*zBpoX%pNm;H3EyDi0!-2;$^@?&?7}P%0$a zPc!1z5K2=3Rd+uiV9=DzHY+P$t*-BPQBG@k13xZb1Tm?NcZJR$S=a$3YgRGUP6&nI zG?w7xKVn>9xCQeATD#KIDl4d;gF(M+-UMY1>^@ldh;s*Y0>kWKh0Q#R-{kCFw=`Z5 zG;GVvqeWy`vOqoH2qHiPWw<%U-+>dnh7`POvF)|!ze1>gRwWSNfOq%n82*l9p;rSh z1dmvvOOb}WMFx0RAiOKjE6NlKZ8Ur>uRiYvSiQp2AmzAI zFTn{QfCMEzi@9+abh1qexXAChM*lBr9)U9mk7g;s?jUsLnz>aZkKF~=_U@IX2D+z* z0go?)MIq5b1sKl7@YnqR@5HD^o6GnLtn&4xRj-GDlL5A9yOqG$#lmtIswFoiC|HP~e*PvuUBW*f)Fg?P* zjx>RBL~=!~D{vNey#|0w2vGp}H$8g$OQFL_$s4-xZsChrn*yU25Ab+IcO!2zfCCkd zX2WwdNYw&;K-UiQ0}Ew2s0AV2;lI0#K&X|VYmK*PQjYKj?YqK1b<c0{@XeO}<+rJWDRxj2>cJ!**zGaLbe+hXjGA_Ui{jzAT)6*I%6 z>`J5GZL60^)t(2~FnA4-Gy<~mVAwcAp@cnb{0eNG|F5XU{plm&9puIKf?Nyz!N3mQ z2Kp(?WxTMfd_BeCN(O-_{9URY3g2}}fu;liPavrfcpOGTy2mK#kRhHzhqYj$l-(rS zC4~}=jN^)L#;}xcOaM4|>O0?eU*xIZ&DuZ-xFfF29GU$@VoaxKCz)+TqD*k1x(jAh z*N^GsS|Cqx=>h|C2y2MLxrpyqdX1F*Li-Ck8?P@af{E6`=j2KijqD-i+?zhTsMCqm z#&E!A6y=dr9mINMkWI&S>c}8L(fw>S z5Q}&SyZv-vqKubZEXRMRhMK$Q2Tkeix`PtZ^|S}spGO>SAW}jBIxpo0ejSYfW2ykt zL(&YV21v#r5vWG4dbNWAd1 z7cq{7nAMzK_E?=hj@8^=vFut`C8z)(qfjXWRp$_ZIEC>n5&>cI!EM!l(`rK=+Dl8d zOGxtA3r2(L0~_~`=Z+%BI|Rp5d1{Xs4>OpJt@4+VI{ZpW@Ms$&UYyqDr~8*Azc16S z8?MLiPXe)b;0S6sWSa@Wv1xzn%7?>(x8~n2@upb&MGWBe;4h^AY7MWzhXg>sb+Y~vMt9(~Nok!bUuMGEHYX`%9+L(A4BT16nM|FrQSWZeV9 z>8#+VU6Y4W>=dAEIr_{(g#KwhZu#eQ-3ou=qgfM4)`T@rUec2jePl@464>~WqYxn< z)f4U((dA5~zx~4B^I-K5*H4Tq z@DaXaF=?~%RlL=?_DD>onk*&?MjvPIgSQ@s0go7Byes!s7gv}*Fu_V8MF64JN+1jk zz&9BVWFOqeW+1i7mq`5pX9O^e5zBDZ9YLmzx%&VWRF-y_L4O>=!2mF947{CW=++9e zI@TEQ0G|CKUFfwCpRZ{3L*sHD6lXoz;mH~n(s$a{yoq=-ku=0a&|Ovq&(>Qn0>xKg z|AMjPxg(pN!B3aeGUW#33>gZp7-<_pF6M3Cnrj?@WDBMHjr0~bwwv|;CA~O8D%vO7 zj~!aw3uY@1sp)9)f$bQHJMN8GL!5K>ub)2B1=#qz%{gS<+_i z$~)4AiPGj&ysKS^xcXV-X}bDUSS!x?DkJh=3^>AczP$|wG*onLQwnBXQwnRYsUTtq;)}S|m`}*S8`$tU zNJg6eV(cL)WwrDkHJLGxaZ;Q zg6%(cG(c2g_emzw8gVdbvnO2UqTi;sK>xAvP?`PfRY)Qb+ir~{K8O~;_*(UQ`5c77 z2)PLGdmI1il_wuJ#MnG5^=kIRo>dxwseY&Y2NJThVoinn&q_FoGi)KUh1J^fnG_&@K% zVVB`7y`k#6{`d0#he-{+F1x4yI02m(1^Xp>!Xc#xz&t)P=N}%87?}Pnsv8dVAu%=D z{d_VZ&7g)=f@AWiT;zoTI3L&R!r7ANqKmi@AOksfm>e`ZljIgYZQeu>8U(3fQqnqv zzXm~ylh}DV>noK~3S(+R~Usvb-D}7h?3eBH9#; zcA6MGboU)M%$(4>sk$Xn7T2KL**Gv!Q`g8UvNH7J%FL5!B`}zLb*aQG>II`ByP%3Y zYeEgS>whn`mNf6v=hm0n7B74$4bKMsM@z<&sPqYso5t$!nCza+>~30~=|DZ0%1m}G z$nC7;k|0}gWSblS>Z`z@k4mN+pxMVS><*D<2puVR-8e;~b6|nCTj*II}xvz`f+c zcAN0e&XQ-_9fnj4`a9oZh|voCBAu_}!nQ$tp5qGXT?7~%a%WEz_x2e4#`7oTsrDMm zd`Nr?JEu8`e8LDyU^+=IqLsR_!jZJpPfml)eAG|_AA1mJg>OVumfeobPM-HS4BI&` z4RoJuVMU5RawneFr3}3@IXixw`OrVLW>XFH3k)id`DWM86`MHEx#t~jNWO`P1|WL( zXw?FT)|$_jjC5qH26w20@RAFbzYWXN!s@L__ci6!)f#5E<9Bq0eZ7XarXjW#X@Gd# z^N&@&E?Y(R^LL!&U0A)EpG>(zjMV_A{^JBPYUsW=CAd^!m{yqFS*r4=QDuw2xLS;& z&&j!j?`w%%=DW02pMm?ke+-hQ82%Q(>uTYw(_4r;y)?4AbeI`a{$ZjQ`RhP;(fi)npnR7d*5YNJuX z%H;w1s~U({EYj(%vhm9;-{$Uo{-lm%_cxIaJq<{~%r|Tp&Xj{~mxr@Nf$o5`_)_Ib z*@q$9os3kUlnK+}Qf4FL6V`6^JyT4mwkKb%V>Xo`;gXZZ;&It%|Je=$cX62d>EoA; z{{5UXRHz;Ynwx%DAMab|b1$0mfYzzkek#o_EHDNFu#blv-DRvm;)Bq%Z$+zV^YwMf z*K68sUE`g1EX@}t0K~h9Vy+1{21pK~=aE^ArmB?DF>xp<%Row4euU~YfT1qAFx`En zQO#lHYi+CNOty$jCw@oiVYGlJ{-`;BZ&_AD>c+Ixri?v&r{pOV9ms`b0TH}`@7}@2 zNW;-%Y*6GAEV9}OGU%Q{Lvr6h5OlI7y``S^v0L-)lpoG8a|9cvfo-ot z_4hhA=+wzKj4Grad)Kus{xz5fNu}pk`TGj^G~Im{pV_Ri*|oojI*G~P8$5;5_M?0% z^AaZoyrK{fy(4)C3PlTdrJd> z!`bpXDV+)gV>OC$L6y3B*ODV}(>VB$yL$C(@Rfd^)wzcOa0g9k_s z6m{Fc{9r|u3wHg~9=%kz+1c)>9=RnJ@V%r3lPc^3wskivpVw0!Zo;|lIIBfQ4s`^g zZ9nhOUkas0581uf{Pg-JLYQy%Y<#m>ZF7ie(|JPPDqa2SYGU94s4T`=cU!zuoKP5B zmpC^mdmF6{g$1n}Jywm%*vvFkKg-t?*O+YM>sx+W0^c9#9^7i}*d%AN85ABUVv~FU z96JqfPkI-%1uH5@tv`Q}x;1}Xx8S1sc2{)$HTd?BT{0=Z;@q@(IlfqmFC?`{>gCfw zOPgdNc=%&hH$fi;0$i@{z2<&7idVU9O8mz|!HHem*8LB+BR~8qd@QWwI?qP?@FH(% zknKJ;eNiZ0bLvXnc!28ye{DJN>X*}r7UvFm!KH@PP36AjBjqHY@U2CUJNe5# ze}=rXMWMF-9|4e*;qWi2HuPPR-WW`YJ9dqZ*mV67$A6`X^}z$GHe{qz;pU4BUKnOe zX3?Ab-g`ngC+}k6w5#XT>Sts?8#W@~0}UTlth+k-pKek-nqHXlGc&C_qmv`51nZ$*7Vn|{-o?ey2c;ptLRK4noKAfyqf}22 zS?y?y<&H*suCY}2JdQoT;#6qA+<2|~M!**5Jsk0m+4|^w0^OUXB4iv#eC3YtAf3eT z8$=8?A+(d`-R&rG!Y{buWlByi32U{nkx?>r)p<+9s*?bn_$mq?Imr+>eVoMqDMQ%c z0*D-SwC(ddv?Cj5{6!ZGipAJrh(~zfX(?c2@KQ!*mW`Tst=>(k_pi>dzrU{^DX#=3 zmuuWCIMM{4=eH@l5Pvn^alldU#s)Fo&)QcOurXw_XSoDs`clk^l?61Sp2M*ca%+jv zOgWDG4siv-hI!(I6|N)O;fL+y3v7glcAuXPw=u9AyRew0Po}iG4*mscHG(MZ*?1vj zY?gE(N97AU2>27582K|HxU15;XWUwAi`*V9c?JgV%GTU^=p@pQ6Nrzi)cN|Tdz2T% z)$Pnp(h*Me-Eh*w#WoNArrt}M$sktU8Tflqs{dAb*+s2&}-rGIxT*Gzf+K5OE4+PoN;FH$2|sdmFrLJr{!+(8I{jl&<$lQCe7ByiHZvwRNYd1 zX5Au57L)q2>KV;WdD08-(Tc_^i?I7Ef+c_9AUDNn7(YDZ;f_y6jP@Z{)(wkKt7U3> zP%}W71TJuMVt?=ud=qE(WfyDb+aiqCOkNU0Ik&xU%X~1Kh7{z z$GvVToZ4(H{vuXm5=2e5Ot{@pjUPA+LmAPs-wW9#s+Meo*WdlP5%@>Kzed|{z&Obr zF0bZ3p2nfW3Ei%}&Vsjwhg!u*O+n)7xk0bXG#w6bL`WAKzwVo><-EkFhy=lx%G_%z1?S#tdphKC zRCumeaRlu@v4xDWuwF1-MOz|j!fxHREqOJN)jvAo%M z&r&+krdWcQ4#b4KusY9!F9&xrS9xMX`-17pLtO>Z_cblEj@c7E%T;_wbYnDQJok}( zZadT>Jfh)a1!a+#nJGW^P6uKEXNFB5> zmq!${OHpiGuYF}?LX!Qox~9!7R`!f~1d3K+W0S;e4YWCFI-=Ei)hjagSMc?tGK)1f z0~->N@qI01(UZmxu^BUNL{A%5zD*UlI&4sOX|23wi)n38X;D95DMF=uf#5znCAgY$ zl;gIg82`BVk4Nxvny;Yw3SxVN$pFa{(kiG1XYpgLL9OAT9adrN~oIkV#pSrx+ zFkqGU95FzG7oc8}xo@iAD!n{U`4VDYMn%TdMJG&7Ozy#of~ObVos-H2;IZC?2lm4` z?gsNkXKi{E=wBaKHm;;4)EO~`vGsQlDOoG8oba}suKeDM2fqWR@G{5fA(SE@21u4H z9YilNx3+;g)I45sK)AVT@e#RX#m8~SM;Y|Vec&8eTj02}d^L_R%9JqR;)5EHs@4`=O&A+xBOyZG!BJ+49a&kQ( z!Ep|&x<@#F@6Q}orOp}vh;XhnD8+SXHiMy0lUWS@r@McY>+!SS4p!x3w)Mho(vnE0 zMbY6MtPB_UxByE@!RUUj3DsScbTM4n#b0GgDyQ3U{Sr}1@;ZM8&V`vD!u6F||{BH1jo^_Nlxqw*|8Iq(QU?bS=CQt_kybHdGVR z@v9Y}PP;Z$b_H>fi`pE?1vOC!(gP+_1T+UVG;dEXi~qBO?fN27_g>U)qfIam!uKAD z%5y^uv8v-GLeU%j^8V^tchs`)`-Y&4Izzh`zs&7lWb`zSuxu1Lq_WXnEsxMOls}PP zkCSZoIV7E2Hxdq!KXjn4pW*yaitTX>sQvJu?Pta3{sI-6V)!PCy1LpzY1W}rV~kkO zX|Md+P3ctnCn}N$^_)j$LJ%+!V2`a;UY2K=wYJOG^#?gAL}nW!Jr{obKe#feO?U3b zOgzkqPM_ZvRWxCU>9X1=X5<9#A6M*B)X2JYKcyi1N_T&z^ZbJijbiFZThe>`uqy8B z`{-K};6>C-U$_ZUXy(3);+Py*wvzHDziKqF#)A7^swWB8jS1hYx`#;mh#Gm~tMmtT zW|yL8)GtNGCr^;H_?^421{b&m?AU>H#^6oAy)Ce`#?xH3h4qDEvgEMB67D^#pPa3N z%GJzF7}l3A4Vmm~1uu#qvKmc1Q0lG z|M0if@Hmk{G_v5C&q*Je+Vpz%1vJkYgq{Kwn$%;d^P4jaChg4ur63It$!UEmh@!|g z_>Lq&zFheD0H-q}_*;6#AtikRaro}6cX1M@n}!x5C%Mf-?>`Okr7ONSk0)`!iLVGf zWW4`o8R~=~^-V_iuzOb9^gxHh5?P1rLE0kd^AusjWg!{})e5gJ1Q*`^I#N*gMCMLw zsl))IhxbYbim!QpuLU45De3~h{hWmF(2MNLMNN6tXUP2-n@l{H(*aXdily{-r7nA} zYS+gJiRY$~ezH`oYWYct`-lQg_}QJ3{$Rf=`D6CzTv=Ea&kC3{| zq{_$2q5|Y@i&*XizRn|SZYkI_(2A?%pWumfncxZHpS+ivQ!rx#Y!95)4%$$LC^SM* zu#jvq&VP@B7(kHqob7?!GA5QM6;yqdYFK}rT1|7_7oGs1!rD2Zx-Pm2oN}4up_+>v z%D>r}7zPRiihr3u#BPU&Y&Q$to|z1qU!xbTKc94+zX9C?*IEyQH)p4CGA~hW2uOWGd%B>zX|7k|>4pmHD zp6%xSJaI!w7jQi27577d{arLjt^H3fmL5t*byMqyY`k>r%HTj40hKLm6tbsM9lyMK z6eAlI9wQr_)F;V+7zDAb$i?wT!T>6J;*ERWX6@q@p!cLs)2(L!VLpHm;x5LDKYs<% z*PCjmQX`~gjH<}$(Sd>etEl!Ot<=DGad(*TXIbYGimTt-uB>7@%6Vo5`s=}*D5ggU zf$#z|6V2MZyysSO>-82tx|n*cT#N34vouT|6qj@cb~SrJtK%rJIcM#A({p)K%uk)R zC3s9&Qz>8PmOD>~7Q-Vtb95ch>2kfPOvWIUC_J%h#k;3ISVe8Y@@zrtygq`;s3E=~ zdJM=q^w#WQSHl-&NC5$i8}h?!gZYEd{aqK2i+QGldcZ5-x3U7w$LNnXlVvwHd+iLg zyi5D})wyGN?S^3rsAB+#GoWulVU^obm(II7_)?kLe8jFb6oi#nwlzP!57JJ}fc`gf z_n2qhizwFF_a^MpqPf+5nDy1k#&CWaxm}3)K4*S2#A{V5g z5z%Vv4Pv1JCG#b~bDxV(9wXVa6-)-&fZXjxui)hcd*vK}4U9_3)6UbrCw~+3|JwN~ z$D8+aZU~0T%caqyo)}%hZ zE(2S;rfxsZzfEuKpu|xA5o@mfpr?OJUHhG0YvPhVZgD#M;q|krEtUk2L_cy>2g|%% z_AXEXfLh#v8|E>9?Fr-|B(=9ZZ}I4@Q&?%-1CmcF*@^`x*qToehC1scXn9(+?4f4w zQ4`a+EI_mP*eV4s<(mq1RG_6~0W>hTvc(qRgkbLA$F^mEDjEE8oDf7G6W2TdY|HP0 z-UJzZKQwd;jK8dewxsmkbkPM@&*#gT$bS-aGHzMKIALEvulUG;gucvW>y+b)OVwxm9x;KSsRIV5^JdV*hf}d zd>^;752Q=k0c)_ox3R#$P7S_LAND%AODoZ`>lncaaj%$|sY2w`KT5ytzX<|1NDx{!LQJby!>I56-5}Zb)2t4-Ie@b>oxrGW@Q9o?u z4$&TxuFE$}rwK(g;BflD}eL845i-e&2}?yffPJ_wPL0`w*L`*Nk@j-g-MwuQ22cK zBKh?J)opEmi@5CkW#viqdv=PgB(e!#Z)}jot3b@x^BIpVgtNb;n>ZpZCBO2vl3ae( zO(#Re=R3MA%1_N^xeH@If~!!P4ydg%k9F0a1q1OV^E0X0*z z+t?uV4l|1$W7GMkX1B0z@YGW4$QssXyfEm3s?+tM*wt9K`0Ms|hXs)Gs%Ctn&LE@& zlY-KBiBIS%BJ;9qaqh=9%x`yoQhW%GqBNUG@wCcHH zS?UGUE-L2x(a2+V@i3K_-j+sU1wj^|RC0xH!tC~$FId7LSWA~kt{45F4YeMZ$jV-` z;U*;QIex;MP6HC;#)SQH&LWXE7DS02p!aVe2P}viYkd$#ONlW6ZmRUCa03?u@C^vk zY5oYIAEn7|G|e9}eQ|HY^rLcH+|JERlHsarG z0_4_{D4BQrokWcU;e*NHZ8f+13t#_pCv6W`u3TiBO^|Ybp}Ck1wJEo)hCcfqr$VFD z;41lEud#D|#)!P`WPjcH9v9q{t3oU1b*jFW@1&=sRC_J|N$TqR7HJ0;Ba|c8R4G00 zO5;xSWeIRNiD?g5;r`JPVkKvn#LF|of=U31LQ<&;xrUOj70x{qsGSfbUx%t2X*0+s zXt6J8GGP!jE=2Ev_yuZ=_Kz&j2(3F%1j=Mkke+SbYXr9!2yKn-qhMqvnSRRzyiKLs zpy9b70Y`Fy)1sL5E^Td<9MH{Y!bqE#@F?~#D-9P@T;5Pw7^+GCDI4$@ko1_@i{z2$ z48qMj&T*br1y>f7`H(j=huSGW5KeG#z`yZPSXoQF@auU9m~9&LJFeE<@X9^~s3`;r5w;){g%6y}*==+N|Jqh_hsH%$EAmL> zI`krZcSA`{6uGL+vD(=T$S)V5nEBzMtp{qEe=3M+x#yp)kVjz&WhmEKxiBam=E#M7 zWaE5%A0BzqN>1EJ13#}ro%f8l@g38PE}h!F7HEa&d?vHd&_X?Cik^DV3mWRRLt zwbp#p$Q4s=?oV;0~bv=xqrl73h z*LAmw^`{{`lbASs2OLMB2-eL!71#o>RIjkVDm1)&@*uGG0Qyk|W}iS;7RrRoP$onY zS!@+rjksc@v=&`BvyPrmgES)6h0FtrQ*+kd*t>^={ep#>LH!STBq1c4+2rbouDCov zV@XO4okt0ya8ZWsE~>e(k@v<~B(sJ1n6+Ti%S|b_+}3S{3`zSk65M-Y%EaykAk)nU z)BSK&Q+d6NmIY#-Az4@26JRp}grX`@oz29Cw8(5i+sDK(Q{bblTj`CR-p_cEC=b@7 zLwD{N!47pavdrA`~J5k>rZ2tyZXxEaO_i%&$I>JcyPH9qBe?*_u(0WYw_mPONknv4#mg8 z@bQr8ZoCr(nTY)oSs&zAZyF(!2p<*%RSwY~Ec*>bYr(hd#OS%8!^P#1=m4CYj*BJZ zQV>8lqE3xXinyvL8fV3EFcj#~Oobj^Ad!jVoJtF^VPwo8EsM`_!0{jl+V_J`Vd>Uo z8fD|fjIJ>6#=c(607xm6F{)rSklai3-ccCE(Uv&0lw5=7*K~!2h*0SqyFlbaqW2Q9 z9Toa1mtH33yI?`9?V2P^-++aZ4q@qc2TqD6D!x7p7KANxgg?U-#Rk*I<{^9v)|cA0 zYx}!xwkH2sUjsB0Q$=2OB*mO?1=jtC3^1AG05UJW5ybbsFLQJU+g}<~Sg+RXzYh^g zc!6x?_T#X;$K79`@SiqwX!%^4vkkHv5N2Y`AEeFzx$8n$VE(bD5Jn~-(2z0#SFZg} zNFSozBdQ{NWIE0buJ_bRL3n0y6p)DgjHU=DBx+2A;8rQF%nH&sj7UUM{=uw@4NQ#D z8yPZ-Y{<+gvOv#3?Db(W*h>$Ji2C%2Tf>@{A{*-*Mb{_E96xx9xC0O*_u3Zb0E8|K z!}oq~2BnxbmSCc-;xBv=Aqom~nR|-P{$dPXE6^o;0V_P<-gbpC*(&(k(XKn;zJ>VW zqB&ULMC3Ue&7;7pvcaZfAjL!Av~MSRIY4zWVui=}7Hk+~xaB({xiW8qJm!vog9y5q z^#TU0^g9K+37rQZrwf(L^K6_`iA*-@U?Rd+)bFx&=*g3?&fp}mHY%wY6fgJGUTig= z8*r?kuBQ!dBJP#+Nl_Ir%+41__kgSwbH#e+4Qqk_F73`MyvZuL=uQ)? z&P=^Mrw1{>(U5Q=v>uUNb^`5`TV(Au?&-+rKd*D}x+1@_a!&0HX=ChJ&f{VT*%)s6 zsTWu6cnTQ;0FLkVbPWR~2BNm@q|d)~gGseam&ZnwP&x>)Z;8> z`U~cgiN}ulWa!Oq+|<^)R}Z`l4@~lzgK35+G)QXN1RwN2Z5_HU*D7kX0V)^GOk^%J zJAy-L$SXJ*LK;J*Vl;MCUUa|v>xqw_{;s<^T@UX7?nXEm@BSx^zy)*%$#q)JaJikO z6k&7e@QMkOBZ;#9F>!VL(nH zLuM!zguZj-uXgyHL>?8C-+{UW}5v+o@;u1pwVvcr4IZ^5V6j|;k% z2M6r%Edy&7gH(yJ<3(Q?;Cm=-6<=IHl#ua~6{du-ObPqeO=fR^e)SPH$3ELv)2t?E z0AhgS>ypSZt0vbOFg}|u3)bHRG$;I9q^puI6$$YfFP@x?!}!;CT$3s;S$EPV7KDii zex0Ta-`oOxAKODfh8Znvc@mAN#O$)(xuI>R)nCl1>jx8PX0h8{256EIf>9zLOb3EN z`rcj%v;l$|LQWA6eER&>J4M3vIVCry*&ZTb_3o~CupKSdB{t??{u%Bzv#!+;%I_vN z{*?29mc}h)#@!s@N)M>q1Ro`N4Rq=ugD~Abr4S18Rm7!Y-kRIKl8q&8?EK?oDz#L<0?L!2?u*j{VcAH8Wu2nn!g z*Cty`fD~|yu$Ut3Fjrgb@e3H$eT3lmE?0Z3C?gNDo(u5F`?Af45Z<00BqKaswJ#6< zr`aqe3Jc6G^Zm{vfz{m(t9!D* zAeGVR##=^(Jq@T&pfOLbV`=trgKv%p-+XZ2lTU}xSxCaWKs#j^LVAMs|Jrr9#wPF{ zR4tIXp4tR119rZV9IQp@!X+pMUlx^LsMoraq*LSb#bpDCZiUy&L~Hiu7|Fva2|<<0bO9V_QSTEz&-K-k5tL8>E4gx+6d@Rh#~zjs{?L! z7bM|Ufa{(xE%s+edO=M17l=!T!jgqn_RJ^{4L86VY~d6J4rd{YKc5>npoxrK(M=5Z zzYiVALD=qc@B#ve-Di_ShDa!vG_@@U?igCkc0bdyK1aeV01Z$DXuyjo-bZ2xy#@+g zmyvquCy2m`9q&@t0v`D?A-_Jt>b28Lzv5t{NKk~&ldlt#&mSPQR`g2_JQ}`*&O{Ja zq^VTsq$zL0GhX-v`xl#_Rivn|Wuvh$CZbb+DLL_Hmt1xRw&~)+-6CtEb^ZOzPy^lr zBZ(9hJ_bKi48NOy=`OYZFL{Q+?LxWQR_u@0tXx&MA^U!;5b$d+{yH?l(u}mI$EYH8 zmI%KY6o*kECqzpTDPKh{&@V|5D4D#sUMOLGc$U!jE;;P$yGh%@D{xC1KPYfjeHpVd z()ztetcf?I;58WR4Cq0_YdAt*pw5#_jWonS9T>Je;U`MgGb$pcuIk``WazH*JR9_@ z4iBt$0;Y0dgWH{&N5wCwZ%zaM>^U6AlDY4|tftJMJ+d{bPmkXL;Z%V={yG@$UK9!H zOB%K=sRN`O=?gkgu=mSa=PA+6Fb#a8mtkrB@O`xyxMFT=CL<`y+S4!>SDE74c1u~P zWN7?Uig!m-%Id>H7oYT->EaCshL)VakKcLT_46{oLm_3j@F^EAg)rqmw}R1%nl#ol zWrTr7_V`xe`mn+I1;c}X{pwNe1=JV?RQi~5If3E~9?`|N3@F8a|7v;Cpx3SllC`FM z!-wXo9#f;|R~3Rm5RLcPVzRa4jDb^f<)>CK-$DkA>AwwqFPA`YKL9f9d-h&bN4j3~ zJddd#G1^d8Y0Lj9;<82Kstre{(mY1s%d_v>c_x--CKOib-(R602;f1L6-qSU@+IM@ zy+?uqnRfm2WJ#l1i+x~WV-P|KbIU%#Y4GoSXJ{=%jh&2(u=&*ecQo_Epm&4P58!Mz z=um>Y1JbyLtqUygYI+*4N45hHgHqZ-G)5GVCncvnN!N{!50eLyswVjIgxS9$umY;F&lr_8q;cV?WnHiO1oS2(^cGcSdR!(I zdeV*+gDsNd#;F!*b-hu!NWlhMlS zZ2$=voRQtTiJOh-+AFjy=i|R2C*6Kr4zU;;^ljW#ojKdM$=XW;j;Dhl2D8Q27zQb{ z`(hRh)IC4ySYXaDy}T zt)z~#ccaGE&T9r7T>*p7a((*(5RpRZzp*s%7{4;+sVEfWeLS)q3EMR?S|~$TEP*@F z!Yx3jE6NIqSd6~eMA2~Q-?N1l7e^X5yuu}HTjt>uS^}K=XRsfiRREwc1nZAank|lB zdTj|3ez8@LAzd=jC3vqYrogRhQi_x%!m({OoEzFi%$~=&s9ssMiPihDL%n(CICQ{` zkXB*j5n(u(!TZ$^2g)2HKy5Eh$f@N<8RUA%J!?%Ve(#p|J-)EAheIpaZ_d`6G`B5t zDp7NzQQv@2B`3Vc(HAF>dm)I8-%3B89t_+wMG--2O5Iq=^5^sxwPS!-kZIZg*E}50)R?qHtQr zsMhLycCx*BfCYk$$*}HQ9R__-mu*HJm|8l^pGWM5gP|tL3!0kvc#sBI?E-b(*!YA2 zPG}8EHWbi~kc+5-)mJnntfOgjG!TtXNbq_rkhLNf z$bcO>5T5JRpBK6bhYlL`vOG{aUaQ=fO<9%0Si4*JUo~Vn$iZXXB?u z8kop*!%oTUjS73OM;YMD%M9=&8Y3Zr^u{TgSoibFWTp~@f_OH zmT3(6E7voJUn-skdI+U+9By3QC30*rFn`=3J{rnCYFkSsZi|#2g2vkYj$O(NlaL)) zA=kE93W_mf5oi)9jWDqn$h?FwNECx~1=OZZu)ICwPoKBWosq@IU;;txP3@V*vDoYF zPbrBaa1+a!el}YX*99p{JwT_%1fSlp9g=w_!tstqNJY@sKmv*iS6E$)9@g}j-|3UF z4^<5e_96ucHMrI}ui^9(GwVn!pR7Hn2JCkc)r%_{pT zgbw#2Ne%CM!tgG{`=>li*gDn$(^-Oq|v0RS8tSO{Bkk`xh*4@qsO|R$$~a zEJBE)wGO8M{cFf~x=Yhh;%8IzMU7TH!t9+;xI$JPEd9j1B;2En_Q!PYj@QK5!L^+p zy9a;6%Wn;vK0s%Qf}eUDQwMcA{0&6D{=K$n1He34#;nRsd7Yp5p14aLr#bhG&17wV ztiA!iZZ?Cfwc~ZPr9rYs?}Q~0N~TZiDf7~wD;8;!uK1i1D;0%l{V z_74knBv{Q}wL*oIJ&-;QVuolY8&M=8HM$IvF;#r|nk6wJD(6OJ4TDO)=4$NFw+%iR zLt7bf?wM_TF4DUd)CWKd?59A*?87pzo}$_mXN!QgvgWgJ8^(WS7@YgVut-MmV%rO( zEC58rw zr5{tL>r&jO`7`{-)NOhy0JKszKvq`$yc=?`(_oY8`VtI+Zy0Q#4l%Q(F&s#Ub?2){ zlD!55(xCs@{|HAvFGF4APjyJn{-p3w5)l)k-_F&27pf^7#6NLpAZmwJp(RLbO+Tb~ z!fjt8FpVRTri`Vy54@-%-xpJDB2m0F$~HqZx=Z4PMIPI%;_0?ChVP~6Lv7Qa=!^B! zpNK?(i2&=EU=c==~(!@^rX0t+kU#A=b`NRy)FaG*n-WJ9>X1I`m_f zb7?gARf=s?6g5U>Rs1*W&=_GX{$_RpL{(#RaTqVHAD&?SgZWKQ70u0J)mCSbEjfYq zRJAhS^Ua3o%M^N)c>rxuB$yr@)}y|yN;yN=WxZ$c5J5CdQsbjw!-G63T3bV z1=-zmSD>HTt+*?0P|y@pa@sL@OcfseHX&3;*|D zk^2q?r5liO{^!@Au=uO0{J;P81GvF^%j`=3=jHyq^1l=EXO{oVj{X?NAH(?LF@A4| zKYsbY4wyej$-gJ=AH(=#7=I$Ue}mXR!RLSZhChb!$1wine!n+_=$~};-!%RI$)bSw p5*o|I#ME#?`-sW^KXy&mGSADFaqpM6gt0N5&^fK0bJXs}{{RdbEw2Co literal 0 HcmV?d00001 diff --git a/Linphone/Assets.xcassets/AppIcon.appiconset/Contents.json b/Linphone/Assets.xcassets/AppIcon.appiconset/Contents.json index 532cd729c..eab818e5a 100644 --- a/Linphone/Assets.xcassets/AppIcon.appiconset/Contents.json +++ b/Linphone/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -1,6 +1,7 @@ { "images" : [ { + "filename" : "1024.png", "idiom" : "universal", "platform" : "ios", "size" : "1024x1024" diff --git a/Linphone/Core/CoreContext.swift b/Linphone/Core/CoreContext.swift index a07c6b926..a7a0abb4a 100644 --- a/Linphone/Core/CoreContext.swift +++ b/Linphone/Core/CoreContext.swift @@ -38,7 +38,7 @@ final class CoreContext: ObservableObject { private var mCore: Core! private var mIterateSuscription: AnyCancellable? private var mCoreSuscriptions = Set() - + private init() { do { try initialiseCore() @@ -68,17 +68,17 @@ final class CoreContext: ObservableObject { Factory.Instance.logCollectionPath = configDir Factory.Instance.enableLogCollection(state: LogCollectionState.Enabled) - let url = NSURL(fileURLWithPath: configDir) - if let pathComponent = url.appendingPathComponent("linphonerc") { - let filePath = pathComponent.path - let fileManager = FileManager.default - if !fileManager.fileExists(atPath: filePath) { - let path = Bundle.main.path(forResource: "linphonerc-default", ofType: nil) - if path != nil { - try? FileManager.default.copyItem(at: NSURL(fileURLWithPath: path!) as URL, to: pathComponent) - } - } - } + let url = NSURL(fileURLWithPath: configDir) + if let pathComponent = url.appendingPathComponent("linphonerc") { + let filePath = pathComponent.path + let fileManager = FileManager.default + if !fileManager.fileExists(atPath: filePath) { + let path = Bundle.main.path(forResource: "linphonerc-default", ofType: nil) + if path != nil { + try? FileManager.default.copyItem(at: NSURL(fileURLWithPath: path!) as URL, to: pathComponent) + } + } + } let config = try? Factory.Instance.createConfigWithFactory( path: "\(configDir)/linphonerc", @@ -87,7 +87,7 @@ final class CoreContext: ObservableObject { if config != nil { self.mCore = try? Factory.Instance.createCoreWithConfig(config: config!, systemContext: nil) } - + self.mCore.autoIterateEnabled = false self.mCore.callkitEnabled = true self.mCore.pushNotificationEnabled = true @@ -113,11 +113,14 @@ final class CoreContext: ObservableObject { NSLog("New configuration state is \(cbVal.status) = \(cbVal.message)\n") if cbVal.status == Config.ConfiguringState.Successful { ToastViewModel.shared.toastMessage = "Successful" - ToastViewModel.shared.displayToast.toggle() - } else { - ToastViewModel.shared.toastMessage = "Failed" - ToastViewModel.shared.displayToast.toggle() - } + ToastViewModel.shared.displayToast = true + } + /* + else { + ToastViewModel.shared.toastMessage = "Failed" + ToastViewModel.shared.displayToast = true + } + */ }) self.mCoreSuscriptions.insert(self.mCore.publisher?.onAccountRegistrationStateChanged?.postOnMainQueue { (cbVal: (core: Core, account: Account, state: RegistrationState, message: String)) in @@ -135,7 +138,7 @@ final class CoreContext: ObservableObject { self.loggingInProgress = true } else { ToastViewModel.shared.toastMessage = "Registration failed" - ToastViewModel.shared.displayToast.toggle() + ToastViewModel.shared.displayToast = true self.loggingInProgress = false self.loggedIn = false } @@ -166,9 +169,9 @@ final class CoreContext: ObservableObject { cbValue.info, forPasteboardType: UTType.plainText.identifier ) - + ToastViewModel.shared.toastMessage = "Success_copied_into_clipboard" - ToastViewModel.shared.displayToast.toggle() + ToastViewModel.shared.displayToast = true } }) diff --git a/Linphone/Info.plist b/Linphone/Info.plist index 340c36eb2..33e2c5a4d 100644 --- a/Linphone/Info.plist +++ b/Linphone/Info.plist @@ -2,6 +2,8 @@ + UIUserInterfaceStyle + Light NSCameraUsageDescription Camera usage is required for video VOIP calls NSMicrophoneUsageDescription diff --git a/Linphone/LinphoneApp.swift b/Linphone/LinphoneApp.swift index 950f6722f..9596a76ce 100644 --- a/Linphone/LinphoneApp.swift +++ b/Linphone/LinphoneApp.swift @@ -38,8 +38,14 @@ struct LinphoneApp: App { if !sharedMainViewModel.welcomeViewDisplayed { WelcomeView() } else if coreContext.defaultAccount == nil || sharedMainViewModel.displayProfileMode { - AssistantView() + ZStack { + AssistantView() + + ToastView() + .zIndex(3) + } } else if coreContext.defaultAccount != nil + && coreContext.loggedIn && contactViewModel != nil && editContactViewModel != nil && historyViewModel != nil diff --git a/Linphone/TelecomManager/TelecomManager.swift b/Linphone/TelecomManager/TelecomManager.swift index 506ba21e4..aa93fd28a 100644 --- a/Linphone/TelecomManager/TelecomManager.swift +++ b/Linphone/TelecomManager/TelecomManager.swift @@ -120,10 +120,10 @@ class TelecomManager: ObservableObject { } } - func doCallWithCore(addr: Address) { + func doCallWithCore(addr: Address, isVideo: Bool) { CoreContext.shared.doOnCoreQueue { core in do { - try self.startCallCallKit(core: core, addr: addr, isSas: false, isVideo: false, isConference: false) + try self.startCallCallKit(core: core, addr: addr, isSas: false, isVideo: isVideo, isConference: false) } catch { Log.error("[TelecomManager] unable to create address for a new outgoing call : \(addr) \(error) ") } diff --git a/Linphone/UI/Assistant/Fragments/LoginFragment.swift b/Linphone/UI/Assistant/Fragments/LoginFragment.swift index 8ae3e46e1..51fbd099c 100644 --- a/Linphone/UI/Assistant/Fragments/LoginFragment.swift +++ b/Linphone/UI/Assistant/Fragments/LoginFragment.swift @@ -63,6 +63,8 @@ struct LoginFragment: View { TextField("username", text: $accountLoginViewModel.username) .default_text_style(styleSize: 15) + .disableAutocorrection(true) + .autocapitalization(.none) .disabled(coreContext.loggedIn) .frame(height: 25) .padding(.horizontal, 20) @@ -90,6 +92,8 @@ struct LoginFragment: View { } else { TextField("password", text: $accountLoginViewModel.passwd) .default_text_style(styleSize: 15) + .disableAutocorrection(true) + .autocapitalization(.none) .frame(height: 25) .focused($isPasswordFocused) } @@ -287,6 +291,8 @@ struct LoginFragment: View { .background(.black.opacity(0.65)) } } + .navigationTitle("") + .navigationBarHidden(true) } .navigationViewStyle(StackNavigationViewStyle()) } diff --git a/Linphone/UI/Assistant/Fragments/RegisterFragment.swift b/Linphone/UI/Assistant/Fragments/RegisterFragment.swift index 194bd5b5a..dbc339513 100644 --- a/Linphone/UI/Assistant/Fragments/RegisterFragment.swift +++ b/Linphone/UI/Assistant/Fragments/RegisterFragment.swift @@ -65,8 +65,11 @@ struct RegisterFragment: View { } } } + .navigationTitle("") + .navigationBarHidden(true) } .navigationViewStyle(StackNavigationViewStyle()) + .navigationTitle("") .navigationBarHidden(true) } } diff --git a/Linphone/UI/Assistant/Fragments/ThirdPartySipAccountLoginFragment.swift b/Linphone/UI/Assistant/Fragments/ThirdPartySipAccountLoginFragment.swift index 6bb7e3bd0..8a8d5aa0b 100644 --- a/Linphone/UI/Assistant/Fragments/ThirdPartySipAccountLoginFragment.swift +++ b/Linphone/UI/Assistant/Fragments/ThirdPartySipAccountLoginFragment.swift @@ -81,6 +81,8 @@ struct ThirdPartySipAccountLoginFragment: View { TextField("username", text: $accountLoginViewModel.username) .default_text_style(styleSize: 15) + .disableAutocorrection(true) + .autocapitalization(.none) .disabled(coreContext.loggedIn) .frame(height: 25) .padding(.horizontal, 20) @@ -108,6 +110,8 @@ struct ThirdPartySipAccountLoginFragment: View { } else { TextField("password", text: $accountLoginViewModel.passwd) .default_text_style(styleSize: 15) + .disableAutocorrection(true) + .autocapitalization(.none) .frame(height: 25) .focused($isPasswordFocused) } @@ -139,6 +143,8 @@ struct ThirdPartySipAccountLoginFragment: View { 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) @@ -158,6 +164,8 @@ struct ThirdPartySipAccountLoginFragment: View { TextField("Display Name", text: $accountLoginViewModel.displayName) .default_text_style(styleSize: 15) + .disableAutocorrection(true) + .autocapitalization(.none) .disabled(coreContext.loggedIn) .frame(height: 25) .padding(.horizontal, 20) @@ -204,8 +212,6 @@ struct ThirdPartySipAccountLoginFragment: View { Button(action: { self.accountLoginViewModel.login() - accountLoginViewModel.domain = "sip.linphone.org" - accountLoginViewModel.transportType = "TLS" }, label: { Text(coreContext.loggedIn ? "Log out" : "assistant_account_login") .default_text_style_white_600(styleSize: 20) diff --git a/Linphone/UI/Assistant/Fragments/ThirdPartySipAccountWarningFragment.swift b/Linphone/UI/Assistant/Fragments/ThirdPartySipAccountWarningFragment.swift index a3aa14ad5..f9f9fb1f8 100644 --- a/Linphone/UI/Assistant/Fragments/ThirdPartySipAccountWarningFragment.swift +++ b/Linphone/UI/Assistant/Fragments/ThirdPartySipAccountWarningFragment.swift @@ -171,8 +171,11 @@ struct ThirdPartySipAccountWarningFragment: View { .frame(minHeight: geometry.size.height) } } + .navigationTitle("") + .navigationBarHidden(true) } .navigationViewStyle(StackNavigationViewStyle()) + .navigationTitle("") .navigationBarHidden(true) } } diff --git a/Linphone/UI/Assistant/Viewmodel/AccountLoginViewModel.swift b/Linphone/UI/Assistant/Viewmodel/AccountLoginViewModel.swift index dd9149693..8cf56625f 100644 --- a/Linphone/UI/Assistant/Viewmodel/AccountLoginViewModel.swift +++ b/Linphone/UI/Assistant/Viewmodel/AccountLoginViewModel.swift @@ -45,7 +45,7 @@ class AccountLoginViewModel: ObservableObject { core.loadConfigFromXml(xmlUri: assistantLinphone) } } - + // Get the transport protocol to use. // TLS is strongly recommended // Only use UDP if you don't have the choice @@ -106,6 +106,9 @@ class AccountLoginViewModel: ObservableObject { self.coreContext.defaultAccount = account } + self.domain = "sip.linphone.org" + self.transportType = "TLS" + } catch { NSLog(error.localizedDescription) } } } diff --git a/Linphone/UI/Call/CallView.swift b/Linphone/UI/Call/CallView.swift index 4c0bec411..8d06e91b6 100644 --- a/Linphone/UI/Call/CallView.swift +++ b/Linphone/UI/Call/CallView.swift @@ -427,18 +427,29 @@ struct CallView: View { .presentationDetents([.fraction(0.3)]) .frame(maxHeight: .infinity) } + } else { + innerView(geometry: geo) } } } @ViewBuilder + // swiftlint:disable:next cyclomatic_complexity func innerView(geometry: GeometryProxy) -> some View { VStack { if !fullscreenVideo { - Rectangle() - .foregroundColor(Color.orangeMain500) - .edgesIgnoringSafeArea(.top) - .frame(height: 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 { if callViewModel.direction == .Outgoing { @@ -465,13 +476,8 @@ struct CallView: View { ZStack { Text(callViewModel.timeElapsed.convertDurationToString()) - .onAppear { - callViewModel.timeElapsed = 0 - startDate = Date.now - } .onReceive(callViewModel.timer) { firedDate in callViewModel.timeElapsed = Int(firedDate.timeIntervalSince(startDate)) - } .foregroundStyle(.white) .if(callViewModel.isPaused || telecomManager.isPausedByRemote) { view in @@ -646,6 +652,10 @@ struct CallView: View { callViewModel.timeElapsed = Int(firedDate.timeIntervalSince(startDate)) } + .onDisappear { + callViewModel.timeElapsed = 0 + startDate = Date.now + } .padding(.top) .foregroundStyle(.white) @@ -695,16 +705,101 @@ struct CallView: View { if !fullscreenVideo { if telecomManager.callStarted { - if telecomManager.callStarted && idiom != .pad && !(orientation == .landscapeLeft || orientation == .landscapeRight - || UIScreen.main.bounds.size.width > UIScreen.main.bounds.size.height) { - HStack(spacing: 12) { - HStack { - + if #available(iOS 16.0, *) { + if telecomManager.callStarted && idiom != .pad && !(orientation == .landscapeLeft || orientation == .landscapeRight + || UIScreen.main.bounds.size.width > UIScreen.main.bounds.size.height) { + HStack(spacing: 12) { + HStack { + + } + .frame(height: 60) } - .frame(height: 60) + .padding(.horizontal, 25) + .padding(.top, 20) + } else { + HStack(spacing: 12) { + Button { + callViewModel.terminateCall() + } label: { + Image("phone-disconnect") + .renderingMode(.template) + .resizable() + .foregroundStyle(.white) + .frame(width: 32, height: 32) + + } + .frame(width: 90, height: 60) + .background(Color.redDanger500) + .cornerRadius(40) + + Spacer() + + Button { + callViewModel.toggleVideo() + } label: { + Image(callViewModel.cameraDisplayed ? "video-camera" : "video-camera-slash") + .renderingMode(.template) + .resizable() + .foregroundStyle((callViewModel.isPaused || telecomManager.isPausedByRemote) ? Color.gray500 : .white) + .frame(width: 32, height: 32) + + } + .frame(width: 60, height: 60) + .background((callViewModel.isPaused || telecomManager.isPausedByRemote) ? Color.gray600 : Color.gray500) + .cornerRadius(40) + .disabled(callViewModel.isPaused || telecomManager.isPausedByRemote) + + Button { + callViewModel.toggleMuteMicrophone() + } label: { + Image(callViewModel.micMutted ? "microphone-slash" : "microphone") + .renderingMode(.template) + .resizable() + .foregroundStyle(callViewModel.micMutted ? .black : .white) + .frame(width: 32, height: 32) + + } + .frame(width: 60, height: 60) + .background(callViewModel.micMutted ? .white : Color.gray500) + .cornerRadius(40) + + Button { + if AVAudioSession.sharedInstance().availableInputs != nil + && !AVAudioSession.sharedInstance().availableInputs!.filter({ $0.portType.rawValue.contains("Bluetooth") }).isEmpty { + + hideButtonsSheet = true + + DispatchQueue.global().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: { + Image(imageAudioRoute) + .renderingMode(.template) + .resizable() + .foregroundStyle(.white) + .frame(width: 32, height: 32) + .onAppear(perform: getAudioRouteImage) + .onReceive(pub) { _ in + self.getAudioRouteImage() + } + + } + .frame(width: 60, height: 60) + .background(Color.gray500) + .cornerRadius(40) + } + .frame(height: geometry.size.height * 0.15) + .padding(.horizontal, 20) + .padding(.top, -6) } - .padding(.horizontal, 25) - .padding(.top, 20) } else { HStack(spacing: 12) { Button { @@ -726,7 +821,7 @@ struct CallView: View { Button { callViewModel.toggleVideo() } label: { - Image("video-camera") + Image(callViewModel.cameraDisplayed ? "video-camera" : "video-camera-slash") .renderingMode(.template) .resizable() .foregroundStyle((callViewModel.isPaused || telecomManager.isPausedByRemote) ? Color.gray500 : .white) @@ -753,12 +848,32 @@ struct CallView: View { .cornerRadius(40) Button { + if AVAudioSession.sharedInstance().availableInputs != nil + && !AVAudioSession.sharedInstance().availableInputs!.filter({ $0.portType.rawValue.contains("Bluetooth") }).isEmpty { + + hideButtonsSheet = true + + DispatchQueue.global().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: { - Image("speaker-high") + Image(imageAudioRoute) .renderingMode(.template) .resizable() .foregroundStyle(.white) .frame(width: 32, height: 32) + .onAppear(perform: getAudioRouteImage) + .onReceive(pub) { _ in + self.getAudioRouteImage() + } } .frame(width: 60, height: 60) diff --git a/Linphone/UI/Main/Contacts/ContactsView.swift b/Linphone/UI/Main/Contacts/ContactsView.swift index a8191f047..fea21d9c7 100644 --- a/Linphone/UI/Main/Contacts/ContactsView.swift +++ b/Linphone/UI/Main/Contacts/ContactsView.swift @@ -41,8 +41,10 @@ struct ContactsView: View { } } label: { Image("user-plus") + .renderingMode(.template) + .foregroundStyle(.white) .padding() - .background(.white) + .background(Color.orangeMain500) .clipShape(Circle()) .shadow(color: .black.opacity(0.2), radius: 4) diff --git a/Linphone/UI/Main/Contacts/Fragments/ContactInnerActionsFragment.swift b/Linphone/UI/Main/Contacts/Fragments/ContactInnerActionsFragment.swift index 0e256c39e..f15e7bd3a 100644 --- a/Linphone/UI/Main/Contacts/Fragments/ContactInnerActionsFragment.swift +++ b/Linphone/UI/Main/Contacts/Fragments/ContactInnerActionsFragment.swift @@ -90,7 +90,7 @@ struct ContactInnerActionsFragment: View { .onTapGesture { withAnimation { telecomManager.doCallWithCore( - addr: contactsManager.lastSearch[contactViewModel.indexDisplayedFriend!].friend!.addresses[index] + addr: contactsManager.lastSearch[contactViewModel.indexDisplayedFriend!].friend!.addresses[index], isVideo: false ) } } @@ -272,7 +272,9 @@ struct ContactInnerActionsFragment: View { Button { if contactsManager.lastSearch[contactViewModel.indexDisplayedFriend!].friend != nil { contactViewModel.objectWillChange.send() + contactsManager.lastSearch[contactViewModel.indexDisplayedFriend!].friend!.edit() contactsManager.lastSearch[contactViewModel.indexDisplayedFriend!].friend!.starred.toggle() + contactsManager.lastSearch[contactViewModel.indexDisplayedFriend!].friend!.done() } } label: { HStack { diff --git a/Linphone/UI/Main/Contacts/Fragments/ContactInnerFragment.swift b/Linphone/UI/Main/Contacts/Fragments/ContactInnerFragment.swift index a2627196a..72d4baece 100644 --- a/Linphone/UI/Main/Contacts/Fragments/ContactInnerFragment.swift +++ b/Linphone/UI/Main/Contacts/Fragments/ContactInnerFragment.swift @@ -86,19 +86,19 @@ struct ContactInnerFragment: View { contactViewModel: contactViewModel, isShowEditContactFragment: .constant(false), isShowDismissPopup: $isShowDismissPopup)) { - Image("pencil-simple") - .renderingMode(.template) - .resizable() - .foregroundStyle(Color.orangeMain500) - .frame(width: 25, height: 25, alignment: .leading) - .padding(.top, 2) - } - .simultaneousGesture( - TapGesture().onEnded { - editContactViewModel.selectedEditFriend = contactsManager.lastSearch[contactViewModel.indexDisplayedFriend!].friend - editContactViewModel.resetValues() + Image("pencil-simple") + .renderingMode(.template) + .resizable() + .foregroundStyle(Color.orangeMain500) + .frame(width: 25, height: 25, alignment: .leading) + .padding(.top, 2) } - ) + .simultaneousGesture( + TapGesture().onEnded { + editContactViewModel.selectedEditFriend = contactsManager.lastSearch[contactViewModel.indexDisplayedFriend!].friend + editContactViewModel.resetValues() + } + ) } } .frame(maxWidth: .infinity) @@ -132,10 +132,10 @@ struct ContactInnerFragment: View { .frame(maxWidth: .infinity) .padding(.top, 10) - Text(contactAvatarModel.lastPresenceInfo) + Text(contactAvatarModel.lastPresenceInfo) .foregroundStyle(contactAvatarModel.lastPresenceInfo == "Online" - ? Color.greenSuccess500 - : Color.orangeWarning600) + ? Color.greenSuccess500 + : Color.orangeWarning600) .multilineTextAlignment(.center) .default_text_style_300(styleSize: 12) .frame(maxWidth: .infinity) @@ -151,7 +151,7 @@ struct ContactInnerFragment: View { Spacer() Button(action: { - telecomManager.doCallWithCore(addr: contactsManager.lastSearch[contactViewModel.indexDisplayedFriend!].friend!.address!) + telecomManager.doCallWithCore(addr: contactsManager.lastSearch[contactViewModel.indexDisplayedFriend!].friend!.address!, isVideo: false) }, label: { VStack { HStack(alignment: .center) { @@ -180,7 +180,8 @@ struct ContactInnerFragment: View { Image("chat-teardrop-text") .renderingMode(.template) .resizable() - .foregroundStyle(Color.grayMain2c600) + //.foregroundStyle(Color.grayMain2c600) + .foregroundStyle(Color.grayMain2c300) .frame(width: 25, height: 25) .onTapGesture { withAnimation { @@ -200,7 +201,7 @@ struct ContactInnerFragment: View { Spacer() Button(action: { - + telecomManager.doCallWithCore(addr: contactsManager.lastSearch[contactViewModel.indexDisplayedFriend!].friend!.address!, isVideo: true) }, label: { VStack { HStack(alignment: .center) { @@ -209,11 +210,6 @@ struct ContactInnerFragment: View { .resizable() .foregroundStyle(Color.grayMain2c600) .frame(width: 25, height: 25) - .onTapGesture { - withAnimation { - - } - } } .padding(16) .background(Color.grayMain2c200) @@ -229,7 +225,7 @@ struct ContactInnerFragment: View { .padding(.top, 20) .frame(maxWidth: .infinity) .background(Color.gray100) - + ContactInnerActionsFragment( contactViewModel: contactViewModel, editContactViewModel: editContactViewModel, diff --git a/Linphone/UI/Main/Contacts/Fragments/ContactsListBottomSheet.swift b/Linphone/UI/Main/Contacts/Fragments/ContactsListBottomSheet.swift index bf6a9645d..644e6f16c 100644 --- a/Linphone/UI/Main/Contacts/Fragments/ContactsListBottomSheet.swift +++ b/Linphone/UI/Main/Contacts/Fragments/ContactsListBottomSheet.swift @@ -58,7 +58,9 @@ struct ContactsListBottomSheet: View { 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) diff --git a/Linphone/UI/Main/ContentView.swift b/Linphone/UI/Main/ContentView.swift index 69886fde9..267968710 100644 --- a/Linphone/UI/Main/ContentView.swift +++ b/Linphone/UI/Main/ContentView.swift @@ -151,6 +151,7 @@ struct ContentView: View { Menu { if index == 0 { Button { + contactViewModel.indexDisplayedFriend = nil isMenuOpen = false magicSearch.allContact = true MagicSearchSingleton.shared.searchForContacts( @@ -168,6 +169,7 @@ struct ContentView: View { } Button { + contactViewModel.indexDisplayedFriend = nil isMenuOpen = false magicSearch.allContact = false MagicSearchSingleton.shared.searchForContacts( @@ -282,9 +284,8 @@ struct ContentView: View { text = newValue } )) - .default_text_style_white_700(styleSize: 15) + .default_text_style_700(styleSize: 15) .padding(.all, 6) - .accentColor(.white) .focused($focusedField) .onAppear { self.focusedField = true @@ -671,10 +672,8 @@ struct ContentView: View { } } - // if sharedMainViewModel.displayToast { ToastView() .zIndex(3) - // } } } .overlay { @@ -698,12 +697,14 @@ struct ContentView: View { .onChange(of: scenePhase) { newPhase in if newPhase == .active { coreContext.onForeground() + /* if !isShowStartCallFragment { contactsManager.fetchContacts() DispatchQueue.global().asyncAfter(deadline: .now() + 0.5) { historyListViewModel.computeCallLogsList() } } + */ print("Active") } else if newPhase == .inactive { print("Inactive") diff --git a/Linphone/UI/Main/History/Fragments/DialerBottomSheet.swift b/Linphone/UI/Main/History/Fragments/DialerBottomSheet.swift index dc3915be6..f8d2b0ddd 100644 --- a/Linphone/UI/Main/History/Fragments/DialerBottomSheet.swift +++ b/Linphone/UI/Main/History/Fragments/DialerBottomSheet.swift @@ -275,7 +275,7 @@ struct DialerBottomSheet: View { if !startCallViewModel.searchField.isEmpty { do { let address = try Factory.Instance.createAddress(addr: String("sip:" + startCallViewModel.searchField + "@" + startCallViewModel.domain)) - telecomManager.doCallWithCore(addr: address) + telecomManager.doCallWithCore(addr: address, isVideo: false) } catch { } diff --git a/Linphone/UI/Main/History/Fragments/HistoryContactFragment.swift b/Linphone/UI/Main/History/Fragments/HistoryContactFragment.swift index 370009462..a2fd12066 100644 --- a/Linphone/UI/Main/History/Fragments/HistoryContactFragment.swift +++ b/Linphone/UI/Main/History/Fragments/HistoryContactFragment.swift @@ -32,15 +32,15 @@ struct HistoryContactFragment: View { @ObservedObject var contactAvatarModel: ContactAvatarModel @ObservedObject var historyViewModel: HistoryViewModel - @ObservedObject var historyListViewModel: HistoryListViewModel - @ObservedObject var contactViewModel: ContactViewModel - @ObservedObject var editContactViewModel: EditContactViewModel + @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 + @Binding var isShowEditContactFragment: Bool + @Binding var indexPage: Int var body: some View { NavigationView { @@ -72,25 +72,25 @@ struct HistoryContactFragment: View { Spacer() Menu { - let fromAddressFriend = historyViewModel.displayedCall != nil ? contactsManager.getFriendWithAddress(address: historyViewModel.displayedCall!.fromAddress!) : nil - let toAddressFriend = historyViewModel.displayedCall != nil ? contactsManager.getFriendWithAddress(address: historyViewModel.displayedCall!.toAddress!) : nil - let addressFriend = historyViewModel.displayedCall != nil ? (historyViewModel.displayedCall!.dir == .Incoming ? fromAddressFriend : toAddressFriend) : nil - + let fromAddressFriend = historyViewModel.displayedCall != nil ? contactsManager.getFriendWithAddress(address: historyViewModel.displayedCall!.fromAddress!) : nil + let toAddressFriend = historyViewModel.displayedCall != nil ? contactsManager.getFriendWithAddress(address: historyViewModel.displayedCall!.toAddress!) : nil + let addressFriend = historyViewModel.displayedCall != nil ? (historyViewModel.displayedCall!.dir == .Incoming ? fromAddressFriend : toAddressFriend) : nil + Button { isMenuOpen = false - - if contactsManager.getFriendWithAddress( - address: historyViewModel.displayedCall != nil && historyViewModel.displayedCall!.dir == .Outgoing - ? historyViewModel.displayedCall!.toAddress! - : historyViewModel.displayedCall!.fromAddress! - ) != nil { - let addressCall = historyViewModel.displayedCall != nil && historyViewModel.displayedCall!.dir == .Outgoing - ? historyViewModel.displayedCall!.toAddress! - : historyViewModel.displayedCall!.fromAddress! - - let friendIndex = contactsManager.lastSearch.firstIndex( - where: {$0.friend!.addresses.contains(where: {$0.asStringUriOnly() == addressCall.asStringUriOnly()})}) - if friendIndex != nil { + + if contactsManager.getFriendWithAddress( + address: historyViewModel.displayedCall != nil && historyViewModel.displayedCall!.dir == .Outgoing + ? historyViewModel.displayedCall!.toAddress! + : historyViewModel.displayedCall!.fromAddress! + ) != nil { + let addressCall = historyViewModel.displayedCall != nil && historyViewModel.displayedCall!.dir == .Outgoing + ? historyViewModel.displayedCall!.toAddress! + : historyViewModel.displayedCall!.fromAddress! + + let friendIndex = contactsManager.lastSearch.firstIndex( + where: {$0.friend!.addresses.contains(where: {$0.asStringUriOnly() == addressCall.asStringUriOnly()})}) + if friendIndex != nil { withAnimation { historyViewModel.displayedCall = nil @@ -98,28 +98,28 @@ struct HistoryContactFragment: View { contactViewModel.indexDisplayedFriend = friendIndex } - } - } else { - let addressCall = historyViewModel.displayedCall != nil && historyViewModel.displayedCall!.dir == .Outgoing - ? historyViewModel.displayedCall!.toAddress! - : historyViewModel.displayedCall!.fromAddress! - - withAnimation { + } + } else { + let addressCall = historyViewModel.displayedCall != nil && historyViewModel.displayedCall!.dir == .Outgoing + ? historyViewModel.displayedCall!.toAddress! + : historyViewModel.displayedCall!.fromAddress! + + withAnimation { historyViewModel.displayedCall = nil indexPage = 0 isShowEditContactFragment.toggle() - editContactViewModel.sipAddresses.removeAll() - editContactViewModel.sipAddresses.append(String(addressCall.asStringUriOnly().dropFirst(4))) + editContactViewModel.sipAddresses.removeAll() + editContactViewModel.sipAddresses.append(String(addressCall.asStringUriOnly().dropFirst(4))) editContactViewModel.sipAddresses.append("") - } - } - + } + } + } label: { HStack { - Text(addressFriend != nil ? "See contact" : "Add to contacts") + Text(addressFriend != nil ? "See contact" : "Add to contacts") Spacer() - Image(addressFriend != nil ? "user-circle" : "plus-circle") + Image(addressFriend != nil ? "user-circle" : "plus-circle") .resizable() .frame(width: 25, height: 25, alignment: .leading) } @@ -127,18 +127,18 @@ struct HistoryContactFragment: View { Button { isMenuOpen = false - - if historyViewModel.displayedCall != nil && historyViewModel.displayedCall!.dir == .Outgoing { - UIPasteboard.general.setValue( - historyViewModel.displayedCall!.toAddress!.asStringUriOnly().dropFirst(4), - forPasteboardType: UTType.plainText.identifier - ) - } else { - UIPasteboard.general.setValue( - historyViewModel.displayedCall!.fromAddress!.asStringUriOnly().dropFirst(4), - forPasteboardType: UTType.plainText.identifier - ) - } + + if historyViewModel.displayedCall != nil && historyViewModel.displayedCall!.dir == .Outgoing { + UIPasteboard.general.setValue( + historyViewModel.displayedCall!.toAddress!.asStringUriOnly().dropFirst(4), + forPasteboardType: UTType.plainText.identifier + ) + } else { + UIPasteboard.general.setValue( + historyViewModel.displayedCall!.fromAddress!.asStringUriOnly().dropFirst(4), + forPasteboardType: UTType.plainText.identifier + ) + } ToastViewModel.shared.toastMessage = "Success_copied_into_clipboard" ToastViewModel.shared.displayToast.toggle() @@ -194,8 +194,12 @@ struct HistoryContactFragment: View { ScrollView { VStack(spacing: 0) { VStack(spacing: 0) { + if #unavailable(iOS 16.0) { + Rectangle() + .foregroundColor(Color.gray100) + .frame(height: 7) + } VStack(spacing: 0) { - let fromAddressFriend = historyViewModel.displayedCall != nil ? contactsManager.getFriendWithAddress(address: historyViewModel.displayedCall!.fromAddress!) : nil let toAddressFriend = historyViewModel.displayedCall != nil ? contactsManager.getFriendWithAddress(address: historyViewModel.displayedCall!.toAddress!) : nil let addressFriend = historyViewModel.displayedCall != nil ? (historyViewModel.displayedCall!.dir == .Incoming ? fromAddressFriend : toAddressFriend) : nil @@ -223,13 +227,13 @@ struct HistoryContactFragment: View { .default_text_style(styleSize: 14) .frame(maxWidth: .infinity) .padding(.top, 10) - - Text(historyViewModel.displayedCall!.toAddress!.asStringUriOnly()) - .foregroundStyle(Color.grayMain2c700) - .multilineTextAlignment(.center) - .default_text_style(styleSize: 14) - .frame(maxWidth: .infinity) - .padding(.top, 5) + + Text(historyViewModel.displayedCall!.toAddress!.asStringUriOnly()) + .foregroundStyle(Color.grayMain2c700) + .multilineTextAlignment(.center) + .default_text_style(styleSize: 14) + .frame(maxWidth: .infinity) + .padding(.top, 5) Text("") .multilineTextAlignment(.center) @@ -252,13 +256,13 @@ struct HistoryContactFragment: View { .default_text_style(styleSize: 14) .frame(maxWidth: .infinity) .padding(.top, 10) - - Text(historyViewModel.displayedCall!.toAddress!.asStringUriOnly()) - .foregroundStyle(Color.grayMain2c700) - .multilineTextAlignment(.center) - .default_text_style(styleSize: 14) - .frame(maxWidth: .infinity) - .padding(.top, 5) + + Text(historyViewModel.displayedCall!.toAddress!.asStringUriOnly()) + .foregroundStyle(Color.grayMain2c700) + .multilineTextAlignment(.center) + .default_text_style(styleSize: 14) + .frame(maxWidth: .infinity) + .padding(.top, 5) Text("") .multilineTextAlignment(.center) @@ -284,14 +288,14 @@ struct HistoryContactFragment: View { .default_text_style(styleSize: 14) .frame(maxWidth: .infinity) .padding(.top, 10) - - Text(historyViewModel.displayedCall!.fromAddress!.asStringUriOnly()) - .foregroundStyle(Color.grayMain2c700) - .multilineTextAlignment(.center) - .default_text_style(styleSize: 14) - .frame(maxWidth: .infinity) - .padding(.top, 5) - + + Text(historyViewModel.displayedCall!.fromAddress!.asStringUriOnly()) + .foregroundStyle(Color.grayMain2c700) + .multilineTextAlignment(.center) + .default_text_style(styleSize: 14) + .frame(maxWidth: .infinity) + .padding(.top, 5) + Text("") .multilineTextAlignment(.center) .default_text_style_300(styleSize: 12) @@ -313,14 +317,14 @@ struct HistoryContactFragment: View { .default_text_style(styleSize: 14) .frame(maxWidth: .infinity) .padding(.top, 10) - - Text(historyViewModel.displayedCall!.fromAddress!.asStringUriOnly()) - .foregroundStyle(Color.grayMain2c700) - .multilineTextAlignment(.center) - .default_text_style(styleSize: 14) - .frame(maxWidth: .infinity) - .padding(.top, 5) - + + Text(historyViewModel.displayedCall!.fromAddress!.asStringUriOnly()) + .foregroundStyle(Color.grayMain2c700) + .multilineTextAlignment(.center) + .default_text_style(styleSize: 14) + .frame(maxWidth: .infinity) + .padding(.top, 5) + Text("") .multilineTextAlignment(.center) .default_text_style_300(styleSize: 12) @@ -338,22 +342,22 @@ struct HistoryContactFragment: View { .default_text_style(styleSize: 14) .frame(maxWidth: .infinity) .padding(.top, 10) - - if historyViewModel.displayedCall!.dir == .Outgoing && historyViewModel.displayedCall!.toAddress != nil { - Text(historyViewModel.displayedCall!.toAddress!.asStringUriOnly()) - .foregroundStyle(Color.grayMain2c700) - .multilineTextAlignment(.center) - .default_text_style(styleSize: 14) - .frame(maxWidth: .infinity) - .padding(.top, 5) - } else if historyViewModel.displayedCall!.fromAddress != nil { - Text(historyViewModel.displayedCall!.fromAddress!.asStringUriOnly()) - .foregroundStyle(Color.grayMain2c700) - .multilineTextAlignment(.center) - .default_text_style(styleSize: 14) - .frame(maxWidth: .infinity) - .padding(.top, 5) - } + + if historyViewModel.displayedCall!.dir == .Outgoing && historyViewModel.displayedCall!.toAddress != nil { + Text(historyViewModel.displayedCall!.toAddress!.asStringUriOnly()) + .foregroundStyle(Color.grayMain2c700) + .multilineTextAlignment(.center) + .default_text_style(styleSize: 14) + .frame(maxWidth: .infinity) + .padding(.top, 5) + } else if historyViewModel.displayedCall!.fromAddress != nil { + Text(historyViewModel.displayedCall!.fromAddress!.asStringUriOnly()) + .foregroundStyle(Color.grayMain2c700) + .multilineTextAlignment(.center) + .default_text_style(styleSize: 14) + .frame(maxWidth: .infinity) + .padding(.top, 5) + } Text(contactAvatarModel.lastPresenceInfo) .foregroundStyle(contactAvatarModel.lastPresenceInfo == "Online" @@ -369,21 +373,22 @@ struct HistoryContactFragment: View { .frame(minHeight: 150) .frame(maxWidth: .infinity) .padding(.top, 10) + .padding(.bottom, 2) .background(Color.gray100) HStack { Spacer() Button(action: { - if historyViewModel.displayedCall!.dir == .Outgoing && historyViewModel.displayedCall!.toAddress != nil { - telecomManager.doCallWithCore( - addr: historyViewModel.displayedCall!.toAddress! - ) - } else if historyViewModel.displayedCall!.dir == .Incoming && historyViewModel.displayedCall!.fromAddress != nil { - telecomManager.doCallWithCore( - addr: historyViewModel.displayedCall!.fromAddress! - ) - } + if historyViewModel.displayedCall!.dir == .Outgoing && historyViewModel.displayedCall!.toAddress != nil { + telecomManager.doCallWithCore( + addr: historyViewModel.displayedCall!.toAddress!, isVideo: false + ) + } else if historyViewModel.displayedCall!.dir == .Incoming && historyViewModel.displayedCall!.fromAddress != nil { + telecomManager.doCallWithCore( + addr: historyViewModel.displayedCall!.fromAddress!, isVideo: false + ) + } }, label: { VStack { HStack(alignment: .center) { @@ -399,6 +404,7 @@ struct HistoryContactFragment: View { Text("Appel") .default_text_style(styleSize: 14) + .frame(minWidth: 80) } }) @@ -412,7 +418,8 @@ struct HistoryContactFragment: View { Image("chat-teardrop-text") .renderingMode(.template) .resizable() - .foregroundStyle(Color.grayMain2c600) + //.foregroundStyle(Color.grayMain2c600) + .foregroundStyle(Color.grayMain2c300) .frame(width: 25, height: 25) .onTapGesture { withAnimation { @@ -426,13 +433,22 @@ struct HistoryContactFragment: View { Text("Message") .default_text_style(styleSize: 14) + .frame(minWidth: 80) } }) Spacer() Button(action: { - + if historyViewModel.displayedCall!.dir == .Outgoing && historyViewModel.displayedCall!.toAddress != nil { + telecomManager.doCallWithCore( + addr: historyViewModel.displayedCall!.toAddress!, isVideo: true + ) + } else if historyViewModel.displayedCall!.dir == .Incoming && historyViewModel.displayedCall!.fromAddress != nil { + telecomManager.doCallWithCore( + addr: historyViewModel.displayedCall!.fromAddress!, isVideo: true + ) + } }, label: { VStack { HStack(alignment: .center) { @@ -441,11 +457,6 @@ struct HistoryContactFragment: View { .resizable() .foregroundStyle(Color.grayMain2c600) .frame(width: 25, height: 25) - .onTapGesture { - withAnimation { - - } - } } .padding(16) .background(Color.grayMain2c200) @@ -453,71 +464,75 @@ struct HistoryContactFragment: View { Text("Video Call") .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!.dir == .Incoming ? historyViewModel.displayedCall!.fromAddress!.asStringUriOnly() - : historyViewModel.displayedCall!.toAddress!.asStringUriOnly()) : nil - - let callLogsFilter = historyListViewModel.callLogs.filter({ $0.dir == .Incoming - ? $0.fromAddress!.asStringUriOnly() == addressFriend - : $0.toAddress!.asStringUriOnly() == addressFriend }) - - ForEach(0.. Date: Mon, 8 Jan 2024 16:57:26 +0100 Subject: [PATCH 078/486] Record call --- Linphone/Core/CoreContext.swift | 1 - Linphone/LinphoneApp.swift | 8 +++- .../TelecomManager/ProviderDelegate.swift | 8 ++-- Linphone/TelecomManager/TelecomManager.swift | 47 ++++++++++++++++--- Linphone/UI/Call/CallView.swift | 24 +++++++++- .../UI/Call/ViewModel/CallViewModel.swift | 13 +++-- Linphone/UI/Main/ContentView.swift | 9 +++- Linphone/UI/Main/Fragments/ToastView.swift | 7 +++ 8 files changed, 97 insertions(+), 20 deletions(-) diff --git a/Linphone/Core/CoreContext.swift b/Linphone/Core/CoreContext.swift index 98bdb7127..736500641 100644 --- a/Linphone/Core/CoreContext.swift +++ b/Linphone/Core/CoreContext.swift @@ -104,7 +104,6 @@ final class CoreContext: ObservableObject { self.mCore.videoCaptureEnabled = true self.mCore.videoDisplayEnabled = true - self.mCore.recordAwareEnabled = true let videoActivationPolicy = self.mCore.videoActivationPolicy! videoActivationPolicy.automaticallyAccept = true diff --git a/Linphone/LinphoneApp.swift b/Linphone/LinphoneApp.swift index 61e22bdf3..950f6722f 100644 --- a/Linphone/LinphoneApp.swift +++ b/Linphone/LinphoneApp.swift @@ -30,6 +30,7 @@ struct LinphoneApp: App { @State private var historyViewModel: HistoryViewModel? @State private var historyListViewModel: HistoryListViewModel? @State private var startCallViewModel: StartCallViewModel? + @State private var callViewModel: CallViewModel? var body: some Scene { WindowGroup { @@ -43,13 +44,15 @@ struct LinphoneApp: App { && editContactViewModel != nil && historyViewModel != nil && historyListViewModel != nil - && startCallViewModel != nil { + && startCallViewModel != nil + && callViewModel != nil { ContentView( contactViewModel: contactViewModel!, editContactViewModel: editContactViewModel!, historyViewModel: historyViewModel!, historyListViewModel: historyListViewModel!, - startCallViewModel: startCallViewModel! + startCallViewModel: startCallViewModel!, + callViewModel: callViewModel! ) } else { SplashScreen() @@ -62,6 +65,7 @@ struct LinphoneApp: App { historyViewModel = HistoryViewModel() historyListViewModel = HistoryListViewModel() startCallViewModel = StartCallViewModel() + callViewModel = CallViewModel() } } } diff --git a/Linphone/TelecomManager/ProviderDelegate.swift b/Linphone/TelecomManager/ProviderDelegate.swift index c12cbed92..55ff1cf40 100644 --- a/Linphone/TelecomManager/ProviderDelegate.swift +++ b/Linphone/TelecomManager/ProviderDelegate.swift @@ -209,9 +209,11 @@ extension ProviderDelegate: CXProviderDelegate { let callInfo = callInfos[uuid] let callId = callInfo?.callId ?? "" - DispatchQueue.main.async { - withAnimation { - TelecomManager.shared.callInProgress = true + if TelecomManager.shared.callInProgress == false { + DispatchQueue.main.async { + withAnimation { + TelecomManager.shared.callInProgress = true + } } } CoreContext.shared.doOnCoreQueue { core in diff --git a/Linphone/TelecomManager/TelecomManager.swift b/Linphone/TelecomManager/TelecomManager.swift index 855e70305..a5270c0e3 100644 --- a/Linphone/TelecomManager/TelecomManager.swift +++ b/Linphone/TelecomManager/TelecomManager.swift @@ -42,6 +42,8 @@ class TelecomManager: ObservableObject { @Published var callInProgress: Bool = false @Published var callStarted: Bool = false + @Published var remoteVideo: Bool = false + @Published var isRemoteRecording: Bool = false var actionToFulFill: CXCallAction? var callkitAudioSessionActivated: Bool? @@ -125,6 +127,19 @@ class TelecomManager: ObservableObject { } } } + + private func makeRecordFilePath() -> String{ + var filePath = "recording_" + let now = Date() + let dateFormat = DateFormatter() + dateFormat.dateFormat = "E-d-MMM-yyyy-HH-mm-ss" + let date = dateFormat.string(from: now) + filePath = filePath.appending("\(date).mkv") + + 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) @@ -154,6 +169,9 @@ class TelecomManager: ObservableObject { // 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 } @@ -184,9 +202,12 @@ class TelecomManager: ObservableObject { } DispatchQueue.main.async { + self.callStarted = true - withAnimation { - self.callInProgress = true + if self.callInProgress == false { + withAnimation { + self.callInProgress = true + } } } } @@ -195,6 +216,8 @@ class TelecomManager: ObservableObject { 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) @@ -311,12 +334,22 @@ class TelecomManager: ObservableObject { if cstate == .PushIncomingReceived { displayIncomingCall(call: call, handle: "Calling", hasVideo: false, callId: callId, displayName: "Calling") } else { - let video = (core.videoActivationPolicy?.automaticallyAccept ?? false) && (call.remoteParams?.videoEnabled ?? false) + remoteVideo = (core.videoActivationPolicy?.automaticallyAccept ?? false) && (call.remoteParams?.videoEnabled ?? false) - if video { + if remoteVideo { Log.info("[Call] Remote video is activated") } + isRemoteRecording = call.remoteParams?.isRecording ?? false + + if isRemoteRecording && ToastViewModel.shared.toastMessage == "" { + + ToastViewModel.shared.toastMessage = "\(call.remoteAddress) is recording" + ToastViewModel.shared.displayToast.toggle() + + Log.info("[Call] Call is recording by \(call.remoteAddress)") + } + if call.userData == nil { let appData = CallAppData() TelecomManager.setAppData(sCall: call, appData: appData) @@ -351,7 +384,7 @@ class TelecomManager: ObservableObject { providerDelegate.callInfos.updateValue(callInfo!, forKey: uuid!) providerDelegate.uuids.removeValue(forKey: callId) providerDelegate.uuids.updateValue(uuid!, forKey: callInfo!.callId) - providerDelegate.updateCall(uuid: uuid!, handle: addr!.asStringUriOnly(), hasVideo: video, displayName: displayName) + providerDelegate.updateCall(uuid: uuid!, handle: addr!.asStringUriOnly(), hasVideo: remoteVideo, displayName: displayName) } } else if TelecomManager.callKitEnabled(core: core) { /* @@ -374,9 +407,9 @@ class TelecomManager: ObservableObject { if uuid != nil { // Tha app is now registered, updated the call already existed. - providerDelegate.updateCall(uuid: uuid!, handle: addr!.asStringUriOnly(), hasVideo: video, displayName: displayName) + providerDelegate.updateCall(uuid: uuid!, handle: addr!.asStringUriOnly(), hasVideo: remoteVideo, displayName: displayName) } else { - displayIncomingCall(call: call, handle: addr!.asStringUriOnly(), hasVideo: video, callId: callId, displayName: displayName) + displayIncomingCall(call: call, handle: addr!.asStringUriOnly(), hasVideo: remoteVideo, callId: callId, displayName: displayName) } } /* else if UIApplication.shared.applicationState != .active { // not support callkit , use notif diff --git a/Linphone/UI/Call/CallView.swift b/Linphone/UI/Call/CallView.swift index cd23af51e..dc681036d 100644 --- a/Linphone/UI/Call/CallView.swift +++ b/Linphone/UI/Call/CallView.swift @@ -273,7 +273,7 @@ struct CallView: View { .frame(width: 32, height: 32) } .frame(width: 60, height: 60) - .background(Color.gray500) + .background(callViewModel.isRecording ? Color.redDanger500 : Color.gray500) .cornerRadius(40) Text("Record") @@ -572,6 +572,28 @@ struct CallView: View { ) } + if callViewModel.isRecording { + HStack { + VStack { + Image("record-fill") + .renderingMode(.template) + .resizable() + .foregroundStyle(Color.redDanger500) + .frame(width: 32, height: 32) + .padding(10) + .if(fullscreenVideo) { view in + view.padding(.top, 30) + } + Spacer() + } + Spacer() + } + .frame( + maxWidth: fullscreenVideo ? geometry.size.width : geometry.size.width - 8, + maxHeight: fullscreenVideo ? geometry.size.height + geometry.safeAreaInsets.top + geometry.safeAreaInsets.bottom : geometry.size.height - 140 + ) + } + if !telecomManager.callStarted && !fullscreenVideo { VStack { ActivityIndicator() diff --git a/Linphone/UI/Call/ViewModel/CallViewModel.swift b/Linphone/UI/Call/ViewModel/CallViewModel.swift index 834b8874b..382e41e5e 100644 --- a/Linphone/UI/Call/ViewModel/CallViewModel.swift +++ b/Linphone/UI/Call/ViewModel/CallViewModel.swift @@ -33,6 +33,8 @@ class CallViewModel: ObservableObject { @Published var avatarModel: ContactAvatarModel? @Published var micMutted: Bool = false @Published var cameraDisplayed: Bool = false + @Published var isRecording: Bool = false + @Published var isRemoteRecording: Bool = false @State var timeElapsed: Int = 0 let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect() @@ -40,7 +42,6 @@ class CallViewModel: ObservableObject { var currentCall: Call? init() { - do { try AVAudioSession.sharedInstance().setCategory(.playAndRecord, mode: .voiceChat, options: .allowBluetooth) try AVAudioSession.sharedInstance().setActive(true) @@ -48,6 +49,10 @@ class CallViewModel: ObservableObject { } + resetCallView() + } + + func resetCallView() { coreContext.doOnCoreQueue { core in if core.currentCall != nil && core.currentCall!.remoteAddress != nil { self.currentCall = core.currentCall @@ -70,6 +75,7 @@ class CallViewModel: ObservableObject { //self.avatarModel = ??? self.micMutted = self.currentCall!.microphoneMuted self.cameraDisplayed = self.currentCall!.cameraEnabled == true + self.isRecording = self.currentCall!.params!.isRecording } } } @@ -164,10 +170,9 @@ class CallViewModel: ObservableObject { } else { Log.info("[CallViewModel] Starting call recording \(self.currentCall!.params!.isRecording)") self.currentCall!.startRecording() - Log.info("[CallViewModel] Starting call recording \(self.currentCall!.params!.isRecording)") } - //var recording = self.currentCall!.params!.isRecording - //isRecording.postValue(recording) + + self.isRecording = self.currentCall!.params!.isRecording } } } diff --git a/Linphone/UI/Main/ContentView.swift b/Linphone/UI/Main/ContentView.swift index 64f63dc5b..ee403524a 100644 --- a/Linphone/UI/Main/ContentView.swift +++ b/Linphone/UI/Main/ContentView.swift @@ -38,6 +38,7 @@ struct ContentView: View { @ObservedObject var historyViewModel: HistoryViewModel @ObservedObject var historyListViewModel: HistoryListViewModel @ObservedObject var startCallViewModel: StartCallViewModel + @ObservedObject var callViewModel: CallViewModel @State var index = 0 @State private var orientation = UIDevice.current.orientation @@ -661,9 +662,12 @@ struct ContentView: View { } if telecomManager.callInProgress { - CallView(callViewModel: CallViewModel()) + CallView(callViewModel: callViewModel) .zIndex(3) .transition(.scale.combined(with: .move(edge: .top))) + .onAppear { + callViewModel.resetCallView() + } } // if sharedMainViewModel.displayToast { @@ -722,7 +726,8 @@ struct ContentView: View { editContactViewModel: EditContactViewModel(), historyViewModel: HistoryViewModel(), historyListViewModel: HistoryListViewModel(), - startCallViewModel: StartCallViewModel() + startCallViewModel: StartCallViewModel(), + callViewModel: CallViewModel() ) } // swiftlint:enable type_body_length diff --git a/Linphone/UI/Main/Fragments/ToastView.swift b/Linphone/UI/Main/Fragments/ToastView.swift index 40d5e7e16..025479ae0 100644 --- a/Linphone/UI/Main/Fragments/ToastView.swift +++ b/Linphone/UI/Main/Fragments/ToastView.swift @@ -54,6 +54,13 @@ struct ToastView: View { .foregroundStyle(Color.greenSuccess500) .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!") From 8469e8583edab4aa7bf2fccefa1f5430a891782b Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Tue, 9 Jan 2024 12:21:07 +0100 Subject: [PATCH 079/486] Toast display when user records call --- Linphone/TelecomManager/TelecomManager.swift | 34 +++++++++++++++++--- Linphone/UI/Main/Fragments/ToastView.swift | 17 ++++++---- 2 files changed, 40 insertions(+), 11 deletions(-) diff --git a/Linphone/TelecomManager/TelecomManager.swift b/Linphone/TelecomManager/TelecomManager.swift index a5270c0e3..22372bc1a 100644 --- a/Linphone/TelecomManager/TelecomManager.swift +++ b/Linphone/TelecomManager/TelecomManager.swift @@ -342,12 +342,38 @@ class TelecomManager: ObservableObject { isRemoteRecording = call.remoteParams?.isRecording ?? false - if isRemoteRecording && ToastViewModel.shared.toastMessage == "" { + if isRemoteRecording && ToastViewModel.shared.toastMessage.isEmpty { - ToastViewModel.shared.toastMessage = "\(call.remoteAddress) is recording" - ToastViewModel.shared.displayToast.toggle() + DispatchQueue.main.async { + 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! + } + } + + ToastViewModel.shared.toastMessage = "\(displayName) is recording" + ToastViewModel.shared.displayToast = true + } - Log.info("[Call] Call is recording by \(call.remoteAddress)") + Log.info("[Call] Call is recording by \(call.remoteAddress!.asStringUriOnly())") + } + + if !isRemoteRecording && ToastViewModel.shared.toastMessage.contains("is recording") { + + DispatchQueue.main.async { + withAnimation { + ToastViewModel.shared.toastMessage = "" + ToastViewModel.shared.displayToast = false + } + } + + Log.info("[Call] Call is recording Stop recording by \(call.remoteAddress!.asStringUriOnly())") } if call.userData == nil { diff --git a/Linphone/UI/Main/Fragments/ToastView.swift b/Linphone/UI/Main/Fragments/ToastView.swift index 025479ae0..9f8dc277e 100644 --- a/Linphone/UI/Main/Fragments/ToastView.swift +++ b/Linphone/UI/Main/Fragments/ToastView.swift @@ -100,19 +100,22 @@ struct ToastView: View { .stroke(toastViewModel.toastMessage.contains("Success") ? Color.greenSuccess500 : Color.redDanger500, lineWidth: 1) ) .onTapGesture { - withAnimation { - toastViewModel.toastMessage = "" - toastViewModel.displayToast = false - } - } - .onAppear { - DispatchQueue.main.asyncAfter(deadline: .now() + 2) { + 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() } From d97f07942f4b756401db0d6b8dd2f3adb886b335 Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Tue, 9 Jan 2024 16:42:11 +0100 Subject: [PATCH 080/486] Refresh view when call is paused --- .../phone-list.imageset/Contents.json | 21 + .../phone-list.imageset/phone-list.svg | 6 + .../phone-transfer.imageset/Contents.json | 21 + .../phone-transfer.svg | 4 + Linphone/Core/CoreContext.swift | 4 - Linphone/Localizable.xcstrings | 21 +- Linphone/Ressources/linphonerc-factory | 2 + .../TelecomManager/ProviderDelegate.swift | 158 ++-- Linphone/TelecomManager/TelecomManager.swift | 282 +++---- Linphone/UI/Call/CallView.swift | 705 ++++++++++-------- .../UI/Call/ViewModel/CallViewModel.swift | 13 +- 11 files changed, 677 insertions(+), 560 deletions(-) create mode 100644 Linphone/Assets.xcassets/phone-list.imageset/Contents.json create mode 100644 Linphone/Assets.xcassets/phone-list.imageset/phone-list.svg create mode 100644 Linphone/Assets.xcassets/phone-transfer.imageset/Contents.json create mode 100644 Linphone/Assets.xcassets/phone-transfer.imageset/phone-transfer.svg 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-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/Core/CoreContext.swift b/Linphone/Core/CoreContext.swift index 736500641..a07c6b926 100644 --- a/Linphone/Core/CoreContext.swift +++ b/Linphone/Core/CoreContext.swift @@ -105,10 +105,6 @@ final class CoreContext: ObservableObject { self.mCore.videoCaptureEnabled = true self.mCore.videoDisplayEnabled = true - let videoActivationPolicy = self.mCore.videoActivationPolicy! - videoActivationPolicy.automaticallyAccept = true - self.mCore.videoActivationPolicy! = videoActivationPolicy - try? self.mCore.start() // Create a Core listener to listen for the callback we need diff --git a/Linphone/Localizable.xcstrings b/Linphone/Localizable.xcstrings index 467b2feee..8dc0dee51 100644 --- a/Linphone/Localizable.xcstrings +++ b/Linphone/Localizable.xcstrings @@ -107,6 +107,9 @@ }, "+" : { + }, + "|" : { + }, "0" : { @@ -268,6 +271,9 @@ }, "Deny all" : { + }, + "Dialer" : { + }, "Display Name" : { @@ -415,9 +421,6 @@ }, "Outgoing Call" : { - }, - "Participants" : { - }, "password" : { "extractionState" : "manual", @@ -438,6 +441,12 @@ }, "Pause" : { + }, + "Paused" : { + + }, + "Paused by remote" : { + }, "Personnalize your profil mode" : { @@ -474,9 +483,6 @@ }, "Scan QR code" : { - }, - "Screen share" : { - }, "Search contact or history call" : { @@ -540,6 +546,9 @@ }, "to Linphone" : { + }, + "Transfer" : { + }, "Transport" : { diff --git a/Linphone/Ressources/linphonerc-factory b/Linphone/Ressources/linphonerc-factory index 4074322fe..0abf8269d 100644 --- a/Linphone/Ressources/linphonerc-factory +++ b/Linphone/Ressources/linphonerc-factory @@ -31,6 +31,8 @@ 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 diff --git a/Linphone/TelecomManager/ProviderDelegate.swift b/Linphone/TelecomManager/ProviderDelegate.swift index 55ff1cf40..44ef6095e 100644 --- a/Linphone/TelecomManager/ProviderDelegate.swift +++ b/Linphone/TelecomManager/ProviderDelegate.swift @@ -1,21 +1,21 @@ /* -* 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 . -*/ + * 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 @@ -56,19 +56,19 @@ class CallInfo { } /* -* A delegate to support callkit. -*/ + * 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 { get { let providerConfiguration = CXProviderConfiguration() @@ -97,18 +97,18 @@ class ProviderDelegate: NSObject { 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 - } - } - */ + 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 @@ -140,7 +140,7 @@ class ProviderDelegate: NSObject { } } } - + func updateCall(uuid: UUID, handle: String, hasVideo: Bool = false, displayName: String) { let update = CXCallUpdate() update.remoteHandle = CXHandle(type: .generic, value: handle) @@ -148,11 +148,11 @@ class ProviderDelegate: NSObject { 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) } @@ -164,7 +164,7 @@ class ProviderDelegate: NSObject { 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 @@ -188,7 +188,7 @@ extension ProviderDelegate: CXProviderDelegate { 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!) @@ -203,7 +203,7 @@ extension ProviderDelegate: CXProviderDelegate { action.fulfill() } } - + func provider(_ provider: CXProvider, perform action: CXAnswerCallAction) { let uuid = action.callUUID let callInfo = callInfos[uuid] @@ -221,15 +221,17 @@ extension ProviderDelegate: CXProviderDelegate { let call = core.getCallByCallid(callId: callId) - if UIApplication.shared.applicationState != .active { - TelecomManager.shared.backgroundContextCall = call - TelecomManager.shared.backgroundContextCameraIsEnabled = call?.params?.videoEnabled == true || call?.callLog?.wasConference() == true - if #available(iOS 16.0, *) { - if call?.cameraEnabled == true { - call?.cameraEnabled = AVCaptureSession().isMultitaskingCameraAccessSupported + DispatchQueue.main.async() { + if UIApplication.shared.applicationState != .active { + TelecomManager.shared.backgroundContextCall = call + TelecomManager.shared.backgroundContextCameraIsEnabled = call?.params?.videoEnabled == true || call?.callLog?.wasConference() == 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 } - } else { - call?.cameraEnabled = false // Disable camera while app is not on foreground } } TelecomManager.shared.callkitAudioSessionActivated = false @@ -242,7 +244,7 @@ extension ProviderDelegate: CXProviderDelegate { action.fulfill() } } - + func provider(_ provider: CXProvider, perform action: CXSetHeldCallAction) { let uuid = action.callUUID let callId = callInfos[uuid]?.callId ?? "" @@ -275,29 +277,29 @@ extension ProviderDelegate: CXProviderDelegate { action.fulfill() } else { if call?.conference != nil && core.callsNb > 1 {/* - try TelecomManager.shared.lc?.enterConference() - action.fulfill() - NotificationCenter.default.post(name: Notification.Name("LinphoneCallUpdate"), object: self) - */} 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(actived: true) - TelecomManager.shared.callkitAudioSessionActivated = true - } + try TelecomManager.shared.lc?.enterConference() + action.fulfill() + NotificationCenter.default.post(name: Notification.Name("LinphoneCallUpdate"), object: self) + */} 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(actived: true) + TelecomManager.shared.callkitAudioSessionActivated = true + } } } } catch { @@ -332,7 +334,7 @@ extension ProviderDelegate: CXProviderDelegate { } } } - + 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)).") @@ -340,7 +342,7 @@ extension ProviderDelegate: CXProviderDelegate { action.fulfill() } } - + func provider(_ provider: CXProvider, perform action: CXSetMutedCallAction) { let uuid = action.callUUID let callId = callInfos[uuid]?.callId @@ -350,7 +352,7 @@ extension ProviderDelegate: CXProviderDelegate { action.fulfill() } } - + func provider(_ provider: CXProvider, perform action: CXPlayDTMFCallAction) { let uuid = action.callUUID let callId = callInfos[uuid]?.callId ?? "" @@ -368,18 +370,18 @@ extension ProviderDelegate: CXProviderDelegate { 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.") @@ -387,7 +389,7 @@ extension ProviderDelegate: CXProviderDelegate { TelecomManager.shared.callkitAudioSessionActivated = true } } - + func provider(_ provider: CXProvider, didDeactivate audioSession: AVAudioSession) { CoreContext.shared.doOnCoreQueue { core in Log.info("CallKit: audio session deactivated.") diff --git a/Linphone/TelecomManager/TelecomManager.swift b/Linphone/TelecomManager/TelecomManager.swift index 22372bc1a..506ba21e4 100644 --- a/Linphone/TelecomManager/TelecomManager.swift +++ b/Linphone/TelecomManager/TelecomManager.swift @@ -41,9 +41,11 @@ class TelecomManager: ObservableObject { let callController: CXCallController // to support callkit @Published var callInProgress: Bool = false - @Published var callStarted: Bool = false + @Published var callStarted: Bool = false + @Published var outgoingCallStarted: Bool = false @Published var remoteVideo: Bool = false - @Published var isRemoteRecording: Bool = false + @Published var isRecordingByRemote: Bool = false + @Published var isPausedByRemote: Bool = false var actionToFulFill: CXCallAction? var callkitAudioSessionActivated: Bool? @@ -118,15 +120,15 @@ class TelecomManager: ObservableObject { } } - func doCallWithCore(addr: Address) { - CoreContext.shared.doOnCoreQueue { core in + func doCallWithCore(addr: Address) { + CoreContext.shared.doOnCoreQueue { core in do { try self.startCallCallKit(core: core, addr: addr, isSas: false, isVideo: false, isConference: false) } catch { Log.error("[TelecomManager] unable to create address for a new outgoing call : \(addr) \(error) ") } - } - } + } + } private func makeRecordFilePath() -> String{ var filePath = "recording_" @@ -140,25 +142,25 @@ class TelecomManager: ObservableObject { 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 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 @@ -176,13 +178,13 @@ class TelecomManager: ObservableObject { lcallParams.mediaEncryption = .ZRTP } if isConference { - /* 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 - }*/ + /* 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 = isVideo } @@ -202,7 +204,7 @@ class TelecomManager: ObservableObject { } DispatchQueue.main.async { - + self.outgoingCallStarted = true self.callStarted = true if self.callInProgress == false { withAnimation { @@ -220,12 +222,12 @@ class TelecomManager: ObservableObject { 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 - }*/ + 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 @@ -234,10 +236,10 @@ class TelecomManager: ObservableObject { // 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 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 @@ -249,9 +251,9 @@ class TelecomManager: ObservableObject { try call.acceptWithParams(params: callParams) - DispatchQueue.main.async { - self.callStarted = true - } + DispatchQueue.main.async { + self.callStarted = true + } } catch { Log.error("accept call failed \(error)") } @@ -334,17 +336,18 @@ class TelecomManager: ObservableObject { if cstate == .PushIncomingReceived { displayIncomingCall(call: call, handle: "Calling", hasVideo: false, callId: callId, displayName: "Calling") } else { - remoteVideo = (core.videoActivationPolicy?.automaticallyAccept ?? false) && (call.remoteParams?.videoEnabled ?? false) - if remoteVideo { - Log.info("[Call] Remote video is activated") - } - - isRemoteRecording = call.remoteParams?.isRecording ?? false - - if isRemoteRecording && ToastViewModel.shared.toastMessage.isEmpty { + DispatchQueue.main.async { + self.remoteVideo = (core.videoActivationPolicy?.automaticallyAccept ?? false) && (call.remoteParams?.videoEnabled ?? false) - DispatchQueue.main.async { + if self.remoteVideo { + Log.info("[Call] Remote video is activated") + } + + self.isRecordingByRemote = call.remoteParams?.isRecording ?? false + + if self.isRecordingByRemote && ToastViewModel.shared.toastMessage.isEmpty { + var displayName = "" let friend = ContactsManager.shared.getFriendWithAddress(address: call.remoteAddress!) if friend != nil && friend!.address != nil && friend!.address!.displayName != nil { @@ -358,22 +361,27 @@ class TelecomManager: ObservableObject { } ToastViewModel.shared.toastMessage = "\(displayName) is recording" - ToastViewModel.shared.displayToast = true + ToastViewModel.shared.displayToast = true + + Log.info("[Call] Call is recording by \(call.remoteAddress!.asStringUriOnly())") } - Log.info("[Call] Call is recording by \(call.remoteAddress!.asStringUriOnly())") - } - - if !isRemoteRecording && ToastViewModel.shared.toastMessage.contains("is recording") { - - DispatchQueue.main.async { + if !self.isRecordingByRemote && ToastViewModel.shared.toastMessage.contains("is recording") { + withAnimation { ToastViewModel.shared.toastMessage = "" ToastViewModel.shared.displayToast = false } + + Log.info("[Call] Recording is stopped by \(call.remoteAddress!.asStringUriOnly())") } - Log.info("[Call] Call is recording Stop recording by \(call.remoteAddress!.asStringUriOnly())") + switch call.state { + case Call.State.PausedByRemote: + self.isPausedByRemote = true + default: + self.isPausedByRemote = false + } } if call.userData == nil { @@ -381,24 +389,28 @@ class TelecomManager: ObservableObject { TelecomManager.setAppData(sCall: call, appData: appData) } /* - if let conference = call.conference, ConferenceViewModel.shared.conference.value == nil { - Log.info("[Call] Found conference attached to call and no conference in dedicated view model, init & configure it") - ConferenceViewModel.shared.initConference(conference) - ConferenceViewModel.shared.configureConference(conference) - } - */ + if let conference = call.conference, ConferenceViewModel.shared.conference.value == nil { + Log.info("[Call] Found conference attached to call and no conference in dedicated view model, init & configure it") + ConferenceViewModel.shared.initConference(conference) + ConferenceViewModel.shared.configureConference(conference) + } + */ switch cstate { case .IncomingReceived: let addr = call.remoteAddress let displayName = incomingDisplayName(call: call) - #if targetEnvironment(simulator) +#if targetEnvironment(simulator) DispatchQueue.main.async { - withAnimation { - TelecomManager.shared.callInProgress = true + self.outgoingCallStarted = false + self.callStarted = true + if self.callInProgress == false { + withAnimation { + self.callInProgress = true + } } } - #endif +#endif if call.replacedCall != nil { endCallKitReplacedCall = false @@ -415,17 +427,17 @@ class TelecomManager: ObservableObject { } else if TelecomManager.callKitEnabled(core: core) { /* let isConference = isConferenceCall(call: call) - let isEarlyConference = isConference && CallsViewModel.shared.currentCallData.value??.isConferenceCall.value != true // Conference info not be received yet. - if (isEarlyConference) { - CallsViewModel.shared.currentCallData.readCurrentAndObserve { _ in - let uuid = providerDelegate.uuids["\(callId)"] - if (uuid != nil) { - displayName = "\(VoipTexts.conference_incoming_title): \(CallsViewModel.shared.currentCallData.value??.remoteConferenceSubject.value ?? "") (\(CallsViewModel.shared.currentCallData.value??.conferenceParticipantsCountLabel.value ?? ""))" - providerDelegate.updateCall(uuid: uuid!, handle: addr!.asStringUriOnly(), hasVideo: video, displayName: displayName) - } - } - } - */ + let isEarlyConference = isConference && CallsViewModel.shared.currentCallData.value??.isConferenceCall.value != true // Conference info not be received yet. + if (isEarlyConference) { + CallsViewModel.shared.currentCallData.readCurrentAndObserve { _ in + let uuid = providerDelegate.uuids["\(callId)"] + if (uuid != nil) { + displayName = "\(VoipTexts.conference_incoming_title): \(CallsViewModel.shared.currentCallData.value??.remoteConferenceSubject.value ?? "") (\(CallsViewModel.shared.currentCallData.value??.conferenceParticipantsCountLabel.value ?? ""))" + providerDelegate.updateCall(uuid: uuid!, handle: addr!.asStringUriOnly(), hasVideo: video, displayName: displayName) + } + } + } + */ let uuid = providerDelegate.uuids["\(callId)"] if call.replacedCall == nil { TelecomManager.uuidReplacedCall = callId @@ -438,18 +450,23 @@ class TelecomManager: ObservableObject { displayIncomingCall(call: call, handle: addr!.asStringUriOnly(), hasVideo: remoteVideo, callId: callId, displayName: displayName) } } /* else if UIApplication.shared.applicationState != .active { - // not support callkit , use notif - let content = UNMutableNotificationContent() - content.title = NSLocalizedString("Incoming call", comment: "") - content.body = displayName - content.sound = UNNotificationSound.init(named: UNNotificationSoundName.init("notes_of_the_optimistic.caf")) - content.categoryIdentifier = "call_cat" - content.userInfo = ["CallId": callId] - let req = UNNotificationRequest.init(identifier: "call_request", content: content, trigger: nil) - UNUserNotificationCenter.current().add(req, withCompletionHandler: nil) - } */ + // not support callkit , use notif + let content = UNMutableNotificationContent() + content.title = NSLocalizedString("Incoming call", comment: "") + content.body = displayName + content.sound = UNNotificationSound.init(named: UNNotificationSoundName.init("notes_of_the_optimistic.caf")) + content.categoryIdentifier = "call_cat" + content.userInfo = ["CallId": callId] + let req = UNNotificationRequest.init(identifier: "call_request", content: content, trigger: nil) + UNUserNotificationCenter.current().add(req, withCompletionHandler: nil) + } */ 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!] @@ -463,10 +480,10 @@ class TelecomManager: ObservableObject { } /* - if speakerBeforePause { - speakerBeforePause = false - AudioRouteUtils.routeAudioToSpeaker(core: core) - } + if speakerBeforePause { + speakerBeforePause = false + AudioRouteUtils.routeAudioToSpeaker(core: core) + } */ actionToFulFill?.fulfill() @@ -491,12 +508,12 @@ class TelecomManager: ObservableObject { providerDelegate.reportOutgoingCallStartedConnecting(uuid: uuid!) } else { if false { /* isConferenceCall(call: call) { - let uuid = UUID() - let callInfo = CallInfo.newOutgoingCallInfo(addr: call.remoteAddress!, isSas: call.params?.mediaEncryption == .ZRTP, displayName: VoipTexts.conference_default_title, isVideo: call.params?.videoEnabled == true, isConference:true) - providerDelegate.callInfos.updateValue(callInfo, forKey: uuid) - providerDelegate.uuids.updateValue(uuid, forKey: "") - providerDelegate.reportOutgoingCallStartedConnecting(uuid: uuid) - Core.get().activateAudioSession(actived: true) */ + let uuid = UUID() + let callInfo = CallInfo.newOutgoingCallInfo(addr: call.remoteAddress!, isSas: call.params?.mediaEncryption == .ZRTP, displayName: VoipTexts.conference_default_title, isVideo: call.params?.videoEnabled == true, isConference:true) + providerDelegate.callInfos.updateValue(callInfo, forKey: uuid) + providerDelegate.uuids.updateValue(uuid, forKey: "") + providerDelegate.reportOutgoingCallStartedConnecting(uuid: uuid) + Core.get().activateAudioSession(actived: true) */ } else { referedToCall = callId } @@ -505,19 +522,6 @@ class TelecomManager: ObservableObject { case .End, .Error: - DispatchQueue.main.async { - withAnimation { - self.callInProgress = false - self.callStarted = false - } - } - var displayName = "Unknown" - if call.dir == .Incoming { - displayName = incomingDisplayName(call: call) - } else { // if let addr = call.remoteAddress, let contactName = FastAddressBook.displayName(for: addr.getCobject) { - displayName = "TODOContactName" - } - UIDevice.current.isProximityMonitoringEnabled = false if core.callsNb == 0 { core.outputAudioDevice = core.defaultOutputAudioDevice @@ -527,18 +531,34 @@ class TelecomManager: ObservableObject { // bluetoothEnabled = 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) + DispatchQueue.main.async { + withAnimation { + self.outgoingCallStarted = false + self.callInProgress = false + self.callStarted = false + } - // 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)") + var displayName = "Unknown" + if call.dir == .Incoming { + displayName = self.incomingDisplayName(call: call) + } else { // if let addr = call.remoteAddress, let contactName = FastAddressBook.displayName(for: addr.getCobject) { + displayName = "TODOContactName" + } + + + 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)") + } } } } @@ -583,22 +603,6 @@ class TelecomManager: ObservableObject { default: break } - - // AudioRouteUtils.isBluetoothAvailable(core: core) - // AudioRouteUtils.isHeadsetAudioRouteAvailable(core: core) - // AudioRouteUtils.isBluetoothAudioRouteAvailable(core: core) - - /* - let readyForRoutechange = callkitAudioSessionActivated == nil || (callkitAudioSessionActivated == true) - if readyForRoutechange && (cstate == .IncomingReceived || cstate == .OutgoingInit || cstate == .Connected || cstate == .StreamsRunning) { - if (call.currentParams?.videoEnabled ?? false) && AudioRouteUtils.isReceiverEnabled(core: core) && call.conference == nil { - AudioRouteUtils.routeAudioToSpeaker(core: core, call: call) - } else if AudioRouteUtils.isBluetoothAvailable(core: core) { - // Use bluetooth device by default if one is available - AudioRouteUtils.routeAudioToBluetooth(core: core, call: call) - } - } - */ } // post Notification kLinphoneCallUpdate NotificationCenter.default.post(name: Notification.Name("LinphoneCallUpdate"), object: self, userInfo: [ diff --git a/Linphone/UI/Call/CallView.swift b/Linphone/UI/Call/CallView.swift index dc681036d..9f1d99f5a 100644 --- a/Linphone/UI/Call/CallView.swift +++ b/Linphone/UI/Call/CallView.swift @@ -24,10 +24,10 @@ import AVFAudio import linphonesw struct CallView: View { - - @ObservedObject private var coreContext = CoreContext.shared - @ObservedObject private var telecomManager = TelecomManager.shared - @ObservedObject private var contactsManager = ContactsManager.shared + + @ObservedObject private var coreContext = CoreContext.shared + @ObservedObject private var telecomManager = TelecomManager.shared + @ObservedObject private var contactsManager = ContactsManager.shared @ObservedObject var callViewModel: CallViewModel @@ -35,8 +35,8 @@ struct CallView: View { @State private var orientation = UIDevice.current.orientation let pub = NotificationCenter.default.publisher(for: AVAudioSession.routeChangeNotification) - - @State var startDate = Date.now + + @State var startDate = Date.now @State var audioRouteSheet: Bool = false @State var hideButtonsSheet: Bool = false @State var options: Int = 1 @@ -45,10 +45,10 @@ struct CallView: View { @State var angleDegree = 0.0 @State var fullscreenVideo = false - - var body: some View { - GeometryReader { geo in - if #available(iOS 16.4, *) { + + var body: some View { + GeometryReader { geo in + if #available(iOS 16.4, *) { innerView(geometry: geo) .sheet(isPresented: .constant( @@ -60,54 +60,55 @@ struct CallView: View { ) ) { GeometryReader { _ in - VStack(spacing: 0) { - HStack(spacing: 12) { - Button { + VStack(spacing: 0) { + HStack(spacing: 12) { + Button { callViewModel.terminateCall() - } label: { - Image("phone-disconnect") - .renderingMode(.template) - .resizable() - .foregroundStyle(.white) - .frame(width: 32, height: 32) - - } - .frame(width: 90, height: 60) - .background(Color.redDanger500) - .cornerRadius(40) - - Spacer() - - Button { + } label: { + Image("phone-disconnect") + .renderingMode(.template) + .resizable() + .foregroundStyle(.white) + .frame(width: 32, height: 32) + + } + .frame(width: 90, height: 60) + .background(Color.redDanger500) + .cornerRadius(40) + + Spacer() + + Button { callViewModel.toggleVideo() - } label: { + } label: { Image(callViewModel.cameraDisplayed ? "video-camera" : "video-camera-slash") - .renderingMode(.template) - .resizable() - .foregroundStyle(.white) - .frame(width: 32, height: 32) - - } - .frame(width: 60, height: 60) - .background(Color.gray500) - .cornerRadius(40) - - Button { + .renderingMode(.template) + .resizable() + .foregroundStyle((callViewModel.isPaused || telecomManager.isPausedByRemote) ? Color.gray500 : .white) + .frame(width: 32, height: 32) + + } + .frame(width: 60, height: 60) + .background((callViewModel.isPaused || telecomManager.isPausedByRemote) ? Color.gray600 : Color.gray500) + .cornerRadius(40) + .disabled(callViewModel.isPaused || telecomManager.isPausedByRemote) + + Button { callViewModel.toggleMuteMicrophone() - } label: { + } label: { Image(callViewModel.micMutted ? "microphone-slash" : "microphone") - .renderingMode(.template) - .resizable() + .renderingMode(.template) + .resizable() .foregroundStyle(callViewModel.micMutted ? .black : .white) - .frame(width: 32, height: 32) - - } - .frame(width: 60, height: 60) + .frame(width: 32, height: 32) + + } + .frame(width: 60, height: 60) .background(callViewModel.micMutted ? .white : Color.gray500) - .cornerRadius(40) - - Button { - if AVAudioSession.sharedInstance().availableInputs != nil + .cornerRadius(40) + + Button { + if AVAudioSession.sharedInstance().availableInputs != nil && !AVAudioSession.sharedInstance().availableInputs!.filter({ $0.portType.rawValue.contains("Bluetooth") }).isEmpty { hideButtonsSheet = true @@ -123,200 +124,206 @@ struct CallView: View { } } - } label: { + } label: { Image(imageAudioRoute) - .renderingMode(.template) - .resizable() - .foregroundStyle(.white) - .frame(width: 32, height: 32) + .renderingMode(.template) + .resizable() + .foregroundStyle(.white) + .frame(width: 32, height: 32) .onAppear(perform: getAudioRouteImage) .onReceive(pub) { (output) in self.getAudioRouteImage() } - - } - .frame(width: 60, height: 60) - .background(Color.gray500) - .cornerRadius(40) - } - .frame(height: geo.size.height * 0.15) - .padding(.horizontal, 20) + + } + .frame(width: 60, height: 60) + .background(Color.gray500) + .cornerRadius(40) + } + .frame(height: geo.size.height * 0.15) + .padding(.horizontal, 20) .padding(.top, -6) - - HStack(spacing: 0) { - VStack { - Button { - } label: { - Image("screencast") - .renderingMode(.template) - .resizable() - .foregroundStyle(.white) - .frame(width: 32, height: 32) - } - .frame(width: 60, height: 60) - .background(Color.gray500) - .cornerRadius(40) - - Text("Screen share") - .foregroundStyle(.white) - .default_text_style(styleSize: 15) - } - .frame(width: geo.size.width * 0.25, height: geo.size.width * 0.25) - - VStack { - Button { - } label: { - Image("users") - .renderingMode(.template) - .resizable() - .foregroundStyle(.white) - .frame(width: 32, height: 32) - } - .frame(width: 60, height: 60) - .background(Color.gray500) - .cornerRadius(40) - - Text("Participants") - .foregroundStyle(.white) - .default_text_style(styleSize: 15) - } - .frame(width: geo.size.width * 0.25, height: geo.size.width * 0.25) - - VStack { - Button { - } label: { - Image("chat-teardrop-text") - .renderingMode(.template) - .resizable() - .foregroundStyle(.white) - .frame(width: 32, height: 32) - } - .frame(width: 60, height: 60) - .background(Color.gray500) - .cornerRadius(40) - - Text("Messages") - .foregroundStyle(.white) - .default_text_style(styleSize: 15) - } - .frame(width: geo.size.width * 0.25, height: geo.size.width * 0.25) - - VStack { - Button { - } label: { - Image("notebook") - .renderingMode(.template) - .resizable() - .foregroundStyle(.white) - .frame(width: 32, height: 32) - } - .frame(width: 60, height: 60) - .background(Color.gray500) - .cornerRadius(40) - - Text("Disposition") - .foregroundStyle(.white) - .default_text_style(styleSize: 15) - } - .frame(width: geo.size.width * 0.25, height: geo.size.width * 0.25) - } - .frame(height: geo.size.height * 0.15) - - HStack(spacing: 0) { - VStack { - Button { - } label: { - Image("phone-call") - .renderingMode(.template) - .resizable() - .foregroundStyle(.white) - .frame(width: 32, height: 32) - } - .frame(width: 60, height: 60) - .background(Color.gray500) - .cornerRadius(40) - - Text("Call list") - .foregroundStyle(.white) - .default_text_style(styleSize: 15) - } - .frame(width: geo.size.width * 0.25, height: geo.size.width * 0.25) - - VStack { - Button { + + HStack(spacing: 0) { + VStack { + Button { + } label: { + Image("phone-transfer") + .renderingMode(.template) + .resizable() + .foregroundStyle(.white) + .frame(width: 32, height: 32) + } + .frame(width: 60, height: 60) + .background(Color.gray500) + .cornerRadius(40) + + Text("Transfer") + .foregroundStyle(.white) + .default_text_style(styleSize: 15) + } + .frame(width: geo.size.width * 0.25, height: geo.size.width * 0.25) + + VStack { + Button { + } label: { + Image("phone-plus") + .renderingMode(.template) + .resizable() + .foregroundStyle(.white) + .frame(width: 32, height: 32) + } + .frame(width: 60, height: 60) + .background(Color.gray500) + .cornerRadius(40) + + Text("New call") + .foregroundStyle(.white) + .default_text_style(styleSize: 15) + } + .frame(width: geo.size.width * 0.25, height: geo.size.width * 0.25) + + VStack { + Button { + } label: { + Image("phone-list") + .renderingMode(.template) + .resizable() + .foregroundStyle(.white) + .frame(width: 32, height: 32) + } + .frame(width: 60, height: 60) + .background(Color.gray500) + .cornerRadius(40) + + Text("Call list") + .foregroundStyle(.white) + .default_text_style(styleSize: 15) + } + .frame(width: geo.size.width * 0.25, height: geo.size.width * 0.25) + + VStack { + Button { + } label: { + Image("dialer") + .renderingMode(.template) + .resizable() + .foregroundStyle(.white) + .frame(width: 32, height: 32) + } + .frame(width: 60, height: 60) + .background(Color.gray500) + .cornerRadius(40) + + Text("Dialer") + .foregroundStyle(.white) + .default_text_style(styleSize: 15) + } + .frame(width: geo.size.width * 0.25, height: geo.size.width * 0.25) + } + .frame(height: geo.size.height * 0.15) + + HStack(spacing: 0) { + VStack { + Button { + } label: { + Image("chat-teardrop-text") + .renderingMode(.template) + .resizable() + //.foregroundStyle((callViewModel.isPaused || telecomManager.isPausedByRemote) ? Color.gray500 : .white) + .foregroundStyle(Color.gray500) + .frame(width: 32, height: 32) + } + .frame(width: 60, height: 60) + //.background((callViewModel.isPaused || telecomManager.isPausedByRemote) ? Color.gray600 : Color.gray500) + .background(Color.gray600) + .cornerRadius(40) + //.disabled(callViewModel.isPaused || telecomManager.isPausedByRemote) + .disabled(true) + + Text("Messages") + .foregroundStyle(.white) + .default_text_style(styleSize: 15) + } + .frame(width: geo.size.width * 0.25, height: geo.size.width * 0.25) + + VStack { + Button { callViewModel.togglePause() - } label: { - Image("pause") - .renderingMode(.template) - .resizable() - .foregroundStyle(.white) - .frame(width: 32, height: 32) - } - .frame(width: 60, height: 60) - .background(Color.gray500) - .cornerRadius(40) - - Text("Pause") - .foregroundStyle(.white) - .default_text_style(styleSize: 15) - } - .frame(width: geo.size.width * 0.25, height: geo.size.width * 0.25) - - VStack { - Button { + } label: { + Image(callViewModel.isPaused ? "play" : "pause") + .renderingMode(.template) + .resizable() + .foregroundStyle(telecomManager.isPausedByRemote ? Color.gray500 : .white) + .frame(width: 32, height: 32) + } + .frame(width: 60, height: 60) + .background(telecomManager.isPausedByRemote ? Color.gray600 : (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.25, height: geo.size.width * 0.25) + + VStack { + Button { callViewModel.toggleRecording() - } label: { - Image("record-fill") - .renderingMode(.template) - .resizable() - .foregroundStyle(.white) - .frame(width: 32, height: 32) - } - .frame(width: 60, height: 60) - .background(callViewModel.isRecording ? Color.redDanger500 : Color.gray500) - .cornerRadius(40) - - Text("Record") - .foregroundStyle(.white) - .default_text_style(styleSize: 15) - } - .frame(width: geo.size.width * 0.25, height: geo.size.width * 0.25) - - VStack { - Button { - } label: { - Image("video-camera") - .renderingMode(.template) - .resizable() - .foregroundStyle(.white) - .frame(width: 32, height: 32) - } - .frame(width: 60, height: 60) - .background(Color.gray500) - .cornerRadius(40) - - Text("Disposition") - .foregroundStyle(.white) - .default_text_style(styleSize: 15) - } - .frame(width: geo.size.width * 0.25, height: geo.size.width * 0.25) - .hidden() - } - .frame(height: geo.size.height * 0.15) - - Spacer() - } - .frame(maxHeight: .infinity, alignment: .top) + } label: { + Image("record-fill") + .renderingMode(.template) + .resizable() + .foregroundStyle((callViewModel.isPaused || telecomManager.isPausedByRemote) ? Color.gray500 : .white) + .frame(width: 32, height: 32) + } + .frame(width: 60, height: 60) + .background((callViewModel.isPaused || telecomManager.isPausedByRemote) ? Color.gray600 : (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.25, height: geo.size.width * 0.25) + + VStack { + Button { + } label: { + Image("video-camera") + .renderingMode(.template) + .resizable() + .foregroundStyle(.white) + .frame(width: 32, height: 32) + } + .frame(width: 60, height: 60) + .background(Color.gray500) + .cornerRadius(40) + + Text("Disposition") + .foregroundStyle(.white) + .default_text_style(styleSize: 15) + } + .frame(width: geo.size.width * 0.25, height: geo.size.width * 0.25) + .hidden() + } + .frame(height: geo.size.height * 0.15) + + Spacer() + } + .frame(maxHeight: .infinity, alignment: .top) .presentationBackground(.black) - .presentationDetents([.fraction(0.1), .medium]) - .interactiveDismissDisabled() - .presentationBackgroundInteraction(.enabled) - } - } - .sheet(isPresented: $audioRouteSheet, onDismiss: { + .presentationDetents([.fraction(0.1), .fraction(0.45)]) + .interactiveDismissDisabled() + .presentationBackgroundInteraction(.enabled) + } + } + .sheet(isPresented: $audioRouteSheet, onDismiss: { audioRouteSheet = false hideButtonsSheet = false - }) { + }) { VStack(spacing: 0) { Button(action: { options = 1 @@ -346,9 +353,9 @@ struct CallView: View { Image(!callViewModel.isHeadPhoneAvailable() ? "ear" : "headset") .renderingMode(.template) - .resizable() + .resizable() .foregroundStyle(.white) - .frame(width: 25, height: 25, alignment: .leading) + .frame(width: 25, height: 25, alignment: .leading) } }) .frame(maxHeight: .infinity) @@ -416,16 +423,16 @@ struct CallView: View { } .padding(.horizontal, 20) .presentationBackground(Color.gray600) - .presentationDetents([.fraction(0.3)]) + .presentationDetents([.fraction(0.3)]) .frame(maxHeight: .infinity) } - } - } - } - - @ViewBuilder + } + } + } + + @ViewBuilder func innerView(geometry: GeometryProxy) -> some View { - VStack { + VStack { if !fullscreenVideo { Rectangle() .foregroundColor(Color.orangeMain500) @@ -451,6 +458,35 @@ struct CallView: View { .foregroundStyle(.white) } + if !telecomManager.outgoingCallStarted && telecomManager.callInProgress { + Text("|") + .foregroundStyle(.white) + + ZStack { + Text(callViewModel.timeElapsed.convertDurationToString()) + .onAppear { + callViewModel.timeElapsed = 0 + startDate = Date.now + } + .onReceive(callViewModel.timer) { firedDate in + callViewModel.timeElapsed = Int(firedDate.timeIntervalSince(startDate)) + + } + .foregroundStyle(.white) + .if(callViewModel.isPaused || telecomManager.isPausedByRemote) { view in + view.hidden() + } + + if callViewModel.isPaused { + Text("Paused") + .foregroundStyle(.white) + } else if telecomManager.isPausedByRemote { + Text("Paused by remote") + .foregroundStyle(.white) + } + } + } + Spacer() if callViewModel.cameraDisplayed { @@ -469,65 +505,65 @@ struct CallView: View { .frame(height: 40) .zIndex(1) } - - ZStack { - VStack { - Spacer() - - if callViewModel.remoteAddress != nil { - let addressFriend = contactsManager.getFriendWithAddress(address: callViewModel.remoteAddress!) - - let contactAvatarModel = addressFriend != nil - ? ContactsManager.shared.avatarListModel.first(where: { - ($0.friend!.consolidatedPresence == .Online || $0.friend!.consolidatedPresence == .Busy) - && $0.friend!.name == addressFriend!.name - && $0.friend!.address!.asStringUriOnly() == addressFriend!.address!.asStringUriOnly() - }) - : ContactAvatarModel(friend: nil, withPresence: false) - - if addressFriend != nil && addressFriend!.photo != nil && !addressFriend!.photo!.isEmpty { - if contactAvatarModel != nil { - Avatar(contactAvatarModel: contactAvatarModel!, avatarSize: 100, hidePresence: true) - } - } else { - if callViewModel.remoteAddress!.displayName != nil { - Image(uiImage: contactsManager.textToImage( - firstName: callViewModel.remoteAddress!.displayName!, - lastName: callViewModel.remoteAddress!.displayName!.components(separatedBy: " ").count > 1 - ? callViewModel.remoteAddress!.displayName!.components(separatedBy: " ")[1] - : "")) - .resizable() - .frame(width: 100, height: 100) - .clipShape(Circle()) - - } else { - Image(uiImage: contactsManager.textToImage( - firstName: callViewModel.remoteAddress!.username ?? "Username Error", - lastName: callViewModel.remoteAddress!.username!.components(separatedBy: " ").count > 1 - ? callViewModel.remoteAddress!.username!.components(separatedBy: " ")[1] - : "")) - .resizable() - .frame(width: 100, height: 100) - .clipShape(Circle()) - } - - } - } else { - Image("profil-picture-default") - .resizable() - .frame(width: 100, height: 100) - .clipShape(Circle()) - } - - Text(callViewModel.displayName) - .padding(.top) - .foregroundStyle(.white) - - Text(callViewModel.remoteAddressString) - .foregroundStyle(.white) - - Spacer() - } + + ZStack { + VStack { + Spacer() + + if callViewModel.remoteAddress != nil { + let addressFriend = contactsManager.getFriendWithAddress(address: callViewModel.remoteAddress!) + + let contactAvatarModel = addressFriend != nil + ? ContactsManager.shared.avatarListModel.first(where: { + ($0.friend!.consolidatedPresence == .Online || $0.friend!.consolidatedPresence == .Busy) + && $0.friend!.name == addressFriend!.name + && $0.friend!.address!.asStringUriOnly() == addressFriend!.address!.asStringUriOnly() + }) + : ContactAvatarModel(friend: nil, withPresence: false) + + if addressFriend != nil && addressFriend!.photo != nil && !addressFriend!.photo!.isEmpty { + if contactAvatarModel != nil { + Avatar(contactAvatarModel: contactAvatarModel!, avatarSize: 100, hidePresence: true) + } + } else { + if callViewModel.remoteAddress!.displayName != nil { + Image(uiImage: contactsManager.textToImage( + firstName: callViewModel.remoteAddress!.displayName!, + lastName: callViewModel.remoteAddress!.displayName!.components(separatedBy: " ").count > 1 + ? callViewModel.remoteAddress!.displayName!.components(separatedBy: " ")[1] + : "")) + .resizable() + .frame(width: 100, height: 100) + .clipShape(Circle()) + + } else { + Image(uiImage: contactsManager.textToImage( + firstName: callViewModel.remoteAddress!.username ?? "Username Error", + lastName: callViewModel.remoteAddress!.username!.components(separatedBy: " ").count > 1 + ? callViewModel.remoteAddress!.username!.components(separatedBy: " ")[1] + : "")) + .resizable() + .frame(width: 100, height: 100) + .clipShape(Circle()) + } + + } + } else { + Image("profil-picture-default") + .resizable() + .frame(width: 100, height: 100) + .clipShape(Circle()) + } + + Text(callViewModel.displayName) + .padding(.top) + .foregroundStyle(.white) + + Text(callViewModel.remoteAddressString) + .foregroundStyle(.white) + + Spacer() + } LinphoneVideoViewHolder { view in coreContext.doOnCoreQueue { core in @@ -535,7 +571,7 @@ struct CallView: View { } } .frame( - width: + width: angleDegree == 0 ? 120 * ((geometry.size.height + geometry.safeAreaInsets.top + geometry.safeAreaInsets.bottom) / 160) : 120 * ((geometry.size.width + geometry.safeAreaInsets.leading + geometry.safeAreaInsets.trailing) / 120), @@ -577,8 +613,8 @@ struct CallView: View { VStack { Image("record-fill") .renderingMode(.template) - .resizable() - .foregroundStyle(Color.redDanger500) + .resizable() + .foregroundStyle(Color.redDanger500) .frame(width: 32, height: 32) .padding(10) .if(fullscreenVideo) { view in @@ -594,31 +630,39 @@ struct CallView: View { ) } - if !telecomManager.callStarted && !fullscreenVideo { - VStack { - ActivityIndicator() - .frame(width: 20, height: 20) - .padding(.top, 100) - + if telecomManager.outgoingCallStarted { + VStack { + ActivityIndicator() + .frame(width: 20, height: 20) + .padding(.top, 100) + Text(callViewModel.counterToMinutes()) + .onAppear { + callViewModel.timeElapsed = 0 + startDate = Date.now + } .onReceive(callViewModel.timer) { firedDate in callViewModel.timeElapsed = Int(firedDate.timeIntervalSince(startDate)) - - } - .padding(.top) - .foregroundStyle(.white) - - Spacer() - } - .background(.clear) - } - } + + } + .padding(.top) + .foregroundStyle(.white) + + Spacer() + } + .frame( + maxWidth: fullscreenVideo ? geometry.size.width : geometry.size.width - 8, + maxHeight: fullscreenVideo ? geometry.size.height + geometry.safeAreaInsets.top + geometry.safeAreaInsets.bottom : geometry.size.height - 140 + ) + .background(.clear) + } + } .frame( maxWidth: fullscreenVideo ? geometry.size.width : geometry.size.width - 8, maxHeight: fullscreenVideo ? geometry.size.height + geometry.safeAreaInsets.top + geometry.safeAreaInsets.bottom : geometry.size.height - 140 ) - .background(Color.gray600) - .cornerRadius(20) + .background(Color.gray600) + .cornerRadius(20) .padding(.horizontal, fullscreenVideo ? 0 : 4) .onRotate { newOrientation in orientation = newOrientation @@ -647,7 +691,7 @@ struct CallView: View { callViewModel.orientationUpdate(orientation: orientation) } - + if !fullscreenVideo { if telecomManager.callStarted { if telecomManager.callStarted && idiom != .pad && !(orientation == .landscapeLeft || orientation == .landscapeRight @@ -684,13 +728,14 @@ struct CallView: View { Image("video-camera") .renderingMode(.template) .resizable() - .foregroundStyle(.white) + .foregroundStyle((callViewModel.isPaused || telecomManager.isPausedByRemote) ? Color.gray500 : .white) .frame(width: 32, height: 32) } .frame(width: 60, height: 60) - .background(Color.gray500) + .background((callViewModel.isPaused || telecomManager.isPausedByRemote) ? Color.gray600 : Color.gray500) .cornerRadius(40) + .disabled(callViewModel.isPaused || telecomManager.isPausedByRemote) Button { callViewModel.toggleMuteMicrophone() @@ -764,13 +809,13 @@ struct CallView: View { .padding(.top, 20) } } - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - .background(Color.gray900) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color.gray900) .if(fullscreenVideo) { view in view.ignoresSafeArea(.all) } - } + } func getAudioRouteImage() { imageAudioRoute = AVAudioSession.sharedInstance().currentRoute.outputs.filter({ $0.portType.rawValue == "Speaker" }).isEmpty diff --git a/Linphone/UI/Call/ViewModel/CallViewModel.swift b/Linphone/UI/Call/ViewModel/CallViewModel.swift index 382e41e5e..26f9dd2ed 100644 --- a/Linphone/UI/Call/ViewModel/CallViewModel.swift +++ b/Linphone/UI/Call/ViewModel/CallViewModel.swift @@ -35,7 +35,8 @@ class CallViewModel: ObservableObject { @Published var cameraDisplayed: Bool = false @Published var isRecording: Bool = false @Published var isRemoteRecording: Bool = false - @State var timeElapsed: Int = 0 + @Published var isPaused: Bool = false + @Published var timeElapsed: Int = 0 let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect() @@ -76,6 +77,8 @@ class CallViewModel: ObservableObject { self.micMutted = self.currentCall!.microphoneMuted self.cameraDisplayed = self.currentCall!.cameraEnabled == true self.isRecording = self.currentCall!.params!.isRecording + self.isPaused = self.isCallPaused() + self.timeElapsed = 0 } } } @@ -83,8 +86,9 @@ class CallViewModel: ObservableObject { func terminateCall() { withAnimation { - telecomManager.callInProgress = false + telecomManager.outgoingCallStarted = false telecomManager.callStarted = false + telecomManager.callInProgress = false } coreContext.doOnCoreQueue { _ in @@ -98,6 +102,7 @@ class CallViewModel: ObservableObject { func acceptCall() { withAnimation { + telecomManager.outgoingCallStarted = false telecomManager.callInProgress = true telecomManager.callStarted = true } @@ -184,9 +189,11 @@ class CallViewModel: ObservableObject { if self.isCallPaused() { Log.info("[CallViewModel] Resuming call \(self.currentCall!.remoteAddress!.asStringUriOnly())") try self.currentCall!.resume() + self.isPaused = false } else { Log.info("[CallViewModel] Pausing call \(self.currentCall!.remoteAddress!.asStringUriOnly())") try self.currentCall!.pause() + self.isPaused = true } } catch _ { @@ -195,7 +202,7 @@ class CallViewModel: ObservableObject { } } - private func isCallPaused() -> Bool { + func isCallPaused() -> Bool { var result = false if self.currentCall != nil { switch self.currentCall!.state { From 1ddf2602b91b8c6694260eb542ff2794c3f8eed9 Mon Sep 17 00:00:00 2001 From: QuentinArguillere Date: Tue, 9 Jan 2024 16:50:40 +0100 Subject: [PATCH 081/486] Fix or disable several swiftlint warnings --- Linphone/UI/Call/CallView.swift | 4 +++- .../UI/Main/Contacts/Fragments/ContactsListFragment.swift | 5 ++++- Linphone/UI/Main/Contacts/Model/ContactAvatarModel.swift | 6 ++++-- Linphone/UI/Main/ContentView.swift | 2 ++ .../UI/Main/History/Fragments/HistoryContactFragment.swift | 4 ++++ .../UI/Main/History/Fragments/HistoryListFragment.swift | 4 ++++ 6 files changed, 21 insertions(+), 4 deletions(-) diff --git a/Linphone/UI/Call/CallView.swift b/Linphone/UI/Call/CallView.swift index 9f1d99f5a..4c0bec411 100644 --- a/Linphone/UI/Call/CallView.swift +++ b/Linphone/UI/Call/CallView.swift @@ -18,6 +18,7 @@ */ // swiftlint:disable type_body_length +// swiftlint:disable line_length import SwiftUI import CallKit import AVFAudio @@ -131,7 +132,7 @@ struct CallView: View { .foregroundStyle(.white) .frame(width: 32, height: 32) .onAppear(perform: getAudioRouteImage) - .onReceive(pub) { (output) in + .onReceive(pub) { _ in self.getAudioRouteImage() } @@ -836,3 +837,4 @@ struct CallView: View { CallView(callViewModel: CallViewModel()) } // swiftlint:enable type_body_length +// swiftlint:enable line_length diff --git a/Linphone/UI/Main/Contacts/Fragments/ContactsListFragment.swift b/Linphone/UI/Main/Contacts/Fragments/ContactsListFragment.swift index 41bfc686b..3f594f977 100644 --- a/Linphone/UI/Main/Contacts/Fragments/ContactsListFragment.swift +++ b/Linphone/UI/Main/Contacts/Fragments/ContactsListFragment.swift @@ -98,5 +98,8 @@ struct ContactsListFragment: View { } #Preview { - ContactsListFragment(contactViewModel: ContactViewModel(), contactsListViewModel: ContactsListViewModel(), showingSheet: .constant(false), startCallFunc: {_ in }) + ContactsListFragment(contactViewModel: ContactViewModel() + , contactsListViewModel: ContactsListViewModel() + , showingSheet: .constant(false) + , startCallFunc: {_ in }) } diff --git a/Linphone/UI/Main/Contacts/Model/ContactAvatarModel.swift b/Linphone/UI/Main/Contacts/Model/ContactAvatarModel.swift index fb469c160..efd2e1426 100644 --- a/Linphone/UI/Main/Contacts/Model/ContactAvatarModel.swift +++ b/Linphone/UI/Main/Contacts/Model/ContactAvatarModel.swift @@ -44,7 +44,8 @@ class ContactAvatarModel: ObservableObject { 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) + self.lastPresenceInfo = (friend!.consolidatedPresence == .Online) ? + "Online" : getCallTime(startDate: friend!.presenceModel!.latestActivityTimestamp) } else { self.lastPresenceInfo = "Away" } @@ -68,7 +69,8 @@ class ContactAvatarModel: ObservableObject { self.presenceStatus = cbValue.consolidatedPresence if cbValue.consolidatedPresence == .Online || cbValue.consolidatedPresence == .Busy { if cbValue.consolidatedPresence == .Online || cbValue.presenceModel!.latestActivityTimestamp != -1 { - self.lastPresenceInfo = cbValue.consolidatedPresence == .Online ? "Online" : self.getCallTime(startDate: cbValue.presenceModel!.latestActivityTimestamp) + self.lastPresenceInfo = cbValue.consolidatedPresence == .Online ? + "Online" : self.getCallTime(startDate: cbValue.presenceModel!.latestActivityTimestamp) } else { self.lastPresenceInfo = "Away" } diff --git a/Linphone/UI/Main/ContentView.swift b/Linphone/UI/Main/ContentView.swift index ee403524a..69886fde9 100644 --- a/Linphone/UI/Main/ContentView.swift +++ b/Linphone/UI/Main/ContentView.swift @@ -18,6 +18,7 @@ */ // swiftlint:disable type_body_length +// swiftlint:disable line_length import SwiftUI import linphonesw @@ -731,3 +732,4 @@ struct ContentView: View { ) } // swiftlint:enable type_body_length +// swiftlint:enable line_length diff --git a/Linphone/UI/Main/History/Fragments/HistoryContactFragment.swift b/Linphone/UI/Main/History/Fragments/HistoryContactFragment.swift index 7ebc04890..370009462 100644 --- a/Linphone/UI/Main/History/Fragments/HistoryContactFragment.swift +++ b/Linphone/UI/Main/History/Fragments/HistoryContactFragment.swift @@ -17,6 +17,8 @@ * along with this program. If not, see . */ +// swiftlint:disable line_length + import SwiftUI import UniformTypeIdentifiers @@ -549,3 +551,5 @@ struct HistoryContactFragment: View { indexPage: .constant(1) ) } + +// swiftlint:enable line_length diff --git a/Linphone/UI/Main/History/Fragments/HistoryListFragment.swift b/Linphone/UI/Main/History/Fragments/HistoryListFragment.swift index ceafcb884..71af38981 100644 --- a/Linphone/UI/Main/History/Fragments/HistoryListFragment.swift +++ b/Linphone/UI/Main/History/Fragments/HistoryListFragment.swift @@ -17,6 +17,8 @@ * along with this program. If not, see . */ +// swiftlint:disable line_length + import SwiftUI import linphonesw @@ -217,3 +219,5 @@ struct HistoryListFragment: View { #Preview { HistoryListFragment(historyListViewModel: HistoryListViewModel(), historyViewModel: HistoryViewModel(), showingSheet: .constant(false)) } + +// swiftlint:enable line_length From 04dbce540cea98bcdf22d0b546a7e0ddb1ade7af Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Tue, 9 Jan 2024 17:29:51 +0100 Subject: [PATCH 082/486] Fixes --- .../AppIcon.appiconset/1024.png | Bin 0 -> 41298 bytes .../AppIcon.appiconset/Contents.json | 1 + Linphone/Core/CoreContext.swift | 45 ++- Linphone/Info.plist | 2 + Linphone/LinphoneApp.swift | 8 +- Linphone/TelecomManager/TelecomManager.swift | 4 +- .../Assistant/Fragments/LoginFragment.swift | 6 + .../Fragments/RegisterFragment.swift | 3 + .../ThirdPartySipAccountLoginFragment.swift | 10 +- .../ThirdPartySipAccountWarningFragment.swift | 3 + .../Viewmodel/AccountLoginViewModel.swift | 5 +- Linphone/UI/Call/CallView.swift | 153 +++++++- Linphone/UI/Main/Contacts/ContactsView.swift | 4 +- .../ContactInnerActionsFragment.swift | 4 +- .../Fragments/ContactInnerFragment.swift | 44 +-- .../Fragments/ContactsListBottomSheet.swift | 2 + Linphone/UI/Main/ContentView.swift | 9 +- .../History/Fragments/DialerBottomSheet.swift | 2 +- .../Fragments/HistoryContactFragment.swift | 364 +++++++++--------- .../Fragments/HistoryListFragment.swift | 6 +- .../History/Fragments/StartCallFragment.swift | 65 ++-- Linphone/UI/Main/History/HistoryView.swift | 4 +- Linphone/UI/Welcome/WelcomeView.swift | 2 + Linphone/Utils/PermissionManager.swift | 10 + 24 files changed, 474 insertions(+), 282 deletions(-) create mode 100644 Linphone/Assets.xcassets/AppIcon.appiconset/1024.png diff --git a/Linphone/Assets.xcassets/AppIcon.appiconset/1024.png b/Linphone/Assets.xcassets/AppIcon.appiconset/1024.png new file mode 100644 index 0000000000000000000000000000000000000000..7b3df457925f29eb1b3ce0ef1dc0b7380a49b381 GIT binary patch literal 41298 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 literal 0 HcmV?d00001 diff --git a/Linphone/Assets.xcassets/AppIcon.appiconset/Contents.json b/Linphone/Assets.xcassets/AppIcon.appiconset/Contents.json index 532cd729c..eab818e5a 100644 --- a/Linphone/Assets.xcassets/AppIcon.appiconset/Contents.json +++ b/Linphone/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -1,6 +1,7 @@ { "images" : [ { + "filename" : "1024.png", "idiom" : "universal", "platform" : "ios", "size" : "1024x1024" diff --git a/Linphone/Core/CoreContext.swift b/Linphone/Core/CoreContext.swift index a07c6b926..a7a0abb4a 100644 --- a/Linphone/Core/CoreContext.swift +++ b/Linphone/Core/CoreContext.swift @@ -38,7 +38,7 @@ final class CoreContext: ObservableObject { private var mCore: Core! private var mIterateSuscription: AnyCancellable? private var mCoreSuscriptions = Set() - + private init() { do { try initialiseCore() @@ -68,17 +68,17 @@ final class CoreContext: ObservableObject { Factory.Instance.logCollectionPath = configDir Factory.Instance.enableLogCollection(state: LogCollectionState.Enabled) - let url = NSURL(fileURLWithPath: configDir) - if let pathComponent = url.appendingPathComponent("linphonerc") { - let filePath = pathComponent.path - let fileManager = FileManager.default - if !fileManager.fileExists(atPath: filePath) { - let path = Bundle.main.path(forResource: "linphonerc-default", ofType: nil) - if path != nil { - try? FileManager.default.copyItem(at: NSURL(fileURLWithPath: path!) as URL, to: pathComponent) - } - } - } + let url = NSURL(fileURLWithPath: configDir) + if let pathComponent = url.appendingPathComponent("linphonerc") { + let filePath = pathComponent.path + let fileManager = FileManager.default + if !fileManager.fileExists(atPath: filePath) { + let path = Bundle.main.path(forResource: "linphonerc-default", ofType: nil) + if path != nil { + try? FileManager.default.copyItem(at: NSURL(fileURLWithPath: path!) as URL, to: pathComponent) + } + } + } let config = try? Factory.Instance.createConfigWithFactory( path: "\(configDir)/linphonerc", @@ -87,7 +87,7 @@ final class CoreContext: ObservableObject { if config != nil { self.mCore = try? Factory.Instance.createCoreWithConfig(config: config!, systemContext: nil) } - + self.mCore.autoIterateEnabled = false self.mCore.callkitEnabled = true self.mCore.pushNotificationEnabled = true @@ -113,11 +113,14 @@ final class CoreContext: ObservableObject { NSLog("New configuration state is \(cbVal.status) = \(cbVal.message)\n") if cbVal.status == Config.ConfiguringState.Successful { ToastViewModel.shared.toastMessage = "Successful" - ToastViewModel.shared.displayToast.toggle() - } else { - ToastViewModel.shared.toastMessage = "Failed" - ToastViewModel.shared.displayToast.toggle() - } + ToastViewModel.shared.displayToast = true + } + /* + else { + ToastViewModel.shared.toastMessage = "Failed" + ToastViewModel.shared.displayToast = true + } + */ }) self.mCoreSuscriptions.insert(self.mCore.publisher?.onAccountRegistrationStateChanged?.postOnMainQueue { (cbVal: (core: Core, account: Account, state: RegistrationState, message: String)) in @@ -135,7 +138,7 @@ final class CoreContext: ObservableObject { self.loggingInProgress = true } else { ToastViewModel.shared.toastMessage = "Registration failed" - ToastViewModel.shared.displayToast.toggle() + ToastViewModel.shared.displayToast = true self.loggingInProgress = false self.loggedIn = false } @@ -166,9 +169,9 @@ final class CoreContext: ObservableObject { cbValue.info, forPasteboardType: UTType.plainText.identifier ) - + ToastViewModel.shared.toastMessage = "Success_copied_into_clipboard" - ToastViewModel.shared.displayToast.toggle() + ToastViewModel.shared.displayToast = true } }) diff --git a/Linphone/Info.plist b/Linphone/Info.plist index 340c36eb2..33e2c5a4d 100644 --- a/Linphone/Info.plist +++ b/Linphone/Info.plist @@ -2,6 +2,8 @@ + UIUserInterfaceStyle + Light NSCameraUsageDescription Camera usage is required for video VOIP calls NSMicrophoneUsageDescription diff --git a/Linphone/LinphoneApp.swift b/Linphone/LinphoneApp.swift index 950f6722f..9596a76ce 100644 --- a/Linphone/LinphoneApp.swift +++ b/Linphone/LinphoneApp.swift @@ -38,8 +38,14 @@ struct LinphoneApp: App { if !sharedMainViewModel.welcomeViewDisplayed { WelcomeView() } else if coreContext.defaultAccount == nil || sharedMainViewModel.displayProfileMode { - AssistantView() + ZStack { + AssistantView() + + ToastView() + .zIndex(3) + } } else if coreContext.defaultAccount != nil + && coreContext.loggedIn && contactViewModel != nil && editContactViewModel != nil && historyViewModel != nil diff --git a/Linphone/TelecomManager/TelecomManager.swift b/Linphone/TelecomManager/TelecomManager.swift index 506ba21e4..aa93fd28a 100644 --- a/Linphone/TelecomManager/TelecomManager.swift +++ b/Linphone/TelecomManager/TelecomManager.swift @@ -120,10 +120,10 @@ class TelecomManager: ObservableObject { } } - func doCallWithCore(addr: Address) { + func doCallWithCore(addr: Address, isVideo: Bool) { CoreContext.shared.doOnCoreQueue { core in do { - try self.startCallCallKit(core: core, addr: addr, isSas: false, isVideo: false, isConference: false) + try self.startCallCallKit(core: core, addr: addr, isSas: false, isVideo: isVideo, isConference: false) } catch { Log.error("[TelecomManager] unable to create address for a new outgoing call : \(addr) \(error) ") } diff --git a/Linphone/UI/Assistant/Fragments/LoginFragment.swift b/Linphone/UI/Assistant/Fragments/LoginFragment.swift index 8ae3e46e1..51fbd099c 100644 --- a/Linphone/UI/Assistant/Fragments/LoginFragment.swift +++ b/Linphone/UI/Assistant/Fragments/LoginFragment.swift @@ -63,6 +63,8 @@ struct LoginFragment: View { TextField("username", text: $accountLoginViewModel.username) .default_text_style(styleSize: 15) + .disableAutocorrection(true) + .autocapitalization(.none) .disabled(coreContext.loggedIn) .frame(height: 25) .padding(.horizontal, 20) @@ -90,6 +92,8 @@ struct LoginFragment: View { } else { TextField("password", text: $accountLoginViewModel.passwd) .default_text_style(styleSize: 15) + .disableAutocorrection(true) + .autocapitalization(.none) .frame(height: 25) .focused($isPasswordFocused) } @@ -287,6 +291,8 @@ struct LoginFragment: View { .background(.black.opacity(0.65)) } } + .navigationTitle("") + .navigationBarHidden(true) } .navigationViewStyle(StackNavigationViewStyle()) } diff --git a/Linphone/UI/Assistant/Fragments/RegisterFragment.swift b/Linphone/UI/Assistant/Fragments/RegisterFragment.swift index 194bd5b5a..dbc339513 100644 --- a/Linphone/UI/Assistant/Fragments/RegisterFragment.swift +++ b/Linphone/UI/Assistant/Fragments/RegisterFragment.swift @@ -65,8 +65,11 @@ struct RegisterFragment: View { } } } + .navigationTitle("") + .navigationBarHidden(true) } .navigationViewStyle(StackNavigationViewStyle()) + .navigationTitle("") .navigationBarHidden(true) } } diff --git a/Linphone/UI/Assistant/Fragments/ThirdPartySipAccountLoginFragment.swift b/Linphone/UI/Assistant/Fragments/ThirdPartySipAccountLoginFragment.swift index 6bb7e3bd0..8a8d5aa0b 100644 --- a/Linphone/UI/Assistant/Fragments/ThirdPartySipAccountLoginFragment.swift +++ b/Linphone/UI/Assistant/Fragments/ThirdPartySipAccountLoginFragment.swift @@ -81,6 +81,8 @@ struct ThirdPartySipAccountLoginFragment: View { TextField("username", text: $accountLoginViewModel.username) .default_text_style(styleSize: 15) + .disableAutocorrection(true) + .autocapitalization(.none) .disabled(coreContext.loggedIn) .frame(height: 25) .padding(.horizontal, 20) @@ -108,6 +110,8 @@ struct ThirdPartySipAccountLoginFragment: View { } else { TextField("password", text: $accountLoginViewModel.passwd) .default_text_style(styleSize: 15) + .disableAutocorrection(true) + .autocapitalization(.none) .frame(height: 25) .focused($isPasswordFocused) } @@ -139,6 +143,8 @@ struct ThirdPartySipAccountLoginFragment: View { 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) @@ -158,6 +164,8 @@ struct ThirdPartySipAccountLoginFragment: View { TextField("Display Name", text: $accountLoginViewModel.displayName) .default_text_style(styleSize: 15) + .disableAutocorrection(true) + .autocapitalization(.none) .disabled(coreContext.loggedIn) .frame(height: 25) .padding(.horizontal, 20) @@ -204,8 +212,6 @@ struct ThirdPartySipAccountLoginFragment: View { Button(action: { self.accountLoginViewModel.login() - accountLoginViewModel.domain = "sip.linphone.org" - accountLoginViewModel.transportType = "TLS" }, label: { Text(coreContext.loggedIn ? "Log out" : "assistant_account_login") .default_text_style_white_600(styleSize: 20) diff --git a/Linphone/UI/Assistant/Fragments/ThirdPartySipAccountWarningFragment.swift b/Linphone/UI/Assistant/Fragments/ThirdPartySipAccountWarningFragment.swift index a3aa14ad5..f9f9fb1f8 100644 --- a/Linphone/UI/Assistant/Fragments/ThirdPartySipAccountWarningFragment.swift +++ b/Linphone/UI/Assistant/Fragments/ThirdPartySipAccountWarningFragment.swift @@ -171,8 +171,11 @@ struct ThirdPartySipAccountWarningFragment: View { .frame(minHeight: geometry.size.height) } } + .navigationTitle("") + .navigationBarHidden(true) } .navigationViewStyle(StackNavigationViewStyle()) + .navigationTitle("") .navigationBarHidden(true) } } diff --git a/Linphone/UI/Assistant/Viewmodel/AccountLoginViewModel.swift b/Linphone/UI/Assistant/Viewmodel/AccountLoginViewModel.swift index dd9149693..8cf56625f 100644 --- a/Linphone/UI/Assistant/Viewmodel/AccountLoginViewModel.swift +++ b/Linphone/UI/Assistant/Viewmodel/AccountLoginViewModel.swift @@ -45,7 +45,7 @@ class AccountLoginViewModel: ObservableObject { core.loadConfigFromXml(xmlUri: assistantLinphone) } } - + // Get the transport protocol to use. // TLS is strongly recommended // Only use UDP if you don't have the choice @@ -106,6 +106,9 @@ class AccountLoginViewModel: ObservableObject { self.coreContext.defaultAccount = account } + self.domain = "sip.linphone.org" + self.transportType = "TLS" + } catch { NSLog(error.localizedDescription) } } } diff --git a/Linphone/UI/Call/CallView.swift b/Linphone/UI/Call/CallView.swift index 4c0bec411..8d06e91b6 100644 --- a/Linphone/UI/Call/CallView.swift +++ b/Linphone/UI/Call/CallView.swift @@ -427,18 +427,29 @@ struct CallView: View { .presentationDetents([.fraction(0.3)]) .frame(maxHeight: .infinity) } + } else { + innerView(geometry: geo) } } } @ViewBuilder + // swiftlint:disable:next cyclomatic_complexity func innerView(geometry: GeometryProxy) -> some View { VStack { if !fullscreenVideo { - Rectangle() - .foregroundColor(Color.orangeMain500) - .edgesIgnoringSafeArea(.top) - .frame(height: 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 { if callViewModel.direction == .Outgoing { @@ -465,13 +476,8 @@ struct CallView: View { ZStack { Text(callViewModel.timeElapsed.convertDurationToString()) - .onAppear { - callViewModel.timeElapsed = 0 - startDate = Date.now - } .onReceive(callViewModel.timer) { firedDate in callViewModel.timeElapsed = Int(firedDate.timeIntervalSince(startDate)) - } .foregroundStyle(.white) .if(callViewModel.isPaused || telecomManager.isPausedByRemote) { view in @@ -646,6 +652,10 @@ struct CallView: View { callViewModel.timeElapsed = Int(firedDate.timeIntervalSince(startDate)) } + .onDisappear { + callViewModel.timeElapsed = 0 + startDate = Date.now + } .padding(.top) .foregroundStyle(.white) @@ -695,16 +705,101 @@ struct CallView: View { if !fullscreenVideo { if telecomManager.callStarted { - if telecomManager.callStarted && idiom != .pad && !(orientation == .landscapeLeft || orientation == .landscapeRight - || UIScreen.main.bounds.size.width > UIScreen.main.bounds.size.height) { - HStack(spacing: 12) { - HStack { - + if #available(iOS 16.0, *) { + if telecomManager.callStarted && idiom != .pad && !(orientation == .landscapeLeft || orientation == .landscapeRight + || UIScreen.main.bounds.size.width > UIScreen.main.bounds.size.height) { + HStack(spacing: 12) { + HStack { + + } + .frame(height: 60) } - .frame(height: 60) + .padding(.horizontal, 25) + .padding(.top, 20) + } else { + HStack(spacing: 12) { + Button { + callViewModel.terminateCall() + } label: { + Image("phone-disconnect") + .renderingMode(.template) + .resizable() + .foregroundStyle(.white) + .frame(width: 32, height: 32) + + } + .frame(width: 90, height: 60) + .background(Color.redDanger500) + .cornerRadius(40) + + Spacer() + + Button { + callViewModel.toggleVideo() + } label: { + Image(callViewModel.cameraDisplayed ? "video-camera" : "video-camera-slash") + .renderingMode(.template) + .resizable() + .foregroundStyle((callViewModel.isPaused || telecomManager.isPausedByRemote) ? Color.gray500 : .white) + .frame(width: 32, height: 32) + + } + .frame(width: 60, height: 60) + .background((callViewModel.isPaused || telecomManager.isPausedByRemote) ? Color.gray600 : Color.gray500) + .cornerRadius(40) + .disabled(callViewModel.isPaused || telecomManager.isPausedByRemote) + + Button { + callViewModel.toggleMuteMicrophone() + } label: { + Image(callViewModel.micMutted ? "microphone-slash" : "microphone") + .renderingMode(.template) + .resizable() + .foregroundStyle(callViewModel.micMutted ? .black : .white) + .frame(width: 32, height: 32) + + } + .frame(width: 60, height: 60) + .background(callViewModel.micMutted ? .white : Color.gray500) + .cornerRadius(40) + + Button { + if AVAudioSession.sharedInstance().availableInputs != nil + && !AVAudioSession.sharedInstance().availableInputs!.filter({ $0.portType.rawValue.contains("Bluetooth") }).isEmpty { + + hideButtonsSheet = true + + DispatchQueue.global().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: { + Image(imageAudioRoute) + .renderingMode(.template) + .resizable() + .foregroundStyle(.white) + .frame(width: 32, height: 32) + .onAppear(perform: getAudioRouteImage) + .onReceive(pub) { _ in + self.getAudioRouteImage() + } + + } + .frame(width: 60, height: 60) + .background(Color.gray500) + .cornerRadius(40) + } + .frame(height: geometry.size.height * 0.15) + .padding(.horizontal, 20) + .padding(.top, -6) } - .padding(.horizontal, 25) - .padding(.top, 20) } else { HStack(spacing: 12) { Button { @@ -726,7 +821,7 @@ struct CallView: View { Button { callViewModel.toggleVideo() } label: { - Image("video-camera") + Image(callViewModel.cameraDisplayed ? "video-camera" : "video-camera-slash") .renderingMode(.template) .resizable() .foregroundStyle((callViewModel.isPaused || telecomManager.isPausedByRemote) ? Color.gray500 : .white) @@ -753,12 +848,32 @@ struct CallView: View { .cornerRadius(40) Button { + if AVAudioSession.sharedInstance().availableInputs != nil + && !AVAudioSession.sharedInstance().availableInputs!.filter({ $0.portType.rawValue.contains("Bluetooth") }).isEmpty { + + hideButtonsSheet = true + + DispatchQueue.global().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: { - Image("speaker-high") + Image(imageAudioRoute) .renderingMode(.template) .resizable() .foregroundStyle(.white) .frame(width: 32, height: 32) + .onAppear(perform: getAudioRouteImage) + .onReceive(pub) { _ in + self.getAudioRouteImage() + } } .frame(width: 60, height: 60) diff --git a/Linphone/UI/Main/Contacts/ContactsView.swift b/Linphone/UI/Main/Contacts/ContactsView.swift index a8191f047..fea21d9c7 100644 --- a/Linphone/UI/Main/Contacts/ContactsView.swift +++ b/Linphone/UI/Main/Contacts/ContactsView.swift @@ -41,8 +41,10 @@ struct ContactsView: View { } } label: { Image("user-plus") + .renderingMode(.template) + .foregroundStyle(.white) .padding() - .background(.white) + .background(Color.orangeMain500) .clipShape(Circle()) .shadow(color: .black.opacity(0.2), radius: 4) diff --git a/Linphone/UI/Main/Contacts/Fragments/ContactInnerActionsFragment.swift b/Linphone/UI/Main/Contacts/Fragments/ContactInnerActionsFragment.swift index 0e256c39e..f15e7bd3a 100644 --- a/Linphone/UI/Main/Contacts/Fragments/ContactInnerActionsFragment.swift +++ b/Linphone/UI/Main/Contacts/Fragments/ContactInnerActionsFragment.swift @@ -90,7 +90,7 @@ struct ContactInnerActionsFragment: View { .onTapGesture { withAnimation { telecomManager.doCallWithCore( - addr: contactsManager.lastSearch[contactViewModel.indexDisplayedFriend!].friend!.addresses[index] + addr: contactsManager.lastSearch[contactViewModel.indexDisplayedFriend!].friend!.addresses[index], isVideo: false ) } } @@ -272,7 +272,9 @@ struct ContactInnerActionsFragment: View { Button { if contactsManager.lastSearch[contactViewModel.indexDisplayedFriend!].friend != nil { contactViewModel.objectWillChange.send() + contactsManager.lastSearch[contactViewModel.indexDisplayedFriend!].friend!.edit() contactsManager.lastSearch[contactViewModel.indexDisplayedFriend!].friend!.starred.toggle() + contactsManager.lastSearch[contactViewModel.indexDisplayedFriend!].friend!.done() } } label: { HStack { diff --git a/Linphone/UI/Main/Contacts/Fragments/ContactInnerFragment.swift b/Linphone/UI/Main/Contacts/Fragments/ContactInnerFragment.swift index a2627196a..72d4baece 100644 --- a/Linphone/UI/Main/Contacts/Fragments/ContactInnerFragment.swift +++ b/Linphone/UI/Main/Contacts/Fragments/ContactInnerFragment.swift @@ -86,19 +86,19 @@ struct ContactInnerFragment: View { contactViewModel: contactViewModel, isShowEditContactFragment: .constant(false), isShowDismissPopup: $isShowDismissPopup)) { - Image("pencil-simple") - .renderingMode(.template) - .resizable() - .foregroundStyle(Color.orangeMain500) - .frame(width: 25, height: 25, alignment: .leading) - .padding(.top, 2) - } - .simultaneousGesture( - TapGesture().onEnded { - editContactViewModel.selectedEditFriend = contactsManager.lastSearch[contactViewModel.indexDisplayedFriend!].friend - editContactViewModel.resetValues() + Image("pencil-simple") + .renderingMode(.template) + .resizable() + .foregroundStyle(Color.orangeMain500) + .frame(width: 25, height: 25, alignment: .leading) + .padding(.top, 2) } - ) + .simultaneousGesture( + TapGesture().onEnded { + editContactViewModel.selectedEditFriend = contactsManager.lastSearch[contactViewModel.indexDisplayedFriend!].friend + editContactViewModel.resetValues() + } + ) } } .frame(maxWidth: .infinity) @@ -132,10 +132,10 @@ struct ContactInnerFragment: View { .frame(maxWidth: .infinity) .padding(.top, 10) - Text(contactAvatarModel.lastPresenceInfo) + Text(contactAvatarModel.lastPresenceInfo) .foregroundStyle(contactAvatarModel.lastPresenceInfo == "Online" - ? Color.greenSuccess500 - : Color.orangeWarning600) + ? Color.greenSuccess500 + : Color.orangeWarning600) .multilineTextAlignment(.center) .default_text_style_300(styleSize: 12) .frame(maxWidth: .infinity) @@ -151,7 +151,7 @@ struct ContactInnerFragment: View { Spacer() Button(action: { - telecomManager.doCallWithCore(addr: contactsManager.lastSearch[contactViewModel.indexDisplayedFriend!].friend!.address!) + telecomManager.doCallWithCore(addr: contactsManager.lastSearch[contactViewModel.indexDisplayedFriend!].friend!.address!, isVideo: false) }, label: { VStack { HStack(alignment: .center) { @@ -180,7 +180,8 @@ struct ContactInnerFragment: View { Image("chat-teardrop-text") .renderingMode(.template) .resizable() - .foregroundStyle(Color.grayMain2c600) + //.foregroundStyle(Color.grayMain2c600) + .foregroundStyle(Color.grayMain2c300) .frame(width: 25, height: 25) .onTapGesture { withAnimation { @@ -200,7 +201,7 @@ struct ContactInnerFragment: View { Spacer() Button(action: { - + telecomManager.doCallWithCore(addr: contactsManager.lastSearch[contactViewModel.indexDisplayedFriend!].friend!.address!, isVideo: true) }, label: { VStack { HStack(alignment: .center) { @@ -209,11 +210,6 @@ struct ContactInnerFragment: View { .resizable() .foregroundStyle(Color.grayMain2c600) .frame(width: 25, height: 25) - .onTapGesture { - withAnimation { - - } - } } .padding(16) .background(Color.grayMain2c200) @@ -229,7 +225,7 @@ struct ContactInnerFragment: View { .padding(.top, 20) .frame(maxWidth: .infinity) .background(Color.gray100) - + ContactInnerActionsFragment( contactViewModel: contactViewModel, editContactViewModel: editContactViewModel, diff --git a/Linphone/UI/Main/Contacts/Fragments/ContactsListBottomSheet.swift b/Linphone/UI/Main/Contacts/Fragments/ContactsListBottomSheet.swift index bf6a9645d..644e6f16c 100644 --- a/Linphone/UI/Main/Contacts/Fragments/ContactsListBottomSheet.swift +++ b/Linphone/UI/Main/Contacts/Fragments/ContactsListBottomSheet.swift @@ -58,7 +58,9 @@ struct ContactsListBottomSheet: View { 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) diff --git a/Linphone/UI/Main/ContentView.swift b/Linphone/UI/Main/ContentView.swift index 69886fde9..267968710 100644 --- a/Linphone/UI/Main/ContentView.swift +++ b/Linphone/UI/Main/ContentView.swift @@ -151,6 +151,7 @@ struct ContentView: View { Menu { if index == 0 { Button { + contactViewModel.indexDisplayedFriend = nil isMenuOpen = false magicSearch.allContact = true MagicSearchSingleton.shared.searchForContacts( @@ -168,6 +169,7 @@ struct ContentView: View { } Button { + contactViewModel.indexDisplayedFriend = nil isMenuOpen = false magicSearch.allContact = false MagicSearchSingleton.shared.searchForContacts( @@ -282,9 +284,8 @@ struct ContentView: View { text = newValue } )) - .default_text_style_white_700(styleSize: 15) + .default_text_style_700(styleSize: 15) .padding(.all, 6) - .accentColor(.white) .focused($focusedField) .onAppear { self.focusedField = true @@ -671,10 +672,8 @@ struct ContentView: View { } } - // if sharedMainViewModel.displayToast { ToastView() .zIndex(3) - // } } } .overlay { @@ -698,12 +697,14 @@ struct ContentView: View { .onChange(of: scenePhase) { newPhase in if newPhase == .active { coreContext.onForeground() + /* if !isShowStartCallFragment { contactsManager.fetchContacts() DispatchQueue.global().asyncAfter(deadline: .now() + 0.5) { historyListViewModel.computeCallLogsList() } } + */ print("Active") } else if newPhase == .inactive { print("Inactive") diff --git a/Linphone/UI/Main/History/Fragments/DialerBottomSheet.swift b/Linphone/UI/Main/History/Fragments/DialerBottomSheet.swift index dc3915be6..f8d2b0ddd 100644 --- a/Linphone/UI/Main/History/Fragments/DialerBottomSheet.swift +++ b/Linphone/UI/Main/History/Fragments/DialerBottomSheet.swift @@ -275,7 +275,7 @@ struct DialerBottomSheet: View { if !startCallViewModel.searchField.isEmpty { do { let address = try Factory.Instance.createAddress(addr: String("sip:" + startCallViewModel.searchField + "@" + startCallViewModel.domain)) - telecomManager.doCallWithCore(addr: address) + telecomManager.doCallWithCore(addr: address, isVideo: false) } catch { } diff --git a/Linphone/UI/Main/History/Fragments/HistoryContactFragment.swift b/Linphone/UI/Main/History/Fragments/HistoryContactFragment.swift index 370009462..a2fd12066 100644 --- a/Linphone/UI/Main/History/Fragments/HistoryContactFragment.swift +++ b/Linphone/UI/Main/History/Fragments/HistoryContactFragment.swift @@ -32,15 +32,15 @@ struct HistoryContactFragment: View { @ObservedObject var contactAvatarModel: ContactAvatarModel @ObservedObject var historyViewModel: HistoryViewModel - @ObservedObject var historyListViewModel: HistoryListViewModel - @ObservedObject var contactViewModel: ContactViewModel - @ObservedObject var editContactViewModel: EditContactViewModel + @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 + @Binding var isShowEditContactFragment: Bool + @Binding var indexPage: Int var body: some View { NavigationView { @@ -72,25 +72,25 @@ struct HistoryContactFragment: View { Spacer() Menu { - let fromAddressFriend = historyViewModel.displayedCall != nil ? contactsManager.getFriendWithAddress(address: historyViewModel.displayedCall!.fromAddress!) : nil - let toAddressFriend = historyViewModel.displayedCall != nil ? contactsManager.getFriendWithAddress(address: historyViewModel.displayedCall!.toAddress!) : nil - let addressFriend = historyViewModel.displayedCall != nil ? (historyViewModel.displayedCall!.dir == .Incoming ? fromAddressFriend : toAddressFriend) : nil - + let fromAddressFriend = historyViewModel.displayedCall != nil ? contactsManager.getFriendWithAddress(address: historyViewModel.displayedCall!.fromAddress!) : nil + let toAddressFriend = historyViewModel.displayedCall != nil ? contactsManager.getFriendWithAddress(address: historyViewModel.displayedCall!.toAddress!) : nil + let addressFriend = historyViewModel.displayedCall != nil ? (historyViewModel.displayedCall!.dir == .Incoming ? fromAddressFriend : toAddressFriend) : nil + Button { isMenuOpen = false - - if contactsManager.getFriendWithAddress( - address: historyViewModel.displayedCall != nil && historyViewModel.displayedCall!.dir == .Outgoing - ? historyViewModel.displayedCall!.toAddress! - : historyViewModel.displayedCall!.fromAddress! - ) != nil { - let addressCall = historyViewModel.displayedCall != nil && historyViewModel.displayedCall!.dir == .Outgoing - ? historyViewModel.displayedCall!.toAddress! - : historyViewModel.displayedCall!.fromAddress! - - let friendIndex = contactsManager.lastSearch.firstIndex( - where: {$0.friend!.addresses.contains(where: {$0.asStringUriOnly() == addressCall.asStringUriOnly()})}) - if friendIndex != nil { + + if contactsManager.getFriendWithAddress( + address: historyViewModel.displayedCall != nil && historyViewModel.displayedCall!.dir == .Outgoing + ? historyViewModel.displayedCall!.toAddress! + : historyViewModel.displayedCall!.fromAddress! + ) != nil { + let addressCall = historyViewModel.displayedCall != nil && historyViewModel.displayedCall!.dir == .Outgoing + ? historyViewModel.displayedCall!.toAddress! + : historyViewModel.displayedCall!.fromAddress! + + let friendIndex = contactsManager.lastSearch.firstIndex( + where: {$0.friend!.addresses.contains(where: {$0.asStringUriOnly() == addressCall.asStringUriOnly()})}) + if friendIndex != nil { withAnimation { historyViewModel.displayedCall = nil @@ -98,28 +98,28 @@ struct HistoryContactFragment: View { contactViewModel.indexDisplayedFriend = friendIndex } - } - } else { - let addressCall = historyViewModel.displayedCall != nil && historyViewModel.displayedCall!.dir == .Outgoing - ? historyViewModel.displayedCall!.toAddress! - : historyViewModel.displayedCall!.fromAddress! - - withAnimation { + } + } else { + let addressCall = historyViewModel.displayedCall != nil && historyViewModel.displayedCall!.dir == .Outgoing + ? historyViewModel.displayedCall!.toAddress! + : historyViewModel.displayedCall!.fromAddress! + + withAnimation { historyViewModel.displayedCall = nil indexPage = 0 isShowEditContactFragment.toggle() - editContactViewModel.sipAddresses.removeAll() - editContactViewModel.sipAddresses.append(String(addressCall.asStringUriOnly().dropFirst(4))) + editContactViewModel.sipAddresses.removeAll() + editContactViewModel.sipAddresses.append(String(addressCall.asStringUriOnly().dropFirst(4))) editContactViewModel.sipAddresses.append("") - } - } - + } + } + } label: { HStack { - Text(addressFriend != nil ? "See contact" : "Add to contacts") + Text(addressFriend != nil ? "See contact" : "Add to contacts") Spacer() - Image(addressFriend != nil ? "user-circle" : "plus-circle") + Image(addressFriend != nil ? "user-circle" : "plus-circle") .resizable() .frame(width: 25, height: 25, alignment: .leading) } @@ -127,18 +127,18 @@ struct HistoryContactFragment: View { Button { isMenuOpen = false - - if historyViewModel.displayedCall != nil && historyViewModel.displayedCall!.dir == .Outgoing { - UIPasteboard.general.setValue( - historyViewModel.displayedCall!.toAddress!.asStringUriOnly().dropFirst(4), - forPasteboardType: UTType.plainText.identifier - ) - } else { - UIPasteboard.general.setValue( - historyViewModel.displayedCall!.fromAddress!.asStringUriOnly().dropFirst(4), - forPasteboardType: UTType.plainText.identifier - ) - } + + if historyViewModel.displayedCall != nil && historyViewModel.displayedCall!.dir == .Outgoing { + UIPasteboard.general.setValue( + historyViewModel.displayedCall!.toAddress!.asStringUriOnly().dropFirst(4), + forPasteboardType: UTType.plainText.identifier + ) + } else { + UIPasteboard.general.setValue( + historyViewModel.displayedCall!.fromAddress!.asStringUriOnly().dropFirst(4), + forPasteboardType: UTType.plainText.identifier + ) + } ToastViewModel.shared.toastMessage = "Success_copied_into_clipboard" ToastViewModel.shared.displayToast.toggle() @@ -194,8 +194,12 @@ struct HistoryContactFragment: View { ScrollView { VStack(spacing: 0) { VStack(spacing: 0) { + if #unavailable(iOS 16.0) { + Rectangle() + .foregroundColor(Color.gray100) + .frame(height: 7) + } VStack(spacing: 0) { - let fromAddressFriend = historyViewModel.displayedCall != nil ? contactsManager.getFriendWithAddress(address: historyViewModel.displayedCall!.fromAddress!) : nil let toAddressFriend = historyViewModel.displayedCall != nil ? contactsManager.getFriendWithAddress(address: historyViewModel.displayedCall!.toAddress!) : nil let addressFriend = historyViewModel.displayedCall != nil ? (historyViewModel.displayedCall!.dir == .Incoming ? fromAddressFriend : toAddressFriend) : nil @@ -223,13 +227,13 @@ struct HistoryContactFragment: View { .default_text_style(styleSize: 14) .frame(maxWidth: .infinity) .padding(.top, 10) - - Text(historyViewModel.displayedCall!.toAddress!.asStringUriOnly()) - .foregroundStyle(Color.grayMain2c700) - .multilineTextAlignment(.center) - .default_text_style(styleSize: 14) - .frame(maxWidth: .infinity) - .padding(.top, 5) + + Text(historyViewModel.displayedCall!.toAddress!.asStringUriOnly()) + .foregroundStyle(Color.grayMain2c700) + .multilineTextAlignment(.center) + .default_text_style(styleSize: 14) + .frame(maxWidth: .infinity) + .padding(.top, 5) Text("") .multilineTextAlignment(.center) @@ -252,13 +256,13 @@ struct HistoryContactFragment: View { .default_text_style(styleSize: 14) .frame(maxWidth: .infinity) .padding(.top, 10) - - Text(historyViewModel.displayedCall!.toAddress!.asStringUriOnly()) - .foregroundStyle(Color.grayMain2c700) - .multilineTextAlignment(.center) - .default_text_style(styleSize: 14) - .frame(maxWidth: .infinity) - .padding(.top, 5) + + Text(historyViewModel.displayedCall!.toAddress!.asStringUriOnly()) + .foregroundStyle(Color.grayMain2c700) + .multilineTextAlignment(.center) + .default_text_style(styleSize: 14) + .frame(maxWidth: .infinity) + .padding(.top, 5) Text("") .multilineTextAlignment(.center) @@ -284,14 +288,14 @@ struct HistoryContactFragment: View { .default_text_style(styleSize: 14) .frame(maxWidth: .infinity) .padding(.top, 10) - - Text(historyViewModel.displayedCall!.fromAddress!.asStringUriOnly()) - .foregroundStyle(Color.grayMain2c700) - .multilineTextAlignment(.center) - .default_text_style(styleSize: 14) - .frame(maxWidth: .infinity) - .padding(.top, 5) - + + Text(historyViewModel.displayedCall!.fromAddress!.asStringUriOnly()) + .foregroundStyle(Color.grayMain2c700) + .multilineTextAlignment(.center) + .default_text_style(styleSize: 14) + .frame(maxWidth: .infinity) + .padding(.top, 5) + Text("") .multilineTextAlignment(.center) .default_text_style_300(styleSize: 12) @@ -313,14 +317,14 @@ struct HistoryContactFragment: View { .default_text_style(styleSize: 14) .frame(maxWidth: .infinity) .padding(.top, 10) - - Text(historyViewModel.displayedCall!.fromAddress!.asStringUriOnly()) - .foregroundStyle(Color.grayMain2c700) - .multilineTextAlignment(.center) - .default_text_style(styleSize: 14) - .frame(maxWidth: .infinity) - .padding(.top, 5) - + + Text(historyViewModel.displayedCall!.fromAddress!.asStringUriOnly()) + .foregroundStyle(Color.grayMain2c700) + .multilineTextAlignment(.center) + .default_text_style(styleSize: 14) + .frame(maxWidth: .infinity) + .padding(.top, 5) + Text("") .multilineTextAlignment(.center) .default_text_style_300(styleSize: 12) @@ -338,22 +342,22 @@ struct HistoryContactFragment: View { .default_text_style(styleSize: 14) .frame(maxWidth: .infinity) .padding(.top, 10) - - if historyViewModel.displayedCall!.dir == .Outgoing && historyViewModel.displayedCall!.toAddress != nil { - Text(historyViewModel.displayedCall!.toAddress!.asStringUriOnly()) - .foregroundStyle(Color.grayMain2c700) - .multilineTextAlignment(.center) - .default_text_style(styleSize: 14) - .frame(maxWidth: .infinity) - .padding(.top, 5) - } else if historyViewModel.displayedCall!.fromAddress != nil { - Text(historyViewModel.displayedCall!.fromAddress!.asStringUriOnly()) - .foregroundStyle(Color.grayMain2c700) - .multilineTextAlignment(.center) - .default_text_style(styleSize: 14) - .frame(maxWidth: .infinity) - .padding(.top, 5) - } + + if historyViewModel.displayedCall!.dir == .Outgoing && historyViewModel.displayedCall!.toAddress != nil { + Text(historyViewModel.displayedCall!.toAddress!.asStringUriOnly()) + .foregroundStyle(Color.grayMain2c700) + .multilineTextAlignment(.center) + .default_text_style(styleSize: 14) + .frame(maxWidth: .infinity) + .padding(.top, 5) + } else if historyViewModel.displayedCall!.fromAddress != nil { + Text(historyViewModel.displayedCall!.fromAddress!.asStringUriOnly()) + .foregroundStyle(Color.grayMain2c700) + .multilineTextAlignment(.center) + .default_text_style(styleSize: 14) + .frame(maxWidth: .infinity) + .padding(.top, 5) + } Text(contactAvatarModel.lastPresenceInfo) .foregroundStyle(contactAvatarModel.lastPresenceInfo == "Online" @@ -369,21 +373,22 @@ struct HistoryContactFragment: View { .frame(minHeight: 150) .frame(maxWidth: .infinity) .padding(.top, 10) + .padding(.bottom, 2) .background(Color.gray100) HStack { Spacer() Button(action: { - if historyViewModel.displayedCall!.dir == .Outgoing && historyViewModel.displayedCall!.toAddress != nil { - telecomManager.doCallWithCore( - addr: historyViewModel.displayedCall!.toAddress! - ) - } else if historyViewModel.displayedCall!.dir == .Incoming && historyViewModel.displayedCall!.fromAddress != nil { - telecomManager.doCallWithCore( - addr: historyViewModel.displayedCall!.fromAddress! - ) - } + if historyViewModel.displayedCall!.dir == .Outgoing && historyViewModel.displayedCall!.toAddress != nil { + telecomManager.doCallWithCore( + addr: historyViewModel.displayedCall!.toAddress!, isVideo: false + ) + } else if historyViewModel.displayedCall!.dir == .Incoming && historyViewModel.displayedCall!.fromAddress != nil { + telecomManager.doCallWithCore( + addr: historyViewModel.displayedCall!.fromAddress!, isVideo: false + ) + } }, label: { VStack { HStack(alignment: .center) { @@ -399,6 +404,7 @@ struct HistoryContactFragment: View { Text("Appel") .default_text_style(styleSize: 14) + .frame(minWidth: 80) } }) @@ -412,7 +418,8 @@ struct HistoryContactFragment: View { Image("chat-teardrop-text") .renderingMode(.template) .resizable() - .foregroundStyle(Color.grayMain2c600) + //.foregroundStyle(Color.grayMain2c600) + .foregroundStyle(Color.grayMain2c300) .frame(width: 25, height: 25) .onTapGesture { withAnimation { @@ -426,13 +433,22 @@ struct HistoryContactFragment: View { Text("Message") .default_text_style(styleSize: 14) + .frame(minWidth: 80) } }) Spacer() Button(action: { - + if historyViewModel.displayedCall!.dir == .Outgoing && historyViewModel.displayedCall!.toAddress != nil { + telecomManager.doCallWithCore( + addr: historyViewModel.displayedCall!.toAddress!, isVideo: true + ) + } else if historyViewModel.displayedCall!.dir == .Incoming && historyViewModel.displayedCall!.fromAddress != nil { + telecomManager.doCallWithCore( + addr: historyViewModel.displayedCall!.fromAddress!, isVideo: true + ) + } }, label: { VStack { HStack(alignment: .center) { @@ -441,11 +457,6 @@ struct HistoryContactFragment: View { .resizable() .foregroundStyle(Color.grayMain2c600) .frame(width: 25, height: 25) - .onTapGesture { - withAnimation { - - } - } } .padding(16) .background(Color.grayMain2c200) @@ -453,71 +464,75 @@ struct HistoryContactFragment: View { Text("Video Call") .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!.dir == .Incoming ? historyViewModel.displayedCall!.fromAddress!.asStringUriOnly() - : historyViewModel.displayedCall!.toAddress!.asStringUriOnly()) : nil - - let callLogsFilter = historyListViewModel.callLogs.filter({ $0.dir == .Incoming - ? $0.fromAddress!.asStringUriOnly() == addressFriend - : $0.toAddress!.asStringUriOnly() == addressFriend }) - - ForEach(0.. Date: Fri, 12 Jan 2024 14:58:39 +0100 Subject: [PATCH 083/486] Fix side menu buttons --- Linphone/Core/CoreContext.swift | 6 ++- Linphone/Localizable.xcstrings | 8 +-- Linphone/UI/Main/Fragments/SideMenu.swift | 61 +++++++++++++++++----- Linphone/UI/Main/Fragments/ToastView.swift | 14 +++++ 4 files changed, 70 insertions(+), 19 deletions(-) diff --git a/Linphone/Core/CoreContext.swift b/Linphone/Core/CoreContext.swift index a7a0abb4a..e71947f38 100644 --- a/Linphone/Core/CoreContext.swift +++ b/Linphone/Core/CoreContext.swift @@ -170,8 +170,10 @@ final class CoreContext: ObservableObject { forPasteboardType: UTType.plainText.identifier ) - ToastViewModel.shared.toastMessage = "Success_copied_into_clipboard" - ToastViewModel.shared.displayToast = true + DispatchQueue.main.async { + ToastViewModel.shared.toastMessage = "Success_send_logs" + ToastViewModel.shared.displayToast = true + } } }) diff --git a/Linphone/Localizable.xcstrings b/Linphone/Localizable.xcstrings index 8dc0dee51..ec823b015 100644 --- a/Linphone/Localizable.xcstrings +++ b/Linphone/Localizable.xcstrings @@ -371,7 +371,10 @@ "Log out" : { }, - "Logout" : { + "Logs cleared" : { + + }, + "Logs URL copied into clipboard" : { }, "Message" : { @@ -382,9 +385,6 @@ }, "Missed call" : { - }, - "My Profile" : { - }, "New call" : { diff --git a/Linphone/UI/Main/Fragments/SideMenu.swift b/Linphone/UI/Main/Fragments/SideMenu.swift index 029f90ff1..e9fdc8d70 100644 --- a/Linphone/UI/Main/Fragments/SideMenu.swift +++ b/Linphone/UI/Main/Fragments/SideMenu.swift @@ -43,19 +43,44 @@ struct SideMenu: View { HStack { List { - Text("My Profile").onTapGesture { - print("My Profile") - } - Text("Send logs").onTapGesture { - sendLogs() - } - Text("Clear logs").onTapGesture { - print("Clear logs") - Core.resetLogCollection() - } - Text("Logout").onTapGesture { - print("Logout") - } + /* + Text("My Profile") + .frame(height: 40) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color.white) + .onTapGesture { + print("My Profile") + self.menuClose() + } + */ + Text("Send logs") + .frame(height: 40) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color.white) + .onTapGesture { + print("Send logs") + sendLogs() + self.menuClose() + } + Text("Clear logs") + .frame(height: 40) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color.white) + .onTapGesture { + print("Clear logs") + clearLogs() + self.menuClose() + } + /* + Text("Logout") + .frame(height: 40) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color.white) + .onTapGesture { + print("Logout") + self.menuClose() + } + */ } .frame(width: self.width - safeAreaInsets.leading) .background(Color.white) @@ -75,4 +100,14 @@ struct SideMenu: View { core.uploadLogCollection() } } + + func clearLogs() { + coreContext.doOnCoreQueue { core in + Core.resetLogCollection() + DispatchQueue.main.async { + ToastViewModel.shared.toastMessage = "Success_clear_logs" + ToastViewModel.shared.displayToast = true + } + } + } } diff --git a/Linphone/UI/Main/Fragments/ToastView.swift b/Linphone/UI/Main/Fragments/ToastView.swift index 9f8dc277e..565128b7e 100644 --- a/Linphone/UI/Main/Fragments/ToastView.swift +++ b/Linphone/UI/Main/Fragments/ToastView.swift @@ -48,6 +48,20 @@ struct ToastView: View { .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_copied_into_clipboard": Text("SIP address copied into clipboard") .multilineTextAlignment(.center) From 12f98293311cf94dceb58b2b7a5ef80c353c336e Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Fri, 12 Jan 2024 15:19:43 +0100 Subject: [PATCH 084/486] Add user agent --- Linphone/Core/CoreContext.swift | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Linphone/Core/CoreContext.swift b/Linphone/Core/CoreContext.swift index e71947f38..d21138495 100644 --- a/Linphone/Core/CoreContext.swift +++ b/Linphone/Core/CoreContext.swift @@ -92,6 +92,8 @@ final class CoreContext: ObservableObject { self.mCore.callkitEnabled = true self.mCore.pushNotificationEnabled = true + self.mCore.setUserAgent(name: "Linphone iOS 6.0 Beta (\(UIDevice.current.localizedModel)) - Linphone SDK : \(self.coreVersion)", version: "6.0") + self.mCoreSuscriptions.insert(self.mCore.publisher?.onGlobalStateChanged?.postOnMainQueue { (cbVal: (core: Core, state: GlobalState, message: String)) in if cbVal.state == GlobalState.On { self.defaultAccount = self.mCore.defaultAccount From aa18757a48c1a53f06305b61dcf76d6ac00715df Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Fri, 12 Jan 2024 15:41:12 +0100 Subject: [PATCH 085/486] Disable useless buttons in call view --- Linphone/UI/Call/CallView.swift | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/Linphone/UI/Call/CallView.swift b/Linphone/UI/Call/CallView.swift index 8d06e91b6..c7d34b298 100644 --- a/Linphone/UI/Call/CallView.swift +++ b/Linphone/UI/Call/CallView.swift @@ -152,12 +152,13 @@ struct CallView: View { Image("phone-transfer") .renderingMode(.template) .resizable() - .foregroundStyle(.white) + .foregroundStyle(Color.gray500) .frame(width: 32, height: 32) } .frame(width: 60, height: 60) - .background(Color.gray500) + .background(Color.gray600) .cornerRadius(40) + .disabled(true) Text("Transfer") .foregroundStyle(.white) @@ -171,12 +172,13 @@ struct CallView: View { Image("phone-plus") .renderingMode(.template) .resizable() - .foregroundStyle(.white) + .foregroundStyle(Color.gray500) .frame(width: 32, height: 32) } .frame(width: 60, height: 60) - .background(Color.gray500) + .background(Color.gray600) .cornerRadius(40) + .disabled(true) Text("New call") .foregroundStyle(.white) @@ -190,12 +192,13 @@ struct CallView: View { Image("phone-list") .renderingMode(.template) .resizable() - .foregroundStyle(.white) + .foregroundStyle(Color.gray500) .frame(width: 32, height: 32) } .frame(width: 60, height: 60) - .background(Color.gray500) + .background(Color.gray600) .cornerRadius(40) + .disabled(true) Text("Call list") .foregroundStyle(.white) @@ -209,12 +212,13 @@ struct CallView: View { Image("dialer") .renderingMode(.template) .resizable() - .foregroundStyle(.white) + .foregroundStyle(Color.gray500) .frame(width: 32, height: 32) } .frame(width: 60, height: 60) - .background(Color.gray500) + .background(Color.gray600) .cornerRadius(40) + .disabled(true) Text("Dialer") .foregroundStyle(.white) From aa1c585024284b663afceb709e41288865f7e4d5 Mon Sep 17 00:00:00 2001 From: QuentinArguillere Date: Fri, 12 Jan 2024 15:50:21 +0100 Subject: [PATCH 086/486] Remove mentions of macOS in the xcodeproj --- Linphone.xcodeproj/project.pbxproj | 24 ++++++++++++++---------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/Linphone.xcodeproj/project.pbxproj b/Linphone.xcodeproj/project.pbxproj index c49a03650..e18215bf2 100644 --- a/Linphone.xcodeproj/project.pbxproj +++ b/Linphone.xcodeproj/project.pbxproj @@ -835,7 +835,7 @@ ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = NO; CODE_SIGN_ENTITLEMENTS = Linphone/Linphone.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 4; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_ASSET_PATHS = "\"Linphone/Preview Content\""; DEVELOPMENT_TEAM = Z2V957B3D6; @@ -843,8 +843,9 @@ ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = Linphone/Info.plist; - INFOPLIST_KEY_NSCameraUsageDescription = "Share photos with your friends and customize avatars"; + 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[sdk=iphoneos*]" = YES; "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES; @@ -856,15 +857,16 @@ "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 = 1.0; + MARKETING_VERSION = 6.0; PRODUCT_BUNDLE_IDENTIFIER = org.linphone.phone; PRODUCT_NAME = "$(TARGET_NAME)"; - SDKROOT = auto; - SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx"; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = "iphonesimulator iphoneos"; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; @@ -879,7 +881,7 @@ ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = NO; CODE_SIGN_ENTITLEMENTS = Linphone/Linphone.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 4; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_ASSET_PATHS = "\"Linphone/Preview Content\""; DEVELOPMENT_TEAM = Z2V957B3D6; @@ -887,8 +889,9 @@ ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = Linphone/Info.plist; - INFOPLIST_KEY_NSCameraUsageDescription = "Share photos with your friends and customize avatars"; + 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[sdk=iphoneos*]" = YES; "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES; @@ -900,15 +903,16 @@ "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 = 1.0; + MARKETING_VERSION = 6.0; PRODUCT_BUNDLE_IDENTIFIER = org.linphone.phone; PRODUCT_NAME = "$(TARGET_NAME)"; - SDKROOT = auto; - SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx"; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = "iphonesimulator iphoneos"; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; From e66a0802f5a45b21cd4c1ad2e7384f2091bba56f Mon Sep 17 00:00:00 2001 From: QuentinArguillere Date: Fri, 12 Jan 2024 15:50:34 +0100 Subject: [PATCH 087/486] Update podfile to use 5.4.0-alpha sdk --- Podfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Podfile b/Podfile index 18951b82a..2e9ccd0fd 100644 --- a/Podfile +++ b/Podfile @@ -5,7 +5,7 @@ source "https://github.com/CocoaPods/Specs.git" def basic_pods if ENV['PODFILE_PATH'].nil? - pod 'linphone-sdk', '~> 5.3.0-alpha' + pod 'linphone-sdk', '~> 5.4.0-alpha' else pod 'linphone-sdk', :path => ENV['PODFILE_PATH'] # local sdk end From 4efc28da9ec344de20c591902a7d22e7d4e622b4 Mon Sep 17 00:00:00 2001 From: QuentinArguillere Date: Fri, 12 Jan 2024 15:54:03 +0100 Subject: [PATCH 088/486] Update push provider - use "apns.dev" or "apns" depending on wether we're using a DEBUG or RELEASE build --- Linphone/Core/CoreContext.swift | 18 +++++++++++++++++- .../Viewmodel/AccountLoginViewModel.swift | 7 ++++++- 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/Linphone/Core/CoreContext.swift b/Linphone/Core/CoreContext.swift index d21138495..f69d10386 100644 --- a/Linphone/Core/CoreContext.swift +++ b/Linphone/Core/CoreContext.swift @@ -104,10 +104,25 @@ final class CoreContext: ObservableObject { } }) + self.mCoreSuscriptions.insert(self.mCore.publisher?.onGlobalStateChanged?.postOnCoreQueue { (cbVal: (core: Core, state: GlobalState, message: String)) in + if cbVal.state == GlobalState.On { +#if DEBUG + let pushEnvironment = ".dev" +#else + let pushEnvironment = "" +#endif + for account in cbVal.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.mCore.videoCaptureEnabled = true self.mCore.videoDisplayEnabled = true - try? self.mCore.start() // Create a Core listener to listen for the callback we need // In this case, we want to know about the account registration status @@ -186,6 +201,7 @@ final class CoreContext: ObservableObject { self.mCore.iterate() } + try? self.mCore.start() } } diff --git a/Linphone/UI/Assistant/Viewmodel/AccountLoginViewModel.swift b/Linphone/UI/Assistant/Viewmodel/AccountLoginViewModel.swift index 8cf56625f..819931bd3 100644 --- a/Linphone/UI/Assistant/Viewmodel/AccountLoginViewModel.swift +++ b/Linphone/UI/Assistant/Viewmodel/AccountLoginViewModel.swift @@ -91,7 +91,12 @@ class AccountLoginViewModel: ObservableObject { accountParams.registerEnabled = true accountParams.pushNotificationAllowed = true accountParams.remotePushNotificationAllowed = false - accountParams.pushNotificationConfig?.provider = "apns.dev" +#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) From 3f4e8d79cfa05c91771b65a19a62b13b9e389b42 Mon Sep 17 00:00:00 2001 From: QuentinArguillere Date: Fri, 12 Jan 2024 15:56:07 +0100 Subject: [PATCH 089/486] Add crashlytics (WIP) --- Linphone/LinphoneApp.swift | 9 +++++++++ Linphone/Utils/Log.swift | 6 ++++++ Podfile | 10 ++++++++-- 3 files changed, 23 insertions(+), 2 deletions(-) diff --git a/Linphone/LinphoneApp.swift b/Linphone/LinphoneApp.swift index 9596a76ce..0930db967 100644 --- a/Linphone/LinphoneApp.swift +++ b/Linphone/LinphoneApp.swift @@ -18,6 +18,9 @@ */ import SwiftUI +#if USE_CRASHLYTICS +import Firebase +#endif @main struct LinphoneApp: App { @@ -32,6 +35,12 @@ struct LinphoneApp: App { @State private var startCallViewModel: StartCallViewModel? @State private var callViewModel: CallViewModel? + init() { +#if USE_CRASHLYTICS + FirebaseApp.configure() +#endif + } + var body: some Scene { WindowGroup { if coreContext.coreIsStarted { diff --git a/Linphone/Utils/Log.swift b/Linphone/Utils/Log.swift index bb4e61cb1..23901a029 100644 --- a/Linphone/Utils/Log.swift +++ b/Linphone/Utils/Log.swift @@ -24,6 +24,9 @@ import UIKit import os import linphonesw import linphone +#if USE_CRASHLYTICS +import Firebase +#endif class Log: LoggingServiceDelegate { @@ -88,6 +91,9 @@ class Log: LoggingServiceDelegate { } else { NSLog(log) } +#if USE_CRASHLYTICS + Crashlytics.crashlytics().log("\(levelStr) [\(domain)] \(message)\n") +#endif } func onLogMessageWritten(logService: linphonesw.LoggingService, domain: String, level: linphonesw.LogLevel, message: String) { diff --git a/Podfile b/Podfile index 2e9ccd0fd..0c727cb84 100644 --- a/Podfile +++ b/Podfile @@ -9,10 +9,16 @@ def basic_pods 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 From 1b498258a31ee7c6cb4b5bcdaa6f391ec746fde1 Mon Sep 17 00:00:00 2001 From: QuentinArguillere Date: Fri, 12 Jan 2024 15:56:48 +0100 Subject: [PATCH 090/486] Crahslytics - add GoogleService-Info.plist --- GoogleService-Info.plist | 36 ++++++++++++++++++++++++++++++ Linphone.xcodeproj/project.pbxproj | 12 ++++++++++ 2 files changed, 48 insertions(+) create mode 100644 GoogleService-Info.plist 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 index e18215bf2..b4c229e18 100644 --- a/Linphone.xcodeproj/project.pbxproj +++ b/Linphone.xcodeproj/project.pbxproj @@ -7,6 +7,7 @@ objects = { /* Begin PBXBuildFile section */ + 660D8A712B517D260092694D /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = 660D8A702B517D260092694D /* GoogleService-Info.plist */; }; 662B69D92B25DE18007118BF /* TelecomManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 662B69D82B25DE18007118BF /* TelecomManager.swift */; }; 662B69DB2B25DE25007118BF /* ProviderDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 662B69DA2B25DE25007118BF /* ProviderDelegate.swift */; }; 66C491F92B24D25B00CEA16D /* ConfigExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 66C491F82B24D25A00CEA16D /* ConfigExtension.swift */; }; @@ -97,6 +98,7 @@ /* End PBXBuildFile section */ /* Begin PBXFileReference section */ + 660D8A702B517D260092694D /* GoogleService-Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = "GoogleService-Info.plist"; 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 = ""; }; 66C491F82B24D25A00CEA16D /* ConfigExtension.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ConfigExtension.swift; sourceTree = ""; }; @@ -249,6 +251,7 @@ D719ABAA2ABC67BF00B41C10 = { isa = PBXGroup; children = ( + 660D8A702B517D260092694D /* GoogleService-Info.plist */, D719ABB52ABC67BF00B41C10 /* Linphone */, D719ABB42ABC67BF00B41C10 /* Products */, A31AF2AB8C6A3D7B7EA3B424 /* Pods */, @@ -600,6 +603,7 @@ 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; }; @@ -837,10 +841,15 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 4; 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", + ); GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = Linphone/Info.plist; INFOPLIST_KEY_NSCameraUsageDescription = "Camera usage is required for video VOIP calls"; @@ -863,6 +872,7 @@ "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; MACOSX_DEPLOYMENT_TARGET = 13.3; MARKETING_VERSION = 6.0; + OTHER_SWIFT_FLAGS = "$(inherited)"; PRODUCT_BUNDLE_IDENTIFIER = org.linphone.phone; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; @@ -887,6 +897,7 @@ DEVELOPMENT_TEAM = Z2V957B3D6; ENABLE_HARDENED_RUNTIME = YES; ENABLE_PREVIEWS = YES; + GCC_PREPROCESSOR_DEFINITIONS = "$(inherited)"; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = Linphone/Info.plist; INFOPLIST_KEY_NSCameraUsageDescription = "Camera usage is required for video VOIP calls"; @@ -909,6 +920,7 @@ "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; MACOSX_DEPLOYMENT_TARGET = 13.3; MARKETING_VERSION = 6.0; + OTHER_SWIFT_FLAGS = "$(inherited)"; PRODUCT_BUNDLE_IDENTIFIER = org.linphone.phone; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; From 6bf6aa3aa6c2e58d0ce236d4358e9fe776ae00e0 Mon Sep 17 00:00:00 2001 From: QuentinArguillere Date: Fri, 12 Jan 2024 15:57:13 +0100 Subject: [PATCH 091/486] Add encryption parameters to info.plist for upload on appstoreconnect --- Linphone/Info.plist | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/Linphone/Info.plist b/Linphone/Info.plist index 33e2c5a4d..0cc800b37 100644 --- a/Linphone/Info.plist +++ b/Linphone/Info.plist @@ -2,12 +2,10 @@ - UIUserInterfaceStyle - Light - NSCameraUsageDescription - Camera usage is required for video VOIP calls - NSMicrophoneUsageDescription - Microphone usage is required for VOIP calls + ITSAppUsesNonExemptEncryption + + ITSEncryptionExportComplianceCode + b5cb085f-772a-4a4f-8c77-5d1332b1f93f UIAppFonts NotoSans-Light.ttf From 2f1bd572b06c9f0f3fa3a5924b49b4d7401739b8 Mon Sep 17 00:00:00 2001 From: QuentinArguillere Date: Fri, 12 Jan 2024 15:58:03 +0100 Subject: [PATCH 092/486] Add USE_CRASHLYTICS flag management to podfile, and fix crashlytics log build --- Linphone/Utils/Log.swift | 2 +- Podfile | 38 ++++++++++++++++++++++++++++++++------ 2 files changed, 33 insertions(+), 7 deletions(-) diff --git a/Linphone/Utils/Log.swift b/Linphone/Utils/Log.swift index 23901a029..a2351724c 100644 --- a/Linphone/Utils/Log.swift +++ b/Linphone/Utils/Log.swift @@ -92,7 +92,7 @@ class Log: LoggingServiceDelegate { NSLog(log) } #if USE_CRASHLYTICS - Crashlytics.crashlytics().log("\(levelStr) [\(domain)] \(message)\n") + Crashlytics.crashlytics().log(log) #endif } diff --git a/Podfile b/Podfile index 0c727cb84..696a25e90 100644 --- a/Podfile +++ b/Podfile @@ -31,9 +31,35 @@ target 'Linphone' do end post_install do |installer| - installer.pods_project.targets.each do |target| - target.build_configurations.each do |config| - config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '15.0' - end - end -end \ No newline at end of file + 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 + From 937444c5d00f1d463d89aa7c9f1127c4a5fb4ecb Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Fri, 12 Jan 2024 16:01:04 +0100 Subject: [PATCH 093/486] Fix video display in call view --- Linphone/UI/Call/CallView.swift | 14 ++++++++------ Linphone/UI/Call/ViewModel/CallViewModel.swift | 3 --- 2 files changed, 8 insertions(+), 9 deletions(-) diff --git a/Linphone/UI/Call/CallView.swift b/Linphone/UI/Call/CallView.swift index c7d34b298..709a814df 100644 --- a/Linphone/UI/Call/CallView.swift +++ b/Linphone/UI/Call/CallView.swift @@ -82,7 +82,7 @@ struct CallView: View { Button { callViewModel.toggleVideo() } label: { - Image(callViewModel.cameraDisplayed ? "video-camera" : "video-camera-slash") + Image(telecomManager.remoteVideo ? "video-camera" : "video-camera-slash") .renderingMode(.template) .resizable() .foregroundStyle((callViewModel.isPaused || telecomManager.isPausedByRemote) ? Color.gray500 : .white) @@ -500,7 +500,7 @@ struct CallView: View { Spacer() - if callViewModel.cameraDisplayed { + if telecomManager.remoteVideo { Button { callViewModel.switchCamera() } label: { @@ -594,10 +594,12 @@ struct CallView: View { .scaledToFill() .clipped() .onTapGesture { - fullscreenVideo.toggle() + if telecomManager.remoteVideo { + fullscreenVideo.toggle() + } } - if callViewModel.cameraDisplayed { + if telecomManager.remoteVideo { HStack { Spacer() VStack { @@ -741,7 +743,7 @@ struct CallView: View { Button { callViewModel.toggleVideo() } label: { - Image(callViewModel.cameraDisplayed ? "video-camera" : "video-camera-slash") + Image(telecomManager.remoteVideo ? "video-camera" : "video-camera-slash") .renderingMode(.template) .resizable() .foregroundStyle((callViewModel.isPaused || telecomManager.isPausedByRemote) ? Color.gray500 : .white) @@ -825,7 +827,7 @@ struct CallView: View { Button { callViewModel.toggleVideo() } label: { - Image(callViewModel.cameraDisplayed ? "video-camera" : "video-camera-slash") + Image(telecomManager.remoteVideo ? "video-camera" : "video-camera-slash") .renderingMode(.template) .resizable() .foregroundStyle((callViewModel.isPaused || telecomManager.isPausedByRemote) ? Color.gray500 : .white) diff --git a/Linphone/UI/Call/ViewModel/CallViewModel.swift b/Linphone/UI/Call/ViewModel/CallViewModel.swift index 26f9dd2ed..b6abd4aa5 100644 --- a/Linphone/UI/Call/ViewModel/CallViewModel.swift +++ b/Linphone/UI/Call/ViewModel/CallViewModel.swift @@ -75,7 +75,6 @@ class CallViewModel: ObservableObject { //self.avatarModel = ??? self.micMutted = self.currentCall!.microphoneMuted - self.cameraDisplayed = self.currentCall!.cameraEnabled == true self.isRecording = self.currentCall!.params!.isRecording self.isPaused = self.isCallPaused() self.timeElapsed = 0 @@ -139,8 +138,6 @@ class CallViewModel: ObservableObject { "[CallViewModel] Updating call with video enabled set to \(params.videoEnabled)" ) try self.currentCall!.update(params: params) - - self.cameraDisplayed = self.currentCall!.cameraEnabled == true } catch { } From e9784ddc6173dd08f85bcdfbe45d794664ef9408 Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Thu, 11 Jan 2024 14:17:05 +0100 Subject: [PATCH 094/486] Add custom bottom sheet in call view --- Linphone/UI/Call/CallView.swift | 1167 +++++++++-------- .../Contacts/Fragments/ContactFragment.swift | 66 +- .../Contacts/Fragments/ContactsFragment.swift | 48 +- .../Fragments/EditContactFragment.swift | 38 +- Linphone/UI/Main/ContentView.swift | 55 +- .../History/Fragments/HistoryFragment.swift | 43 +- 6 files changed, 715 insertions(+), 702 deletions(-) diff --git a/Linphone/UI/Call/CallView.swift b/Linphone/UI/Call/CallView.swift index 8d06e91b6..755575926 100644 --- a/Linphone/UI/Call/CallView.swift +++ b/Linphone/UI/Call/CallView.swift @@ -49,390 +49,131 @@ struct CallView: View { var body: some View { GeometryReader { geo in - if #available(iOS 16.4, *) { + if #available(iOS 16.0, *), idiom != .pad { innerView(geometry: geo) - .sheet(isPresented: - .constant( - telecomManager.callStarted - && !fullscreenVideo - && !hideButtonsSheet - && idiom != .pad - && !(orientation == .landscapeLeft || orientation == .landscapeRight || UIScreen.main.bounds.size.width > UIScreen.main.bounds.size.height) - ) - ) { - GeometryReader { _ in - VStack(spacing: 0) { - HStack(spacing: 12) { - Button { - callViewModel.terminateCall() - } label: { - Image("phone-disconnect") - .renderingMode(.template) - .resizable() - .foregroundStyle(.white) - .frame(width: 32, height: 32) - - } - .frame(width: 90, height: 60) - .background(Color.redDanger500) - .cornerRadius(40) - - Spacer() - - Button { - callViewModel.toggleVideo() - } label: { - Image(callViewModel.cameraDisplayed ? "video-camera" : "video-camera-slash") - .renderingMode(.template) - .resizable() - .foregroundStyle((callViewModel.isPaused || telecomManager.isPausedByRemote) ? Color.gray500 : .white) - .frame(width: 32, height: 32) - - } - .frame(width: 60, height: 60) - .background((callViewModel.isPaused || telecomManager.isPausedByRemote) ? Color.gray600 : Color.gray500) - .cornerRadius(40) - .disabled(callViewModel.isPaused || telecomManager.isPausedByRemote) - - Button { - callViewModel.toggleMuteMicrophone() - } label: { - Image(callViewModel.micMutted ? "microphone-slash" : "microphone") - .renderingMode(.template) - .resizable() - .foregroundStyle(callViewModel.micMutted ? .black : .white) - .frame(width: 32, height: 32) - - } - .frame(width: 60, height: 60) - .background(callViewModel.micMutted ? .white : Color.gray500) - .cornerRadius(40) - - Button { - if AVAudioSession.sharedInstance().availableInputs != nil - && !AVAudioSession.sharedInstance().availableInputs!.filter({ $0.portType.rawValue.contains("Bluetooth") }).isEmpty { - - hideButtonsSheet = true - - DispatchQueue.global().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: { - Image(imageAudioRoute) - .renderingMode(.template) - .resizable() - .foregroundStyle(.white) - .frame(width: 32, height: 32) - .onAppear(perform: getAudioRouteImage) - .onReceive(pub) { _ in - self.getAudioRouteImage() - } - - } - .frame(width: 60, height: 60) - .background(Color.gray500) - .cornerRadius(40) - } - .frame(height: geo.size.height * 0.15) - .padding(.horizontal, 20) - .padding(.top, -6) - - HStack(spacing: 0) { - VStack { - Button { - } label: { - Image("phone-transfer") - .renderingMode(.template) - .resizable() - .foregroundStyle(.white) - .frame(width: 32, height: 32) - } - .frame(width: 60, height: 60) - .background(Color.gray500) - .cornerRadius(40) - - Text("Transfer") - .foregroundStyle(.white) - .default_text_style(styleSize: 15) - } - .frame(width: geo.size.width * 0.25, height: geo.size.width * 0.25) - - VStack { - Button { - } label: { - Image("phone-plus") - .renderingMode(.template) - .resizable() - .foregroundStyle(.white) - .frame(width: 32, height: 32) - } - .frame(width: 60, height: 60) - .background(Color.gray500) - .cornerRadius(40) - - Text("New call") - .foregroundStyle(.white) - .default_text_style(styleSize: 15) - } - .frame(width: geo.size.width * 0.25, height: geo.size.width * 0.25) - - VStack { - Button { - } label: { - Image("phone-list") - .renderingMode(.template) - .resizable() - .foregroundStyle(.white) - .frame(width: 32, height: 32) - } - .frame(width: 60, height: 60) - .background(Color.gray500) - .cornerRadius(40) - - Text("Call list") - .foregroundStyle(.white) - .default_text_style(styleSize: 15) - } - .frame(width: geo.size.width * 0.25, height: geo.size.width * 0.25) - - VStack { - Button { - } label: { - Image("dialer") - .renderingMode(.template) - .resizable() - .foregroundStyle(.white) - .frame(width: 32, height: 32) - } - .frame(width: 60, height: 60) - .background(Color.gray500) - .cornerRadius(40) - - Text("Dialer") - .foregroundStyle(.white) - .default_text_style(styleSize: 15) - } - .frame(width: geo.size.width * 0.25, height: geo.size.width * 0.25) - } - .frame(height: geo.size.height * 0.15) - - HStack(spacing: 0) { - VStack { - Button { - } label: { - Image("chat-teardrop-text") - .renderingMode(.template) - .resizable() - //.foregroundStyle((callViewModel.isPaused || telecomManager.isPausedByRemote) ? Color.gray500 : .white) - .foregroundStyle(Color.gray500) - .frame(width: 32, height: 32) - } - .frame(width: 60, height: 60) - //.background((callViewModel.isPaused || telecomManager.isPausedByRemote) ? Color.gray600 : Color.gray500) - .background(Color.gray600) - .cornerRadius(40) - //.disabled(callViewModel.isPaused || telecomManager.isPausedByRemote) - .disabled(true) - - Text("Messages") - .foregroundStyle(.white) - .default_text_style(styleSize: 15) - } - .frame(width: geo.size.width * 0.25, height: geo.size.width * 0.25) - - VStack { - Button { - callViewModel.togglePause() - } label: { - Image(callViewModel.isPaused ? "play" : "pause") - .renderingMode(.template) - .resizable() - .foregroundStyle(telecomManager.isPausedByRemote ? Color.gray500 : .white) - .frame(width: 32, height: 32) - } - .frame(width: 60, height: 60) - .background(telecomManager.isPausedByRemote ? Color.gray600 : (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.25, height: geo.size.width * 0.25) - - VStack { - Button { - callViewModel.toggleRecording() - } label: { - Image("record-fill") - .renderingMode(.template) - .resizable() - .foregroundStyle((callViewModel.isPaused || telecomManager.isPausedByRemote) ? Color.gray500 : .white) - .frame(width: 32, height: 32) - } - .frame(width: 60, height: 60) - .background((callViewModel.isPaused || telecomManager.isPausedByRemote) ? Color.gray600 : (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.25, height: geo.size.width * 0.25) - - VStack { - Button { - } label: { - Image("video-camera") - .renderingMode(.template) - .resizable() - .foregroundStyle(.white) - .frame(width: 32, height: 32) - } - .frame(width: 60, height: 60) - .background(Color.gray500) - .cornerRadius(40) - - Text("Disposition") - .foregroundStyle(.white) - .default_text_style(styleSize: 15) - } - .frame(width: geo.size.width * 0.25, height: geo.size.width * 0.25) - .hidden() - } - .frame(height: geo.size.height * 0.15) - - Spacer() - } - .frame(maxHeight: .infinity, alignment: .top) - .presentationBackground(.black) - .presentationDetents([.fraction(0.1), .fraction(0.45)]) - .interactiveDismissDisabled() - .presentationBackgroundInteraction(.enabled) - } - } .sheet(isPresented: $audioRouteSheet, onDismiss: { audioRouteSheet = false hideButtonsSheet = false }) { - VStack(spacing: 0) { - Button(action: { - options = 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(options == 1 ? "radio-button-fill" : "radio-button") - .renderingMode(.template) - .resizable() - .foregroundStyle(.white) - .frame(width: 25, height: 25, alignment: .leading) - - 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) - } - }) - .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) - - Text("Speaker") - .default_text_style_white(styleSize: 15) - - Spacer() - - Image("speaker-high") - .renderingMode(.template) - .resizable() - .foregroundStyle(.white) - .frame(width: 25, height: 25, alignment: .leading) - } - }) - .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) - - Text("Bluetooth") - .default_text_style_white(styleSize: 15) - - Spacer() - - Image("bluetooth") - .renderingMode(.template) - .resizable() - .foregroundStyle(.white) - .frame(width: 25, height: 25, alignment: .leading) - } - }) - .frame(maxHeight: .infinity) - } - .padding(.horizontal, 20) - .presentationBackground(Color.gray600) - .presentationDetents([.fraction(0.3)]) - .frame(maxHeight: .infinity) + innerBottomSheet() + .presentationDetents([.fraction(0.3)]) } } else { innerView(geometry: geo) + .halfSheet(showSheet: $audioRouteSheet) { + innerBottomSheet() + } onDismiss: { + audioRouteSheet = false + hideButtonsSheet = false + } } } } + @ViewBuilder + func innerBottomSheet() -> some View { + VStack(spacing: 0) { + Button(action: { + options = 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(options == 1 ? "radio-button-fill" : "radio-button") + .renderingMode(.template) + .resizable() + .foregroundStyle(.white) + .frame(width: 25, height: 25, alignment: .leading) + + 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) + } + }) + .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) + + Text("Speaker") + .default_text_style_white(styleSize: 15) + + Spacer() + + Image("speaker-high") + .renderingMode(.template) + .resizable() + .foregroundStyle(.white) + .frame(width: 25, height: 25, alignment: .leading) + } + }) + .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) + + Text("Bluetooth") + .default_text_style_white(styleSize: 15) + + Spacer() + + Image("bluetooth") + .renderingMode(.template) + .resizable() + .foregroundStyle(.white) + .frame(width: 25, height: 25, alignment: .leading) + } + }) + .frame(maxHeight: .infinity) + } + .padding(.horizontal, 20) + .background(Color.gray600) + .frame(maxHeight: .infinity) + } + @ViewBuilder // swiftlint:disable:next cyclomatic_complexity func innerView(geometry: GeometryProxy) -> some View { @@ -663,19 +404,20 @@ struct CallView: View { } .frame( maxWidth: fullscreenVideo ? geometry.size.width : geometry.size.width - 8, - maxHeight: fullscreenVideo ? geometry.size.height + geometry.safeAreaInsets.top + geometry.safeAreaInsets.bottom : geometry.size.height - 140 + maxHeight: fullscreenVideo ? geometry.size.height + geometry.safeAreaInsets.top + geometry.safeAreaInsets.bottom : geometry.size.height - (0.1 * geometry.size.height) - 60 ) .background(.clear) } } .frame( maxWidth: fullscreenVideo ? geometry.size.width : geometry.size.width - 8, - maxHeight: fullscreenVideo ? geometry.size.height + geometry.safeAreaInsets.top + geometry.safeAreaInsets.bottom : geometry.size.height - 140 + maxHeight: fullscreenVideo ? geometry.size.height + geometry.safeAreaInsets.top + geometry.safeAreaInsets.bottom : geometry.size.height - (0.1 * geometry.size.height) - 60 ) .background(Color.gray600) .cornerRadius(20) .padding(.horizontal, fullscreenVideo ? 0 : 4) .onRotate { newOrientation in + let oldOrientation = orientation orientation = newOrientation if orientation == .portrait || orientation == .portraitUpsideDown { angleDegree = 0 @@ -687,6 +429,14 @@ struct CallView: View { } } + if (oldOrientation != orientation && oldOrientation != .faceUp) || (oldOrientation == .faceUp && (orientation == .landscapeLeft || orientation == .landscapeRight)) { + telecomManager.callStarted = false + + DispatchQueue.global().asyncAfter(deadline: .now() + 0.5) { + telecomManager.callStarted = true + } + } + callViewModel.orientationUpdate(orientation: orientation) } .onAppear { @@ -700,191 +450,28 @@ struct CallView: View { } } + telecomManager.callStarted = false + + DispatchQueue.global().asyncAfter(deadline: .now() + 0.5) { + telecomManager.callStarted = true + } + callViewModel.orientationUpdate(orientation: orientation) } if !fullscreenVideo { if telecomManager.callStarted { - if #available(iOS 16.0, *) { - if telecomManager.callStarted && idiom != .pad && !(orientation == .landscapeLeft || orientation == .landscapeRight - || UIScreen.main.bounds.size.width > UIScreen.main.bounds.size.height) { - HStack(spacing: 12) { - HStack { - - } - .frame(height: 60) - } - .padding(.horizontal, 25) - .padding(.top, 20) - } else { - HStack(spacing: 12) { - Button { - callViewModel.terminateCall() - } label: { - Image("phone-disconnect") - .renderingMode(.template) - .resizable() - .foregroundStyle(.white) - .frame(width: 32, height: 32) - - } - .frame(width: 90, height: 60) - .background(Color.redDanger500) - .cornerRadius(40) - - Spacer() - - Button { - callViewModel.toggleVideo() - } label: { - Image(callViewModel.cameraDisplayed ? "video-camera" : "video-camera-slash") - .renderingMode(.template) - .resizable() - .foregroundStyle((callViewModel.isPaused || telecomManager.isPausedByRemote) ? Color.gray500 : .white) - .frame(width: 32, height: 32) - - } - .frame(width: 60, height: 60) - .background((callViewModel.isPaused || telecomManager.isPausedByRemote) ? Color.gray600 : Color.gray500) - .cornerRadius(40) - .disabled(callViewModel.isPaused || telecomManager.isPausedByRemote) - - Button { - callViewModel.toggleMuteMicrophone() - } label: { - Image(callViewModel.micMutted ? "microphone-slash" : "microphone") - .renderingMode(.template) - .resizable() - .foregroundStyle(callViewModel.micMutted ? .black : .white) - .frame(width: 32, height: 32) - - } - .frame(width: 60, height: 60) - .background(callViewModel.micMutted ? .white : Color.gray500) - .cornerRadius(40) - - Button { - if AVAudioSession.sharedInstance().availableInputs != nil - && !AVAudioSession.sharedInstance().availableInputs!.filter({ $0.portType.rawValue.contains("Bluetooth") }).isEmpty { - - hideButtonsSheet = true - - DispatchQueue.global().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: { - Image(imageAudioRoute) - .renderingMode(.template) - .resizable() - .foregroundStyle(.white) - .frame(width: 32, height: 32) - .onAppear(perform: getAudioRouteImage) - .onReceive(pub) { _ in - self.getAudioRouteImage() - } - - } - .frame(width: 60, height: 60) - .background(Color.gray500) - .cornerRadius(40) - } - .frame(height: geometry.size.height * 0.15) - .padding(.horizontal, 20) - .padding(.top, -6) - } - } else { - HStack(spacing: 12) { - Button { - callViewModel.terminateCall() - } label: { - Image("phone-disconnect") - .renderingMode(.template) - .resizable() - .foregroundStyle(.white) - .frame(width: 32, height: 32) - - } - .frame(width: 90, height: 60) - .background(Color.redDanger500) - .cornerRadius(40) - - Spacer() - - Button { - callViewModel.toggleVideo() - } label: { - Image(callViewModel.cameraDisplayed ? "video-camera" : "video-camera-slash") - .renderingMode(.template) - .resizable() - .foregroundStyle((callViewModel.isPaused || telecomManager.isPausedByRemote) ? Color.gray500 : .white) - .frame(width: 32, height: 32) - - } - .frame(width: 60, height: 60) - .background((callViewModel.isPaused || telecomManager.isPausedByRemote) ? Color.gray600 : Color.gray500) - .cornerRadius(40) - .disabled(callViewModel.isPaused || telecomManager.isPausedByRemote) - - Button { - callViewModel.toggleMuteMicrophone() - } label: { - Image(callViewModel.micMutted ? "microphone-slash" : "microphone") - .renderingMode(.template) - .resizable() - .foregroundStyle(callViewModel.micMutted ? .black : .white) - .frame(width: 32, height: 32) - - } - .frame(width: 60, height: 60) - .background(callViewModel.micMutted ? .white : Color.gray500) - .cornerRadius(40) - - Button { - if AVAudioSession.sharedInstance().availableInputs != nil - && !AVAudioSession.sharedInstance().availableInputs!.filter({ $0.portType.rawValue.contains("Bluetooth") }).isEmpty { - - hideButtonsSheet = true - - DispatchQueue.global().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: { - Image(imageAudioRoute) - .renderingMode(.template) - .resizable() - .foregroundStyle(.white) - .frame(width: 32, height: 32) - .onAppear(perform: getAudioRouteImage) - .onReceive(pub) { _ in - self.getAudioRouteImage() - } - - } - .frame(width: 60, height: 60) - .background(Color.gray500) - .cornerRadius(40) - } - .frame(height: geometry.size.height * 0.15) - .padding(.horizontal, 20) - .padding(.top, -6) - } + let scene = UIApplication.shared.connectedScenes.first as? UIWindowScene + let bottomInset = scene?.windows.first?.safeAreaInsets + + BottomSheetView( + content: bottomSheetContent(geo: geometry), + minHeight: (0.1 * geometry.size.height) + (bottomInset != nil ? bottomInset!.bottom : 0), + maxHeight: (0.45 * geometry.size.height) + (bottomInset != nil ? bottomInset!.bottom : 0), + currentHeight: (0.1 * geometry.size.height) + (bottomInset != nil ? bottomInset!.bottom : 0) + ) } else { +#if targetEnvironment(simulator) HStack(spacing: 12) { HStack { Spacer() @@ -923,6 +510,15 @@ struct CallView: View { } .padding(.horizontal, 25) .padding(.top, 20) +#else + HStack(spacing: 12) { + HStack { + } + .frame(height: 60) + } + .padding(.horizontal, 25) + .padding(.top, 20) +#endif } } } @@ -933,6 +529,418 @@ struct CallView: View { } } + func bottomSheetContent(geo: GeometryProxy) -> some View { + GeometryReader { _ in + VStack(spacing: 0) { + Rectangle() + .fill(Color.gray500) + .frame(width: 100, height: 5) + .cornerRadius(10) + .padding(.top, 5) + HStack(spacing: 12) { + Button { + callViewModel.terminateCall() + } label: { + Image("phone-disconnect") + .renderingMode(.template) + .resizable() + .foregroundStyle(.white) + .frame(width: 32, height: 32) + + } + .frame(width: 90, height: 60) + .background(Color.redDanger500) + .cornerRadius(40) + + Spacer() + + Button { + callViewModel.toggleVideo() + } label: { + Image(callViewModel.cameraDisplayed ? "video-camera" : "video-camera-slash") + .renderingMode(.template) + .resizable() + .foregroundStyle((callViewModel.isPaused || telecomManager.isPausedByRemote) ? Color.gray500 : .white) + .frame(width: 32, height: 32) + + } + .frame(width: 60, height: 60) + .background((callViewModel.isPaused || telecomManager.isPausedByRemote) ? Color.gray600 : Color.gray500) + .cornerRadius(40) + .disabled(callViewModel.isPaused || telecomManager.isPausedByRemote) + + Button { + callViewModel.toggleMuteMicrophone() + } label: { + Image(callViewModel.micMutted ? "microphone-slash" : "microphone") + .renderingMode(.template) + .resizable() + .foregroundStyle(callViewModel.micMutted ? .black : .white) + .frame(width: 32, height: 32) + + } + .frame(width: 60, height: 60) + .background(callViewModel.micMutted ? .white : Color.gray500) + .cornerRadius(40) + + Button { + if AVAudioSession.sharedInstance().availableInputs != nil + && !AVAudioSession.sharedInstance().availableInputs!.filter({ $0.portType.rawValue.contains("Bluetooth") }).isEmpty { + + hideButtonsSheet = true + + DispatchQueue.global().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: { + Image(imageAudioRoute) + .renderingMode(.template) + .resizable() + .foregroundStyle(.white) + .frame(width: 32, height: 32) + .onAppear(perform: getAudioRouteImage) + .onReceive(pub) { _ in + self.getAudioRouteImage() + } + + } + .frame(width: 60, height: 60) + .background(Color.gray500) + .cornerRadius(40) + } + .frame(height: geo.size.height * 0.15) + .padding(.horizontal, 20) + .padding(.top, (orientation != .landscapeLeft && orientation != .landscapeRight) ? (geo.safeAreaInsets.bottom != 0 ? -15 : -30) : -10) + + if orientation != .landscapeLeft && orientation != .landscapeRight { + HStack(spacing: 0) { + VStack { + Button { + } label: { + Image("phone-transfer") + .renderingMode(.template) + .resizable() + .foregroundStyle(.white) + .frame(width: 32, height: 32) + } + .frame(width: 60, height: 60) + .background(Color.gray500) + .cornerRadius(40) + + Text("Transfer") + .foregroundStyle(.white) + .default_text_style(styleSize: 15) + } + .frame(width: geo.size.width * 0.25, height: geo.size.width * 0.25) + + VStack { + Button { + } label: { + Image("phone-plus") + .renderingMode(.template) + .resizable() + .foregroundStyle(.white) + .frame(width: 32, height: 32) + } + .frame(width: 60, height: 60) + .background(Color.gray500) + .cornerRadius(40) + + Text("New call") + .foregroundStyle(.white) + .default_text_style(styleSize: 15) + } + .frame(width: geo.size.width * 0.25, height: geo.size.width * 0.25) + + VStack { + Button { + } label: { + Image("phone-list") + .renderingMode(.template) + .resizable() + .foregroundStyle(.white) + .frame(width: 32, height: 32) + } + .frame(width: 60, height: 60) + .background(Color.gray500) + .cornerRadius(40) + + Text("Call list") + .foregroundStyle(.white) + .default_text_style(styleSize: 15) + } + .frame(width: geo.size.width * 0.25, height: geo.size.width * 0.25) + + VStack { + Button { + } label: { + Image("dialer") + .renderingMode(.template) + .resizable() + .foregroundStyle(.white) + .frame(width: 32, height: 32) + } + .frame(width: 60, height: 60) + .background(Color.gray500) + .cornerRadius(40) + + Text("Dialer") + .foregroundStyle(.white) + .default_text_style(styleSize: 15) + } + .frame(width: geo.size.width * 0.25, height: geo.size.width * 0.25) + } + .frame(height: geo.size.height * 0.15) + + HStack(spacing: 0) { + VStack { + Button { + } label: { + Image("chat-teardrop-text") + .renderingMode(.template) + .resizable() + //.foregroundStyle((callViewModel.isPaused || telecomManager.isPausedByRemote) ? Color.gray500 : .white) + .foregroundStyle(Color.gray500) + .frame(width: 32, height: 32) + } + .frame(width: 60, height: 60) + //.background((callViewModel.isPaused || telecomManager.isPausedByRemote) ? Color.gray600 : Color.gray500) + .background(Color.gray600) + .cornerRadius(40) + //.disabled(callViewModel.isPaused || telecomManager.isPausedByRemote) + .disabled(true) + + Text("Messages") + .foregroundStyle(.white) + .default_text_style(styleSize: 15) + } + .frame(width: geo.size.width * 0.25, height: geo.size.width * 0.25) + + VStack { + Button { + callViewModel.togglePause() + } label: { + Image(callViewModel.isPaused ? "play" : "pause") + .renderingMode(.template) + .resizable() + .foregroundStyle(telecomManager.isPausedByRemote ? Color.gray500 : .white) + .frame(width: 32, height: 32) + } + .frame(width: 60, height: 60) + .background(telecomManager.isPausedByRemote ? Color.gray600 : (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.25, height: geo.size.width * 0.25) + + VStack { + Button { + callViewModel.toggleRecording() + } label: { + Image("record-fill") + .renderingMode(.template) + .resizable() + .foregroundStyle((callViewModel.isPaused || telecomManager.isPausedByRemote) ? Color.gray500 : .white) + .frame(width: 32, height: 32) + } + .frame(width: 60, height: 60) + .background((callViewModel.isPaused || telecomManager.isPausedByRemote) ? Color.gray600 : (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.25, height: geo.size.width * 0.25) + + VStack { + Button { + } label: { + Image("video-camera") + .renderingMode(.template) + .resizable() + .foregroundStyle(.white) + .frame(width: 32, height: 32) + } + .frame(width: 60, height: 60) + .background(Color.gray500) + .cornerRadius(40) + + Text("Disposition") + .foregroundStyle(.white) + .default_text_style(styleSize: 15) + } + .frame(width: geo.size.width * 0.25, height: geo.size.width * 0.25) + .hidden() + } + .frame(height: geo.size.height * 0.15) + } else { + HStack { + VStack { + Button { + } label: { + Image("phone-transfer") + .renderingMode(.template) + .resizable() + .foregroundStyle(.white) + .frame(width: 32, height: 32) + } + .frame(width: 60, height: 60) + .background(Color.gray500) + .cornerRadius(40) + + Text("Transfer") + .foregroundStyle(.white) + .default_text_style(styleSize: 15) + } + .frame(width: geo.size.width * 0.125, height: geo.size.width * 0.125) + + VStack { + Button { + } label: { + Image("phone-plus") + .renderingMode(.template) + .resizable() + .foregroundStyle(.white) + .frame(width: 32, height: 32) + } + .frame(width: 60, height: 60) + .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) + + VStack { + Button { + } label: { + Image("phone-list") + .renderingMode(.template) + .resizable() + .foregroundStyle(.white) + .frame(width: 32, height: 32) + } + .frame(width: 60, height: 60) + .background(Color.gray500) + .cornerRadius(40) + + Text("Call list") + .foregroundStyle(.white) + .default_text_style(styleSize: 15) + } + .frame(width: geo.size.width * 0.125, height: geo.size.width * 0.125) + + VStack { + Button { + } label: { + Image("dialer") + .renderingMode(.template) + .resizable() + .foregroundStyle(.white) + .frame(width: 32, height: 32) + } + .frame(width: 60, height: 60) + .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) + + VStack { + Button { + } label: { + Image("chat-teardrop-text") + .renderingMode(.template) + .resizable() + //.foregroundStyle((callViewModel.isPaused || telecomManager.isPausedByRemote) ? Color.gray500 : .white) + .foregroundStyle(Color.gray500) + .frame(width: 32, height: 32) + } + .frame(width: 60, height: 60) + //.background((callViewModel.isPaused || telecomManager.isPausedByRemote) ? Color.gray600 : Color.gray500) + .background(Color.gray600) + .cornerRadius(40) + //.disabled(callViewModel.isPaused || telecomManager.isPausedByRemote) + .disabled(true) + + 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: { + Image(callViewModel.isPaused ? "play" : "pause") + .renderingMode(.template) + .resizable() + .foregroundStyle(telecomManager.isPausedByRemote ? Color.gray500 : .white) + .frame(width: 32, height: 32) + } + .frame(width: 60, height: 60) + .background(telecomManager.isPausedByRemote ? Color.gray600 : (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) + + VStack { + Button { + callViewModel.toggleRecording() + } label: { + Image("record-fill") + .renderingMode(.template) + .resizable() + .foregroundStyle((callViewModel.isPaused || telecomManager.isPausedByRemote) ? Color.gray500 : .white) + .frame(width: 32, height: 32) + } + .frame(width: 60, height: 60) + .background((callViewModel.isPaused || telecomManager.isPausedByRemote) ? Color.gray600 : (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) + } + .frame(height: geo.size.height * 0.15) + .padding(.horizontal, 20) + .padding(.top, 30) + } + Spacer() + } + .background(Color.gray900) + .frame(maxHeight: .infinity, alignment: .top) + } + } + func getAudioRouteImage() { imageAudioRoute = AVAudioSession.sharedInstance().currentRoute.outputs.filter({ $0.portType.rawValue == "Speaker" }).isEmpty ? ( @@ -948,6 +956,59 @@ struct CallView: View { } } +struct BottomSheetView: View { + let content: Content + + @State var minHeight: CGFloat + @State var maxHeight: CGFloat + + @State var currentHeight: CGFloat + + var body: some View { + GeometryReader { geometry in + VStack(spacing: 0.0) { + content + } + .onAppear { + self.currentHeight = minHeight + } + .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 + currentHeight -= value.translation.height + currentHeight = min(max(currentHeight, minHeight), maxHeight) + } + .onEnded { _ in + withAnimation { + currentHeight = (currentHeight - minHeight <= maxHeight - currentHeight) ? minHeight : maxHeight + } + } + ) + .offset(y: maxHeight - currentHeight) + } + .edgesIgnoringSafeArea(.bottom) + } +} + #Preview { CallView(callViewModel: CallViewModel()) } diff --git a/Linphone/UI/Main/Contacts/Fragments/ContactFragment.swift b/Linphone/UI/Main/Contacts/Fragments/ContactFragment.swift index e29473d97..aa805937e 100644 --- a/Linphone/UI/Main/Contacts/Fragments/ContactFragment.swift +++ b/Linphone/UI/Main/Contacts/Fragments/ContactFragment.swift @@ -35,45 +35,25 @@ struct ContactFragment: View { var body: some View { let indexDisplayed = contactViewModel.indexDisplayedFriend != nil ? contactViewModel.indexDisplayedFriend! : 0 - if #available(iOS 16.0, *) { - if idiom != .pad { - ContactInnerFragment( - contactAvatarModel: ContactsManager.shared.avatarListModel[indexDisplayed], - contactViewModel: contactViewModel, - editContactViewModel: editContactViewModel, - cnContact: CNContact(), - isShowDeletePopup: $isShowDeletePopup, - showingSheet: $showingSheet, - showShareSheet: $showShareSheet, - isShowDismissPopup: $isShowDismissPopup - ) - .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, - cnContact: CNContact(), - isShowDeletePopup: $isShowDeletePopup, - showingSheet: $showingSheet, - showShareSheet: $showShareSheet, - isShowDismissPopup: $isShowDismissPopup - ) - .halfSheet(showSheet: $showingSheet) { - ContactListBottomSheet(contactViewModel: contactViewModel, showingSheet: $showingSheet) - } onDismiss: {} - .sheet(isPresented: $showShareSheet) { - ShareSheet(friendToShare: ContactsManager.shared.lastSearch[contactViewModel.indexDisplayedFriend!].friend!) - .edgesIgnoringSafeArea(.bottom) - } + if #available(iOS 16.0, *), idiom != .pad { + ContactInnerFragment( + contactAvatarModel: ContactsManager.shared.avatarListModel[indexDisplayed], + contactViewModel: contactViewModel, + editContactViewModel: editContactViewModel, + cnContact: CNContact(), + isShowDeletePopup: $isShowDeletePopup, + showingSheet: $showingSheet, + showShareSheet: $showShareSheet, + isShowDismissPopup: $isShowDismissPopup + ) + .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( @@ -89,10 +69,10 @@ struct ContactFragment: View { .halfSheet(showSheet: $showingSheet) { ContactListBottomSheet(contactViewModel: contactViewModel, showingSheet: $showingSheet) } onDismiss: {} - .sheet(isPresented: $showShareSheet) { - ShareSheet(friendToShare: ContactsManager.shared.lastSearch[contactViewModel.indexDisplayedFriend!].friend!) - .edgesIgnoringSafeArea(.bottom) - } + .sheet(isPresented: $showShareSheet) { + ShareSheet(friendToShare: ContactsManager.shared.lastSearch[contactViewModel.indexDisplayedFriend!].friend!) + .edgesIgnoringSafeArea(.bottom) + } } } diff --git a/Linphone/UI/Main/Contacts/Fragments/ContactsFragment.swift b/Linphone/UI/Main/Contacts/Fragments/ContactsFragment.swift index 8382da399..3a453d5d6 100644 --- a/Linphone/UI/Main/Contacts/Fragments/ContactsFragment.swift +++ b/Linphone/UI/Main/Contacts/Fragments/ContactsFragment.swift @@ -32,38 +32,22 @@ struct ContactsFragment: View { var body: some View { ZStack { - if #available(iOS 16.0, *) { - if idiom != .pad { - ContactsInnerFragment(contactViewModel: contactViewModel, showingSheet: $showingSheet) - .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) - .halfSheet(showSheet: $showingSheet) { - ContactsListBottomSheet( - contactViewModel: contactViewModel, - isShowDeletePopup: $isShowDeletePopup, - showingSheet: $showingSheet, - showShareSheet: $showShareSheet - ) - } onDismiss: {} - .sheet(isPresented: $showShareSheet) { - ShareSheet(friendToShare: contactViewModel.selectedFriendToShare!) - .edgesIgnoringSafeArea(.bottom) - } - } + if #available(iOS 16.0, *), idiom != .pad { + ContactsInnerFragment(contactViewModel: contactViewModel, showingSheet: $showingSheet) + .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) .halfSheet(showSheet: $showingSheet) { diff --git a/Linphone/UI/Main/Contacts/Fragments/EditContactFragment.swift b/Linphone/UI/Main/Contacts/Fragments/EditContactFragment.swift index 1f1c86112..9c85f1873 100644 --- a/Linphone/UI/Main/Contacts/Fragments/EditContactFragment.swift +++ b/Linphone/UI/Main/Contacts/Fragments/EditContactFragment.swift @@ -27,6 +27,9 @@ struct EditContactFragment: View { @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 @@ -50,16 +53,33 @@ struct EditContactFragment: View { ZStack { VStack(spacing: 1) { if editContactViewModel.selectedEditFriend == nil { - Rectangle() - .foregroundColor(delayedColor) - .edgesIgnoringSafeArea(.top) - .frame(height: 0) - .task(delayColor) + 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 { - Rectangle() - .foregroundColor(Color.orangeMain500) - .edgesIgnoringSafeArea(.top) - .frame(height: 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 { diff --git a/Linphone/UI/Main/ContentView.swift b/Linphone/UI/Main/ContentView.swift index 267968710..a0a2a0899 100644 --- a/Linphone/UI/Main/ContentView.swift +++ b/Linphone/UI/Main/ContentView.swift @@ -515,40 +515,23 @@ struct ContentView: View { if isShowStartCallFragment { - if #available(iOS 16.4, *) { - if idiom != .pad { - StartCallFragment( + if #available(iOS 16.4, *), idiom != .pad { + StartCallFragment( + startCallViewModel: startCallViewModel, + isShowStartCallFragment: $isShowStartCallFragment, + showingDialer: $showingDialer + ) + .zIndex(3) + .transition(.move(edge: .bottom)) + .sheet(isPresented: $showingDialer) { + DialerBottomSheet( startCallViewModel: startCallViewModel, - isShowStartCallFragment: $isShowStartCallFragment, showingDialer: $showingDialer ) - .zIndex(3) - .transition(.move(edge: .bottom)) - .sheet(isPresented: $showingDialer) { - DialerBottomSheet( - startCallViewModel: startCallViewModel, - showingDialer: $showingDialer - ) - .presentationDetents([.medium]) - // .interactiveDismissDisabled() - .presentationBackgroundInteraction(.enabled(upThrough: .medium)) - } - } else { - StartCallFragment( - startCallViewModel: startCallViewModel, - isShowStartCallFragment: $isShowStartCallFragment, - showingDialer: $showingDialer - ) - .zIndex(3) - .transition(.move(edge: .bottom)) - .halfSheet(showSheet: $showingDialer) { - DialerBottomSheet( - startCallViewModel: startCallViewModel, - showingDialer: $showingDialer - ) - } onDismiss: {} + .presentationDetents([.medium]) + // .interactiveDismissDisabled() + .presentationBackgroundInteraction(.enabled(upThrough: .medium)) } - } else { StartCallFragment( startCallViewModel: startCallViewModel, @@ -698,12 +681,12 @@ struct ContentView: View { if newPhase == .active { coreContext.onForeground() /* - if !isShowStartCallFragment { - contactsManager.fetchContacts() - DispatchQueue.global().asyncAfter(deadline: .now() + 0.5) { - historyListViewModel.computeCallLogsList() - } - } + if !isShowStartCallFragment { + contactsManager.fetchContacts() + DispatchQueue.global().asyncAfter(deadline: .now() + 0.5) { + historyListViewModel.computeCallLogsList() + } + } */ print("Active") } else if newPhase == .inactive { diff --git a/Linphone/UI/Main/History/Fragments/HistoryFragment.swift b/Linphone/UI/Main/History/Fragments/HistoryFragment.swift index 36a39e7b6..bce55db80 100644 --- a/Linphone/UI/Main/History/Fragments/HistoryFragment.swift +++ b/Linphone/UI/Main/History/Fragments/HistoryFragment.swift @@ -33,35 +33,20 @@ struct HistoryFragment: View { var body: some View { ZStack { - if #available(iOS 16.0, *) { - if idiom != .pad { - HistoryListFragment(historyListViewModel: historyListViewModel, historyViewModel: historyViewModel, showingSheet: $showingSheet) - .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) - .halfSheet(showSheet: $showingSheet) { - HistoryListBottomSheet( - historyViewModel: historyViewModel, - contactViewModel: contactViewModel, - editContactViewModel: editContactViewModel, - historyListViewModel: historyListViewModel, - showingSheet: $showingSheet, - index: $index, - isShowEditContactFragment: $isShowEditContactFragment - ) - } onDismiss: {} - } + if #available(iOS 16.0, *), idiom != .pad { + HistoryListFragment(historyListViewModel: historyListViewModel, historyViewModel: historyViewModel, showingSheet: $showingSheet) + .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) .halfSheet(showSheet: $showingSheet) { From 054d6224919331d961225fe0788e37ed2956649c Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Mon, 15 Jan 2024 16:03:39 +0100 Subject: [PATCH 095/486] Resolve merge conflicts after git rebase --- Linphone/Core/CoreContext.swift | 2 - Linphone/Localizable.xcstrings | 9 - Linphone/UI/Call/CallView.swift | 318 +++----------------------------- 3 files changed, 28 insertions(+), 301 deletions(-) diff --git a/Linphone/Core/CoreContext.swift b/Linphone/Core/CoreContext.swift index 593cea6b8..3e579d97c 100644 --- a/Linphone/Core/CoreContext.swift +++ b/Linphone/Core/CoreContext.swift @@ -123,8 +123,6 @@ final class CoreContext: ObservableObject { self.mCore.videoCaptureEnabled = true self.mCore.videoDisplayEnabled = true - try? self.mCore.start() - // Create a Core listener to listen for the callback we need // In this case, we want to know about the account registration status self.mCoreSuscriptions.insert(self.mCore.publisher?.onConfiguringStatus?.postOnMainQueue { (cbVal: (core: Core, status: Config.ConfiguringState, message: String)) in diff --git a/Linphone/Localizable.xcstrings b/Linphone/Localizable.xcstrings index ec823b015..7b82fa2c6 100644 --- a/Linphone/Localizable.xcstrings +++ b/Linphone/Localizable.xcstrings @@ -3,9 +3,6 @@ "strings" : { "" : { - }, - " " : { - }, " et " : { @@ -337,9 +334,6 @@ }, "Incoming call" : { - }, - "Incoming Call" : { - }, "Information" : { @@ -418,9 +412,6 @@ }, "Outgoing call" : { - }, - "Outgoing Call" : { - }, "password" : { "extractionState" : "manual", diff --git a/Linphone/UI/Call/CallView.swift b/Linphone/UI/Call/CallView.swift index 5de1d37d8..a70340599 100644 --- a/Linphone/UI/Call/CallView.swift +++ b/Linphone/UI/Call/CallView.swift @@ -51,280 +51,6 @@ struct CallView: View { GeometryReader { geo in if #available(iOS 16.0, *), idiom != .pad { innerView(geometry: geo) - .sheet(isPresented: - .constant( - telecomManager.callStarted - && !fullscreenVideo - && !hideButtonsSheet - && idiom != .pad - && !(orientation == .landscapeLeft || orientation == .landscapeRight || UIScreen.main.bounds.size.width > UIScreen.main.bounds.size.height) - ) - ) { - GeometryReader { _ in - VStack(spacing: 0) { - HStack(spacing: 12) { - Button { - callViewModel.terminateCall() - } label: { - Image("phone-disconnect") - .renderingMode(.template) - .resizable() - .foregroundStyle(.white) - .frame(width: 32, height: 32) - - } - .frame(width: 90, height: 60) - .background(Color.redDanger500) - .cornerRadius(40) - - Spacer() - - Button { - callViewModel.toggleVideo() - } label: { - Image(telecomManager.remoteVideo ? "video-camera" : "video-camera-slash") - .renderingMode(.template) - .resizable() - .foregroundStyle((callViewModel.isPaused || telecomManager.isPausedByRemote) ? Color.gray500 : .white) - .frame(width: 32, height: 32) - - } - .frame(width: 60, height: 60) - .background((callViewModel.isPaused || telecomManager.isPausedByRemote) ? Color.gray600 : Color.gray500) - .cornerRadius(40) - .disabled(callViewModel.isPaused || telecomManager.isPausedByRemote) - - Button { - callViewModel.toggleMuteMicrophone() - } label: { - Image(callViewModel.micMutted ? "microphone-slash" : "microphone") - .renderingMode(.template) - .resizable() - .foregroundStyle(callViewModel.micMutted ? .black : .white) - .frame(width: 32, height: 32) - - } - .frame(width: 60, height: 60) - .background(callViewModel.micMutted ? .white : Color.gray500) - .cornerRadius(40) - - Button { - if AVAudioSession.sharedInstance().availableInputs != nil - && !AVAudioSession.sharedInstance().availableInputs!.filter({ $0.portType.rawValue.contains("Bluetooth") }).isEmpty { - - hideButtonsSheet = true - - DispatchQueue.global().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: { - Image(imageAudioRoute) - .renderingMode(.template) - .resizable() - .foregroundStyle(.white) - .frame(width: 32, height: 32) - .onAppear(perform: getAudioRouteImage) - .onReceive(pub) { _ in - self.getAudioRouteImage() - } - - } - .frame(width: 60, height: 60) - .background(Color.gray500) - .cornerRadius(40) - } - .frame(height: geo.size.height * 0.15) - .padding(.horizontal, 20) - .padding(.top, -6) - - HStack(spacing: 0) { - VStack { - Button { - } label: { - Image("phone-transfer") - .renderingMode(.template) - .resizable() - .foregroundStyle(Color.gray500) - .frame(width: 32, height: 32) - } - .frame(width: 60, height: 60) - .background(Color.gray600) - .cornerRadius(40) - .disabled(true) - - Text("Transfer") - .foregroundStyle(.white) - .default_text_style(styleSize: 15) - } - .frame(width: geo.size.width * 0.25, height: geo.size.width * 0.25) - - VStack { - Button { - } label: { - Image("phone-plus") - .renderingMode(.template) - .resizable() - .foregroundStyle(Color.gray500) - .frame(width: 32, height: 32) - } - .frame(width: 60, height: 60) - .background(Color.gray600) - .cornerRadius(40) - .disabled(true) - - Text("New call") - .foregroundStyle(.white) - .default_text_style(styleSize: 15) - } - .frame(width: geo.size.width * 0.25, height: geo.size.width * 0.25) - - VStack { - Button { - } label: { - Image("phone-list") - .renderingMode(.template) - .resizable() - .foregroundStyle(Color.gray500) - .frame(width: 32, height: 32) - } - .frame(width: 60, height: 60) - .background(Color.gray600) - .cornerRadius(40) - .disabled(true) - - Text("Call list") - .foregroundStyle(.white) - .default_text_style(styleSize: 15) - } - .frame(width: geo.size.width * 0.25, height: geo.size.width * 0.25) - - VStack { - Button { - } label: { - Image("dialer") - .renderingMode(.template) - .resizable() - .foregroundStyle(Color.gray500) - .frame(width: 32, height: 32) - } - .frame(width: 60, height: 60) - .background(Color.gray600) - .cornerRadius(40) - .disabled(true) - - Text("Dialer") - .foregroundStyle(.white) - .default_text_style(styleSize: 15) - } - .frame(width: geo.size.width * 0.25, height: geo.size.width * 0.25) - } - .frame(height: geo.size.height * 0.15) - - HStack(spacing: 0) { - VStack { - Button { - } label: { - Image("chat-teardrop-text") - .renderingMode(.template) - .resizable() - //.foregroundStyle((callViewModel.isPaused || telecomManager.isPausedByRemote) ? Color.gray500 : .white) - .foregroundStyle(Color.gray500) - .frame(width: 32, height: 32) - } - .frame(width: 60, height: 60) - //.background((callViewModel.isPaused || telecomManager.isPausedByRemote) ? Color.gray600 : Color.gray500) - .background(Color.gray600) - .cornerRadius(40) - //.disabled(callViewModel.isPaused || telecomManager.isPausedByRemote) - .disabled(true) - - Text("Messages") - .foregroundStyle(.white) - .default_text_style(styleSize: 15) - } - .frame(width: geo.size.width * 0.25, height: geo.size.width * 0.25) - - VStack { - Button { - callViewModel.togglePause() - } label: { - Image(callViewModel.isPaused ? "play" : "pause") - .renderingMode(.template) - .resizable() - .foregroundStyle(telecomManager.isPausedByRemote ? Color.gray500 : .white) - .frame(width: 32, height: 32) - } - .frame(width: 60, height: 60) - .background(telecomManager.isPausedByRemote ? Color.gray600 : (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.25, height: geo.size.width * 0.25) - - VStack { - Button { - callViewModel.toggleRecording() - } label: { - Image("record-fill") - .renderingMode(.template) - .resizable() - .foregroundStyle((callViewModel.isPaused || telecomManager.isPausedByRemote) ? Color.gray500 : .white) - .frame(width: 32, height: 32) - } - .frame(width: 60, height: 60) - .background((callViewModel.isPaused || telecomManager.isPausedByRemote) ? Color.gray600 : (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.25, height: geo.size.width * 0.25) - - VStack { - Button { - } label: { - Image("video-camera") - .renderingMode(.template) - .resizable() - .foregroundStyle(.white) - .frame(width: 32, height: 32) - } - .frame(width: 60, height: 60) - .background(Color.gray500) - .cornerRadius(40) - - Text("Disposition") - .foregroundStyle(.white) - .default_text_style(styleSize: 15) - } - .frame(width: geo.size.width * 0.25, height: geo.size.width * 0.25) - .hidden() - } - .frame(height: geo.size.height * 0.15) - - Spacer() - } - .frame(maxHeight: .infinity, alignment: .top) - .presentationBackground(.black) - .presentationDetents([.fraction(0.1), .fraction(0.45)]) - .interactiveDismissDisabled() - .presentationBackgroundInteraction(.enabled) - } - } .sheet(isPresented: $audioRouteSheet, onDismiss: { audioRouteSheet = false hideButtonsSheet = false @@ -904,12 +630,13 @@ struct CallView: View { Image("phone-transfer") .renderingMode(.template) .resizable() - .foregroundStyle(.white) + .foregroundStyle(Color.gray500) .frame(width: 32, height: 32) } .frame(width: 60, height: 60) - .background(Color.gray500) + .background(Color.gray600) .cornerRadius(40) + .disabled(true) Text("Transfer") .foregroundStyle(.white) @@ -923,12 +650,13 @@ struct CallView: View { Image("phone-plus") .renderingMode(.template) .resizable() - .foregroundStyle(.white) + .foregroundStyle(Color.gray500) .frame(width: 32, height: 32) } .frame(width: 60, height: 60) - .background(Color.gray500) + .background(Color.gray600) .cornerRadius(40) + .disabled(true) Text("New call") .foregroundStyle(.white) @@ -942,12 +670,13 @@ struct CallView: View { Image("phone-list") .renderingMode(.template) .resizable() - .foregroundStyle(.white) + .foregroundStyle(Color.gray500) .frame(width: 32, height: 32) } .frame(width: 60, height: 60) - .background(Color.gray500) + .background(Color.gray600) .cornerRadius(40) + .disabled(true) Text("Call list") .foregroundStyle(.white) @@ -961,18 +690,21 @@ struct CallView: View { Image("dialer") .renderingMode(.template) .resizable() - .foregroundStyle(.white) + .foregroundStyle(Color.gray500) .frame(width: 32, height: 32) } .frame(width: 60, height: 60) - .background(Color.gray500) + .background(Color.gray600) .cornerRadius(40) + .disabled(true) Text("Dialer") .foregroundStyle(.white) .default_text_style(styleSize: 15) } .frame(width: geo.size.width * 0.25, height: geo.size.width * 0.25) + + } .frame(height: geo.size.height * 0.15) @@ -1071,12 +803,13 @@ struct CallView: View { Image("phone-transfer") .renderingMode(.template) .resizable() - .foregroundStyle(.white) + .foregroundStyle(Color.gray500) .frame(width: 32, height: 32) } .frame(width: 60, height: 60) - .background(Color.gray500) + .background(Color.gray600) .cornerRadius(40) + .disabled(true) Text("Transfer") .foregroundStyle(.white) @@ -1090,12 +823,13 @@ struct CallView: View { Image("phone-plus") .renderingMode(.template) .resizable() - .foregroundStyle(.white) + .foregroundStyle(Color.gray500) .frame(width: 32, height: 32) } .frame(width: 60, height: 60) - .background(Color.gray500) + .background(Color.gray600) .cornerRadius(40) + .disabled(true) Text("New call") .foregroundStyle(.white) @@ -1109,12 +843,13 @@ struct CallView: View { Image("phone-list") .renderingMode(.template) .resizable() - .foregroundStyle(.white) + .foregroundStyle(Color.gray500) .frame(width: 32, height: 32) } .frame(width: 60, height: 60) - .background(Color.gray500) + .background(Color.gray600) .cornerRadius(40) + .disabled(true) Text("Call list") .foregroundStyle(.white) @@ -1128,12 +863,13 @@ struct CallView: View { Image("dialer") .renderingMode(.template) .resizable() - .foregroundStyle(.white) + .foregroundStyle(Color.gray500) .frame(width: 32, height: 32) } .frame(width: 60, height: 60) - .background(Color.gray500) + .background(Color.gray600) .cornerRadius(40) + .disabled(true) Text("Dialer") .foregroundStyle(.white) @@ -1141,6 +877,8 @@ struct CallView: View { } .frame(width: geo.size.width * 0.125, height: geo.size.width * 0.125) + + VStack { Button { } label: { From 2e5ba428a0b2c43c5f4bf8c66c459fe6ad3cf3a6 Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Mon, 15 Jan 2024 17:20:18 +0100 Subject: [PATCH 096/486] Fixes for first release --- Linphone/Core/CoreContext.swift | 4 +-- Linphone/TelecomManager/TelecomManager.swift | 9 +++++ .../Assistant/Fragments/LoginFragment.swift | 1 + .../Fragments/PermissionsFragment.swift | 4 ++- .../Fragments/ProfileModeFragment.swift | 4 +++ .../Fragments/QrCodeScannerFragment.swift | 2 ++ .../Fragments/RegisterFragment.swift | 4 ++- .../ThirdPartySipAccountLoginFragment.swift | 4 ++- .../ThirdPartySipAccountWarningFragment.swift | 4 ++- Linphone/UI/Call/CallView.swift | 34 +++++++++++-------- .../UI/Call/ViewModel/CallViewModel.swift | 4 +-- .../ContactInnerActionsFragment.swift | 9 +++++ .../Fragments/ContactInnerFragment.swift | 4 +++ .../Fragments/ContactListBottomSheet.swift | 3 ++ .../Fragments/ContactsInnerFragment.swift | 1 + .../Fragments/ContactsListBottomSheet.swift | 3 ++ .../Fragments/EditContactFragment.swift | 5 +++ Linphone/UI/Main/ContentView.swift | 12 +++++-- Linphone/UI/Main/Fragments/ToastView.swift | 1 + .../Fragments/HistoryContactFragment.swift | 6 ++++ .../Fragments/HistoryListBottomSheet.swift | 4 +++ .../Fragments/HistoryListFragment.swift | 1 + .../History/Fragments/StartCallFragment.swift | 3 ++ 23 files changed, 101 insertions(+), 25 deletions(-) diff --git a/Linphone/Core/CoreContext.swift b/Linphone/Core/CoreContext.swift index 3e579d97c..61ea05a5a 100644 --- a/Linphone/Core/CoreContext.swift +++ b/Linphone/Core/CoreContext.swift @@ -153,10 +153,10 @@ final class CoreContext: ObservableObject { } else if cbVal.state == .Progress { self.loggingInProgress = true } else { - ToastViewModel.shared.toastMessage = "Registration failed" - ToastViewModel.shared.displayToast = true self.loggingInProgress = false self.loggedIn = false + ToastViewModel.shared.toastMessage = "Registration failed" + ToastViewModel.shared.displayToast = true } }) diff --git a/Linphone/TelecomManager/TelecomManager.swift b/Linphone/TelecomManager/TelecomManager.swift index aa93fd28a..98a65005c 100644 --- a/Linphone/TelecomManager/TelecomManager.swift +++ b/Linphone/TelecomManager/TelecomManager.swift @@ -338,8 +338,17 @@ class TelecomManager: ObservableObject { } else { DispatchQueue.main.async { + let oldRemoteVideo = self.remoteVideo self.remoteVideo = (core.videoActivationPolicy?.automaticallyAccept ?? false) && (call.remoteParams?.videoEnabled ?? false) + if oldRemoteVideo != self.remoteVideo && self.remoteVideo { + do { + try AVAudioSession.sharedInstance().overrideOutputAudioPort(.speaker) + } catch _ { + + } + } + if self.remoteVideo { Log.info("[Call] Remote video is activated") } diff --git a/Linphone/UI/Assistant/Fragments/LoginFragment.swift b/Linphone/UI/Assistant/Fragments/LoginFragment.swift index 51fbd099c..c7e1da5e2 100644 --- a/Linphone/UI/Assistant/Fragments/LoginFragment.swift +++ b/Linphone/UI/Assistant/Fragments/LoginFragment.swift @@ -123,6 +123,7 @@ struct LoginFragment: View { 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) diff --git a/Linphone/UI/Assistant/Fragments/PermissionsFragment.swift b/Linphone/UI/Assistant/Fragments/PermissionsFragment.swift index fb7eed70a..e56089e6b 100644 --- a/Linphone/UI/Assistant/Fragments/PermissionsFragment.swift +++ b/Linphone/UI/Assistant/Fragments/PermissionsFragment.swift @@ -45,7 +45,9 @@ struct PermissionsFragment: View { .resizable() .foregroundStyle(Color.grayMain2c500) .frame(width: 25, height: 25, alignment: .leading) - .padding(.top, -65) + .padding(.all, 10) + .padding(.top, -75) + .padding(.leading, -10) .onTapGesture { withAnimation { dismiss() diff --git a/Linphone/UI/Assistant/Fragments/ProfileModeFragment.swift b/Linphone/UI/Assistant/Fragments/ProfileModeFragment.swift index fbe34e463..9c11671f5 100644 --- a/Linphone/UI/Assistant/Fragments/ProfileModeFragment.swift +++ b/Linphone/UI/Assistant/Fragments/ProfileModeFragment.swift @@ -58,6 +58,7 @@ struct ProfileModeFragment: View { Image("info") .resizable() .frame(width: 25, height: 25) + .padding(.all, 10) .onTapGesture { withAnimation { self.isShowPopupForDefault = true @@ -94,6 +95,7 @@ struct ProfileModeFragment: View { Image("info") .resizable() .frame(width: 25, height: 25) + .padding(.all, 10) .onTapGesture { withAnimation { self.isShowPopupForDefault = false @@ -139,6 +141,8 @@ struct ProfileModeFragment: View { } .onAppear { UserDefaults.standard.set(false, forKey: "display_profile_mode") + //Skip this view + sharedMainViewModel.changeHideProfileMode() } if self.isShowPopup { diff --git a/Linphone/UI/Assistant/Fragments/QrCodeScannerFragment.swift b/Linphone/UI/Assistant/Fragments/QrCodeScannerFragment.swift index a12a98419..a3a739dfc 100644 --- a/Linphone/UI/Assistant/Fragments/QrCodeScannerFragment.swift +++ b/Linphone/UI/Assistant/Fragments/QrCodeScannerFragment.swift @@ -45,6 +45,8 @@ struct QrCodeScannerFragment: View { .resizable() .foregroundStyle(Color.white) .frame(width: 25, height: 25, alignment: .leading) + .padding(.all, 10) + .padding(.leading, -10) } .padding() .padding(.top, 50) diff --git a/Linphone/UI/Assistant/Fragments/RegisterFragment.swift b/Linphone/UI/Assistant/Fragments/RegisterFragment.swift index dbc339513..5b8178f30 100644 --- a/Linphone/UI/Assistant/Fragments/RegisterFragment.swift +++ b/Linphone/UI/Assistant/Fragments/RegisterFragment.swift @@ -42,7 +42,9 @@ struct RegisterFragment: View { .resizable() .foregroundStyle(Color.grayMain2c500) .frame(width: 25, height: 25, alignment: .leading) - .padding(.top, -65) + .padding(.all, 10) + .padding(.top, -75) + .padding(.leading, -10) .onTapGesture { withAnimation { dismiss() diff --git a/Linphone/UI/Assistant/Fragments/ThirdPartySipAccountLoginFragment.swift b/Linphone/UI/Assistant/Fragments/ThirdPartySipAccountLoginFragment.swift index 8a8d5aa0b..9069efd75 100644 --- a/Linphone/UI/Assistant/Fragments/ThirdPartySipAccountLoginFragment.swift +++ b/Linphone/UI/Assistant/Fragments/ThirdPartySipAccountLoginFragment.swift @@ -52,7 +52,9 @@ struct ThirdPartySipAccountLoginFragment: View { .resizable() .foregroundStyle(Color.grayMain2c500) .frame(width: 25, height: 25, alignment: .leading) - .padding(.top, -65) + .padding(.all, 10) + .padding(.top, -75) + .padding(.leading, -10) .onTapGesture { withAnimation { accountLoginViewModel.domain = "sip.linphone.org" diff --git a/Linphone/UI/Assistant/Fragments/ThirdPartySipAccountWarningFragment.swift b/Linphone/UI/Assistant/Fragments/ThirdPartySipAccountWarningFragment.swift index f9f9fb1f8..b63164bd5 100644 --- a/Linphone/UI/Assistant/Fragments/ThirdPartySipAccountWarningFragment.swift +++ b/Linphone/UI/Assistant/Fragments/ThirdPartySipAccountWarningFragment.swift @@ -46,7 +46,9 @@ struct ThirdPartySipAccountWarningFragment: View { .resizable() .foregroundStyle(Color.grayMain2c500) .frame(width: 25, height: 25, alignment: .leading) - .padding(.top, -65) + .padding(.all, 10) + .padding(.top, -75) + .padding(.leading, -10) .onTapGesture { withAnimation { dismiss() diff --git a/Linphone/UI/Call/CallView.swift b/Linphone/UI/Call/CallView.swift index a70340599..58f8fe232 100644 --- a/Linphone/UI/Call/CallView.swift +++ b/Linphone/UI/Call/CallView.swift @@ -93,6 +93,7 @@ struct CallView: View { .resizable() .foregroundStyle(.white) .frame(width: 25, height: 25, alignment: .leading) + .padding(.all, 10) Text(!callViewModel.isHeadPhoneAvailable() ? "Earpiece" : "Headphones") .default_text_style_white(styleSize: 15) @@ -104,6 +105,7 @@ struct CallView: View { .resizable() .foregroundStyle(.white) .frame(width: 25, height: 25, alignment: .leading) + .padding(.all, 10) } }) .frame(maxHeight: .infinity) @@ -123,6 +125,7 @@ struct CallView: View { .resizable() .foregroundStyle(.white) .frame(width: 25, height: 25, alignment: .leading) + .padding(.all, 10) Text("Speaker") .default_text_style_white(styleSize: 15) @@ -134,6 +137,7 @@ struct CallView: View { .resizable() .foregroundStyle(.white) .frame(width: 25, height: 25, alignment: .leading) + .padding(.all, 10) } }) .frame(maxHeight: .infinity) @@ -154,6 +158,7 @@ struct CallView: View { .resizable() .foregroundStyle(.white) .frame(width: 25, height: 25, alignment: .leading) + .padding(.all, 10) Text("Bluetooth") .default_text_style_white(styleSize: 15) @@ -165,6 +170,7 @@ struct CallView: View { .resizable() .foregroundStyle(.white) .frame(width: 25, height: 25, alignment: .leading) + .padding(.all, 10) } }) .frame(maxHeight: .infinity) @@ -178,7 +184,7 @@ struct CallView: View { // swiftlint:disable:next cyclomatic_complexity func innerView(geometry: GeometryProxy) -> some View { VStack { - if !fullscreenVideo { + if !fullscreenVideo || (fullscreenVideo && telecomManager.isPausedByRemote) { if #available(iOS 16.0, *) { Rectangle() .foregroundColor(Color.orangeMain500) @@ -353,8 +359,8 @@ struct CallView: View { } } .frame( - maxWidth: fullscreenVideo ? geometry.size.width : geometry.size.width - 8, - maxHeight: fullscreenVideo ? geometry.size.height + geometry.safeAreaInsets.top + geometry.safeAreaInsets.bottom : geometry.size.height - 140 + 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 - 140 ) } @@ -367,7 +373,7 @@ struct CallView: View { .foregroundStyle(Color.redDanger500) .frame(width: 32, height: 32) .padding(10) - .if(fullscreenVideo) { view in + .if(fullscreenVideo && !telecomManager.isPausedByRemote) { view in view.padding(.top, 30) } Spacer() @@ -375,8 +381,8 @@ struct CallView: View { Spacer() } .frame( - maxWidth: fullscreenVideo ? geometry.size.width : geometry.size.width - 8, - maxHeight: fullscreenVideo ? geometry.size.height + geometry.safeAreaInsets.top + geometry.safeAreaInsets.bottom : geometry.size.height - 140 + 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 - 140 ) } @@ -405,19 +411,19 @@ struct CallView: View { Spacer() } .frame( - maxWidth: fullscreenVideo ? geometry.size.width : geometry.size.width - 8, - maxHeight: fullscreenVideo ? geometry.size.height + geometry.safeAreaInsets.top + geometry.safeAreaInsets.bottom : geometry.size.height - (0.1 * geometry.size.height) - 60 + 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 - (0.1 * geometry.size.height) - 60 ) .background(.clear) } } .frame( - maxWidth: fullscreenVideo ? geometry.size.width : geometry.size.width - 8, - maxHeight: fullscreenVideo ? geometry.size.height + geometry.safeAreaInsets.top + geometry.safeAreaInsets.bottom : geometry.size.height - (0.1 * geometry.size.height) - 60 + 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 - (0.1 * geometry.size.height) - 60 ) .background(Color.gray600) .cornerRadius(20) - .padding(.horizontal, fullscreenVideo ? 0 : 4) + .padding(.horizontal, fullscreenVideo && !telecomManager.isPausedByRemote ? 0 : 4) .onRotate { newOrientation in let oldOrientation = orientation orientation = newOrientation @@ -461,7 +467,7 @@ struct CallView: View { callViewModel.orientationUpdate(orientation: orientation) } - if !fullscreenVideo { + if !fullscreenVideo || (fullscreenVideo && telecomManager.isPausedByRemote) { if telecomManager.callStarted { let scene = UIApplication.shared.connectedScenes.first as? UIWindowScene let bottomInset = scene?.windows.first?.safeAreaInsets @@ -526,7 +532,7 @@ struct CallView: View { } .frame(maxWidth: .infinity, maxHeight: .infinity) .background(Color.gray900) - .if(fullscreenVideo) { view in + .if(fullscreenVideo && !telecomManager.isPausedByRemote) { view in view.ignoresSafeArea(.all) } } @@ -559,7 +565,7 @@ struct CallView: View { Button { callViewModel.toggleVideo() } label: { - Image(callViewModel.cameraDisplayed ? "video-camera" : "video-camera-slash") + Image(telecomManager.remoteVideo ? "video-camera" : "video-camera-slash") .renderingMode(.template) .resizable() .foregroundStyle((callViewModel.isPaused || telecomManager.isPausedByRemote) ? Color.gray500 : .white) diff --git a/Linphone/UI/Call/ViewModel/CallViewModel.swift b/Linphone/UI/Call/ViewModel/CallViewModel.swift index 0764d41ec..96f95bbaf 100644 --- a/Linphone/UI/Call/ViewModel/CallViewModel.swift +++ b/Linphone/UI/Call/ViewModel/CallViewModel.swift @@ -32,7 +32,6 @@ class CallViewModel: ObservableObject { @Published var remoteAddress: Address? @Published var avatarModel: ContactAvatarModel? @Published var micMutted: Bool = false - @Published var cameraDisplayed: Bool = false @Published var isRecording: Bool = false @Published var isRemoteRecording: Bool = false @Published var isPaused: Bool = false @@ -58,7 +57,7 @@ class CallViewModel: ObservableObject { if core.currentCall != nil && core.currentCall!.remoteAddress != nil { self.currentCall = core.currentCall DispatchQueue.main.async { - self.direction = .Incoming + self.direction = self.currentCall!.dir self.remoteAddressString = String(self.currentCall!.remoteAddress!.asStringUriOnly().dropFirst(4)) self.remoteAddress = self.currentCall!.remoteAddress! @@ -75,7 +74,6 @@ class CallViewModel: ObservableObject { //self.avatarModel = ??? self.micMutted = self.currentCall!.microphoneMuted - self.cameraDisplayed = self.currentCall!.cameraEnabled == true self.isRecording = self.currentCall!.params!.isRecording self.isPaused = self.isCallPaused() self.timeElapsed = 0 diff --git a/Linphone/UI/Main/Contacts/Fragments/ContactInnerActionsFragment.swift b/Linphone/UI/Main/Contacts/Fragments/ContactInnerActionsFragment.swift index f15e7bd3a..76f69898d 100644 --- a/Linphone/UI/Main/Contacts/Fragments/ContactInnerActionsFragment.swift +++ b/Linphone/UI/Main/Contacts/Fragments/ContactInnerActionsFragment.swift @@ -48,6 +48,7 @@ struct ContactInnerActionsFragment: View { .resizable() .foregroundStyle(Color.grayMain2c600) .frame(width: 25, height: 25, alignment: .leading) + .padding(.all, 10) } .padding(.top, 30) .padding(.bottom, 10) @@ -82,6 +83,7 @@ struct ContactInnerActionsFragment: View { .resizable() .foregroundStyle(Color.grayMain2c600) .frame(width: 25, height: 25) + .padding(.all, 10) } .padding(.vertical, 15) .padding(.horizontal, 20) @@ -222,6 +224,7 @@ struct ContactInnerActionsFragment: View { .resizable() .foregroundStyle(Color.grayMain2c600) .frame(width: 25, height: 25) + .padding(.all, 10) Text("Edit") .default_text_style(styleSize: 14) @@ -245,6 +248,7 @@ struct ContactInnerActionsFragment: View { .resizable() .foregroundStyle(Color.grayMain2c600) .frame(width: 25, height: 25) + .padding(.all, 10) Text("Edit") .default_text_style(styleSize: 14) @@ -285,6 +289,7 @@ struct ContactInnerActionsFragment: View { .foregroundStyle(contactViewModel.indexDisplayedFriend != nil && contactsManager.lastSearch[contactViewModel.indexDisplayedFriend!].friend != nil && contactsManager.lastSearch[contactViewModel.indexDisplayedFriend!].friend!.starred == true ? Color.redDanger500 : Color.grayMain2c500) .frame(width: 25, height: 25) + .padding(.all, 10) Text(contactViewModel.indexDisplayedFriend != nil && contactsManager.lastSearch[contactViewModel.indexDisplayedFriend!].friend != nil && contactsManager.lastSearch[contactViewModel.indexDisplayedFriend!].friend!.starred == true @@ -314,6 +319,7 @@ struct ContactInnerActionsFragment: View { .resizable() .foregroundStyle(Color.grayMain2c600) .frame(width: 25, height: 25) + .padding(.all, 10) Text("Share") .default_text_style(styleSize: 14) @@ -340,6 +346,7 @@ struct ContactInnerActionsFragment: View { .resizable() .foregroundStyle(Color.grayMain2c600) .frame(width: 25, height: 25) + .padding(.all, 10) Text("Mute") .default_text_style(styleSize: 14) @@ -365,6 +372,7 @@ struct ContactInnerActionsFragment: View { .resizable() .foregroundStyle(Color.grayMain2c600) .frame(width: 25, height: 25) + .padding(.all, 10) Text("Block") .default_text_style(styleSize: 14) @@ -394,6 +402,7 @@ struct ContactInnerActionsFragment: View { .resizable() .foregroundStyle(Color.redDanger500) .frame(width: 25, height: 25) + .padding(.all, 10) Text("Delete this contact") .foregroundStyle(Color.redDanger500) diff --git a/Linphone/UI/Main/Contacts/Fragments/ContactInnerFragment.swift b/Linphone/UI/Main/Contacts/Fragments/ContactInnerFragment.swift index 72d4baece..0103f233a 100644 --- a/Linphone/UI/Main/Contacts/Fragments/ContactInnerFragment.swift +++ b/Linphone/UI/Main/Contacts/Fragments/ContactInnerFragment.swift @@ -57,7 +57,9 @@ struct ContactInnerFragment: View { .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 @@ -78,6 +80,7 @@ struct ContactInnerFragment: View { .resizable() .foregroundStyle(Color.orangeMain500) .frame(width: 25, height: 25, alignment: .leading) + .padding(.all, 10) .padding(.top, 2) }) } else { @@ -91,6 +94,7 @@ struct ContactInnerFragment: View { .resizable() .foregroundStyle(Color.orangeMain500) .frame(width: 25, height: 25, alignment: .leading) + .padding(.all, 10) .padding(.top, 2) } .simultaneousGesture( diff --git a/Linphone/UI/Main/Contacts/Fragments/ContactListBottomSheet.swift b/Linphone/UI/Main/Contacts/Fragments/ContactListBottomSheet.swift index c9fa76b1b..f9c006e0a 100644 --- a/Linphone/UI/Main/Contacts/Fragments/ContactListBottomSheet.swift +++ b/Linphone/UI/Main/Contacts/Fragments/ContactListBottomSheet.swift @@ -79,6 +79,7 @@ struct ContactListBottomSheet: View { .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) @@ -114,6 +115,7 @@ struct ContactListBottomSheet: View { .resizable() .foregroundStyle(Color.grayMain2c500) .frame(width: 25, height: 25, alignment: .leading) + .padding(.all, 10) Text("Invitation") .default_text_style(styleSize: 16) Spacer() @@ -143,6 +145,7 @@ struct ContactListBottomSheet: View { .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) diff --git a/Linphone/UI/Main/Contacts/Fragments/ContactsInnerFragment.swift b/Linphone/UI/Main/Contacts/Fragments/ContactsInnerFragment.swift index 6833bd144..ee46863d0 100644 --- a/Linphone/UI/Main/Contacts/Fragments/ContactsInnerFragment.swift +++ b/Linphone/UI/Main/Contacts/Fragments/ContactsInnerFragment.swift @@ -44,6 +44,7 @@ struct ContactsInnerFragment: View { .resizable() .foregroundStyle(Color.grayMain2c600) .frame(width: 25, height: 25, alignment: .leading) + .padding(.all, 10) } .padding(.top, 30) .padding(.horizontal, 16) diff --git a/Linphone/UI/Main/Contacts/Fragments/ContactsListBottomSheet.swift b/Linphone/UI/Main/Contacts/Fragments/ContactsListBottomSheet.swift index 644e6f16c..0237a5990 100644 --- a/Linphone/UI/Main/Contacts/Fragments/ContactsListBottomSheet.swift +++ b/Linphone/UI/Main/Contacts/Fragments/ContactsListBottomSheet.swift @@ -87,6 +87,7 @@ struct ContactsListBottomSheet: View { : 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") @@ -129,6 +130,7 @@ struct ContactsListBottomSheet: View { .resizable() .foregroundStyle(Color.grayMain2c500) .frame(width: 25, height: 25, alignment: .leading) + .padding(.all, 10) Text("Share") .default_text_style(styleSize: 16) Spacer() @@ -166,6 +168,7 @@ struct ContactsListBottomSheet: View { .resizable() .foregroundStyle(Color.redDanger500) .frame(width: 25, height: 25, alignment: .leading) + .padding(.all, 10) Text("Delete") .foregroundStyle(Color.redDanger500) .default_text_style(styleSize: 16) diff --git a/Linphone/UI/Main/Contacts/Fragments/EditContactFragment.swift b/Linphone/UI/Main/Contacts/Fragments/EditContactFragment.swift index 9c85f1873..01ae1fcfe 100644 --- a/Linphone/UI/Main/Contacts/Fragments/EditContactFragment.swift +++ b/Linphone/UI/Main/Contacts/Fragments/EditContactFragment.swift @@ -88,7 +88,9 @@ struct EditContactFragment: View { .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 @@ -130,6 +132,7 @@ struct EditContactFragment: View { .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 { @@ -346,6 +349,7 @@ struct EditContactFragment: View { : Color.grayMain2c600 ) .frame(width: 25, height: 25) + .padding(.all, 10) }) .disabled(editContactViewModel.sipAddresses[index].isEmpty && editContactViewModel.sipAddresses.count == index + 1) .frame(maxHeight: .infinity) @@ -394,6 +398,7 @@ struct EditContactFragment: View { : Color.grayMain2c600 ) .frame(width: 25, height: 25) + .padding(.all, 10) }) .disabled(editContactViewModel.phoneNumbers[index].isEmpty && editContactViewModel.phoneNumbers.count == index + 1) .frame(maxHeight: .infinity) diff --git a/Linphone/UI/Main/ContentView.swift b/Linphone/UI/Main/ContentView.swift index 92ec3fd72..242aa1d62 100644 --- a/Linphone/UI/Main/ContentView.swift +++ b/Linphone/UI/Main/ContentView.swift @@ -146,6 +146,7 @@ struct ContentView: View { .resizable() .foregroundStyle(.white) .frame(width: 25, height: 25, alignment: .leading) + .padding(.all, 10) } Menu { @@ -164,6 +165,7 @@ struct ContentView: View { Image("green-check") .resizable() .frame(width: 25, height: 25, alignment: .leading) + .padding(.all, 10) } } } @@ -182,6 +184,7 @@ struct ContentView: View { Image("green-check") .resizable() .frame(width: 25, height: 25, alignment: .leading) + .padding(.all, 10) } } } @@ -196,6 +199,7 @@ struct ContentView: View { Image("trash-simple-red") .resizable() .frame(width: 25, height: 25, alignment: .leading) + .padding(.all, 10) } } } @@ -205,15 +209,16 @@ struct ContentView: View { .resizable() .foregroundStyle(.white) .frame(width: 25, height: 25, alignment: .leading) + .padding(.all, 10) } - .padding(.leading) + .padding(.trailing, 10) .onTapGesture { isMenuOpen = true } } .frame(maxWidth: .infinity) .frame(height: 50) - .padding(.horizontal) + .padding(.leading) .padding(.bottom, 5) .background(Color.orangeMain500) } else { @@ -239,6 +244,8 @@ struct ContentView: View { .resizable() .foregroundStyle(.white) .frame(width: 25, height: 25, alignment: .leading) + .padding(.all, 10) + .padding(.leading, -10) } if #available(iOS 16.0, *) { @@ -305,6 +312,7 @@ struct ContentView: View { .resizable() .foregroundStyle(.white) .frame(width: 25, height: 25, alignment: .leading) + .padding(.all, 10) } .padding(.leading) } diff --git a/Linphone/UI/Main/Fragments/ToastView.swift b/Linphone/UI/Main/Fragments/ToastView.swift index 565128b7e..ed77961b6 100644 --- a/Linphone/UI/Main/Fragments/ToastView.swift +++ b/Linphone/UI/Main/Fragments/ToastView.swift @@ -31,6 +31,7 @@ struct ToastView: View { .resizable() .renderingMode(.template) .frame(width: 25, height: 25, alignment: .leading) + .padding(.all, 10) .foregroundStyle(toastViewModel.toastMessage.contains("Success") ? Color.greenSuccess500 : Color.redDanger500) switch toastViewModel.toastMessage { diff --git a/Linphone/UI/Main/History/Fragments/HistoryContactFragment.swift b/Linphone/UI/Main/History/Fragments/HistoryContactFragment.swift index a2fd12066..a5021b64a 100644 --- a/Linphone/UI/Main/History/Fragments/HistoryContactFragment.swift +++ b/Linphone/UI/Main/History/Fragments/HistoryContactFragment.swift @@ -58,7 +58,9 @@ struct HistoryContactFragment: View { .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 @@ -122,6 +124,7 @@ struct HistoryContactFragment: View { Image(addressFriend != nil ? "user-circle" : "plus-circle") .resizable() .frame(width: 25, height: 25, alignment: .leading) + .padding(.all, 10) } } @@ -150,6 +153,7 @@ struct HistoryContactFragment: View { Image("copy") .resizable() .frame(width: 25, height: 25, alignment: .leading) + .padding(.all, 10) } } @@ -171,6 +175,7 @@ struct HistoryContactFragment: View { Image("trash-simple-red") .resizable() .frame(width: 25, height: 25, alignment: .leading) + .padding(.all, 10) } } } label: { @@ -179,6 +184,7 @@ struct HistoryContactFragment: View { .resizable() .foregroundStyle(Color.orangeMain500) .frame(width: 25, height: 25, alignment: .leading) + .padding(.all, 10) } .padding(.leading) .onTapGesture { diff --git a/Linphone/UI/Main/History/Fragments/HistoryListBottomSheet.swift b/Linphone/UI/Main/History/Fragments/HistoryListBottomSheet.swift index fb8dfd731..6c8516695 100644 --- a/Linphone/UI/Main/History/Fragments/HistoryListBottomSheet.swift +++ b/Linphone/UI/Main/History/Fragments/HistoryListBottomSheet.swift @@ -116,6 +116,7 @@ struct HistoryListBottomSheet: View { .resizable() .foregroundStyle(Color.grayMain2c500) .frame(width: 25, height: 25, alignment: .leading) + .padding(.all, 10) Text("See contact") .default_text_style(styleSize: 16) Spacer() @@ -125,6 +126,7 @@ struct HistoryListBottomSheet: View { .resizable() .foregroundStyle(Color.grayMain2c500) .frame(width: 25, height: 25, alignment: .leading) + .padding(.all, 10) Text("Add the contact") .default_text_style(styleSize: 16) Spacer() @@ -175,6 +177,7 @@ struct HistoryListBottomSheet: View { .resizable() .foregroundStyle(Color.grayMain2c500) .frame(width: 25, height: 25, alignment: .leading) + .padding(.all, 10) Text("Copy SIP address") .default_text_style(styleSize: 16) Spacer() @@ -215,6 +218,7 @@ struct HistoryListBottomSheet: View { .resizable() .foregroundStyle(Color.redDanger500) .frame(width: 25, height: 25, alignment: .leading) + .padding(.all, 10) Text("Delete") .foregroundStyle(Color.redDanger500) .default_text_style(styleSize: 16) diff --git a/Linphone/UI/Main/History/Fragments/HistoryListFragment.swift b/Linphone/UI/Main/History/Fragments/HistoryListFragment.swift index 0fd6a7c9a..5858db1b7 100644 --- a/Linphone/UI/Main/History/Fragments/HistoryListFragment.swift +++ b/Linphone/UI/Main/History/Fragments/HistoryListFragment.swift @@ -159,6 +159,7 @@ struct HistoryListFragment: View { Image("phone") .resizable() .frame(width: 25, height: 25) + .padding(.all, 10) .padding(.trailing, 5) .highPriorityGesture( TapGesture() diff --git a/Linphone/UI/Main/History/Fragments/StartCallFragment.swift b/Linphone/UI/Main/History/Fragments/StartCallFragment.swift index 7a391f3de..29d24fc6b 100644 --- a/Linphone/UI/Main/History/Fragments/StartCallFragment.swift +++ b/Linphone/UI/Main/History/Fragments/StartCallFragment.swift @@ -51,7 +51,9 @@ struct StartCallFragment: View { .resizable() .foregroundStyle(Color.orangeMain500) .frame(width: 25, height: 25, alignment: .leading) + .padding(.all, 10) .padding(.top, 2) + .padding(.leading, -10) .onTapGesture { DispatchQueue.global().asyncAfter(deadline: .now() + 0.2) { magicSearch.searchForContacts( @@ -139,6 +141,7 @@ struct StartCallFragment: View { .resizable() .foregroundStyle(Color.grayMain2c500) .frame(width: 25, height: 25) + .padding(.all, 10) }) } } From 33b07f1440e223d8f7021377f5daa659f7ebd13d Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Tue, 16 Jan 2024 15:44:23 +0100 Subject: [PATCH 097/486] Fix account deletion when network is disconnected and can delete account in the side view --- Linphone/Core/CoreContext.swift | 22 +++++++++++++++-- Linphone/Localizable.xcstrings | 3 +++ Linphone/TelecomManager/TelecomManager.swift | 2 +- Linphone/UI/Main/Fragments/SideMenu.swift | 25 ++++++++++++++++++-- Linphone/UI/Main/Fragments/ToastView.swift | 4 ++-- 5 files changed, 49 insertions(+), 7 deletions(-) diff --git a/Linphone/Core/CoreContext.swift b/Linphone/Core/CoreContext.swift index 61ea05a5a..f146b8767 100644 --- a/Linphone/Core/CoreContext.swift +++ b/Linphone/Core/CoreContext.swift @@ -23,6 +23,7 @@ import linphonesw import Combine import UniformTypeIdentifiers +import Network final class CoreContext: ObservableObject { @@ -39,6 +40,8 @@ final class CoreContext: ObservableObject { private var mIterateSuscription: AnyCancellable? private var mCoreSuscriptions = Set() + let monitor = NWPathMonitor() + private init() { do { try initialiseCore() @@ -130,7 +133,7 @@ final class CoreContext: ObservableObject { if cbVal.status == Config.ConfiguringState.Successful { ToastViewModel.shared.toastMessage = "Successful" ToastViewModel.shared.displayToast = true - } + } /* else { ToastViewModel.shared.toastMessage = "Failed" @@ -157,6 +160,21 @@ final class CoreContext: ObservableObject { self.loggedIn = false ToastViewModel.shared.toastMessage = "Registration failed" ToastViewModel.shared.displayToast = true + + self.monitor.pathUpdateHandler = { path in + if path.status == .satisfied { + if cbVal.state != .Ok && cbVal.state != .Progress { + let params = cbVal.account.params + let clonedParams = params?.clone() + clonedParams?.registerEnabled = false + cbVal.account.params = clonedParams + + cbVal.core.removeAccount(account: cbVal.account) + cbVal.core.clearAccounts() + cbVal.core.clearAllAuthInfo() + } + } + } } }) @@ -188,7 +206,7 @@ final class CoreContext: ObservableObject { DispatchQueue.main.async { ToastViewModel.shared.toastMessage = "Success_send_logs" - ToastViewModel.shared.displayToast = true + ToastViewModel.shared.displayToast = true } } }) diff --git a/Linphone/Localizable.xcstrings b/Linphone/Localizable.xcstrings index 7b82fa2c6..865f0fc3e 100644 --- a/Linphone/Localizable.xcstrings +++ b/Linphone/Localizable.xcstrings @@ -364,6 +364,9 @@ }, "Log out" : { + }, + "Logout" : { + }, "Logs cleared" : { diff --git a/Linphone/TelecomManager/TelecomManager.swift b/Linphone/TelecomManager/TelecomManager.swift index 98a65005c..93a80e1d3 100644 --- a/Linphone/TelecomManager/TelecomManager.swift +++ b/Linphone/TelecomManager/TelecomManager.swift @@ -341,7 +341,7 @@ class TelecomManager: ObservableObject { let oldRemoteVideo = self.remoteVideo self.remoteVideo = (core.videoActivationPolicy?.automaticallyAccept ?? false) && (call.remoteParams?.videoEnabled ?? false) - if oldRemoteVideo != self.remoteVideo && self.remoteVideo { + if self.remoteVideo && self.remoteVideo != oldRemoteVideo { do { try AVAudioSession.sharedInstance().overrideOutputAudioPort(.speaker) } catch _ { diff --git a/Linphone/UI/Main/Fragments/SideMenu.swift b/Linphone/UI/Main/Fragments/SideMenu.swift index e9fdc8d70..95b838a10 100644 --- a/Linphone/UI/Main/Fragments/SideMenu.swift +++ b/Linphone/UI/Main/Fragments/SideMenu.swift @@ -71,16 +71,15 @@ struct SideMenu: View { clearLogs() self.menuClose() } - /* Text("Logout") .frame(height: 40) .frame(maxWidth: .infinity, alignment: .leading) .background(Color.white) .onTapGesture { print("Logout") + logout() self.menuClose() } - */ } .frame(width: self.width - safeAreaInsets.leading) .background(Color.white) @@ -110,4 +109,26 @@ struct SideMenu: View { } } } + + func logout() { + coreContext.doOnCoreQueue { core in + if core.defaultAccount != nil { + let authInfo = core.defaultAccount!.findAuthInfo() + if authInfo != nil { + Log.info("$TAG Found auth info for account, removing it") + core.removeAuthInfo(info: authInfo!) + } else { + Log.warn("$TAG Failed to find matching auth info for account") + } + + core.removeAccount(account: core.defaultAccount!) + Log.info("$TAG Account has been removed") + + DispatchQueue.main.async { + coreContext.defaultAccount = nil + coreContext.loggedIn = false + } + } + } + } } diff --git a/Linphone/UI/Main/Fragments/ToastView.swift b/Linphone/UI/Main/Fragments/ToastView.swift index ed77961b6..567be383a 100644 --- a/Linphone/UI/Main/Fragments/ToastView.swift +++ b/Linphone/UI/Main/Fragments/ToastView.swift @@ -31,7 +31,6 @@ struct ToastView: View { .resizable() .renderingMode(.template) .frame(width: 25, height: 25, alignment: .leading) - .padding(.all, 10) .foregroundStyle(toastViewModel.toastMessage.contains("Success") ? Color.greenSuccess500 : Color.redDanger500) switch toastViewModel.toastMessage { @@ -130,7 +129,8 @@ struct ToastView: View { toastViewModel.displayToast = false } } - } } + } + } Spacer() } From bca8612eab5109eb14a0c9d44ab29f86a4cbc896 Mon Sep 17 00:00:00 2001 From: QuentinArguillere Date: Tue, 16 Jan 2024 21:04:59 +0100 Subject: [PATCH 098/486] Finish crashlytics integration. Add "CRASH ME" button in contacts view, uncomment when testing to make sure it's properly connected --- Linphone.xcodeproj/project.pbxproj | 24 ++++++++++++++++++++ Linphone/Core/CoreContext.swift | 4 ++++ Linphone/LinphoneApp.swift | 17 +++++++++----- Linphone/UI/Main/Contacts/ContactsView.swift | 4 ++++ 4 files changed, 43 insertions(+), 6 deletions(-) diff --git a/Linphone.xcodeproj/project.pbxproj b/Linphone.xcodeproj/project.pbxproj index b4c229e18..ff5c4c176 100644 --- a/Linphone.xcodeproj/project.pbxproj +++ b/Linphone.xcodeproj/project.pbxproj @@ -542,6 +542,7 @@ D719ABB02ABC67BF00B41C10 /* Frameworks */, D719ABB12ABC67BF00B41C10 /* Resources */, D7FB55122AD53FE200A5AB15 /* Run Script */, + 66BF2D4B2B558A3100A5F2E3 /* Crashlytics */, ); buildRules = ( ); @@ -610,6 +611,29 @@ /* End PBXResourcesBuildPhase section */ /* Begin PBXShellScriptBuildPhase section */ + 66BF2D4B2B558A3100A5F2E3 /* Crashlytics */ = { + isa = PBXShellScriptBuildPhase; + 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; diff --git a/Linphone/Core/CoreContext.swift b/Linphone/Core/CoreContext.swift index f146b8767..a7211f1d6 100644 --- a/Linphone/Core/CoreContext.swift +++ b/Linphone/Core/CoreContext.swift @@ -245,6 +245,10 @@ final class CoreContext: ObservableObject { } } } + + func crashForCrashlytics() { + fatalError("Crashing app to test crashlytics") + } } // swiftlint:enable large_tuple diff --git a/Linphone/LinphoneApp.swift b/Linphone/LinphoneApp.swift index 0930db967..95378929c 100644 --- a/Linphone/LinphoneApp.swift +++ b/Linphone/LinphoneApp.swift @@ -22,9 +22,20 @@ import SwiftUI import Firebase #endif +class AppDelegate: NSObject, UIApplicationDelegate { + func application(_ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool { +#if USE_CRASHLYTICS + FirebaseApp.configure() +#endif + return true + } +} + @main struct LinphoneApp: App { + @UIApplicationDelegateAdaptor(AppDelegate.self) var delegate @ObservedObject private var coreContext = CoreContext.shared @ObservedObject private var sharedMainViewModel = SharedMainViewModel.shared @@ -35,12 +46,6 @@ struct LinphoneApp: App { @State private var startCallViewModel: StartCallViewModel? @State private var callViewModel: CallViewModel? - init() { -#if USE_CRASHLYTICS - FirebaseApp.configure() -#endif - } - var body: some Scene { WindowGroup { if coreContext.coreIsStarted { diff --git a/Linphone/UI/Main/Contacts/ContactsView.swift b/Linphone/UI/Main/Contacts/ContactsView.swift index fea21d9c7..80a9fa5d1 100644 --- a/Linphone/UI/Main/Contacts/ContactsView.swift +++ b/Linphone/UI/Main/Contacts/ContactsView.swift @@ -50,6 +50,10 @@ struct ContactsView: View { } .padding() + // For testing crashlytics + /*Button(action: CoreContext.shared.crashForCrashlytics, label: { + Text("CRASH ME") + })*/ } } .navigationViewStyle(.stack) From e23309765a4ede0e3a9a79f3b602f6c6a4a7e93b Mon Sep 17 00:00:00 2001 From: QuentinArguillere Date: Tue, 16 Jan 2024 21:18:39 +0100 Subject: [PATCH 099/486] Remove lime, groupchat, conference, ephemeral capabilities from core. Also remove conference factory from account when we login. THIS SHOULD BE UNDONE WHEN PROCEEDING TO NEXT STEP OF THE 6.0 RELEASE --- Linphone/Core/CoreContext.swift | 18 ++++++++++++++---- .../Viewmodel/AccountLoginViewModel.swift | 6 ++++++ 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/Linphone/Core/CoreContext.swift b/Linphone/Core/CoreContext.swift index a7211f1d6..8891a3254 100644 --- a/Linphone/Core/CoreContext.swift +++ b/Linphone/Core/CoreContext.swift @@ -120,6 +120,16 @@ final class CoreContext: ObservableObject { newParams?.pushNotificationConfig?.provider = "apns" + pushEnvironment account.params = newParams } + + // Remove specs for 6.0 first version + Log.info("Removing spec 'conference' from core for this version") + self.mCore.removeLinphoneSpec(spec: "conference") + Log.info("Removing spec 'ephemeral' from core for this version") + self.mCore.removeLinphoneSpec(spec: "ephemeral") + Log.info("Removing spec 'groupchat' from core for this version") + self.mCore.removeLinphoneSpec(spec: "groupchat") + Log.info("Removing spec 'lime' from core for this version") + self.mCore.removeLinphoneSpec(spec: "lime") } }) @@ -129,7 +139,7 @@ final class CoreContext: ObservableObject { // Create a Core listener to listen for the callback we need // In this case, we want to know about the account registration status self.mCoreSuscriptions.insert(self.mCore.publisher?.onConfiguringStatus?.postOnMainQueue { (cbVal: (core: Core, status: Config.ConfiguringState, message: String)) in - NSLog("New configuration state is \(cbVal.status) = \(cbVal.message)\n") + Log.info("New configuration state is \(cbVal.status) = \(cbVal.message)\n") if cbVal.status == Config.ConfiguringState.Successful { ToastViewModel.shared.toastMessage = "Successful" ToastViewModel.shared.displayToast = true @@ -145,7 +155,7 @@ final class CoreContext: ObservableObject { self.mCoreSuscriptions.insert(self.mCore.publisher?.onAccountRegistrationStateChanged?.postOnMainQueue { (cbVal: (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. - NSLog("New registration state is \(cbVal.state) for user id " + + Log.info("New registration state is \(cbVal.state) for user id " + "\( String(describing: cbVal.account.params?.identityAddress?.asString())) = \(cbVal.message)\n") if cbVal.state == .Ok { self.loggingInProgress = false @@ -227,7 +237,7 @@ final class CoreContext: ObservableObject { // We can't rely on defaultAccount?.params?.isPublishEnabled // as it will be modified by the SDK when changing the presence status if self.mCore.config!.getBool(section: "app", key: "publish_presence", defaultValue: true) { - NSLog("App is in foreground, PUBLISHING presence as Online") + Log.info("App is in foreground, PUBLISHING presence as Online") self.mCore.consolidatedPresence = ConsolidatedPresence.Online } } @@ -238,7 +248,7 @@ final class CoreContext: ObservableObject { // We can't rely on defaultAccount?.params?.isPublishEnabled // as it will be modified by the SDK when changing the presence status if self.mCore.config!.getBool(section: "app", key: "publish_presence", defaultValue: true) { - NSLog("App is in background, un-PUBLISHING presence info") + 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.mCore.consolidatedPresence = ConsolidatedPresence.Offline diff --git a/Linphone/UI/Assistant/Viewmodel/AccountLoginViewModel.swift b/Linphone/UI/Assistant/Viewmodel/AccountLoginViewModel.swift index 819931bd3..98a4f43f9 100644 --- a/Linphone/UI/Assistant/Viewmodel/AccountLoginViewModel.swift +++ b/Linphone/UI/Assistant/Viewmodel/AccountLoginViewModel.swift @@ -98,6 +98,12 @@ class AccountLoginViewModel: ObservableObject { #endif accountParams.pushNotificationConfig?.provider = "apns" + pushEnvironment + // Temporary disable these features are they are not used for 6.0 first version + accountParams.conferenceFactoryUri = nil + accountParams.conferenceFactoryAddress = nil + accountParams.audioVideoConferenceFactoryAddress = nil + accountParams.limeServerUrl = nil + // Now that our AccountParams is configured, we can create the Account object let account = try core.createAccount(params: accountParams) From 1c8771885408a1e42b074d50d79662c49e1aed0f Mon Sep 17 00:00:00 2001 From: QuentinArguillere Date: Tue, 16 Jan 2024 21:18:52 +0100 Subject: [PATCH 100/486] Fix typo --- Linphone/UI/Assistant/Fragments/PermissionsFragment.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Linphone/UI/Assistant/Fragments/PermissionsFragment.swift b/Linphone/UI/Assistant/Fragments/PermissionsFragment.swift index e56089e6b..40e2fdc8c 100644 --- a/Linphone/UI/Assistant/Fragments/PermissionsFragment.swift +++ b/Linphone/UI/Assistant/Fragments/PermissionsFragment.swift @@ -86,7 +86,7 @@ struct PermissionsFragment: View { .background(Color.grayMain2c200) .cornerRadius(40) - Text("**Notifications** : Pour vous informé quand vous recevez un message ou un appel.") + Text("**Notifications** : Pour vous informer quand vous recevez un message ou un appel.") .default_text_style(styleSize: 15) .padding(.leading, 10) } From 35fdb0de9bffb8e2705775ae77d2ddd2c28cae0c Mon Sep 17 00:00:00 2001 From: QuentinArguillere Date: Thu, 18 Jan 2024 11:28:22 +0100 Subject: [PATCH 101/486] Add encryption strings to info.plist for testflight distribution --- Linphone.xcodeproj/project.pbxproj | 6 ++++++ Linphone/Info.plist | 10 ++++------ 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/Linphone.xcodeproj/project.pbxproj b/Linphone.xcodeproj/project.pbxproj index ff5c4c176..fe58d02a7 100644 --- a/Linphone.xcodeproj/project.pbxproj +++ b/Linphone.xcodeproj/project.pbxproj @@ -880,12 +880,15 @@ 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"; @@ -928,12 +931,15 @@ 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"; diff --git a/Linphone/Info.plist b/Linphone/Info.plist index 33e2c5a4d..0cc800b37 100644 --- a/Linphone/Info.plist +++ b/Linphone/Info.plist @@ -2,12 +2,10 @@ - UIUserInterfaceStyle - Light - NSCameraUsageDescription - Camera usage is required for video VOIP calls - NSMicrophoneUsageDescription - Microphone usage is required for VOIP calls + ITSAppUsesNonExemptEncryption + + ITSEncryptionExportComplianceCode + b5cb085f-772a-4a4f-8c77-5d1332b1f93f UIAppFonts NotoSans-Light.ttf From 99b4868f7eed778fcc8e93f32ca1702ce8d14e27 Mon Sep 17 00:00:00 2001 From: QuentinArguillere Date: Mon, 22 Jan 2024 17:22:12 +0100 Subject: [PATCH 102/486] Add specific CoreDelegate to process the PushIncoming state of incoming calls. This is currently required, because Publisher-type callbacks are queue and not instantly called, which does not work when we have to report incoming calls for callkit before iOS kills us. --- Linphone/Core/CoreContext.swift | 20 +++++++++++++++++--- Linphone/TelecomManager/TelecomManager.swift | 2 +- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/Linphone/Core/CoreContext.swift b/Linphone/Core/CoreContext.swift index 8891a3254..f2c8a82a2 100644 --- a/Linphone/Core/CoreContext.swift +++ b/Linphone/Core/CoreContext.swift @@ -42,6 +42,8 @@ final class CoreContext: ObservableObject { let monitor = NWPathMonitor() + private var mCorePushIncomingDelegate: CoreDelegate! + private init() { do { try initialiseCore() @@ -63,14 +65,16 @@ final class CoreContext: ObservableObject { } func initialiseCore() throws { - LoggingService.Instance.logLevel = LogLevel.Debug coreQueue.async { + + LoggingService.Instance.logLevel = LogLevel.Debug let configDir = Factory.Instance.getConfigDir(context: nil) Factory.Instance.logCollectionPath = configDir Factory.Instance.enableLogCollection(state: LogCollectionState.Enabled) - + + Log.info("Initialising core") let url = NSURL(fileURLWithPath: configDir) if let pathComponent = url.appendingPathComponent("linphonerc") { let filePath = pathComponent.path @@ -91,6 +95,7 @@ final class CoreContext: ObservableObject { self.mCore = try? Factory.Instance.createCoreWithConfig(config: config!, systemContext: nil) } + self.mCore.pushRegistryDispatchQueue = Unmanaged.passUnretained(coreQueue).toOpaque() self.mCore.autoIterateEnabled = false self.mCore.callkitEnabled = true self.mCore.pushNotificationEnabled = true @@ -207,6 +212,16 @@ final class CoreContext: ObservableObject { TelecomManager.shared.onCallStateChanged(core: cbVal.core, call: cbVal.call, state: cbVal.state, message: cbVal.message) }) + self.mCorePushIncomingDelegate = CoreDelegateStub(onCallStateChanged: { (core: Core, call: Call, cstate: Call.State, message: String) in + if cstate == .PushIncomingReceived { + let callLog = call.callLog + let callId = callLog?.callId ?? "" + Log.info("PushIncomingReceived in core delegate, display callkit call") + TelecomManager.shared.displayIncomingCall(call: call, handle: "Calling", hasVideo: false, callId: callId, displayName: "Calling") + } + }) + self.mCore.addDelegate(delegate: self.mCorePushIncomingDelegate) + self.mCoreSuscriptions.insert(self.mCore.publisher?.onLogCollectionUploadStateChanged?.postOnMainQueue { (cbValue: (_: Core, _: Core.LogCollectionUploadState, info: String)) in if cbValue.info.starts(with: "https") { UIPasteboard.general.setValue( @@ -231,7 +246,6 @@ final class CoreContext: ObservableObject { try? self.mCore.start() } } - func onForeground() { coreQueue.async { // We can't rely on defaultAccount?.params?.isPublishEnabled diff --git a/Linphone/TelecomManager/TelecomManager.swift b/Linphone/TelecomManager/TelecomManager.swift index 93a80e1d3..d31ed8b4d 100644 --- a/Linphone/TelecomManager/TelecomManager.swift +++ b/Linphone/TelecomManager/TelecomManager.swift @@ -334,7 +334,7 @@ class TelecomManager: ObservableObject { let callLog = call.callLog let callId = callLog?.callId ?? "" if cstate == .PushIncomingReceived { - displayIncomingCall(call: call, handle: "Calling", hasVideo: false, callId: callId, displayName: "Calling") + Log.info("PushIncomingReceived on TelecomManager -- Ignore, should be processed by a the dedicated CoreDelegate for callkit display") } else { DispatchQueue.main.async { From 9ef28d00f6c866c8ac1324cb151cfff32630e7f3 Mon Sep 17 00:00:00 2001 From: QuentinArguillere Date: Tue, 23 Jan 2024 13:17:57 +0100 Subject: [PATCH 103/486] Add ZRTP Popup --- Linphone.xcodeproj/project.pbxproj | 12 ++ Linphone/Core/CoreContext.swift | 10 +- Linphone/Localizable.xcstrings | 14 +- Linphone/TelecomManager/TelecomManager.swift | 9 +- Linphone/UI/Call/CallView.swift | 145 +++++++++----- Linphone/UI/Call/Fragments/ZRTPPopup.swift | 180 ++++++++++++++++++ .../UI/Call/ViewModel/CallViewModel.swift | 114 +++++++++++ Linphone/UI/Main/Fragments/ToastView.swift | 27 ++- Linphone/Utils/ActivityIndicator.swift | 62 +++--- 9 files changed, 478 insertions(+), 95 deletions(-) create mode 100644 Linphone/UI/Call/Fragments/ZRTPPopup.swift diff --git a/Linphone.xcodeproj/project.pbxproj b/Linphone.xcodeproj/project.pbxproj index fe58d02a7..6e6a82566 100644 --- a/Linphone.xcodeproj/project.pbxproj +++ b/Linphone.xcodeproj/project.pbxproj @@ -55,6 +55,7 @@ D74C9CFF2ACAEC5E0021626A /* PopupView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D74C9CFE2ACAEC5E0021626A /* PopupView.swift */; }; D74C9D012ACB098C0021626A /* PermissionManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = D74C9D002ACB098C0021626A /* PermissionManager.swift */; }; D750D3392AD3E6EE00EC99C5 /* PopupLoadingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D750D3382AD3E6EE00EC99C5 /* PopupLoadingView.swift */; }; + D75759322B56D40900E7AC10 /* ZRTPPopup.swift in Sources */ = {isa = PBXBuildFile; fileRef = D75759312B56D40900E7AC10 /* ZRTPPopup.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 */; }; @@ -148,6 +149,7 @@ D74C9CFE2ACAEC5E0021626A /* PopupView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PopupView.swift; sourceTree = ""; }; D74C9D002ACB098C0021626A /* PermissionManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PermissionManager.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 = ""; }; 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 = ""; }; @@ -401,6 +403,14 @@ path = Fragments; sourceTree = ""; }; + D75759302B56D3CE00E7AC10 /* Fragments */ = { + isa = PBXGroup; + children = ( + D75759312B56D40900E7AC10 /* ZRTPPopup.swift */, + ); + path = Fragments; + sourceTree = ""; + }; D7702EF02AC7200600557C00 /* Welcome */ = { isa = PBXGroup; children = ( @@ -490,6 +500,7 @@ D7B5678C2B28883700DE63EB /* Call */ = { isa = PBXGroup; children = ( + D75759302B56D3CE00E7AC10 /* Fragments */, D7B99E972B29B37F00BE7BF2 /* ViewModel */, D7B5678D2B28888F00DE63EB /* CallView.swift */, ); @@ -702,6 +713,7 @@ D726E4392B16440C0083C415 /* ContactAvatarModel.swift in Sources */, D76005F62B0798B00054B79A /* IntExtension.swift in Sources */, D7E6D0512AEBDBD500A57AAF /* ContactsListBottomSheet.swift in Sources */, + D75759322B56D40900E7AC10 /* ZRTPPopup.swift in Sources */, D7A2EDD62AC18115005D90FC /* SharedMainViewModel.swift in Sources */, D7A03FC62ACC458A0081A588 /* SplashScreen.swift in Sources */, D7A03FC02ACC2E390081A588 /* HistoryView.swift in Sources */, diff --git a/Linphone/Core/CoreContext.swift b/Linphone/Core/CoreContext.swift index f2c8a82a2..6f743354f 100644 --- a/Linphone/Core/CoreContext.swift +++ b/Linphone/Core/CoreContext.swift @@ -73,7 +73,7 @@ final class CoreContext: ObservableObject { Factory.Instance.logCollectionPath = configDir Factory.Instance.enableLogCollection(state: LogCollectionState.Enabled) - + Log.info("Initialising core") let url = NSURL(fileURLWithPath: configDir) if let pathComponent = url.appendingPathComponent("linphonerc") { @@ -102,6 +102,9 @@ final class CoreContext: ObservableObject { self.mCore.setUserAgent(name: "Linphone iOS 6.0 Beta (\(UIDevice.current.localizedModel)) - Linphone SDK : \(self.coreVersion)", version: "6.0") + self.mCore.videoCaptureEnabled = true + self.mCore.videoDisplayEnabled = true + self.mCoreSuscriptions.insert(self.mCore.publisher?.onGlobalStateChanged?.postOnMainQueue { (cbVal: (core: Core, state: GlobalState, message: String)) in if cbVal.state == GlobalState.On { self.defaultAccount = self.mCore.defaultAccount @@ -138,9 +141,6 @@ final class CoreContext: ObservableObject { } }) - self.mCore.videoCaptureEnabled = true - self.mCore.videoDisplayEnabled = true - // Create a Core listener to listen for the callback we need // In this case, we want to know about the account registration status self.mCoreSuscriptions.insert(self.mCore.publisher?.onConfiguringStatus?.postOnMainQueue { (cbVal: (core: Core, status: Config.ConfiguringState, message: String)) in @@ -161,7 +161,7 @@ final class CoreContext: ObservableObject { // 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 \(cbVal.state) for user id " + - "\( String(describing: cbVal.account.params?.identityAddress?.asString())) = \(cbVal.message)\n") + "\( String(describing: cbVal.account.params?.identityAddress?.asString())) = \(cbVal.message)\n") if cbVal.state == .Ok { self.loggingInProgress = false self.loggedIn = true diff --git a/Linphone/Localizable.xcstrings b/Linphone/Localizable.xcstrings index 865f0fc3e..3064c318a 100644 --- a/Linphone/Localizable.xcstrings +++ b/Linphone/Localizable.xcstrings @@ -43,7 +43,7 @@ "**Micro** : Pour permettre à vos correspondants de vous entendre." : { }, - "**Notifications** : Pour vous informé quand vous recevez un message ou un appel." : { + "**Notifications** : Pour vous informer quand vous recevez un message ou un appel." : { }, "#" : { @@ -358,6 +358,9 @@ }, "Last name" : { + }, + "Letters don't match!" : { + }, "Linphone" : { @@ -474,6 +477,9 @@ }, "Remove picture" : { + }, + "Say %@ and click on the letters given by your correspondent:" : { + }, "Scan QR code" : { @@ -528,6 +534,9 @@ }, "The user name or password is incorrects" : { + }, + "This call is completely securised" : { + }, "This contact will be deleted definitively." : { @@ -581,6 +590,9 @@ }, "Username error" : { + }, + "Validate the device" : { + }, "Video Call" : { diff --git a/Linphone/TelecomManager/TelecomManager.swift b/Linphone/TelecomManager/TelecomManager.swift index d31ed8b4d..a80beb43c 100644 --- a/Linphone/TelecomManager/TelecomManager.swift +++ b/Linphone/TelecomManager/TelecomManager.swift @@ -44,8 +44,8 @@ class TelecomManager: ObservableObject { @Published var callStarted: Bool = false @Published var outgoingCallStarted: Bool = false @Published var remoteVideo: Bool = false - @Published var isRecordingByRemote: Bool = false - @Published var isPausedByRemote: Bool = false + @Published var isRecordingByRemote: Bool = false + @Published var isPausedByRemote: Bool = false var actionToFulFill: CXCallAction? var callkitAudioSessionActivated: Bool? @@ -130,7 +130,7 @@ class TelecomManager: ObservableObject { } } - private func makeRecordFilePath() -> String{ + private func makeRecordFilePath() -> String { var filePath = "recording_" let now = Date() let dateFormat = DateFormatter() @@ -408,11 +408,10 @@ class TelecomManager: ObservableObject { case .IncomingReceived: let addr = call.remoteAddress let displayName = incomingDisplayName(call: call) - #if targetEnvironment(simulator) DispatchQueue.main.async { self.outgoingCallStarted = false - self.callStarted = true + self.callStarted = false if self.callInProgress == false { withAnimation { self.callInProgress = true diff --git a/Linphone/UI/Call/CallView.swift b/Linphone/UI/Call/CallView.swift index 58f8fe232..3e302e8b7 100644 --- a/Linphone/UI/Call/CallView.swift +++ b/Linphone/UI/Call/CallView.swift @@ -49,23 +49,32 @@ struct CallView: View { var body: some View { GeometryReader { geo in - if #available(iOS 16.0, *), idiom != .pad { - innerView(geometry: geo) - .sheet(isPresented: $audioRouteSheet, onDismiss: { - audioRouteSheet = false - hideButtonsSheet = false - }) { - innerBottomSheet() - .presentationDetents([.fraction(0.3)]) - } - } else { - innerView(geometry: geo) - .halfSheet(showSheet: $audioRouteSheet) { - innerBottomSheet() - } onDismiss: { - audioRouteSheet = false - hideButtonsSheet = false - } + ZStack { + if #available(iOS 16.0, *), idiom != .pad { + innerView(geometry: geo) + .sheet(isPresented: $audioRouteSheet, onDismiss: { + audioRouteSheet = false + hideButtonsSheet = false + }) { + innerBottomSheet() + .presentationDetents([.fraction(0.3)]) + } + } else { + innerView(geometry: geo) + .halfSheet(showSheet: $audioRouteSheet) { + innerBottomSheet() + } onDismiss: { + audioRouteSheet = false + hideButtonsSheet = false + } + } + if callViewModel.zrtpPopupDisplayed == true { + ZRTPPopup(callViewModel: callViewModel) + .background(.black.opacity(0.65)) + .onTapGesture { + callViewModel.zrtpPopupDisplayed = false + } + } } } } @@ -243,6 +252,17 @@ struct CallView: View { Spacer() + if callViewModel.isMediaEncrypted { + Button { + callViewModel.showZrtpSasDialogIfPossible() + } label: { + Image(callViewModel.isZrtpPq ? "media-encryption-zrtp-pq" : "media-encryption-srtp") + .resizable() + .frame(width: 30, height: 30) + .padding(.horizontal) + } + } + if telecomManager.remoteVideo { Button { callViewModel.switchCamera() @@ -263,50 +283,71 @@ struct CallView: View { ZStack { VStack { Spacer() - - if callViewModel.remoteAddress != nil { - let addressFriend = contactsManager.getFriendWithAddress(address: callViewModel.remoteAddress!) + ZStack { - let contactAvatarModel = addressFriend != nil - ? ContactsManager.shared.avatarListModel.first(where: { - ($0.friend!.consolidatedPresence == .Online || $0.friend!.consolidatedPresence == .Busy) - && $0.friend!.name == addressFriend!.name - && $0.friend!.address!.asStringUriOnly() == addressFriend!.address!.asStringUriOnly() - }) - : ContactAvatarModel(friend: nil, withPresence: false) + if callViewModel.isRemoteDeviceTrusted { + Circle() + .fill(Color.blueInfo500) + .frame(width: 105, height: 105) + } - if addressFriend != nil && addressFriend!.photo != nil && !addressFriend!.photo!.isEmpty { - if contactAvatarModel != nil { - Avatar(contactAvatarModel: contactAvatarModel!, avatarSize: 100, hidePresence: true) + if callViewModel.remoteAddress != nil { + let addressFriend = contactsManager.getFriendWithAddress(address: callViewModel.remoteAddress!) + + let contactAvatarModel = addressFriend != nil + ? ContactsManager.shared.avatarListModel.first(where: { + ($0.friend!.consolidatedPresence == .Online || $0.friend!.consolidatedPresence == .Busy) + && $0.friend!.name == addressFriend!.name + && $0.friend!.address!.asStringUriOnly() == addressFriend!.address!.asStringUriOnly() + }) + : ContactAvatarModel(friend: nil, withPresence: false) + + if addressFriend != nil && addressFriend!.photo != nil && !addressFriend!.photo!.isEmpty { + if contactAvatarModel != nil { + Avatar(contactAvatarModel: contactAvatarModel!, avatarSize: 100, hidePresence: true) + } + } else { + if callViewModel.remoteAddress!.displayName != nil { + Image(uiImage: contactsManager.textToImage( + firstName: callViewModel.remoteAddress!.displayName!, + lastName: callViewModel.remoteAddress!.displayName!.components(separatedBy: " ").count > 1 + ? callViewModel.remoteAddress!.displayName!.components(separatedBy: " ")[1] + : "")) + .resizable() + .frame(width: 100, height: 100) + .clipShape(Circle()) + + } else { + Image(uiImage: contactsManager.textToImage( + firstName: callViewModel.remoteAddress!.username ?? "Username Error", + lastName: callViewModel.remoteAddress!.username!.components(separatedBy: " ").count > 1 + ? callViewModel.remoteAddress!.username!.components(separatedBy: " ")[1] + : "")) + .resizable() + .frame(width: 100, height: 100) + .clipShape(Circle()) + } + } } else { - if callViewModel.remoteAddress!.displayName != nil { - Image(uiImage: contactsManager.textToImage( - firstName: callViewModel.remoteAddress!.displayName!, - lastName: callViewModel.remoteAddress!.displayName!.components(separatedBy: " ").count > 1 - ? callViewModel.remoteAddress!.displayName!.components(separatedBy: " ")[1] - : "")) + Image("profil-picture-default") .resizable() .frame(width: 100, height: 100) .clipShape(Circle()) - - } else { - Image(uiImage: contactsManager.textToImage( - firstName: callViewModel.remoteAddress!.username ?? "Username Error", - lastName: callViewModel.remoteAddress!.username!.components(separatedBy: " ").count > 1 - ? callViewModel.remoteAddress!.username!.components(separatedBy: " ")[1] - : "")) - .resizable() - .frame(width: 100, height: 100) - .clipShape(Circle()) - } - } - } else { - Image("profil-picture-default") - .resizable() + + if callViewModel.isRemoteDeviceTrusted { + VStack { + Spacer() + HStack { + Image("trusted") + .resizable() + .frame(width: 25, height: 25) + Spacer() + } + } .frame(width: 100, height: 100) - .clipShape(Circle()) + } } Text(callViewModel.displayName) diff --git a/Linphone/UI/Call/Fragments/ZRTPPopup.swift b/Linphone/UI/Call/Fragments/ZRTPPopup.swift new file mode 100644 index 000000000..8e1a1b448 --- /dev/null +++ b/Linphone/UI/Call/Fragments/ZRTPPopup.swift @@ -0,0 +1,180 @@ +/* + * 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 Foundation + +struct ZRTPPopup: View { + + @ObservedObject private var telecomManager = TelecomManager.shared + @ObservedObject private var sharedMainViewModel = SharedMainViewModel.shared + + @ObservedObject var callViewModel: CallViewModel + + @State private var letters1: String = "AA" + @State private var letters2: String = "BB" + @State private var letters3: String = "CC" + @State private var letters4: String = "DD" + + var body: some View { + GeometryReader { geometry in + VStack(alignment: .leading) { + Text("Validate the device") + .default_text_style_600(styleSize: 20) + + Text("Say \(callViewModel.upperCaseAuthTokenToRead) and click on the letters given by your correspondent:") + .default_text_style(styleSize: 15) + .padding(.bottom, 20) + + HStack(spacing: 25) { + Spacer() + + HStack(alignment: .center) { + Text(letters1) + .default_text_style(styleSize: 30) + .frame(width: 60, height: 60) + } + .padding(10) + .background(Color.grayMain2c200) + .cornerRadius(40) + .onTapGesture { + callViewModel.lettersClicked(letters: letters1) + callViewModel.zrtpPopupDisplayed = false + } + + HStack(alignment: .center) { + Text(letters2) + .default_text_style(styleSize: 30) + .frame(width: 60, height: 60) + } + .padding(10) + .background(Color.grayMain2c200) + .cornerRadius(40) + .onTapGesture { + callViewModel.lettersClicked(letters: letters2) + callViewModel.zrtpPopupDisplayed = false + } + + Spacer() + } + .padding(.bottom, 20) + + HStack(spacing: 25) { + Spacer() + + HStack(alignment: .center) { + Text(letters3) + .default_text_style(styleSize: 30) + .frame(width: 60, height: 60) + } + .padding(10) + .background(Color.grayMain2c200) + .cornerRadius(40) + .onTapGesture { + callViewModel.lettersClicked(letters: letters3) + callViewModel.zrtpPopupDisplayed = false + } + + HStack(alignment: .center) { + Text(letters4) + .default_text_style(styleSize: 30) + .frame(width: 60, height: 60) + } + .padding(10) + .background(Color.grayMain2c200) + .cornerRadius(40) + .onTapGesture { + callViewModel.lettersClicked(letters: letters4) + callViewModel.zrtpPopupDisplayed = false + } + + Spacer() + } + .padding(.bottom, 20) + + HStack { + Text("Skip") + .underline() + .tint(Color.grayMain2c600) + .default_text_style_600(styleSize: 15) + .foregroundStyle(Color.grayMain2c500) + } + .frame(maxWidth: .infinity) + .padding(.bottom, 30) + .onTapGesture { + callViewModel.zrtpPopupDisplayed = false + } + + Button(action: { + callViewModel.zrtpPopupDisplayed = false + }, label: { + Text("Letters don't match!") + .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) + } + .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) + .onAppear { + + var random = SystemRandomNumberGenerator() + let correctLetters = Int(random.next(upperBound: UInt32(4))) + + letters1 = (correctLetters == 0) ? callViewModel.upperCaseAuthTokenToListen : self.randomAlphanumericString(2) + letters2 = (correctLetters == 1) ? callViewModel.upperCaseAuthTokenToListen : self.randomAlphanumericString(2) + letters3 = (correctLetters == 2) ? callViewModel.upperCaseAuthTokenToListen : self.randomAlphanumericString(2) + letters4 = (correctLetters == 3) ? callViewModel.upperCaseAuthTokenToListen : self.randomAlphanumericString(2) + } + } + } + + func randomAlphanumericString(_ length: Int) -> String { + let letters = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" + let len = UInt32(letters.count) + var random = SystemRandomNumberGenerator() + var randomString = "" + for _ in 0..() + init() { do { try AVAudioSession.sharedInstance().setCategory(.playAndRecord, mode: .voiceChat, options: .allowBluetooth) @@ -78,6 +87,10 @@ class CallViewModel: ObservableObject { self.isPaused = self.isCallPaused() self.timeElapsed = 0 } + + self.callSuscriptions.insert(self.currentCall!.publisher?.onEncryptionChanged?.postOnMainQueue {(cbVal: (call: Call, on: Bool, authenticationToken: String?)) in + _ = self.updateEncryption() + }) } } } @@ -268,4 +281,105 @@ class CallViewModel: ObservableObject { } } } + + func lettersClicked(letters: String) { + let verified = letters == self.upperCaseAuthTokenToListen + Log.info( + "[ZRTPPopup] User clicked on \(verified ? "right" : "wrong") letters" + ) + + if verified { + coreContext.doOnCoreQueue { core in + if core.currentCall != nil { + core.currentCall!.authenticationTokenVerified = verified + } + } + } + } + + private func updateEncryption() -> Bool { + if currentCall != nil && currentCall!.currentParams != nil { + switch currentCall!.currentParams!.mediaEncryption { + case MediaEncryption.ZRTP: + let authToken = currentCall!.authenticationToken + let isDeviceTrusted = currentCall!.authenticationTokenVerified && authToken != nil + + Log.info( + "[CallViewModel] Current call media encryption is ZRTP, auth token is \(isDeviceTrusted ? "trusted" : "not trusted yet")" + ) + + isRemoteDeviceTrusted = isDeviceTrusted + + if isDeviceTrusted { + ToastViewModel.shared.toastMessage = "Info_call_securised" + ToastViewModel.shared.displayToast = true + } + + /* + 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!") + } + */ + + isMediaEncrypted = true + // When Post Quantum is available, ZRTP is Post Quantum + isZrtpPq = Core.getPostQuantumAvailable + + if !isDeviceTrusted && authToken != nil && !authToken!.isEmpty { + Log.info("[CallViewModel] Showing ZRTP SAS confirmation dialog") + showZrtpSasDialog(authToken: authToken!) + } + + return isDeviceTrusted + case MediaEncryption.SRTP, MediaEncryption.DTLS: + isMediaEncrypted = true + isZrtpPq = false + return false + default: + isMediaEncrypted = false + isZrtpPq = false + return false + } + } + return false + } + + func showZrtpSasDialogIfPossible() { + if currentCall != nil && currentCall!.currentParams != nil && currentCall!.currentParams!.mediaEncryption == MediaEncryption.ZRTP { + let authToken = currentCall!.authenticationToken + 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) + + 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 + } + } } diff --git a/Linphone/UI/Main/Fragments/ToastView.swift b/Linphone/UI/Main/Fragments/ToastView.swift index 567be383a..67e01a6b4 100644 --- a/Linphone/UI/Main/Fragments/ToastView.swift +++ b/Linphone/UI/Main/Fragments/ToastView.swift @@ -27,11 +27,17 @@ struct ToastView: View { VStack { if toastViewModel.displayToast { HStack { - 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) + 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": @@ -68,7 +74,14 @@ struct ToastView: View { .foregroundStyle(Color.greenSuccess500) .default_text_style(styleSize: 15) .padding(8) - + + case "Info_call_securised": + Text("This call is completely securised") + .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) @@ -111,7 +124,7 @@ struct ToastView: View { .overlay( RoundedRectangle(cornerRadius: 50) .inset(by: 0.5) - .stroke(toastViewModel.toastMessage.contains("Success") ? Color.greenSuccess500 : Color.redDanger500, lineWidth: 1) + .stroke(toastViewModel.toastMessage.contains("Success") ? Color.greenSuccess500 : (toastViewModel.toastMessage.contains("Info_") ? Color.blueInfo500 : Color.redDanger500), lineWidth: 1) ) .onTapGesture { if !toastViewModel.toastMessage.contains("is recording") { diff --git a/Linphone/Utils/ActivityIndicator.swift b/Linphone/Utils/ActivityIndicator.swift index 64be15f74..862ff4ed6 100644 --- a/Linphone/Utils/ActivityIndicator.swift +++ b/Linphone/Utils/ActivityIndicator.swift @@ -1,33 +1,45 @@ -// -// ActivityIndicator.swift -// Linphone -// -// Created by Martins Benoît on 13/12/2023. -// +/* + * 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 color1 = Color.white - let color2 = Color.white.opacity(0.5) - - var body: some View { - ZStack { - Circle() - .trim(from: 0, to: 0.7) - .stroke( - AngularGradient(gradient: .init(colors: [color1, color2]), 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() - } - } + + let style = StrokeStyle(lineWidth: 3, lineCap: .round) + @State var animate = false + let color1 = Color.white + let color2 = Color.white.opacity(0.5) + + var body: some View { + ZStack { + Circle() + .trim(from: 0, to: 0.7) + .stroke( + AngularGradient(gradient: .init(colors: [color1, color2]), 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() + ActivityIndicator() } From dedd68326a17d3939423b6dff7ac1dee1337d33b Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Thu, 18 Jan 2024 15:30:42 +0100 Subject: [PATCH 104/486] Disable AVAudioSession at application startup --- Linphone/UI/Call/CallView.swift | 6 ++++++ Linphone/UI/Call/ViewModel/CallViewModel.swift | 18 ++++++++++++++++-- 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/Linphone/UI/Call/CallView.swift b/Linphone/UI/Call/CallView.swift index 3e302e8b7..eafdae478 100644 --- a/Linphone/UI/Call/CallView.swift +++ b/Linphone/UI/Call/CallView.swift @@ -76,6 +76,12 @@ struct CallView: View { } } } + .onAppear { + callViewModel.enableAVAudioSession() + } + .onDisappear { + callViewModel.disableAVAudioSession() + } } } diff --git a/Linphone/UI/Call/ViewModel/CallViewModel.swift b/Linphone/UI/Call/ViewModel/CallViewModel.swift index 4f7aeb8f4..8e98b4914 100644 --- a/Linphone/UI/Call/ViewModel/CallViewModel.swift +++ b/Linphone/UI/Call/ViewModel/CallViewModel.swift @@ -53,12 +53,26 @@ class CallViewModel: ObservableObject { init() { do { try AVAudioSession.sharedInstance().setCategory(.playAndRecord, mode: .voiceChat, options: .allowBluetooth) + } catch _ { + + } + resetCallView() + } + + func enableAVAudioSession(){ + do { try AVAudioSession.sharedInstance().setActive(true) } catch _ { } - - resetCallView() + } + + func disableAVAudioSession(){ + do { + try AVAudioSession.sharedInstance().setActive(false) + } catch _ { + + } } func resetCallView() { From fbd578ea37f3999867bb4444f168ca299b63a933 Mon Sep 17 00:00:00 2001 From: QuentinArguillere Date: Tue, 23 Jan 2024 13:20:34 +0100 Subject: [PATCH 105/486] Add dialer in call view --- Linphone/UI/Call/CallView.swift | 49 ++- Linphone/UI/Main/ContentView.swift | 20 +- .../History/Fragments/DialerBottomSheet.swift | 314 +++++++++++++----- 3 files changed, 290 insertions(+), 93 deletions(-) diff --git a/Linphone/UI/Call/CallView.swift b/Linphone/UI/Call/CallView.swift index eafdae478..53bd8a595 100644 --- a/Linphone/UI/Call/CallView.swift +++ b/Linphone/UI/Call/CallView.swift @@ -47,10 +47,12 @@ struct CallView: View { @State var angleDegree = 0.0 @State var fullscreenVideo = false + @State var showingDialer = false + var body: some View { GeometryReader { geo in ZStack { - if #available(iOS 16.0, *), idiom != .pad { + if #available(iOS 16.4, *), idiom != .pad { innerView(geometry: geo) .sheet(isPresented: $audioRouteSheet, onDismiss: { audioRouteSheet = false @@ -59,6 +61,32 @@ struct CallView: View { innerBottomSheet() .presentationDetents([.fraction(0.3)]) } + .sheet(isPresented: $showingDialer) { + DialerBottomSheet( + startCallViewModel: StartCallViewModel(), + showingDialer: $showingDialer, + currentCall: callViewModel.currentCall + ) + .presentationDetents([.medium]) + .presentationBackgroundInteraction(.enabled(upThrough: .medium)) + } + } else if #available(iOS 16.0, *), idiom != .pad { + innerView(geometry: geo) + .sheet(isPresented: $audioRouteSheet, onDismiss: { + audioRouteSheet = false + hideButtonsSheet = false + }) { + innerBottomSheet() + .presentationDetents([.fraction(0.3)]) + } + .sheet(isPresented: $showingDialer) { + DialerBottomSheet( + startCallViewModel: StartCallViewModel(), + showingDialer: $showingDialer, + currentCall: callViewModel.currentCall + ) + .presentationDetents([.medium]) + } } else { innerView(geometry: geo) .halfSheet(showSheet: $audioRouteSheet) { @@ -67,6 +95,13 @@ struct CallView: View { audioRouteSheet = false hideButtonsSheet = false } + .halfSheet(showSheet: $showingDialer) { + DialerBottomSheet( + startCallViewModel: StartCallViewModel(), + showingDialer: $showingDialer, + currentCall: callViewModel.currentCall + ) + } onDismiss: {} } if callViewModel.zrtpPopupDisplayed == true { ZRTPPopup(callViewModel: callViewModel) @@ -739,17 +774,17 @@ struct CallView: View { VStack { Button { + showingDialer.toggle() } label: { Image("dialer") .renderingMode(.template) .resizable() - .foregroundStyle(Color.gray500) + .foregroundStyle(.white) .frame(width: 32, height: 32) } .frame(width: 60, height: 60) - .background(Color.gray600) + .background(Color.gray500) .cornerRadius(40) - .disabled(true) Text("Dialer") .foregroundStyle(.white) @@ -912,17 +947,17 @@ struct CallView: View { VStack { Button { + showingDialer.toggle() } label: { Image("dialer") .renderingMode(.template) .resizable() - .foregroundStyle(Color.gray500) + .foregroundStyle(.white) .frame(width: 32, height: 32) } .frame(width: 60, height: 60) - .background(Color.gray600) + .background(Color.gray500) .cornerRadius(40) - .disabled(true) Text("Dialer") .foregroundStyle(.white) diff --git a/Linphone/UI/Main/ContentView.swift b/Linphone/UI/Main/ContentView.swift index 242aa1d62..c966069af 100644 --- a/Linphone/UI/Main/ContentView.swift +++ b/Linphone/UI/Main/ContentView.swift @@ -523,7 +523,7 @@ struct ContentView: View { if isShowStartCallFragment { - if #available(iOS 16.4, *), idiom != .pad { + if #available(iOS 16.4, *), idiom != .pad { StartCallFragment( startCallViewModel: startCallViewModel, isShowStartCallFragment: $isShowStartCallFragment, @@ -534,7 +534,8 @@ struct ContentView: View { .sheet(isPresented: $showingDialer) { DialerBottomSheet( startCallViewModel: startCallViewModel, - showingDialer: $showingDialer + showingDialer: $showingDialer, + currentCall: nil ) .presentationDetents([.medium]) // .interactiveDismissDisabled() @@ -551,7 +552,8 @@ struct ContentView: View { .halfSheet(showSheet: $showingDialer) { DialerBottomSheet( startCallViewModel: startCallViewModel, - showingDialer: $showingDialer + showingDialer: $showingDialer, + currentCall: nil ) } onDismiss: {} } @@ -689,12 +691,12 @@ struct ContentView: View { if newPhase == .active { coreContext.onForeground() /* - if !isShowStartCallFragment { - contactsManager.fetchContacts() - DispatchQueue.global().asyncAfter(deadline: .now() + 0.5) { - historyListViewModel.computeCallLogsList() - } - } + if !isShowStartCallFragment { + contactsManager.fetchContacts() + DispatchQueue.global().asyncAfter(deadline: .now() + 0.5) { + historyListViewModel.computeCallLogsList() + } + } */ print("Active") } else if newPhase == .inactive { diff --git a/Linphone/UI/Main/History/Fragments/DialerBottomSheet.swift b/Linphone/UI/Main/History/Fragments/DialerBottomSheet.swift index f8d2b0ddd..0fadeb5d0 100644 --- a/Linphone/UI/Main/History/Fragments/DialerBottomSheet.swift +++ b/Linphone/UI/Main/History/Fragments/DialerBottomSheet.swift @@ -36,8 +36,11 @@ struct DialerBottomSheet: View { @State private var orientation = UIDevice.current.orientation + @State var dialerField = "" @Binding var showingDialer: Bool + let currentCall: Call? + var body: some View { VStack(alignment: .center, spacing: 0) { VStack(alignment: .center, spacing: 0) { @@ -60,11 +63,47 @@ struct DialerBottomSheet: View { .padding(15) } - Spacer() + if currentCall != nil { + HStack { + Text(dialerField) + .default_text_style(styleSize: 25) + .frame(maxWidth: .infinity) + .padding(.horizontal, 10) + .lineLimit(1) + .truncationMode(.head) + + Button { + dialerField = String(dialerField.dropLast()) + } label: { + Image("backspace-fill") + .resizable() + .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 { - startCallViewModel.searchField += "1" + 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") .default_text_style(styleSize: 32) @@ -78,7 +117,17 @@ struct DialerBottomSheet: View { Spacer() Button { - startCallViewModel.searchField += "2" + 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") .default_text_style(styleSize: 32) @@ -92,7 +141,17 @@ struct DialerBottomSheet: View { Spacer() Button { - startCallViewModel.searchField += "3" + 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") .default_text_style(styleSize: 32) @@ -108,7 +167,17 @@ struct DialerBottomSheet: View { HStack { Button { - startCallViewModel.searchField += "4" + 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") .default_text_style(styleSize: 32) @@ -122,7 +191,17 @@ struct DialerBottomSheet: View { Spacer() Button { - startCallViewModel.searchField += "5" + 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") .default_text_style(styleSize: 32) @@ -136,7 +215,17 @@ struct DialerBottomSheet: View { Spacer() Button { - startCallViewModel.searchField += "6" + 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") .default_text_style(styleSize: 32) @@ -153,7 +242,17 @@ struct DialerBottomSheet: View { HStack { Button { - startCallViewModel.searchField += "7" + 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") .default_text_style(styleSize: 32) @@ -167,7 +266,17 @@ struct DialerBottomSheet: View { Spacer() Button { - startCallViewModel.searchField += "8" + 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") .default_text_style(styleSize: 32) @@ -181,7 +290,17 @@ struct DialerBottomSheet: View { Spacer() Button { - startCallViewModel.searchField += "9" + 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") .default_text_style(styleSize: 32) @@ -198,7 +317,17 @@ struct DialerBottomSheet: View { HStack { Button { - startCallViewModel.searchField += "*" + if currentCall != nil { + do { + let digit = ("*".cString(using: String.Encoding.utf8)?[0])! + try currentCall!.sendDtmf(dtmf: digit) + dialerField += "*" + } catch { + + } + } else { + startCallViewModel.searchField += "*" + } } label: { Text("*") .default_text_style(styleSize: 32) @@ -211,43 +340,73 @@ struct DialerBottomSheet: View { Spacer() - Button { - } label: { - ZStack { + if currentCall == nil { + Button { + } label: { + ZStack { + Text("0") + .default_text_style(styleSize: 32) + .multilineTextAlignment(.center) + .frame(width: 60, height: 75) + .padding(.top, -15) + .background(.white) + .clipShape(Circle()) + .shadow(color: .black.opacity(0.2), radius: 4) + Text("+") + .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") .default_text_style(styleSize: 32) .multilineTextAlignment(.center) - .frame(width: 60, height: 75) - .padding(.top, -15) + .frame(width: 60, height: 60) .background(.white) .clipShape(Circle()) .shadow(color: .black.opacity(0.2), radius: 4) - Text("+") - .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" - } - ) Spacer() Button { - startCallViewModel.searchField += "#" + if currentCall != nil { + do { + let digit = ("#".cString(using: String.Encoding.utf8)?[0])! + try currentCall!.sendDtmf(dtmf: digit) + dialerField += "#" + } catch { + + } + } else { + startCallViewModel.searchField += "#" + } } label: { Text("#") .default_text_style(styleSize: 32) @@ -262,52 +421,53 @@ struct DialerBottomSheet: View { .padding(.top, 10) .frame(maxWidth: sharedMainViewModel.maxWidth) - HStack { - + if currentCall == nil { HStack { + HStack { + + } + .frame(width: 60, height: 60) - } - .frame(width: 60, height: 60) - - Spacer() - - Button { - if !startCallViewModel.searchField.isEmpty { - do { - let address = try Factory.Instance.createAddress(addr: String("sip:" + startCallViewModel.searchField + "@" + startCallViewModel.domain)) - telecomManager.doCallWithCore(addr: address, isVideo: false) - } catch { - - } - } - } label: { - Image("phone") - .renderingMode(.template) - .resizable() - .foregroundStyle(.white) - .frame(width: 32, height: 32) + Spacer() - } - .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) + Button { + if !startCallViewModel.searchField.isEmpty { + do { + let address = try Factory.Instance.createAddress(addr: String("sip:" + startCallViewModel.searchField + "@" + startCallViewModel.domain)) + telecomManager.doCallWithCore(addr: address, isVideo: false) + } catch { + + } + } + } 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) } - .frame(width: 60, height: 60) + .padding(.horizontal, 60) + .padding(.top, 20) + .frame(maxWidth: sharedMainViewModel.maxWidth) } - .padding(.horizontal, 60) - .padding(.top, 20) - .frame(maxWidth: sharedMainViewModel.maxWidth) Spacer() } @@ -325,6 +485,6 @@ struct DialerBottomSheet: View { #Preview { DialerBottomSheet( - startCallViewModel: StartCallViewModel(), showingDialer: .constant(false) + startCallViewModel: StartCallViewModel(), showingDialer: .constant(false), currentCall: nil ) } From 55631bf4f48d211ee12d97a395952360a8828d18 Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Fri, 19 Jan 2024 16:49:05 +0100 Subject: [PATCH 106/486] Start a new call when another call is in progress --- Linphone.xcodeproj/project.pbxproj | 8 + .../TelecomManager/ProviderDelegate.swift | 47 +-- Linphone/TelecomManager/TelecomManager.swift | 65 +++- Linphone/UI/Call/CallView.swift | 40 ++- .../UI/Call/Fragments/CallsListFragment.swift | 279 ++++++++++++++++++ .../UI/Call/ViewModel/CallViewModel.swift | 22 +- .../Call/ViewModel/CallsListViewModel.swift | 37 +++ .../ContactInnerActionsFragment.swift | 17 +- .../Fragments/ContactInnerFragment.swift | 5 +- .../Fragments/EditContactFragment.swift | 1 - Linphone/UI/Main/ContentView.swift | 13 +- .../History/Fragments/StartCallFragment.swift | 10 +- 12 files changed, 473 insertions(+), 71 deletions(-) create mode 100644 Linphone/UI/Call/Fragments/CallsListFragment.swift create mode 100644 Linphone/UI/Call/ViewModel/CallsListViewModel.swift diff --git a/Linphone.xcodeproj/project.pbxproj b/Linphone.xcodeproj/project.pbxproj index 6e6a82566..1fea99ca8 100644 --- a/Linphone.xcodeproj/project.pbxproj +++ b/Linphone.xcodeproj/project.pbxproj @@ -95,6 +95,8 @@ 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 */; }; + D7F4D9CD2B5FD83A00CDCD76 /* CallsListViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7F4D9CC2B5FD83A00CDCD76 /* CallsListViewModel.swift */; }; D7FB55112AD447FD00A5AB15 /* RegisterFragment.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7FB55102AD447FD00A5AB15 /* RegisterFragment.swift */; }; /* End PBXBuildFile section */ @@ -190,6 +192,8 @@ 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 = ""; }; + D7F4D9CC2B5FD83A00CDCD76 /* CallsListViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallsListViewModel.swift; sourceTree = ""; }; D7FB55102AD447FD00A5AB15 /* RegisterFragment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RegisterFragment.swift; sourceTree = ""; }; /* End PBXFileReference section */ @@ -407,6 +411,7 @@ isa = PBXGroup; children = ( D75759312B56D40900E7AC10 /* ZRTPPopup.swift */, + D7F4D9CA2B5FD27200CDCD76 /* CallsListFragment.swift */, ); path = Fragments; sourceTree = ""; @@ -511,6 +516,7 @@ isa = PBXGroup; children = ( D7B99E982B29B39000BE7BF2 /* CallViewModel.swift */, + D7F4D9CC2B5FD83A00CDCD76 /* CallsListViewModel.swift */, ); path = ViewModel; sourceTree = ""; @@ -696,6 +702,7 @@ D732A90F2B04C3B400DB42BA /* HistoryFragment.swift in Sources */, D79622342B1DFE600037EACD /* DialerBottomSheet.swift in Sources */, D78290BB2ADD40B2004AA85C /* ContactViewModel.swift in Sources */, + D7F4D9CB2B5FD27200CDCD76 /* CallsListFragment.swift in Sources */, D72992392ADD7F68003AF125 /* HistoryContactFragment.swift in Sources */, D7B5066D2AEFA9B900CEB4E9 /* ContactInnerFragment.swift in Sources */, D7E6D04D2AEBD77600A57AAF /* CustomBottomSheet.swift in Sources */, @@ -708,6 +715,7 @@ D71FCA7F2AE1397200D2E43E /* ContactsListViewModel.swift in Sources */, D71FCA812AE14CFC00D2E43E /* ContactsListFragment.swift in Sources */, D719ABB72ABC67BF00B41C10 /* LinphoneApp.swift in Sources */, + D7F4D9CD2B5FD83A00CDCD76 /* CallsListViewModel.swift in Sources */, D732A91B2B061BD900DB42BA /* HistoryListBottomSheet.swift in Sources */, D72250632ADE9615008FB426 /* HistoryViewModel.swift in Sources */, D726E4392B16440C0083C415 /* ContactAvatarModel.swift in Sources */, diff --git a/Linphone/TelecomManager/ProviderDelegate.swift b/Linphone/TelecomManager/ProviderDelegate.swift index 44ef6095e..284aef309 100644 --- a/Linphone/TelecomManager/ProviderDelegate.swift +++ b/Linphone/TelecomManager/ProviderDelegate.swift @@ -221,7 +221,7 @@ extension ProviderDelegate: CXProviderDelegate { let call = core.getCallByCallid(callId: callId) - DispatchQueue.main.async() { + DispatchQueue.main.async { if UIApplication.shared.applicationState != .active { TelecomManager.shared.backgroundContextCall = call TelecomManager.shared.backgroundContextCameraIsEnabled = call?.params?.videoEnabled == true || call?.callLog?.wasConference() == true @@ -276,30 +276,33 @@ extension ProviderDelegate: CXProviderDelegate { // attempt to resume another one. action.fulfill() } else { - if call?.conference != nil && core.callsNb > 1 {/* + if call?.conference != nil && core.callsNb > 1 { + /* try TelecomManager.shared.lc?.enterConference() action.fulfill() NotificationCenter.default.post(name: Notification.Name("LinphoneCallUpdate"), object: self) - */} 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(actived: true) - TelecomManager.shared.callkitAudioSessionActivated = true - } + */ + } 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(actived: true) + TelecomManager.shared.callkitAudioSessionActivated = true + } } } } catch { diff --git a/Linphone/TelecomManager/TelecomManager.swift b/Linphone/TelecomManager/TelecomManager.swift index a80beb43c..edb5c263e 100644 --- a/Linphone/TelecomManager/TelecomManager.swift +++ b/Linphone/TelecomManager/TelecomManager.swift @@ -46,6 +46,8 @@ class TelecomManager: ObservableObject { @Published var remoteVideo: Bool = false @Published var isRecordingByRemote: Bool = false @Published var isPausedByRemote: Bool = false + @Published var refreshCallViewModel: Bool = false + @Published var remainingCall: Bool = false var actionToFulFill: CXCallAction? var callkitAudioSessionActivated: Bool? @@ -95,7 +97,7 @@ class TelecomManager: ObservableObject { if TelecomManager.callKitEnabled(core: core) {// && !nextCallIsTransfer != true { let uuid = UUID() - let name = "outgoingTODO" // FastAddressBook.displayName(for: addr) ?? "unknow" + let name = addr?.asStringUriOnly() ?? "unknow" // FastAddressBook.displayName(for: addr) ?? "unknow" let handle = CXHandle(type: .generic, value: addr?.asStringUriOnly() ?? "") let startCallAction = CXStartCallAction(call: uuid, handle: handle) let transaction = CXTransaction(action: startCallAction) @@ -104,13 +106,42 @@ class TelecomManager: ObservableObject { providerDelegate.callInfos.updateValue(callInfo, forKey: uuid) providerDelegate.uuids.updateValue(uuid, forKey: "") - // setHeldOtherCalls(core: core, exceptCallid: "") + setHeldOtherCalls(core: core, exceptCallid: "") requestTransaction(transaction, action: "startCall") } 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) + } + } + } + + 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) @@ -419,7 +450,6 @@ class TelecomManager: ObservableObject { } } #endif - if call.replacedCall != nil { endCallKitReplacedCall = false @@ -503,9 +533,12 @@ class TelecomManager: ObservableObject { .OutgoingProgress, .OutgoingRinging, .OutgoingEarlyMedia: + + print("OutgoingInitOutgoingInit \(core.maxCalls)") + if TelecomManager.callKitEnabled(core: core) { let uuid = providerDelegate.uuids[""] - if uuid != nil && callId.isEmpty { + if uuid != nil { let callInfo = providerDelegate.callInfos[uuid!] callInfo!.callId = callId providerDelegate.callInfos.updateValue(callInfo!, forKey: uuid!) @@ -539,11 +572,25 @@ class TelecomManager: ObservableObject { // bluetoothEnabled = false } + //if core.callsNb == 0 { DispatchQueue.main.async { - withAnimation { - self.outgoingCallStarted = false - self.callInProgress = false - self.callStarted = false + if core.callsNb == 0 { + withAnimation { + self.outgoingCallStarted = false + self.callInProgress = false + self.callStarted = 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 + } + } + } } var displayName = "Unknown" @@ -553,7 +600,6 @@ class TelecomManager: ObservableObject { displayName = "TODOContactName" } - if UIApplication.shared.applicationState != .active && (callLog == nil || callLog?.status == .Missed || callLog?.status == .Aborted || callLog?.status == .EarlyAborted) { // Configure the notification's payload. let content = UNMutableNotificationContent() @@ -570,6 +616,7 @@ class TelecomManager: ObservableObject { } } } + //} if TelecomManager.callKitEnabled(core: core) { var uuid = providerDelegate.uuids["\(callId)"] diff --git a/Linphone/UI/Call/CallView.swift b/Linphone/UI/Call/CallView.swift index 53bd8a595..5bf4f3347 100644 --- a/Linphone/UI/Call/CallView.swift +++ b/Linphone/UI/Call/CallView.swift @@ -37,7 +37,6 @@ struct CallView: View { let pub = NotificationCenter.default.publisher(for: AVAudioSession.routeChangeNotification) - @State var startDate = Date.now @State var audioRouteSheet: Bool = false @State var hideButtonsSheet: Bool = false @State var options: Int = 1 @@ -49,6 +48,8 @@ struct CallView: View { @State var showingDialer = false + @Binding var isShowStartCallFragment: Bool + var body: some View { GeometryReader { geo in ZStack { @@ -110,6 +111,13 @@ struct CallView: View { callViewModel.zrtpPopupDisplayed = false } } + + if telecomManager.remainingCall { + HStack {} + .onAppear { + callViewModel.resetCallView() + } + } } .onAppear { callViewModel.enableAVAudioSession() @@ -273,8 +281,8 @@ struct CallView: View { ZStack { Text(callViewModel.timeElapsed.convertDurationToString()) - .onReceive(callViewModel.timer) { firedDate in - callViewModel.timeElapsed = Int(firedDate.timeIntervalSince(startDate)) + .onReceive(callViewModel.timer) { _ in + callViewModel.timeElapsed = callViewModel.currentCall?.duration ?? 0 } .foregroundStyle(.white) .if(callViewModel.isPaused || telecomManager.isPausedByRemote) { view in @@ -477,15 +485,13 @@ struct CallView: View { Text(callViewModel.counterToMinutes()) .onAppear { callViewModel.timeElapsed = 0 - startDate = Date.now } - .onReceive(callViewModel.timer) { firedDate in - callViewModel.timeElapsed = Int(firedDate.timeIntervalSince(startDate)) + .onReceive(callViewModel.timer) { _ in + callViewModel.timeElapsed = callViewModel.currentCall?.duration ?? 0 } .onDisappear { callViewModel.timeElapsed = 0 - startDate = Date.now } .padding(.top) .foregroundStyle(.white) @@ -734,17 +740,20 @@ struct CallView: View { VStack { Button { + withAnimation { + MagicSearchSingleton.shared.searchForSuggestions() + isShowStartCallFragment.toggle() + } } label: { Image("phone-plus") .renderingMode(.template) .resizable() - .foregroundStyle(Color.gray500) + .foregroundStyle(.white) .frame(width: 32, height: 32) } .frame(width: 60, height: 60) - .background(Color.gray600) + .background(Color.gray500) .cornerRadius(40) - .disabled(true) Text("New call") .foregroundStyle(.white) @@ -907,17 +916,20 @@ struct CallView: View { VStack { Button { + withAnimation { + MagicSearchSingleton.shared.searchForSuggestions() + isShowStartCallFragment.toggle() + } } label: { Image("phone-plus") .renderingMode(.template) .resizable() - .foregroundStyle(Color.gray500) + .foregroundStyle(.white) .frame(width: 32, height: 32) } .frame(width: 60, height: 60) - .background(Color.gray600) + .background(Color.gray500) .cornerRadius(40) - .disabled(true) Text("New call") .foregroundStyle(.white) @@ -1112,7 +1124,7 @@ struct BottomSheetView: View { } #Preview { - CallView(callViewModel: CallViewModel()) + CallView(callViewModel: CallViewModel(), isShowStartCallFragment: .constant(false)) } // swiftlint:enable type_body_length // swiftlint:enable line_length diff --git a/Linphone/UI/Call/Fragments/CallsListFragment.swift b/Linphone/UI/Call/Fragments/CallsListFragment.swift new file mode 100644 index 000000000..3acd5cfea --- /dev/null +++ b/Linphone/UI/Call/Fragments/CallsListFragment.swift @@ -0,0 +1,279 @@ +/* + * 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 CallsListFragment: View { + + @ObservedObject var callsListViewModel: CallsListViewModel + + @State private var delayedColor = Color.white + + @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() + + } + .frame(maxWidth: .infinity) + .frame(height: 50) + .padding(.horizontal) + .padding(.bottom, 4) + .background(.white) + + //callsList + } + .background(.white) + } + .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 callsList: some View { + VStack { + List { + ForEach(0.. 1 + ? historyListViewModel.callLogs[index].toAddress!.displayName!.components(separatedBy: " ")[1] + : "")) + .resizable() + .frame(width: 45, height: 45) + .clipShape(Circle()) + + } else { + Image(uiImage: contactsManager.textToImage( + firstName: historyListViewModel.callLogs[index].toAddress!.username ?? "Username Error", + lastName: historyListViewModel.callLogs[index].toAddress!.username!.components(separatedBy: " ").count > 1 + ? historyListViewModel.callLogs[index].toAddress!.username!.components(separatedBy: " ")[1] + : "")) + .resizable() + .frame(width: 45, height: 45) + .clipShape(Circle()) + } + + } else if historyListViewModel.callLogs[index].fromAddress != nil { + if historyListViewModel.callLogs[index].fromAddress!.displayName != nil { + Image(uiImage: contactsManager.textToImage( + firstName: historyListViewModel.callLogs[index].fromAddress!.displayName!, + lastName: historyListViewModel.callLogs[index].fromAddress!.displayName!.components(separatedBy: " ").count > 1 + ? historyListViewModel.callLogs[index].fromAddress!.displayName!.components(separatedBy: " ")[1] + : "")) + .resizable() + .frame(width: 45, height: 45) + .clipShape(Circle()) + } else { + Image(uiImage: contactsManager.textToImage( + firstName: historyListViewModel.callLogs[index].fromAddress!.username ?? "Username Error", + lastName: historyListViewModel.callLogs[index].fromAddress!.username!.components(separatedBy: " ").count > 1 + ? historyListViewModel.callLogs[index].fromAddress!.username!.components(separatedBy: " ")[1] + : "")) + .resizable() + .frame(width: 45, height: 45) + .clipShape(Circle()) + } + } else { + Image("profil-picture-default") + .resizable() + .frame(width: 45, height: 45) + .clipShape(Circle()) + } + } + + VStack(spacing: 0) { + Spacer() + + let fromAddressFriend = contactsManager.getFriendWithAddress(address: historyListViewModel.callLogs[index].fromAddress!) + let toAddressFriend = contactsManager.getFriendWithAddress(address: historyListViewModel.callLogs[index].toAddress!) + let addressFriend = historyListViewModel.callLogs[index].dir == .Incoming ? fromAddressFriend : toAddressFriend + + if addressFriend != nil { + Text(addressFriend!.name!) + .default_text_style(styleSize: 14) + .frame(maxWidth: .infinity, alignment: .leading) + .lineLimit(1) + } else { + if historyListViewModel.callLogs[index].dir == .Outgoing && historyListViewModel.callLogs[index].toAddress != nil { + Text(historyListViewModel.callLogs[index].toAddress!.displayName != nil + ? historyListViewModel.callLogs[index].toAddress!.displayName! + : historyListViewModel.callLogs[index].toAddress!.username!) + .default_text_style(styleSize: 14) + .frame(maxWidth: .infinity, alignment: .leading) + .lineLimit(1) + } else if historyListViewModel.callLogs[index].fromAddress != nil { + Text(historyListViewModel.callLogs[index].fromAddress!.displayName != nil + ? historyListViewModel.callLogs[index].fromAddress!.displayName! + : historyListViewModel.callLogs[index].fromAddress!.username!) + .default_text_style(styleSize: 14) + .frame(maxWidth: .infinity, alignment: .leading) + .lineLimit(1) + } + } + HStack { + Image(historyListViewModel.getCallIconResId(callStatus: historyListViewModel.callLogs[index].status, callDir: historyListViewModel.callLogs[index].dir)) + .resizable() + .frame( + width: historyListViewModel.getCallIconResId(callStatus: historyListViewModel.callLogs[index].status, callDir: historyListViewModel.callLogs[index].dir).contains("rejected") ? 12 : 8, + height: historyListViewModel.getCallIconResId(callStatus: historyListViewModel.callLogs[index].status, callDir: historyListViewModel.callLogs[index].dir).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() + } + + Image("phone") + .resizable() + .frame(width: 25, height: 25) + .padding(.all, 10) + .padding(.trailing, 5) + .highPriorityGesture( + TapGesture() + .onEnded { _ in + withAnimation { + if historyListViewModel.callLogs[index].dir == .Outgoing && historyListViewModel.callLogs[index].toAddress != nil { + telecomManager.doCallWithCore( + addr: historyListViewModel.callLogs[index].toAddress!, isVideo: false + ) + } else if historyListViewModel.callLogs[index].fromAddress != nil { + telecomManager.doCallWithCore( + addr: historyListViewModel.callLogs[index].fromAddress!, isVideo: false + ) + } + historyViewModel.displayedCall = nil + } + } + ) + } + } + .buttonStyle(.borderless) + .listRowInsets(EdgeInsets(top: 5, leading: 20, bottom: 5, trailing: 20)) + .listRowSeparator(.hidden) + .background(.white) + .onTapGesture { + withAnimation { + historyViewModel.displayedCall = historyListViewModel.callLogs[index] + } + } + .onLongPressGesture(minimumDuration: 0.2) { + historyViewModel.selectedCall = historyListViewModel.callLogs[index] + showingSheet.toggle() + } + } + } + .listStyle(.plain) + .overlay( + VStack { + if historyListViewModel.callLogs.isEmpty { + Spacer() + Image("illus-belledonne") + .resizable() + .scaledToFit() + .clipped() + .padding(.all) + Text("No call for the moment...") + .default_text_style_800(styleSize: 16) + Spacer() + Spacer() + } + } + .padding(.all) + ) + } + .navigationTitle("") + .navigationBarHidden(true) + } + */ +} + +#Preview { + CallsListFragment(callsListViewModel: CallsListViewModel(), isShowCallsListFragment: .constant(true)) +} diff --git a/Linphone/UI/Call/ViewModel/CallViewModel.swift b/Linphone/UI/Call/ViewModel/CallViewModel.swift index 8e98b4914..7bc797ad1 100644 --- a/Linphone/UI/Call/ViewModel/CallViewModel.swift +++ b/Linphone/UI/Call/ViewModel/CallViewModel.swift @@ -59,7 +59,7 @@ class CallViewModel: ObservableObject { resetCallView() } - func enableAVAudioSession(){ + func enableAVAudioSession() { do { try AVAudioSession.sharedInstance().setActive(true) } catch _ { @@ -99,7 +99,11 @@ class CallViewModel: ObservableObject { self.micMutted = self.currentCall!.microphoneMuted self.isRecording = self.currentCall!.params!.isRecording self.isPaused = self.isCallPaused() - self.timeElapsed = 0 + self.timeElapsed = self.currentCall?.duration ?? 0 + + let authToken = self.currentCall!.authenticationToken + let isDeviceTrusted = self.currentCall!.authenticationTokenVerified && authToken != nil + self.isRemoteDeviceTrusted = self.telecomManager.callInProgress ? isDeviceTrusted : false } self.callSuscriptions.insert(self.currentCall!.publisher?.onEncryptionChanged?.postOnMainQueue {(cbVal: (call: Call, on: Bool, authenticationToken: String?)) in @@ -110,19 +114,15 @@ class CallViewModel: ObservableObject { } func terminateCall() { - withAnimation { - telecomManager.outgoingCallStarted = false - telecomManager.callStarted = false - telecomManager.callInProgress = false - } - - coreContext.doOnCoreQueue { _ in + coreContext.doOnCoreQueue { core in if self.currentCall != nil { self.telecomManager.terminateCall(call: self.currentCall!) } + + if core.callsNb == 0 { + self.timer.upstream.connect().cancel() + } } - - timer.upstream.connect().cancel() } func acceptCall() { diff --git a/Linphone/UI/Call/ViewModel/CallsListViewModel.swift b/Linphone/UI/Call/ViewModel/CallsListViewModel.swift new file mode 100644 index 000000000..edd847b2e --- /dev/null +++ b/Linphone/UI/Call/ViewModel/CallsListViewModel.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 Foundation + +class CallsListViewModel: ObservableObject { + + var coreContext = CoreContext.shared + + //let nbCalls : Int + + init() { + //self.getCallsList() + } + + func getCallsList() { + coreContext.doOnCoreQueue { core in + core.callsNb + } + } +} diff --git a/Linphone/UI/Main/Contacts/Fragments/ContactInnerActionsFragment.swift b/Linphone/UI/Main/Contacts/Fragments/ContactInnerActionsFragment.swift index 76f69898d..be78afc16 100644 --- a/Linphone/UI/Main/Contacts/Fragments/ContactInnerActionsFragment.swift +++ b/Linphone/UI/Main/Contacts/Fragments/ContactInnerActionsFragment.swift @@ -62,7 +62,9 @@ struct ContactInnerActionsFragment: View { if informationIsOpen { VStack(spacing: 0) { - if contactViewModel.indexDisplayedFriend != nil && contactsManager.lastSearch[contactViewModel.indexDisplayedFriend!].friend != nil { + if contactViewModel.indexDisplayedFriend != nil + && contactsManager.lastSearch.count > contactViewModel.indexDisplayedFriend! + && contactsManager.lastSearch[contactViewModel.indexDisplayedFriend!].friend != nil { ForEach(0.. contactViewModel.indexDisplayedFriend! && contactsManager.lastSearch[contactViewModel.indexDisplayedFriend!].friend != nil && ((contactsManager.lastSearch[contactViewModel.indexDisplayedFriend!].friend!.organization != nil && !contactsManager.lastSearch[contactViewModel.indexDisplayedFriend!].friend!.organization!.isEmpty) @@ -211,7 +214,8 @@ struct ContactInnerActionsFragment: View { .background(Color.gray100) VStack(spacing: 0) { - if contactViewModel.indexDisplayedFriend != nil && contactViewModel.indexDisplayedFriend! < contactsManager.lastSearch.count + if contactViewModel.indexDisplayedFriend != nil + && contactsManager.lastSearch.count > contactViewModel.indexDisplayedFriend! && contactsManager.lastSearch[contactViewModel.indexDisplayedFriend!].friend != nil && contactsManager.lastSearch[contactViewModel.indexDisplayedFriend!].friend!.nativeUri != nil && !contactsManager.lastSearch[contactViewModel.indexDisplayedFriend!].friend!.nativeUri!.isEmpty { @@ -282,15 +286,20 @@ struct ContactInnerActionsFragment: View { } } label: { HStack { - Image(contactViewModel.indexDisplayedFriend != nil && contactsManager.lastSearch[contactViewModel.indexDisplayedFriend!].friend != nil + Image(contactViewModel.indexDisplayedFriend != nil + && contactViewModel.indexDisplayedFriend! < contactsManager.lastSearch.count + && contactsManager.lastSearch[contactViewModel.indexDisplayedFriend!].friend != nil && contactsManager.lastSearch[contactViewModel.indexDisplayedFriend!].friend!.starred == true ? "heart-fill" : "heart") .renderingMode(.template) .resizable() - .foregroundStyle(contactViewModel.indexDisplayedFriend != nil && contactsManager.lastSearch[contactViewModel.indexDisplayedFriend!].friend != nil + .foregroundStyle(contactViewModel.indexDisplayedFriend != nil + && contactViewModel.indexDisplayedFriend! < contactsManager.lastSearch.count + && contactsManager.lastSearch[contactViewModel.indexDisplayedFriend!].friend != nil && contactsManager.lastSearch[contactViewModel.indexDisplayedFriend!].friend!.starred == true ? Color.redDanger500 : Color.grayMain2c500) .frame(width: 25, height: 25) .padding(.all, 10) Text(contactViewModel.indexDisplayedFriend != nil + && contactViewModel.indexDisplayedFriend! < contactsManager.lastSearch.count && contactsManager.lastSearch[contactViewModel.indexDisplayedFriend!].friend != nil && contactsManager.lastSearch[contactViewModel.indexDisplayedFriend!].friend!.starred == true ? "Remove from favourites" diff --git a/Linphone/UI/Main/Contacts/Fragments/ContactInnerFragment.swift b/Linphone/UI/Main/Contacts/Fragments/ContactInnerFragment.swift index 0103f233a..aa8ac1e27 100644 --- a/Linphone/UI/Main/Contacts/Fragments/ContactInnerFragment.swift +++ b/Linphone/UI/Main/Contacts/Fragments/ContactInnerFragment.swift @@ -120,13 +120,16 @@ struct ContactInnerFragment: View { && contactsManager.lastSearch[contactViewModel.indexDisplayedFriend!].friend!.photo != nil && !contactsManager.lastSearch[contactViewModel.indexDisplayedFriend!].friend!.photo!.isEmpty { Avatar(contactAvatarModel: contactAvatarModel, avatarSize: 100) - } else if contactViewModel.indexDisplayedFriend != nil && contactsManager.lastSearch[contactViewModel.indexDisplayedFriend!].friend != nil { + } else if contactViewModel.indexDisplayedFriend != nil + && contactViewModel.indexDisplayedFriend! < contactsManager.lastSearch.count + && contactsManager.lastSearch[contactViewModel.indexDisplayedFriend!].friend != nil { Image("profil-picture-default") .resizable() .frame(width: 100, height: 100) .clipShape(Circle()) } if contactViewModel.indexDisplayedFriend != nil + && contactViewModel.indexDisplayedFriend! < contactsManager.lastSearch.count && contactsManager.lastSearch[contactViewModel.indexDisplayedFriend!].friend != nil && contactsManager.lastSearch[contactViewModel.indexDisplayedFriend!].friend?.name != nil { Text((contactsManager.lastSearch[contactViewModel.indexDisplayedFriend!].friend?.name)!) diff --git a/Linphone/UI/Main/Contacts/Fragments/EditContactFragment.swift b/Linphone/UI/Main/Contacts/Fragments/EditContactFragment.swift index 01ae1fcfe..92c4ed7ff 100644 --- a/Linphone/UI/Main/Contacts/Fragments/EditContactFragment.swift +++ b/Linphone/UI/Main/Contacts/Fragments/EditContactFragment.swift @@ -35,7 +35,6 @@ struct EditContactFragment: View { @Binding var isShowEditContactFragment: Bool @Binding var isShowDismissPopup: Bool - @State private var hasTimeElapsed = false @State private var delayedColor = Color.white @FocusState var isFirstNameFocused: Bool diff --git a/Linphone/UI/Main/ContentView.swift b/Linphone/UI/Main/ContentView.swift index c966069af..c830d265f 100644 --- a/Linphone/UI/Main/ContentView.swift +++ b/Linphone/UI/Main/ContentView.swift @@ -522,14 +522,14 @@ struct ContentView: View { } if isShowStartCallFragment { - if #available(iOS 16.4, *), idiom != .pad { StartCallFragment( startCallViewModel: startCallViewModel, isShowStartCallFragment: $isShowStartCallFragment, - showingDialer: $showingDialer + showingDialer: $showingDialer, + resetCallView: {callViewModel.resetCallView()} ) - .zIndex(3) + .zIndex(4) .transition(.move(edge: .bottom)) .sheet(isPresented: $showingDialer) { DialerBottomSheet( @@ -545,9 +545,10 @@ struct ContentView: View { StartCallFragment( startCallViewModel: startCallViewModel, isShowStartCallFragment: $isShowStartCallFragment, - showingDialer: $showingDialer + showingDialer: $showingDialer, + resetCallView: {callViewModel.resetCallView()} ) - .zIndex(3) + .zIndex(4) .transition(.move(edge: .bottom)) .halfSheet(showSheet: $showingDialer) { DialerBottomSheet( @@ -657,7 +658,7 @@ struct ContentView: View { } if telecomManager.callInProgress { - CallView(callViewModel: callViewModel) + CallView(callViewModel: callViewModel, isShowStartCallFragment: $isShowStartCallFragment) .zIndex(3) .transition(.scale.combined(with: .move(edge: .top))) .onAppear { diff --git a/Linphone/UI/Main/History/Fragments/StartCallFragment.swift b/Linphone/UI/Main/History/Fragments/StartCallFragment.swift index 29d24fc6b..c113de297 100644 --- a/Linphone/UI/Main/History/Fragments/StartCallFragment.swift +++ b/Linphone/UI/Main/History/Fragments/StartCallFragment.swift @@ -32,9 +32,10 @@ struct StartCallFragment: View { @Binding var showingDialer: Bool @FocusState var isSearchFieldFocused: Bool - @State private var hasTimeElapsed = false @State private var delayedColor = Color.white + var resetCallView: () -> Void + var body: some View { ZStack { VStack(spacing: 1) { @@ -141,7 +142,6 @@ struct StartCallFragment: View { .resizable() .foregroundStyle(Color.grayMain2c500) .frame(width: 25, height: 25) - .padding(.all, 10) }) } } @@ -175,6 +175,8 @@ struct StartCallFragment: View { DispatchQueue.global().asyncAfter(deadline: .now() + 0.2) { magicSearch.searchForContacts( sourceFlags: MagicSearch.Source.Friends.rawValue | MagicSearch.Source.LdapServers.rawValue) + + resetCallView() } startCallViewModel.searchField = "" @@ -227,6 +229,8 @@ struct StartCallFragment: View { DispatchQueue.global().asyncAfter(deadline: .now() + 0.2) { magicSearch.searchForContacts( sourceFlags: MagicSearch.Source.Friends.rawValue | MagicSearch.Source.LdapServers.rawValue) + + resetCallView() } startCallViewModel.searchField = "" @@ -279,5 +283,5 @@ struct StartCallFragment: View { } #Preview { - StartCallFragment(startCallViewModel: StartCallViewModel(), isShowStartCallFragment: .constant(true), showingDialer: .constant(false)) + StartCallFragment(startCallViewModel: StartCallViewModel(), isShowStartCallFragment: .constant(true), showingDialer: .constant(false), resetCallView: {}) } From 05cc8277d8fbb6a70d17eec52b931d82087a9de2 Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Tue, 23 Jan 2024 16:51:15 +0100 Subject: [PATCH 107/486] Add calls list in call view --- Linphone.xcodeproj/project.pbxproj | 4 - Linphone/Localizable.xcstrings | 6 + Linphone/TelecomManager/TelecomManager.swift | 10 + Linphone/UI/Call/CallView.swift | 30 ++- .../UI/Call/Fragments/CallsListFragment.swift | 237 ++++++++---------- .../UI/Call/ViewModel/CallViewModel.swift | 8 + .../Call/ViewModel/CallsListViewModel.swift | 37 --- 7 files changed, 156 insertions(+), 176 deletions(-) delete mode 100644 Linphone/UI/Call/ViewModel/CallsListViewModel.swift diff --git a/Linphone.xcodeproj/project.pbxproj b/Linphone.xcodeproj/project.pbxproj index 1fea99ca8..87d0917df 100644 --- a/Linphone.xcodeproj/project.pbxproj +++ b/Linphone.xcodeproj/project.pbxproj @@ -96,7 +96,6 @@ 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 */; }; - D7F4D9CD2B5FD83A00CDCD76 /* CallsListViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7F4D9CC2B5FD83A00CDCD76 /* CallsListViewModel.swift */; }; D7FB55112AD447FD00A5AB15 /* RegisterFragment.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7FB55102AD447FD00A5AB15 /* RegisterFragment.swift */; }; /* End PBXBuildFile section */ @@ -193,7 +192,6 @@ 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 = ""; }; - D7F4D9CC2B5FD83A00CDCD76 /* CallsListViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallsListViewModel.swift; sourceTree = ""; }; D7FB55102AD447FD00A5AB15 /* RegisterFragment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RegisterFragment.swift; sourceTree = ""; }; /* End PBXFileReference section */ @@ -516,7 +514,6 @@ isa = PBXGroup; children = ( D7B99E982B29B39000BE7BF2 /* CallViewModel.swift */, - D7F4D9CC2B5FD83A00CDCD76 /* CallsListViewModel.swift */, ); path = ViewModel; sourceTree = ""; @@ -715,7 +712,6 @@ D71FCA7F2AE1397200D2E43E /* ContactsListViewModel.swift in Sources */, D71FCA812AE14CFC00D2E43E /* ContactsListFragment.swift in Sources */, D719ABB72ABC67BF00B41C10 /* LinphoneApp.swift in Sources */, - D7F4D9CD2B5FD83A00CDCD76 /* CallsListViewModel.swift in Sources */, D732A91B2B061BD900DB42BA /* HistoryListBottomSheet.swift in Sources */, D72250632ADE9615008FB426 /* HistoryViewModel.swift in Sources */, D726E4392B16440C0083C415 /* ContactAvatarModel.swift in Sources */, diff --git a/Linphone/Localizable.xcstrings b/Linphone/Localizable.xcstrings index 3064c318a..add41db0d 100644 --- a/Linphone/Localizable.xcstrings +++ b/Linphone/Localizable.xcstrings @@ -140,6 +140,9 @@ }, "Accept all" : { + }, + "Active" : { + }, "Add a picture" : { @@ -477,6 +480,9 @@ }, "Remove picture" : { + }, + "Resuming" : { + }, "Say %@ and click on the letters given by your correspondent:" : { diff --git a/Linphone/TelecomManager/TelecomManager.swift b/Linphone/TelecomManager/TelecomManager.swift index edb5c263e..80260c035 100644 --- a/Linphone/TelecomManager/TelecomManager.swift +++ b/Linphone/TelecomManager/TelecomManager.swift @@ -113,6 +113,16 @@ class TelecomManager: ObservableObject { } } + func setHeldOtherCallsWithCore(exceptCallid: String) { + CoreContext.shared.doOnCoreQueue { core in + for call in core.calls { + if (call.callLog?.callId != exceptCallid && call.state != .Paused && call.state != .Pausing && call.state != .PausedByRemote) { + self.setHeld(call: call, hold: true) + } + } + } + } + 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) { diff --git a/Linphone/UI/Call/CallView.swift b/Linphone/UI/Call/CallView.swift index 5bf4f3347..17ade1bfe 100644 --- a/Linphone/UI/Call/CallView.swift +++ b/Linphone/UI/Call/CallView.swift @@ -48,6 +48,7 @@ struct CallView: View { @State var showingDialer = false + @State var isShowCallsListFragment = false @Binding var isShowStartCallFragment: Bool var body: some View { @@ -104,6 +105,25 @@ struct CallView: View { ) } onDismiss: {} } + + if isShowCallsListFragment { + CallsListFragment(callViewModel: callViewModel, isShowCallsListFragment: $isShowCallsListFragment) + .zIndex(4) + .transition(.move(edge: .bottom)) + /* + .sheet(isPresented: $showingDialer) { + DialerBottomSheet( + startCallViewModel: startCallViewModel, + showingDialer: $showingDialer, + currentCall: nil + ) + .presentationDetents([.medium]) + // .interactiveDismissDisabled() + .presentationBackgroundInteraction(.enabled(upThrough: .medium)) + } + */ + } + if callViewModel.zrtpPopupDisplayed == true { ZRTPPopup(callViewModel: callViewModel) .background(.black.opacity(0.65)) @@ -116,6 +136,7 @@ struct CallView: View { HStack {} .onAppear { callViewModel.resetCallView() + callViewModel.getCallsList() } } } @@ -763,17 +784,20 @@ struct CallView: View { VStack { Button { + callViewModel.getCallsList() + withAnimation { + isShowCallsListFragment.toggle() + } } label: { Image("phone-list") .renderingMode(.template) .resizable() - .foregroundStyle(Color.gray500) + .foregroundStyle(.white) .frame(width: 32, height: 32) } .frame(width: 60, height: 60) - .background(Color.gray600) + .background(Color.gray500) .cornerRadius(40) - .disabled(true) Text("Call list") .foregroundStyle(.white) diff --git a/Linphone/UI/Call/Fragments/CallsListFragment.swift b/Linphone/UI/Call/Fragments/CallsListFragment.swift index 3acd5cfea..bee565a4d 100644 --- a/Linphone/UI/Call/Fragments/CallsListFragment.swift +++ b/Linphone/UI/Call/Fragments/CallsListFragment.swift @@ -21,7 +21,10 @@ import SwiftUI struct CallsListFragment: View { - @ObservedObject var callsListViewModel: CallsListViewModel + @ObservedObject private var coreContext = CoreContext.shared + @ObservedObject private var contactsManager = ContactsManager.shared + + @ObservedObject var callViewModel: CallViewModel @State private var delayedColor = Color.white @@ -66,7 +69,7 @@ struct CallsListFragment: View { .padding(.bottom, 4) .background(.white) - //callsList + callsList } .background(.white) } @@ -85,174 +88,145 @@ struct CallsListFragment: View { } } - /* var callsList: some View { VStack { List { - ForEach(0.. 1 + ? callViewModel.calls[index].callLog!.remoteAddress!.displayName!.components(separatedBy: " ")[1] + : "")) .resizable() .frame(width: 45, height: 45) .clipShape(Circle()) - } - } else { - if historyListViewModel.callLogs[index].dir == .Outgoing && historyListViewModel.callLogs[index].toAddress != nil { - if historyListViewModel.callLogs[index].toAddress!.displayName != nil { - Image(uiImage: contactsManager.textToImage( - firstName: historyListViewModel.callLogs[index].toAddress!.displayName!, - lastName: historyListViewModel.callLogs[index].toAddress!.displayName!.components(separatedBy: " ").count > 1 - ? historyListViewModel.callLogs[index].toAddress!.displayName!.components(separatedBy: " ")[1] - : "")) - .resizable() - .frame(width: 45, height: 45) - .clipShape(Circle()) } else { Image(uiImage: contactsManager.textToImage( - firstName: historyListViewModel.callLogs[index].toAddress!.username ?? "Username Error", - lastName: historyListViewModel.callLogs[index].toAddress!.username!.components(separatedBy: " ").count > 1 - ? historyListViewModel.callLogs[index].toAddress!.username!.components(separatedBy: " ")[1] + firstName: callViewModel.calls[index].callLog!.remoteAddress!.username ?? "Username Error", + lastName: callViewModel.calls[index].callLog!.remoteAddress!.username!.components(separatedBy: " ").count > 1 + ? callViewModel.calls[index].callLog!.remoteAddress!.username!.components(separatedBy: " ")[1] : "")) - .resizable() - .frame(width: 45, height: 45) - .clipShape(Circle()) - } - - } else if historyListViewModel.callLogs[index].fromAddress != nil { - if historyListViewModel.callLogs[index].fromAddress!.displayName != nil { - Image(uiImage: contactsManager.textToImage( - firstName: historyListViewModel.callLogs[index].fromAddress!.displayName!, - lastName: historyListViewModel.callLogs[index].fromAddress!.displayName!.components(separatedBy: " ").count > 1 - ? historyListViewModel.callLogs[index].fromAddress!.displayName!.components(separatedBy: " ")[1] - : "")) - .resizable() - .frame(width: 45, height: 45) - .clipShape(Circle()) - } else { - Image(uiImage: contactsManager.textToImage( - firstName: historyListViewModel.callLogs[index].fromAddress!.username ?? "Username Error", - lastName: historyListViewModel.callLogs[index].fromAddress!.username!.components(separatedBy: " ").count > 1 - ? historyListViewModel.callLogs[index].fromAddress!.username!.components(separatedBy: " ")[1] - : "")) - .resizable() - .frame(width: 45, height: 45) - .clipShape(Circle()) - } - } else { - Image("profil-picture-default") .resizable() .frame(width: 45, height: 45) .clipShape(Circle()) + } } - } - - VStack(spacing: 0) { - Spacer() - - let fromAddressFriend = contactsManager.getFriendWithAddress(address: historyListViewModel.callLogs[index].fromAddress!) - let toAddressFriend = contactsManager.getFriendWithAddress(address: historyListViewModel.callLogs[index].toAddress!) - let addressFriend = historyListViewModel.callLogs[index].dir == .Incoming ? fromAddressFriend : toAddressFriend if addressFriend != nil { Text(addressFriend!.name!) - .default_text_style(styleSize: 14) - .frame(maxWidth: .infinity, alignment: .leading) - .lineLimit(1) + .default_text_style(styleSize: 16) + .frame(maxWidth: .infinity, alignment: .leading) + .lineLimit(1) } else { - if historyListViewModel.callLogs[index].dir == .Outgoing && historyListViewModel.callLogs[index].toAddress != nil { - Text(historyListViewModel.callLogs[index].toAddress!.displayName != nil - ? historyListViewModel.callLogs[index].toAddress!.displayName! - : historyListViewModel.callLogs[index].toAddress!.username!) - .default_text_style(styleSize: 14) - .frame(maxWidth: .infinity, alignment: .leading) - .lineLimit(1) - } else if historyListViewModel.callLogs[index].fromAddress != nil { - Text(historyListViewModel.callLogs[index].fromAddress!.displayName != nil - ? historyListViewModel.callLogs[index].fromAddress!.displayName! - : historyListViewModel.callLogs[index].fromAddress!.username!) - .default_text_style(styleSize: 14) - .frame(maxWidth: .infinity, alignment: .leading) - .lineLimit(1) - } - } - HStack { - Image(historyListViewModel.getCallIconResId(callStatus: historyListViewModel.callLogs[index].status, callDir: historyListViewModel.callLogs[index].dir)) - .resizable() - .frame( - width: historyListViewModel.getCallIconResId(callStatus: historyListViewModel.callLogs[index].status, callDir: historyListViewModel.callLogs[index].dir).contains("rejected") ? 12 : 8, - height: historyListViewModel.getCallIconResId(callStatus: historyListViewModel.callLogs[index].status, callDir: historyListViewModel.callLogs[index].dir).contains("rejected") ? 6 : 8) - - Text(historyListViewModel.getCallTime(startDate: historyListViewModel.callLogs[index].startDate)) - .default_text_style_300(styleSize: 12) - .frame(maxWidth: .infinity, alignment: .leading) - - Spacer() + Text(callViewModel.calls[index].callLog!.remoteAddress!.displayName != nil + ? callViewModel.calls[index].callLog!.remoteAddress!.displayName! + : callViewModel.calls[index].callLog!.remoteAddress!.username!) + .default_text_style(styleSize: 16) + .frame(maxWidth: .infinity, alignment: .leading) + .lineLimit(1) } Spacer() + + HStack { + if callViewModel.calls[index].state == .PausedByRemote + || callViewModel.calls[index].state == .Pausing + || callViewModel.calls[index].state == .Paused + || callViewModel.calls[index].state == .Resuming { + Text(callViewModel.calls[index].state == .Resuming ? "Resuming" : "Paused") + .default_text_style_300(styleSize: 14) + .frame(maxWidth: .infinity, alignment: .trailing) + .lineLimit(1) + .padding(.horizontal, 4) + + Image("pause") + .resizable() + .frame(width: 25, height: 25) + } else { + Text("Active") + .default_text_style_300(styleSize: 14) + .frame(maxWidth: .infinity, alignment: .trailing) + .lineLimit(1) + .padding(.horizontal, 4) + + Image("phone-call") + .resizable() + .frame(width: 25, height: 25) + } + } } - - Image("phone") - .resizable() - .frame(width: 25, height: 25) - .padding(.all, 10) - .padding(.trailing, 5) - .highPriorityGesture( - TapGesture() - .onEnded { _ in - withAnimation { - if historyListViewModel.callLogs[index].dir == .Outgoing && historyListViewModel.callLogs[index].toAddress != nil { - telecomManager.doCallWithCore( - addr: historyListViewModel.callLogs[index].toAddress!, isVideo: false - ) - } else if historyListViewModel.callLogs[index].fromAddress != nil { - telecomManager.doCallWithCore( - addr: historyListViewModel.callLogs[index].fromAddress!, isVideo: false - ) - } - historyViewModel.displayedCall = nil - } - } - ) } } .buttonStyle(.borderless) - .listRowInsets(EdgeInsets(top: 5, leading: 20, bottom: 5, trailing: 20)) + .listRowInsets(EdgeInsets(top: 10, leading: 20, bottom: 10, trailing: 20)) .listRowSeparator(.hidden) .background(.white) .onTapGesture { - withAnimation { - historyViewModel.displayedCall = historyListViewModel.callLogs[index] + if callViewModel.currentCall != nil && callViewModel.calls[index].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 { + + } + } + } else { + TelecomManager.shared.setHeldOtherCallsWithCore(exceptCallid: "") + TelecomManager.shared.setHeld(call: callViewModel.calls[index], hold: callViewModel.calls[index].state == .StreamsRunning) + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { + callViewModel.resetCallView() + } } + } .onLongPressGesture(minimumDuration: 0.2) { - historyViewModel.selectedCall = historyListViewModel.callLogs[index] - showingSheet.toggle() } } } .listStyle(.plain) .overlay( VStack { - if historyListViewModel.callLogs.isEmpty { + if callViewModel.calls.isEmpty { Spacer() Image("illus-belledonne") .resizable() @@ -271,9 +245,8 @@ struct CallsListFragment: View { .navigationTitle("") .navigationBarHidden(true) } - */ } #Preview { - CallsListFragment(callsListViewModel: CallsListViewModel(), isShowCallsListFragment: .constant(true)) + CallsListFragment(callViewModel: CallViewModel(), isShowCallsListFragment: .constant(true)) } diff --git a/Linphone/UI/Call/ViewModel/CallViewModel.swift b/Linphone/UI/Call/ViewModel/CallViewModel.swift index 7bc797ad1..794fb5213 100644 --- a/Linphone/UI/Call/ViewModel/CallViewModel.swift +++ b/Linphone/UI/Call/ViewModel/CallViewModel.swift @@ -44,6 +44,8 @@ class CallViewModel: ObservableObject { @Published var isZrtpPq: Bool = false @Published var isRemoteDeviceTrusted: Bool = false + var calls: [Call] = [] + let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect() var currentCall: Call? @@ -113,6 +115,12 @@ class CallViewModel: ObservableObject { } } + func getCallsList() { + coreContext.doOnCoreQueue { core in + self.calls = core.calls + } + } + func terminateCall() { coreContext.doOnCoreQueue { core in if self.currentCall != nil { diff --git a/Linphone/UI/Call/ViewModel/CallsListViewModel.swift b/Linphone/UI/Call/ViewModel/CallsListViewModel.swift deleted file mode 100644 index edd847b2e..000000000 --- a/Linphone/UI/Call/ViewModel/CallsListViewModel.swift +++ /dev/null @@ -1,37 +0,0 @@ -/* - * 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 - -class CallsListViewModel: ObservableObject { - - var coreContext = CoreContext.shared - - //let nbCalls : Int - - init() { - //self.getCallsList() - } - - func getCallsList() { - coreContext.doOnCoreQueue { core in - core.callsNb - } - } -} From 4320f9dcce742af345e1dab291998a7c1f2fc0e8 Mon Sep 17 00:00:00 2001 From: "benoit.martins" Date: Tue, 23 Jan 2024 17:28:20 +0100 Subject: [PATCH 108/486] Add bottom sheet in calls list fragment --- Linphone/Localizable.xcstrings | 12 +- .../UI/Call/Fragments/CallsListFragment.swift | 124 +++++++++++++++++- .../UI/Call/ViewModel/CallViewModel.swift | 3 +- 3 files changed, 129 insertions(+), 10 deletions(-) diff --git a/Linphone/Localizable.xcstrings b/Linphone/Localizable.xcstrings index add41db0d..4701df22a 100644 --- a/Linphone/Localizable.xcstrings +++ b/Linphone/Localizable.xcstrings @@ -226,9 +226,6 @@ }, "Contacts" : { - }, - "Content" : { - }, "Continue" : { @@ -322,6 +319,9 @@ }, "First name*" : { + }, + "Hang up call" : { + }, "Headphones" : { @@ -480,6 +480,9 @@ }, "Remove picture" : { + }, + "Resume" : { + }, "Resuming" : { @@ -546,9 +549,6 @@ }, "This contact will be deleted definitively." : { - }, - "Title" : { - }, "TLS" : { diff --git a/Linphone/UI/Call/Fragments/CallsListFragment.swift b/Linphone/UI/Call/Fragments/CallsListFragment.swift index bee565a4d..9901f7a7f 100644 --- a/Linphone/UI/Call/Fragments/CallsListFragment.swift +++ b/Linphone/UI/Call/Fragments/CallsListFragment.swift @@ -18,22 +18,25 @@ */ import SwiftUI +import linphonesw 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 @Binding var isShowCallsListFragment: Bool var body: some View { ZStack { VStack(spacing: 1) { - Rectangle() .foregroundColor(delayedColor) .edgesIgnoringSafeArea(.top) @@ -69,13 +72,127 @@ struct CallsListFragment: View { .padding(.bottom, 4) .background(.white) - callsList + if #available(iOS 16.0, *), idiom != .pad { + callsList + .sheet(isPresented: $isShowCallsListBottomSheet, onDismiss: { + }) { + innerBottomSheet() + .presentationDetents([.fraction(0.2)]) + } + } else { + callsList + .halfSheet(showSheet: $isShowCallsListBottomSheet) { + innerBottomSheet() + } onDismiss: {} + } } .background(.white) } .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 { + TelecomManager.shared.setHeldOtherCallsWithCore(exceptCallid: "") + TelecomManager.shared.setHeld(call: callViewModel.selectedCall!, hold: callViewModel.selectedCall!.state == .StreamsRunning) + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { + 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 @@ -217,9 +334,10 @@ struct CallsListFragment: View { callViewModel.resetCallView() } } - } .onLongPressGesture(minimumDuration: 0.2) { + callViewModel.selectedCall = callViewModel.calls[index] + isShowCallsListBottomSheet = true } } } diff --git a/Linphone/UI/Call/ViewModel/CallViewModel.swift b/Linphone/UI/Call/ViewModel/CallViewModel.swift index 794fb5213..997f70f45 100644 --- a/Linphone/UI/Call/ViewModel/CallViewModel.swift +++ b/Linphone/UI/Call/ViewModel/CallViewModel.swift @@ -42,7 +42,8 @@ class CallViewModel: ObservableObject { @Published var upperCaseAuthTokenToListen = "" @Published var isMediaEncrypted: Bool = false @Published var isZrtpPq: Bool = false - @Published var isRemoteDeviceTrusted: Bool = false + @Published var isRemoteDeviceTrusted: Bool = false + @Published var selectedCall: Call? = nil var calls: [Call] = [] From 5ab64968e3a7a0774128edf9dd6d425c854ba394 Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Thu, 25 Jan 2024 12:14:14 +0100 Subject: [PATCH 109/486] Add a badge counter for call list in call view --- Linphone/Localizable.xcstrings | 9 ++ Linphone/UI/Call/CallView.swift | 91 ++++++++++++++----- .../UI/Call/ViewModel/CallViewModel.swift | 2 + 3 files changed, 78 insertions(+), 24 deletions(-) diff --git a/Linphone/Localizable.xcstrings b/Linphone/Localizable.xcstrings index 4701df22a..7b60b114f 100644 --- a/Linphone/Localizable.xcstrings +++ b/Linphone/Localizable.xcstrings @@ -48,6 +48,9 @@ }, "#" : { + }, + "%lld" : { + }, "%lld Book (Example)" : { "extractionState" : "manual", @@ -226,6 +229,9 @@ }, "Contacts" : { + }, + "Content" : { + }, "Continue" : { @@ -549,6 +555,9 @@ }, "This contact will be deleted definitively." : { + }, + "Title" : { + }, "TLS" : { diff --git a/Linphone/UI/Call/CallView.swift b/Linphone/UI/Call/CallView.swift index 17ade1bfe..ddf800f88 100644 --- a/Linphone/UI/Call/CallView.swift +++ b/Linphone/UI/Call/CallView.swift @@ -783,21 +783,43 @@ struct CallView: View { .frame(width: geo.size.width * 0.25, height: geo.size.width * 0.25) VStack { - Button { - callViewModel.getCallsList() - withAnimation { - isShowCallsListFragment.toggle() + ZStack { + Button { + callViewModel.getCallsList() + withAnimation { + isShowCallsListFragment.toggle() + } + } label: { + Image("phone-list") + .renderingMode(.template) + .resizable() + .foregroundStyle(.white) + .frame(width: 32, height: 32) + } + .frame(width: 60, height: 60) + .background(Color.gray500) + .cornerRadius(40) + + if callViewModel.calls.count > 1 { + VStack { + HStack { + Spacer() + + VStack { + Text("\(callViewModel.calls.count)") + .foregroundStyle(.white) + .default_text_style(styleSize: 15) + } + .frame(width: 20, height: 20) + .background(Color.redDanger500) + .cornerRadius(10) + } + + Spacer() + } + .frame(width: 60, height: 60) } - } label: { - Image("phone-list") - .renderingMode(.template) - .resizable() - .foregroundStyle(.white) - .frame(width: 32, height: 32) } - .frame(width: 60, height: 60) - .background(Color.gray500) - .cornerRadius(40) Text("Call list") .foregroundStyle(.white) @@ -962,18 +984,39 @@ struct CallView: View { .frame(width: geo.size.width * 0.125, height: geo.size.width * 0.125) VStack { - Button { - } label: { - Image("phone-list") - .renderingMode(.template) - .resizable() - .foregroundStyle(Color.gray500) - .frame(width: 32, height: 32) + ZStack { + Button { + } label: { + Image("phone-list") + .renderingMode(.template) + .resizable() + .foregroundStyle(.white) + .frame(width: 32, height: 32) + } + .frame(width: 60, height: 60) + .background(Color.gray500) + .cornerRadius(40) + + if callViewModel.calls.count > 1 { + VStack { + HStack { + Spacer() + + VStack { + Text("\(callViewModel.calls.count)") + .foregroundStyle(.white) + .default_text_style(styleSize: 15) + } + .frame(width: 20, height: 20) + .background(Color.redDanger500) + .cornerRadius(10) + } + + Spacer() + } + .frame(width: 60, height: 60) + } } - .frame(width: 60, height: 60) - .background(Color.gray600) - .cornerRadius(40) - .disabled(true) Text("Call list") .foregroundStyle(.white) diff --git a/Linphone/UI/Call/ViewModel/CallViewModel.swift b/Linphone/UI/Call/ViewModel/CallViewModel.swift index 997f70f45..cb6ebb2cf 100644 --- a/Linphone/UI/Call/ViewModel/CallViewModel.swift +++ b/Linphone/UI/Call/ViewModel/CallViewModel.swift @@ -107,6 +107,8 @@ class CallViewModel: ObservableObject { let authToken = self.currentCall!.authenticationToken let isDeviceTrusted = self.currentCall!.authenticationTokenVerified && authToken != nil self.isRemoteDeviceTrusted = self.telecomManager.callInProgress ? isDeviceTrusted : false + + self.getCallsList() } self.callSuscriptions.insert(self.currentCall!.publisher?.onEncryptionChanged?.postOnMainQueue {(cbVal: (call: Call, on: Bool, authenticationToken: String?)) in From ffb60daaea38a325627639bec9738aa4180fb525 Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Thu, 25 Jan 2024 14:52:04 +0100 Subject: [PATCH 110/486] Fix setHeldOtherCalls when current call is paused, remove setHeldOtherCallsWithCore function --- Linphone/TelecomManager/TelecomManager.swift | 16 +++------------ .../UI/Call/Fragments/CallsListFragment.swift | 20 +++++++++++++++---- .../UI/Call/ViewModel/CallViewModel.swift | 4 ++-- 3 files changed, 21 insertions(+), 19 deletions(-) diff --git a/Linphone/TelecomManager/TelecomManager.swift b/Linphone/TelecomManager/TelecomManager.swift index 80260c035..0dc2aed13 100644 --- a/Linphone/TelecomManager/TelecomManager.swift +++ b/Linphone/TelecomManager/TelecomManager.swift @@ -97,7 +97,7 @@ class TelecomManager: ObservableObject { if TelecomManager.callKitEnabled(core: core) {// && !nextCallIsTransfer != true { let uuid = UUID() - let name = addr?.asStringUriOnly() ?? "unknow" // FastAddressBook.displayName(for: addr) ?? "unknow" + 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) @@ -113,20 +113,12 @@ class TelecomManager: ObservableObject { } } - func setHeldOtherCallsWithCore(exceptCallid: String) { - CoreContext.shared.doOnCoreQueue { core in - for call in core.calls { - if (call.callLog?.callId != exceptCallid && call.state != .Paused && call.state != .Pausing && call.state != .PausedByRemote) { - self.setHeld(call: call, hold: true) - } - } - } - } - 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) } } } @@ -544,8 +536,6 @@ class TelecomManager: ObservableObject { .OutgoingRinging, .OutgoingEarlyMedia: - print("OutgoingInitOutgoingInit \(core.maxCalls)") - if TelecomManager.callKitEnabled(core: core) { let uuid = providerDelegate.uuids[""] if uuid != nil { diff --git a/Linphone/UI/Call/Fragments/CallsListFragment.swift b/Linphone/UI/Call/Fragments/CallsListFragment.swift index 9901f7a7f..60efa482a 100644 --- a/Linphone/UI/Call/Fragments/CallsListFragment.swift +++ b/Linphone/UI/Call/Fragments/CallsListFragment.swift @@ -121,8 +121,14 @@ struct CallsListFragment: View { isShowCallsListBottomSheet = false } else { - TelecomManager.shared.setHeldOtherCallsWithCore(exceptCallid: "") - TelecomManager.shared.setHeld(call: callViewModel.selectedCall!, hold: callViewModel.selectedCall!.state == .StreamsRunning) + 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.2) { callViewModel.resetCallView() @@ -327,8 +333,14 @@ struct CallsListFragment: View { } } } else { - TelecomManager.shared.setHeldOtherCallsWithCore(exceptCallid: "") - TelecomManager.shared.setHeld(call: callViewModel.calls[index], hold: callViewModel.calls[index].state == .StreamsRunning) + 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.calls[index], hold: false) DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { callViewModel.resetCallView() diff --git a/Linphone/UI/Call/ViewModel/CallViewModel.swift b/Linphone/UI/Call/ViewModel/CallViewModel.swift index cb6ebb2cf..7835dd9ef 100644 --- a/Linphone/UI/Call/ViewModel/CallViewModel.swift +++ b/Linphone/UI/Call/ViewModel/CallViewModel.swift @@ -42,8 +42,8 @@ class CallViewModel: ObservableObject { @Published var upperCaseAuthTokenToListen = "" @Published var isMediaEncrypted: Bool = false @Published var isZrtpPq: Bool = false - @Published var isRemoteDeviceTrusted: Bool = false - @Published var selectedCall: Call? = nil + @Published var isRemoteDeviceTrusted: Bool = false + @Published var selectedCall: Call? = nil var calls: [Call] = [] From d91aa94c9a37f1e117655f7fb02de2108b14c3fa Mon Sep 17 00:00:00 2001 From: QuentinArguillere Date: Fri, 26 Jan 2024 15:33:06 +0100 Subject: [PATCH 111/486] Fix build for sdk master : pushRegistryDispatchQueue is no longer in the wrapper, we need to use the C function instead --- Linphone/Core/CoreContext.swift | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/Linphone/Core/CoreContext.swift b/Linphone/Core/CoreContext.swift index 6f743354f..674573e7c 100644 --- a/Linphone/Core/CoreContext.swift +++ b/Linphone/Core/CoreContext.swift @@ -21,6 +21,7 @@ // swiftlint:disable line_length import linphonesw +import linphone // needed for unwrapped function linphone_core_set_push_registry_dispatch_queue import Combine import UniformTypeIdentifiers import Network @@ -95,11 +96,11 @@ final class CoreContext: ObservableObject { self.mCore = try? Factory.Instance.createCoreWithConfig(config: config!, systemContext: nil) } - self.mCore.pushRegistryDispatchQueue = Unmanaged.passUnretained(coreQueue).toOpaque() + linphone_core_set_push_registry_dispatch_queue(self.mCore.getCobject, Unmanaged.passUnretained(coreQueue).toOpaque()) self.mCore.autoIterateEnabled = false self.mCore.callkitEnabled = true self.mCore.pushNotificationEnabled = true - + self.mCore.setUserAgent(name: "Linphone iOS 6.0 Beta (\(UIDevice.current.localizedModel)) - Linphone SDK : \(self.coreVersion)", version: "6.0") self.mCore.videoCaptureEnabled = true From 4048fa3075e5a254f249e1b93d733ab8296a6799 Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Thu, 25 Jan 2024 13:57:57 +0100 Subject: [PATCH 112/486] Add transfer call feature --- Linphone/Localizable.xcstrings | 6 +++++ Linphone/UI/Call/CallView.swift | 24 ++++++++++++------ .../UI/Call/ViewModel/CallViewModel.swift | 1 + Linphone/UI/Main/ContentView.swift | 3 ++- .../History/Fragments/StartCallFragment.swift | 25 +++++++++++++++++-- 5 files changed, 48 insertions(+), 11 deletions(-) diff --git a/Linphone/Localizable.xcstrings b/Linphone/Localizable.xcstrings index 7b60b114f..ecffb1921 100644 --- a/Linphone/Localizable.xcstrings +++ b/Linphone/Localizable.xcstrings @@ -187,6 +187,9 @@ } } } + }, + "Attended transfer" : { + }, "Block the address" : { @@ -567,6 +570,9 @@ }, "Transfer" : { + }, + "Transfer call to" : { + }, "Transport" : { diff --git a/Linphone/UI/Call/CallView.swift b/Linphone/UI/Call/CallView.swift index ddf800f88..999f0c691 100644 --- a/Linphone/UI/Call/CallView.swift +++ b/Linphone/UI/Call/CallView.swift @@ -741,19 +741,23 @@ struct CallView: View { HStack(spacing: 0) { VStack { Button { + withAnimation { + callViewModel.isTransferInsteadCall = true + MagicSearchSingleton.shared.searchForSuggestions() + isShowStartCallFragment.toggle() + } } label: { Image("phone-transfer") .renderingMode(.template) .resizable() - .foregroundStyle(Color.gray500) + .foregroundStyle(.white) .frame(width: 32, height: 32) } .frame(width: 60, height: 60) - .background(Color.gray600) + .background(Color.gray500) .cornerRadius(40) - .disabled(true) - Text("Transfer") + Text(callViewModel.calls.count < 2 ? "Transfer" : "Attended transfer") .foregroundStyle(.white) .default_text_style(styleSize: 15) } @@ -942,19 +946,23 @@ struct CallView: View { HStack { VStack { Button { + withAnimation { + callViewModel.isTransferInsteadCall = true + MagicSearchSingleton.shared.searchForSuggestions() + isShowStartCallFragment.toggle() + } } label: { Image("phone-transfer") .renderingMode(.template) .resizable() - .foregroundStyle(Color.gray500) + .foregroundStyle(.white) .frame(width: 32, height: 32) } .frame(width: 60, height: 60) - .background(Color.gray600) + .background(Color.gray500) .cornerRadius(40) - .disabled(true) - Text("Transfer") + Text(callViewModel.calls.count < 2 ? "Transfer" : "Attended transfer") .foregroundStyle(.white) .default_text_style(styleSize: 15) } diff --git a/Linphone/UI/Call/ViewModel/CallViewModel.swift b/Linphone/UI/Call/ViewModel/CallViewModel.swift index 7835dd9ef..cfd34c8eb 100644 --- a/Linphone/UI/Call/ViewModel/CallViewModel.swift +++ b/Linphone/UI/Call/ViewModel/CallViewModel.swift @@ -44,6 +44,7 @@ class CallViewModel: ObservableObject { @Published var isZrtpPq: Bool = false @Published var isRemoteDeviceTrusted: Bool = false @Published var selectedCall: Call? = nil + @Published var isTransferInsteadCall: Bool = false var calls: [Call] = [] diff --git a/Linphone/UI/Main/ContentView.swift b/Linphone/UI/Main/ContentView.swift index c830d265f..609f0eca7 100644 --- a/Linphone/UI/Main/ContentView.swift +++ b/Linphone/UI/Main/ContentView.swift @@ -524,6 +524,7 @@ struct ContentView: View { if isShowStartCallFragment { if #available(iOS 16.4, *), idiom != .pad { StartCallFragment( + callViewModel: callViewModel, startCallViewModel: startCallViewModel, isShowStartCallFragment: $isShowStartCallFragment, showingDialer: $showingDialer, @@ -538,11 +539,11 @@ struct ContentView: View { currentCall: nil ) .presentationDetents([.medium]) - // .interactiveDismissDisabled() .presentationBackgroundInteraction(.enabled(upThrough: .medium)) } } else { StartCallFragment( + callViewModel: callViewModel, startCallViewModel: startCallViewModel, isShowStartCallFragment: $isShowStartCallFragment, showingDialer: $showingDialer, diff --git a/Linphone/UI/Main/History/Fragments/StartCallFragment.swift b/Linphone/UI/Main/History/Fragments/StartCallFragment.swift index c113de297..2f90d0c43 100644 --- a/Linphone/UI/Main/History/Fragments/StartCallFragment.swift +++ b/Linphone/UI/Main/History/Fragments/StartCallFragment.swift @@ -26,6 +26,7 @@ struct StartCallFragment: View { @ObservedObject var magicSearch = MagicSearchSingleton.shared @ObservedObject private var telecomManager = TelecomManager.shared + @ObservedObject var callViewModel: CallViewModel @ObservedObject var startCallViewModel: StartCallViewModel @Binding var isShowStartCallFragment: Bool @@ -59,6 +60,12 @@ struct StartCallFragment: View { DispatchQueue.global().asyncAfter(deadline: .now() + 0.2) { magicSearch.searchForContacts( sourceFlags: MagicSearch.Source.Friends.rawValue | MagicSearch.Source.LdapServers.rawValue) + + if callViewModel.isTransferInsteadCall == true { + callViewModel.isTransferInsteadCall = false + } + + resetCallView() } startCallViewModel.searchField = "" @@ -69,7 +76,7 @@ struct StartCallFragment: View { } } - Text("New call") + Text(!callViewModel.isTransferInsteadCall ? "New call" : "Transfer call to") .multilineTextAlignment(.leading) .default_text_style_orange_800(styleSize: 16) @@ -176,6 +183,10 @@ struct StartCallFragment: View { magicSearch.searchForContacts( sourceFlags: MagicSearch.Source.Friends.rawValue | MagicSearch.Source.LdapServers.rawValue) + if callViewModel.isTransferInsteadCall == true { + callViewModel.isTransferInsteadCall = false + } + resetCallView() } @@ -230,6 +241,10 @@ struct StartCallFragment: View { magicSearch.searchForContacts( sourceFlags: MagicSearch.Source.Friends.rawValue | MagicSearch.Source.LdapServers.rawValue) + if callViewModel.isTransferInsteadCall == true { + callViewModel.isTransferInsteadCall = false + } + resetCallView() } @@ -283,5 +298,11 @@ struct StartCallFragment: View { } #Preview { - StartCallFragment(startCallViewModel: StartCallViewModel(), isShowStartCallFragment: .constant(true), showingDialer: .constant(false), resetCallView: {}) + StartCallFragment( + callViewModel: CallViewModel(), + startCallViewModel: StartCallViewModel(), + isShowStartCallFragment: .constant(true), + showingDialer: .constant(false), + resetCallView: {} + ) } From 433e28e9454c6979762ff5157ecc0e81f4cb66f2 Mon Sep 17 00:00:00 2001 From: "benoit.martins" Date: Thu, 25 Jan 2024 15:49:28 +0100 Subject: [PATCH 113/486] Add transfer call and attended transfer --- Linphone/Core/CoreContext.swift | 16 +++ Linphone/Localizable.xcstrings | 15 ++- Linphone/UI/Call/CallView.swift | 12 +- .../UI/Call/ViewModel/CallViewModel.swift | 51 ++++++- Linphone/UI/Main/Fragments/ToastView.swift | 29 +++- .../History/Fragments/StartCallFragment.swift | 124 ++++++++++++------ 6 files changed, 198 insertions(+), 49 deletions(-) diff --git a/Linphone/Core/CoreContext.swift b/Linphone/Core/CoreContext.swift index 674573e7c..81825b9a3 100644 --- a/Linphone/Core/CoreContext.swift +++ b/Linphone/Core/CoreContext.swift @@ -237,6 +237,22 @@ final class CoreContext: ObservableObject { } }) + self.mCoreSuscriptions.insert(self.mCore.publisher?.onTransferStateChanged?.postOnMainQueue { (cbValue: (_: Core, transfered: Call, callState: Call.State)) in + Log.info( + "[CoreContext] Transferred call \(cbValue.transfered.remoteAddress!.asStringUriOnly()) state changed \(cbValue.callState)" + ) + if cbValue.callState == Call.State.Connected { + ToastViewModel.shared.toastMessage = "Success_toast_call_transfer_successful" + ToastViewModel.shared.displayToast = true + } else if cbValue.callState == Call.State.OutgoingProgress { + ToastViewModel.shared.toastMessage = "Success_toast_call_transfer_in_progress" + ToastViewModel.shared.displayToast = true + } else if cbValue.callState == Call.State.End || cbValue.callState == Call.State.Error { + ToastViewModel.shared.toastMessage = "Failed_toast_call_transfer_failed" + ToastViewModel.shared.displayToast = true + } + }) + self.mIterateSuscription = Timer.publish(every: 0.02, on: .main, in: .common) .autoconnect() .receive(on: coreQueue) diff --git a/Linphone/Localizable.xcstrings b/Linphone/Localizable.xcstrings index ecffb1921..f42488b85 100644 --- a/Linphone/Localizable.xcstrings +++ b/Linphone/Localizable.xcstrings @@ -199,12 +199,21 @@ }, "Bluetooth" : { + }, + "Call has been successfully transferred" : { + }, "Call history" : { + }, + "Call is being transferred" : { + }, "Call list" : { + }, + "Call transfer failed!" : { + }, "Calls" : { @@ -232,9 +241,6 @@ }, "Contacts" : { - }, - "Content" : { - }, "Continue" : { @@ -558,9 +564,6 @@ }, "This contact will be deleted definitively." : { - }, - "Title" : { - }, "TLS" : { diff --git a/Linphone/UI/Call/CallView.swift b/Linphone/UI/Call/CallView.swift index 999f0c691..ac3d92628 100644 --- a/Linphone/UI/Call/CallView.swift +++ b/Linphone/UI/Call/CallView.swift @@ -741,10 +741,14 @@ struct CallView: View { HStack(spacing: 0) { VStack { Button { - withAnimation { - callViewModel.isTransferInsteadCall = true - MagicSearchSingleton.shared.searchForSuggestions() - isShowStartCallFragment.toggle() + if callViewModel.calls.count < 2 { + withAnimation { + callViewModel.isTransferInsteadCall = true + MagicSearchSingleton.shared.searchForSuggestions() + isShowStartCallFragment.toggle() + } + } else { + callViewModel.transferClicked() } } label: { Image("phone-transfer") diff --git a/Linphone/UI/Call/ViewModel/CallViewModel.swift b/Linphone/UI/Call/ViewModel/CallViewModel.swift index cfd34c8eb..f6cf74e85 100644 --- a/Linphone/UI/Call/ViewModel/CallViewModel.swift +++ b/Linphone/UI/Call/ViewModel/CallViewModel.swift @@ -43,7 +43,7 @@ class CallViewModel: ObservableObject { @Published var isMediaEncrypted: Bool = false @Published var isZrtpPq: Bool = false @Published var isRemoteDeviceTrusted: Bool = false - @Published var selectedCall: Call? = nil + @Published var selectedCall: Call? @Published var isTransferInsteadCall: Bool = false var calls: [Call] = [] @@ -408,4 +408,53 @@ class CallViewModel: ObservableObject { self.zrtpPopupDisplayed = true } } + + func transferClicked() { + coreContext.doOnCoreQueue { core in + var 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!") + } + } + } } diff --git a/Linphone/UI/Main/Fragments/ToastView.swift b/Linphone/UI/Main/Fragments/ToastView.swift index 67e01a6b4..e42c834c3 100644 --- a/Linphone/UI/Main/Fragments/ToastView.swift +++ b/Linphone/UI/Main/Fragments/ToastView.swift @@ -27,7 +27,13 @@ struct ToastView: View { VStack { if toastViewModel.displayToast { HStack { - if toastViewModel.toastMessage.contains("Info_") { + 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("Info_") { Image("trusted") .resizable() .frame(width: 25, height: 25, alignment: .leading) @@ -110,6 +116,27 @@ struct ToastView: View { .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 "Failed_toast_call_transfer_failed": + Text("Call transfer failed!") + .multilineTextAlignment(.center) + .foregroundStyle(Color.redDanger500) + .default_text_style(styleSize: 15) + .padding(8) + default: Text("Error") .multilineTextAlignment(.center) diff --git a/Linphone/UI/Main/History/Fragments/StartCallFragment.swift b/Linphone/UI/Main/History/Fragments/StartCallFragment.swift index 2f90d0c43..7eea3b63f 100644 --- a/Linphone/UI/Main/History/Fragments/StartCallFragment.swift +++ b/Linphone/UI/Main/History/Fragments/StartCallFragment.swift @@ -177,26 +177,50 @@ struct StartCallFragment: View { } ContactsListFragment(contactViewModel: ContactViewModel(), contactsListViewModel: ContactsListViewModel(), showingSheet: .constant(false), startCallFunc: { addr in - showingDialer = false - - DispatchQueue.global().asyncAfter(deadline: .now() + 0.2) { - magicSearch.searchForContacts( - sourceFlags: MagicSearch.Source.Friends.rawValue | MagicSearch.Source.LdapServers.rawValue) + if callViewModel.isTransferInsteadCall { + showingDialer = false - if callViewModel.isTransferInsteadCall == true { - callViewModel.isTransferInsteadCall = false + DispatchQueue.global().asyncAfter(deadline: .now() + 0.2) { + magicSearch.searchForContacts( + sourceFlags: MagicSearch.Source.Friends.rawValue | MagicSearch.Source.LdapServers.rawValue) + + if callViewModel.isTransferInsteadCall == true { + callViewModel.isTransferInsteadCall = false + } + + resetCallView() } - resetCallView() - } - - startCallViewModel.searchField = "" - magicSearch.currentFilterSuggestions = "" - delayColorDismiss() - - withAnimation { - isShowStartCallFragment.toggle() - telecomManager.doCallWithCore(addr: addr, isVideo: false) + startCallViewModel.searchField = "" + magicSearch.currentFilterSuggestions = "" + delayColorDismiss() + + withAnimation { + isShowStartCallFragment.toggle() + callViewModel.blindTransferCallTo(toAddress: addr) + } + } else { + showingDialer = false + + DispatchQueue.global().asyncAfter(deadline: .now() + 0.2) { + 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.doCallWithCore(addr: addr, isVideo: false) + } } }) .padding(.horizontal, 16) @@ -235,29 +259,55 @@ struct StartCallFragment: View { var suggestionsList: some View { ForEach(0.. Date: Tue, 30 Jan 2024 16:58:19 +0100 Subject: [PATCH 114/486] Add green call banner --- Linphone/Localizable.xcstrings | 6 + .../TelecomManager/ProviderDelegate.swift | 1 + Linphone/TelecomManager/TelecomManager.swift | 4 + Linphone/UI/Call/CallView.swift | 17 +- .../UI/Call/ViewModel/CallViewModel.swift | 5 +- .../Fragments/ContactsInnerFragment.swift | 2 +- .../FavoriteContactsListFragment.swift | 3 +- Linphone/UI/Main/ContentView.swift | 1099 +++++++++-------- Linphone/UI/Main/Fragments/SideMenu.swift | 4 +- 9 files changed, 590 insertions(+), 551 deletions(-) diff --git a/Linphone/Localizable.xcstrings b/Linphone/Localizable.xcstrings index f42488b85..c48bf298e 100644 --- a/Linphone/Localizable.xcstrings +++ b/Linphone/Localizable.xcstrings @@ -241,6 +241,9 @@ }, "Contacts" : { + }, + "Content" : { + }, "Continue" : { @@ -564,6 +567,9 @@ }, "This contact will be deleted definitively." : { + }, + "Title" : { + }, "TLS" : { diff --git a/Linphone/TelecomManager/ProviderDelegate.swift b/Linphone/TelecomManager/ProviderDelegate.swift index 284aef309..7a56be324 100644 --- a/Linphone/TelecomManager/ProviderDelegate.swift +++ b/Linphone/TelecomManager/ProviderDelegate.swift @@ -213,6 +213,7 @@ extension ProviderDelegate: CXProviderDelegate { DispatchQueue.main.async { withAnimation { TelecomManager.shared.callInProgress = true + TelecomManager.shared.callDisplayed = true } } } diff --git a/Linphone/TelecomManager/TelecomManager.swift b/Linphone/TelecomManager/TelecomManager.swift index 0dc2aed13..0bf7b0cbc 100644 --- a/Linphone/TelecomManager/TelecomManager.swift +++ b/Linphone/TelecomManager/TelecomManager.swift @@ -41,6 +41,7 @@ class TelecomManager: ObservableObject { let callController: CXCallController // to support callkit @Published var callInProgress: Bool = false + @Published var callDisplayed: Bool = true @Published var callStarted: Bool = false @Published var outgoingCallStarted: Bool = false @Published var remoteVideo: Bool = false @@ -242,6 +243,7 @@ class TelecomManager: ObservableObject { if self.callInProgress == false { withAnimation { self.callInProgress = true + self.callDisplayed = true } } } @@ -448,6 +450,7 @@ class TelecomManager: ObservableObject { if self.callInProgress == false { withAnimation { self.callInProgress = true + self.callDisplayed = true } } } @@ -578,6 +581,7 @@ class TelecomManager: ObservableObject { withAnimation { self.outgoingCallStarted = false self.callInProgress = false + self.callDisplayed = false self.callStarted = false } } else { diff --git a/Linphone/UI/Call/CallView.swift b/Linphone/UI/Call/CallView.swift index ac3d92628..e4b689555 100644 --- a/Linphone/UI/Call/CallView.swift +++ b/Linphone/UI/Call/CallView.swift @@ -44,11 +44,11 @@ struct CallView: View { @State var imageAudioRoute: String = "" @State var angleDegree = 0.0 - @State var fullscreenVideo = false @State var showingDialer = false - @State var isShowCallsListFragment = false + @Binding var fullscreenVideo: Bool + @Binding var isShowCallsListFragment: Bool @Binding var isShowStartCallFragment: Bool var body: some View { @@ -530,7 +530,7 @@ struct CallView: View { 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 - (0.1 * geometry.size.height) - 60 ) - .background(Color.gray600) + .background(Color.gray900) .cornerRadius(20) .padding(.horizontal, fullscreenVideo && !telecomManager.isPausedByRemote ? 0 : 4) .onRotate { newOrientation in @@ -862,6 +862,9 @@ struct CallView: View { HStack(spacing: 0) { VStack { Button { + withAnimation { + telecomManager.callDisplayed = false + } } label: { Image("chat-teardrop-text") .renderingMode(.template) @@ -875,7 +878,7 @@ struct CallView: View { .background(Color.gray600) .cornerRadius(40) //.disabled(callViewModel.isPaused || telecomManager.isPausedByRemote) - .disabled(true) + //.disabled(true) Text("Messages") .foregroundStyle(.white) @@ -1056,8 +1059,6 @@ struct CallView: View { } .frame(width: geo.size.width * 0.125, height: geo.size.width * 0.125) - - VStack { Button { } label: { @@ -1129,7 +1130,7 @@ struct CallView: View { } Spacer() } - .background(Color.gray900) + .background(Color.gray600) .frame(maxHeight: .infinity, alignment: .top) } } @@ -1203,7 +1204,7 @@ struct BottomSheetView: View { } #Preview { - CallView(callViewModel: CallViewModel(), isShowStartCallFragment: .constant(false)) + CallView(callViewModel: CallViewModel(), fullscreenVideo: .constant(false), isShowCallsListFragment: .constant(false), isShowStartCallFragment: .constant(false)) } // swiftlint:enable type_body_length // swiftlint:enable line_length diff --git a/Linphone/UI/Call/ViewModel/CallViewModel.swift b/Linphone/UI/Call/ViewModel/CallViewModel.swift index f6cf74e85..95f17f788 100644 --- a/Linphone/UI/Call/ViewModel/CallViewModel.swift +++ b/Linphone/UI/Call/ViewModel/CallViewModel.swift @@ -121,7 +121,9 @@ class CallViewModel: ObservableObject { func getCallsList() { coreContext.doOnCoreQueue { core in - self.calls = core.calls + DispatchQueue.main.async { + self.calls = core.calls + } } } @@ -141,6 +143,7 @@ class CallViewModel: ObservableObject { withAnimation { telecomManager.outgoingCallStarted = false telecomManager.callInProgress = true + telecomManager.callDisplayed = true telecomManager.callStarted = true } diff --git a/Linphone/UI/Main/Contacts/Fragments/ContactsInnerFragment.swift b/Linphone/UI/Main/Contacts/Fragments/ContactsInnerFragment.swift index ee46863d0..5342c92f5 100644 --- a/Linphone/UI/Main/Contacts/Fragments/ContactsInnerFragment.swift +++ b/Linphone/UI/Main/Contacts/Fragments/ContactsInnerFragment.swift @@ -46,7 +46,7 @@ struct ContactsInnerFragment: View { .frame(width: 25, height: 25, alignment: .leading) .padding(.all, 10) } - .padding(.top, 30) + .padding(.top, 10) .padding(.horizontal, 16) .background(.white) .onTapGesture { diff --git a/Linphone/UI/Main/Contacts/Fragments/FavoriteContactsListFragment.swift b/Linphone/UI/Main/Contacts/Fragments/FavoriteContactsListFragment.swift index ee6267d23..9e2796bfb 100644 --- a/Linphone/UI/Main/Contacts/Fragments/FavoriteContactsListFragment.swift +++ b/Linphone/UI/Main/Contacts/Fragments/FavoriteContactsListFragment.swift @@ -64,7 +64,8 @@ struct FavoriteContactsListFragment: View { } } } - .padding(.all, 10) + .padding(.horizontal, 10) + .padding(.bottom, 10) } } } diff --git a/Linphone/UI/Main/ContentView.swift b/Linphone/UI/Main/ContentView.swift index 609f0eca7..aef8efb45 100644 --- a/Linphone/UI/Main/ContentView.swift +++ b/Linphone/UI/Main/ContentView.swift @@ -56,15 +56,336 @@ struct ContentView: View { @State var isShowStartCallFragment = false @State var isShowDismissPopup = false + @State var fullscreenVideo = false + @State var isShowCallsListFragment = false + var body: some View { GeometryReader { geometry in - ZStack { - VStack(spacing: 0) { - HStack(spacing: 0) { - if orientation == .landscapeLeft - || orientation == .landscapeRight - || UIScreen.main.bounds.size.width > UIScreen.main.bounds.size.height { - VStack { + VStack(spacing: 0) { + if telecomManager.callInProgress && !fullscreenVideo && ((!telecomManager.callDisplayed && callViewModel.calls.count == 1) || callViewModel.calls.count > 1) { + HStack { + + } + .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 { + Group { + Spacer() + Button(action: { + self.index = 0 + historyViewModel.displayedCall = 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) + } + } + }) + + Spacer() + + Button(action: { + self.index = 1 + contactViewModel.indexDisplayedFriend = nil + }, 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) + } + } + }) + + Spacer() + } + } + .frame(width: 75) + .padding(.leading, + orientation == .landscapeRight && geometry.safeAreaInsets.bottom > 0 + ? -geometry.safeAreaInsets.leading + : 0) + } + + 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" : "Calls") + .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) + } + + 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) + } 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 { + historyListViewModel.resetFilterCallLogs() + } + } 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) + .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 { + historyListViewModel.filterCallLogs(filter: text) + } + } + } 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) + .onAppear { + self.focusedField = true + } + .onChange(of: text) { newValue in + magicSearch.currentFilter = newValue + MagicSearchSingleton.shared.searchForContacts( + sourceFlags: MagicSearch.Source.Friends.rawValue | MagicSearch.Source.LdapServers.rawValue) + } + } + + 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) + } + + if self.index == 0 { + ContactsView( + contactViewModel: contactViewModel, + historyViewModel: historyViewModel, + editContactViewModel: editContactViewModel, + isShowEditContactFragment: $isShowEditContactFragment, + isShowDeletePopup: $isShowDeleteContactPopup + ) + } else if self.index == 1 { + HistoryView( + historyListViewModel: historyListViewModel, + historyViewModel: historyViewModel, + contactViewModel: contactViewModel, + editContactViewModel: editContactViewModel, + index: $index, + isShowStartCallFragment: $isShowStartCallFragment, + isShowEditContactFragment: $isShowEditContactFragment + ) + } + } + .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: { @@ -86,6 +407,7 @@ struct ContentView: View { } } }) + .padding(.top) Spacer() @@ -108,567 +430,266 @@ struct ContentView: View { } } }) - + .padding(.top) Spacer() } } - .frame(width: 75) - .padding(.leading, - orientation == .landscapeRight && geometry.safeAreaInsets.bottom > 0 - ? -geometry.safeAreaInsets.leading - : 0) - } - - 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" : "Calls") - .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) - } - - 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(.bottom, 5) - .background(Color.orangeMain500) - } 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 { - historyListViewModel.resetFilterCallLogs() - } - } 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) - .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 { - historyListViewModel.filterCallLogs(filter: text) - } - } - } 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) - .onAppear { - self.focusedField = true - } - .onChange(of: text) { newValue in - magicSearch.currentFilter = newValue - MagicSearchSingleton.shared.searchForContacts( - sourceFlags: MagicSearch.Source.Friends.rawValue | MagicSearch.Source.LdapServers.rawValue) - } - } - - 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) - } - - if self.index == 0 { - ContactsView( - contactViewModel: contactViewModel, - historyViewModel: historyViewModel, - editContactViewModel: editContactViewModel, - isShowEditContactFragment: $isShowEditContactFragment, - isShowDeletePopup: $isShowDeleteContactPopup - ) - } else if self.index == 1 { - HistoryView( - historyListViewModel: historyListViewModel, - historyViewModel: historyViewModel, - contactViewModel: contactViewModel, - editContactViewModel: editContactViewModel, - index: $index, - isShowStartCallFragment: $isShowStartCallFragment, - isShowEditContactFragment: $isShowEditContactFragment - ) - } - } - .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() + .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 !(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 - }, 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) - - Spacer() - - Button(action: { - self.index = 1 - contactViewModel.indexDisplayedFriend = nil - }, 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) - 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 { - 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, - isShowDeletePopup: $isShowDeleteContactPopup, - isShowDismissPopup: $isShowDismissPopup - ) - .frame(maxWidth: .infinity) - .background(Color.gray100) - .ignoresSafeArea(.keyboard) - } else if self.index == 1 { - let fromAddressFriend = historyViewModel.displayedCall != nil ? contactsManager.getFriendWithAddress(address: historyViewModel.displayedCall!.fromAddress!) : nil - let toAddressFriend = historyViewModel.displayedCall != nil ? contactsManager.getFriendWithAddress(address: historyViewModel.displayedCall!.toAddress!) : nil - let addressFriend = historyViewModel.displayedCall != nil ? (historyViewModel.displayedCall!.dir == .Incoming ? fromAddressFriend : toAddressFriend) : nil - - let contactAvatarModel = addressFriend != nil - ? ContactsManager.shared.avatarListModel.first(where: { - ($0.friend!.consolidatedPresence == .Online || $0.friend!.consolidatedPresence == .Busy) - && $0.friend!.name == addressFriend!.name - && $0.friend!.address!.asStringUriOnly() == addressFriend!.address!.asStringUriOnly() - }) - : ContactAvatarModel(friend: nil, withPresence: false) - - if contactAvatarModel != nil { - HistoryContactFragment( - contactAvatarModel: contactAvatarModel!, - historyViewModel: historyViewModel, - historyListViewModel: historyListViewModel, + if contactViewModel.indexDisplayedFriend != nil || historyViewModel.displayedCall != 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, - isShowDeleteAllHistoryPopup: $isShowDeleteAllHistoryPopup, - isShowEditContactFragment: $isShowEditContactFragment, - indexPage: $index + isShowDeletePopup: $isShowDeleteContactPopup, + isShowDismissPopup: $isShowDismissPopup ) .frame(maxWidth: .infinity) .background(Color.gray100) .ignoresSafeArea(.keyboard) + } else if self.index == 1 { + let fromAddressFriend = historyViewModel.displayedCall != nil ? contactsManager.getFriendWithAddress(address: historyViewModel.displayedCall!.fromAddress!) : nil + let toAddressFriend = historyViewModel.displayedCall != nil ? contactsManager.getFriendWithAddress(address: historyViewModel.displayedCall!.toAddress!) : nil + let addressFriend = historyViewModel.displayedCall != nil ? (historyViewModel.displayedCall!.dir == .Incoming ? fromAddressFriend : toAddressFriend) : nil + + let contactAvatarModel = addressFriend != nil + ? ContactsManager.shared.avatarListModel.first(where: { + ($0.friend!.consolidatedPresence == .Online || $0.friend!.consolidatedPresence == .Busy) + && $0.friend!.name == addressFriend!.name + && $0.friend!.address!.asStringUriOnly() == addressFriend!.address!.asStringUriOnly() + }) + : ContactAvatarModel(friend: nil, withPresence: false) + + if contactAvatarModel != nil { + HistoryContactFragment( + contactAvatarModel: contactAvatarModel!, + historyViewModel: historyViewModel, + historyListViewModel: historyListViewModel, + contactViewModel: contactViewModel, + editContactViewModel: editContactViewModel, + isShowDeleteAllHistoryPopup: $isShowDeleteAllHistoryPopup, + isShowEditContactFragment: $isShowEditContactFragment, + indexPage: $index + ) + .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 + .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 + .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) } - .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 - ) - .ignoresSafeArea(.all) - .zIndex(2) - - if isShowEditContactFragment { - EditContactFragment( - editContactViewModel: editContactViewModel, - contactViewModel: contactViewModel, - isShowEditContactFragment: $isShowEditContactFragment, - isShowDismissPopup: $isShowDismissPopup + + SideMenu( + callViewModel: callViewModel, + width: geometry.size.width / 5 * 4, + isOpen: self.sideMenuIsOpen, + menuClose: self.openMenu, + safeAreaInsets: geometry.safeAreaInsets ) - .zIndex(3) - .transition(.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()} + .ignoresSafeArea(.all) + .zIndex(2) + + if isShowEditContactFragment { + EditContactFragment( + editContactViewModel: editContactViewModel, + contactViewModel: contactViewModel, + isShowEditContactFragment: $isShowEditContactFragment, + isShowDismissPopup: $isShowDismissPopup ) - .zIndex(4) + .zIndex(3) .transition(.move(edge: .bottom)) - .sheet(isPresented: $showingDialer) { - DialerBottomSheet( - startCallViewModel: startCallViewModel, - showingDialer: $showingDialer, - currentCall: nil - ) - .presentationDetents([.medium]) - .presentationBackgroundInteraction(.enabled(upThrough: .medium)) + .onAppear { + contactViewModel.indexDisplayedFriend = nil } - } else { - StartCallFragment( - callViewModel: callViewModel, - startCallViewModel: startCallViewModel, - isShowStartCallFragment: $isShowStartCallFragment, - showingDialer: $showingDialer, - resetCallView: {callViewModel.resetCallView()} - ) - .zIndex(4) - .transition(.move(edge: .bottom)) - .halfSheet(showSheet: $showingDialer) { - DialerBottomSheet( - startCallViewModel: startCallViewModel, - showingDialer: $showingDialer, - currentCall: nil - ) - } onDismiss: {} } - } - - 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 { + + if isShowStartCallFragment { + if #available(iOS 16.4, *), idiom != .pad { + StartCallFragment( + callViewModel: callViewModel, + startCallViewModel: startCallViewModel, + isShowStartCallFragment: $isShowStartCallFragment, + showingDialer: $showingDialer, + resetCallView: {callViewModel.resetCallView()} + ) + .zIndex(4) + .transition(.move(edge: .bottom)) + .sheet(isPresented: $showingDialer) { + DialerBottomSheet( + startCallViewModel: startCallViewModel, + showingDialer: $showingDialer, + currentCall: nil + ) + .presentationDetents([.medium]) + .presentationBackgroundInteraction(.enabled(upThrough: .medium)) + } + } else { + StartCallFragment( + callViewModel: callViewModel, + startCallViewModel: startCallViewModel, + isShowStartCallFragment: $isShowStartCallFragment, + showingDialer: $showingDialer, + resetCallView: {callViewModel.resetCallView()} + ) + .zIndex(4) + .transition(.move(edge: .bottom)) + .halfSheet(showSheet: $showingDialer) { + DialerBottomSheet( + startCallViewModel: startCallViewModel, + showingDialer: $showingDialer, + currentCall: nil + ) + } onDismiss: {} + } + } + + 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() } - 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 telecomManager.callInProgress { - CallView(callViewModel: callViewModel, isShowStartCallFragment: $isShowStartCallFragment) + MagicSearchSingleton.shared.searchForContacts( + sourceFlags: MagicSearch.Source.Friends.rawValue | MagicSearch.Source.LdapServers.rawValue) + self.isShowDeleteContactPopup.toggle() + }) + .background(.black.opacity(0.65)) .zIndex(3) - .transition(.scale.combined(with: .move(edge: .top))) - .onAppear { - callViewModel.resetCallView() + .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 telecomManager.callInProgress && telecomManager.callDisplayed { + CallView(callViewModel: callViewModel, fullscreenVideo: $fullscreenVideo, isShowCallsListFragment: $isShowCallsListFragment, isShowStartCallFragment: $isShowStartCallFragment) + .zIndex(3) + .transition(.scale.combined(with: .move(edge: .top))) + .onAppear { + callViewModel.resetCallView() + } + } + + ToastView() + .zIndex(3) } - - ToastView() - .zIndex(3) } } .overlay { diff --git a/Linphone/UI/Main/Fragments/SideMenu.swift b/Linphone/UI/Main/Fragments/SideMenu.swift index 95b838a10..8e86d4b42 100644 --- a/Linphone/UI/Main/Fragments/SideMenu.swift +++ b/Linphone/UI/Main/Fragments/SideMenu.swift @@ -25,6 +25,8 @@ struct SideMenu: View { @ObservedObject private var coreContext = CoreContext.shared + @ObservedObject var callViewModel: CallViewModel + let width: CGFloat let isOpen: Bool let menuClose: () -> Void @@ -88,7 +90,7 @@ struct SideMenu: View { Spacer() } .padding(.leading, safeAreaInsets.leading) - .padding(.top, safeAreaInsets.top) + .padding(.top, TelecomManager.shared.callInProgress ? 0 : safeAreaInsets.top) .padding(.bottom, safeAreaInsets.bottom) } .frame(maxWidth: .infinity, maxHeight: .infinity) From caa35432027dfa6894494ca0d74d24afd9a1a64d Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Thu, 1 Feb 2024 17:01:18 +0100 Subject: [PATCH 115/486] Change bottom sheet in call view, add animatated caret in bottom sheet --- Linphone/Core/CoreContext.swift | 4 +- Linphone/UI/Call/CallView.swift | 101 ++++++++++++++++++++++++-------- 2 files changed, 80 insertions(+), 25 deletions(-) diff --git a/Linphone/Core/CoreContext.swift b/Linphone/Core/CoreContext.swift index 81825b9a3..173e36237 100644 --- a/Linphone/Core/CoreContext.swift +++ b/Linphone/Core/CoreContext.swift @@ -144,9 +144,9 @@ final class CoreContext: ObservableObject { // Create a Core listener to listen for the callback we need // In this case, we want to know about the account registration status - self.mCoreSuscriptions.insert(self.mCore.publisher?.onConfiguringStatus?.postOnMainQueue { (cbVal: (core: Core, status: Config.ConfiguringState, message: String)) in + self.mCoreSuscriptions.insert(self.mCore.publisher?.onConfiguringStatus?.postOnMainQueue { (cbVal: (core: Core, status: ConfiguringState, message: String)) in Log.info("New configuration state is \(cbVal.status) = \(cbVal.message)\n") - if cbVal.status == Config.ConfiguringState.Successful { + if cbVal.status == ConfiguringState.Successful { ToastViewModel.shared.toastMessage = "Successful" ToastViewModel.shared.displayToast = true } diff --git a/Linphone/UI/Call/CallView.swift b/Linphone/UI/Call/CallView.swift index e4b689555..88c61e481 100644 --- a/Linphone/UI/Call/CallView.swift +++ b/Linphone/UI/Call/CallView.swift @@ -51,6 +51,9 @@ struct CallView: View { @Binding var isShowCallsListFragment: Bool @Binding var isShowStartCallFragment: Bool + @State private var pointingUp: CGFloat = 0.0 + @State private var currentOffset: CGFloat = 0.0 + var body: some View { GeometryReader { geo in ZStack { @@ -471,7 +474,7 @@ struct CallView: View { } .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 - 140 + maxHeight: fullscreenVideo && !telecomManager.isPausedByRemote ? geometry.size.height + geometry.safeAreaInsets.top + geometry.safeAreaInsets.bottom : geometry.size.height - (0.18 * geometry.size.height) - geometry.safeAreaInsets.bottom ) } @@ -493,7 +496,7 @@ struct CallView: View { } .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 - 140 + maxHeight: fullscreenVideo && !telecomManager.isPausedByRemote ? geometry.size.height + geometry.safeAreaInsets.top + geometry.safeAreaInsets.bottom : geometry.size.height - (0.18 * geometry.size.height) - geometry.safeAreaInsets.bottom ) } @@ -521,14 +524,14 @@ struct CallView: View { } .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 - (0.1 * geometry.size.height) - 60 + maxHeight: fullscreenVideo && !telecomManager.isPausedByRemote ? geometry.size.height + geometry.safeAreaInsets.top + geometry.safeAreaInsets.bottom : geometry.size.height - (0.18 * geometry.size.height) - geometry.safeAreaInsets.bottom ) .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 - (0.1 * geometry.size.height) - 60 + maxHeight: fullscreenVideo && !telecomManager.isPausedByRemote ? geometry.size.height + geometry.safeAreaInsets.top + geometry.safeAreaInsets.bottom : geometry.size.height - (0.18 * geometry.size.height) - geometry.safeAreaInsets.bottom ) .background(Color.gray900) .cornerRadius(20) @@ -583,10 +586,16 @@ struct CallView: View { BottomSheetView( content: bottomSheetContent(geo: geometry), - minHeight: (0.1 * geometry.size.height) + (bottomInset != nil ? bottomInset!.bottom : 0), - maxHeight: (0.45 * geometry.size.height) + (bottomInset != nil ? bottomInset!.bottom : 0), - currentHeight: (0.1 * geometry.size.height) + (bottomInset != nil ? bottomInset!.bottom : 0) + minHeight: 0.18 * geometry.size.height, + maxHeight: 0.5 * geometry.size.height, + currentOffset: $currentOffset, + pointingUp: $pointingUp, + bottomSafeArea: bottomInset?.bottom ?? 0 ) + .onAppear { + currentOffset = 0.18 * geometry.size.height + pointingUp = 1 - ((currentOffset - 0.18 * geometry.size.height) / (0.5 * geometry.size.height - 0.18 * geometry.size.height)) + } } else { #if targetEnvironment(simulator) HStack(spacing: 12) { @@ -638,8 +647,10 @@ struct CallView: View { #endif } } + + Spacer() } - .frame(maxWidth: .infinity, maxHeight: .infinity) + .frame(maxWidth: .infinity) .background(Color.gray900) .if(fullscreenVideo && !telecomManager.isPausedByRemote) { view in view.ignoresSafeArea(.all) @@ -649,11 +660,25 @@ struct CallView: View { func bottomSheetContent(geo: GeometryProxy) -> some View { GeometryReader { _ in VStack(spacing: 0) { - Rectangle() - .fill(Color.gray500) - .frame(width: 100, height: 5) - .cornerRadius(10) - .padding(.top, 5) + Button { + withAnimation { + if currentOffset < (0.5 * geo.size.height) { + currentOffset = 0.5 * geo.size.height + } else { + currentOffset = 0.18 * geo.size.height + } + + pointingUp = 1 - ((currentOffset - 0.18 * geo.size.height) / (0.5 * geo.size.height - 0.18 * geo.size.height)) + } + } label: { + ChevronShape(pointingUp: pointingUp) + .stroke(style: StrokeStyle(lineWidth: 4, lineCap: .round)) + .frame(width: 50, height: 10) + .foregroundStyle(.white) + .contentShape(Rectangle()) + .padding(.top, 15) + } + HStack(spacing: 12) { Button { callViewModel.terminateCall() @@ -735,7 +760,6 @@ struct CallView: View { } .frame(height: geo.size.height * 0.15) .padding(.horizontal, 20) - .padding(.top, (orientation != .landscapeLeft && orientation != .landscapeRight) ? (geo.safeAreaInsets.bottom != 0 ? -15 : -30) : -10) if orientation != .landscapeLeft && orientation != .landscapeRight { HStack(spacing: 0) { @@ -1156,16 +1180,16 @@ struct BottomSheetView: View { @State var minHeight: CGFloat @State var maxHeight: CGFloat - @State var currentHeight: 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 } - .onAppear { - self.currentHeight = minHeight - } .frame( width: geometry.size.width, height: maxHeight, @@ -1188,18 +1212,49 @@ struct BottomSheetView: View { .highPriorityGesture( DragGesture() .onChanged { value in - currentHeight -= value.translation.height - currentHeight = min(max(currentHeight, minHeight), maxHeight) + currentOffset -= value.translation.height + currentOffset = min(max(currentOffset, minHeight), maxHeight) + pointingUp = 1 - ((currentOffset - minHeight) / (maxHeight - minHeight)) } .onEnded { _ in withAnimation { - currentHeight = (currentHeight - minHeight <= maxHeight - currentHeight) ? minHeight : maxHeight + currentOffset = (currentOffset - minHeight <= maxHeight - currentOffset) ? minHeight : maxHeight + pointingUp = 1 - ((currentOffset - minHeight) / (maxHeight - minHeight)) } } ) - .offset(y: maxHeight - currentHeight) + .offset(y: maxHeight - currentOffset + bottomSafeArea) } - .edgesIgnoringSafeArea(.bottom) + } +} + +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, y: arrowTipStartingPoint)) + + 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 } } From 60d128f4f25f2817ec6663d1d99aff3d932c25c2 Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Mon, 5 Feb 2024 16:54:27 +0100 Subject: [PATCH 116/486] Call view changes --- .../lock_simple.imageset/Contents.json | 21 + .../lock_simple.imageset/lock_simple.svg | 3 + Linphone/Contacts/ContactsManager.swift | 4 +- Linphone/Localizable.xcstrings | 6 +- Linphone/UI/Call/CallView.swift | 968 +++++++++--------- 5 files changed, 519 insertions(+), 483 deletions(-) create mode 100644 Linphone/Assets.xcassets/lock_simple.imageset/Contents.json create mode 100644 Linphone/Assets.xcassets/lock_simple.imageset/lock_simple.svg 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/Contacts/ContactsManager.swift b/Linphone/Contacts/ContactsManager.swift index 84efe85fd..6808a6b49 100644 --- a/Linphone/Contacts/ContactsManager.swift +++ b/Linphone/Contacts/ContactsManager.swift @@ -143,8 +143,8 @@ final class ContactsManager: ObservableObject { func textToImage(firstName: String, lastName: String) -> UIImage { let lblNameInitialize = UILabel() - lblNameInitialize.frame.size = CGSize(width: 100.0, height: 100.0) - lblNameInitialize.font = UIFont(name: "NotoSans-ExtraBold", size: 40) + 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 = "" diff --git a/Linphone/Localizable.xcstrings b/Linphone/Localizable.xcstrings index c48bf298e..589a8fa68 100644 --- a/Linphone/Localizable.xcstrings +++ b/Linphone/Localizable.xcstrings @@ -170,6 +170,9 @@ }, "Appel" : { + }, + "Appel chiffré de bout en bout" : { + }, "assistant_account_login" : { "extractionState" : "manual", @@ -436,9 +439,6 @@ }, "Other actions" : { - }, - "Outgoing call" : { - }, "password" : { "extractionState" : "manual", diff --git a/Linphone/UI/Call/CallView.swift b/Linphone/UI/Call/CallView.swift index 88c61e481..0a587abae 100644 --- a/Linphone/UI/Call/CallView.swift +++ b/Linphone/UI/Call/CallView.swift @@ -40,20 +40,18 @@ struct CallView: View { @State var audioRouteSheet: Bool = false @State var hideButtonsSheet: Bool = false @State var options: Int = 1 - @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 @Binding var fullscreenVideo: Bool @Binding var isShowCallsListFragment: Bool @Binding var isShowStartCallFragment: Bool - @State private var pointingUp: CGFloat = 0.0 - @State private var currentOffset: CGFloat = 0.0 - var body: some View { GeometryReader { geo in ZStack { @@ -113,18 +111,6 @@ struct CallView: View { CallsListFragment(callViewModel: callViewModel, isShowCallsListFragment: $isShowCallsListFragment) .zIndex(4) .transition(.move(edge: .bottom)) - /* - .sheet(isPresented: $showingDialer) { - DialerBottomSheet( - startCallViewModel: startCallViewModel, - showingDialer: $showingDialer, - currentCall: nil - ) - .presentationDetents([.medium]) - // .interactiveDismissDisabled() - .presentationBackgroundInteraction(.enabled(upThrough: .medium)) - } - */ } if callViewModel.zrtpPopupDisplayed == true { @@ -265,318 +251,349 @@ struct CallView: View { @ViewBuilder // swiftlint:disable:next cyclomatic_complexity func innerView(geometry: GeometryProxy) -> some View { - VStack { - if !fullscreenVideo || (fullscreenVideo && telecomManager.isPausedByRemote) { - 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 { - if callViewModel.direction == .Outgoing { - Image("outgoing-call") - .resizable() - .frame(width: 15, height: 15) - .padding(.horizontal) - - Text("Outgoing call") - .foregroundStyle(.white) - } else { - Image("incoming-call") - .resizable() - .frame(width: 15, height: 15) - .padding(.horizontal) - - Text("Incoming call") - .foregroundStyle(.white) + ZStack { + VStack { + if !fullscreenVideo || (fullscreenVideo && telecomManager.isPausedByRemote) { + 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) } - - if !telecomManager.outgoingCallStarted && telecomManager.callInProgress { - Text("|") - .foregroundStyle(.white) - - ZStack { - Text(callViewModel.timeElapsed.convertDurationToString()) - .onReceive(callViewModel.timer) { _ in - callViewModel.timeElapsed = callViewModel.currentCall?.duration ?? 0 - } - .foregroundStyle(.white) - .if(callViewModel.isPaused || telecomManager.isPausedByRemote) { view in - view.hidden() + 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) + } - if callViewModel.isPaused { - Text("Paused") - .foregroundStyle(.white) - } else if telecomManager.isPausedByRemote { - Text("Paused by remote") + 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 { + } label: { + Image("cell-signal-full") + .renderingMode(.template) + .resizable() .foregroundStyle(.white) + .frame(width: 30, height: 30) + .padding(.all, 10) + } + + if telecomManager.remoteVideo { + 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 callViewModel.isMediaEncrypted { + HStack { + Image("lock_simple") + .resizable() + .frame(width: 15, height: 15, alignment: .leading) + .padding(.leading, 50) + .padding(.top, 35) + + Text("Appel chiffré de bout en bout") + .foregroundStyle(Color.blueInfo500) + .default_text_style_white(styleSize: 12) + .padding(.top, 35) + + Spacer() + } + .onTapGesture { + callViewModel.showZrtpSasDialogIfPossible() + } + .frame(height: 40) + .zIndex(1) + } + } + } + + ZStack { + VStack { + Spacer() + ZStack { + + if callViewModel.isRemoteDeviceTrusted { + Circle() + .fill(Color.blueInfo500) + .frame(width: 206, height: 206) + } + + if callViewModel.remoteAddress != nil { + let addressFriend = contactsManager.getFriendWithAddress(address: callViewModel.remoteAddress!) + + let contactAvatarModel = addressFriend != nil + ? ContactsManager.shared.avatarListModel.first(where: { + ($0.friend!.consolidatedPresence == .Online || $0.friend!.consolidatedPresence == .Busy) + && $0.friend!.name == addressFriend!.name + && $0.friend!.address!.asStringUriOnly() == addressFriend!.address!.asStringUriOnly() + }) + : ContactAvatarModel(friend: nil, withPresence: false) + + if addressFriend != nil && addressFriend!.photo != nil && !addressFriend!.photo!.isEmpty { + if contactAvatarModel != nil { + Avatar(contactAvatarModel: contactAvatarModel!, avatarSize: 200, hidePresence: true) + } + } else { + if callViewModel.remoteAddress!.displayName != nil { + Image(uiImage: contactsManager.textToImage( + firstName: callViewModel.remoteAddress!.displayName!, + lastName: callViewModel.remoteAddress!.displayName!.components(separatedBy: " ").count > 1 + ? callViewModel.remoteAddress!.displayName!.components(separatedBy: " ")[1] + : "")) + .resizable() + .frame(width: 200, height: 200) + .clipShape(Circle()) + + } else { + Image(uiImage: contactsManager.textToImage( + firstName: callViewModel.remoteAddress!.username ?? "Username Error", + lastName: callViewModel.remoteAddress!.username!.components(separatedBy: " ").count > 1 + ? callViewModel.remoteAddress!.username!.components(separatedBy: " ")[1] + : "")) + .resizable() + .frame(width: 200, height: 200) + .clipShape(Circle()) + } + + } + } else { + Image("profil-picture-default") + .resizable() + .frame(width: 200, height: 200) + .clipShape(Circle()) + } + + 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() } - Spacer() - - if callViewModel.isMediaEncrypted { - Button { - callViewModel.showZrtpSasDialogIfPossible() - } label: { - Image(callViewModel.isZrtpPq ? "media-encryption-zrtp-pq" : "media-encryption-srtp") - .resizable() - .frame(width: 30, height: 30) - .padding(.horizontal) + LinphoneVideoViewHolder { view in + coreContext.doOnCoreQueue { core in + core.nativeVideoWindow = view + } + } + .frame( + width: + angleDegree == 0 + ? (fullscreenVideo && !telecomManager.isPausedByRemote ? geometry.size.width : geometry.size.width - 8) + : (fullscreenVideo && !telecomManager.isPausedByRemote ? geometry.size.height + geometry.safeAreaInsets.top + geometry.safeAreaInsets.bottom : geometry.size.height - (minBottomSheetHeight * geometry.size.height > 80 ? minBottomSheetHeight * geometry.size.height : 78) - 40 - 20 + geometry.safeAreaInsets.bottom), + height: + angleDegree == 0 + ? (fullscreenVideo && !telecomManager.isPausedByRemote ? geometry.size.height + geometry.safeAreaInsets.top + geometry.safeAreaInsets.bottom : geometry.size.height - (minBottomSheetHeight * geometry.size.height > 80 ? minBottomSheetHeight * geometry.size.height : 78) - 40 - 20 + geometry.safeAreaInsets.bottom) + : (fullscreenVideo && !telecomManager.isPausedByRemote ? geometry.size.width : geometry.size.width - 8) + ) + .scaledToFill() + .clipped() + .onTapGesture { + if telecomManager.remoteVideo { + fullscreenVideo.toggle() } } if telecomManager.remoteVideo { - Button { - callViewModel.switchCamera() - } label: { - Image("camera-rotate") - .renderingMode(.template) - .resizable() - .foregroundStyle(.white) - .frame(width: 30, height: 30) - .padding(.horizontal) - } - } - } - .frame(height: 40) - .zIndex(1) - } - - ZStack { - VStack { - Spacer() - ZStack { - - if callViewModel.isRemoteDeviceTrusted { - Circle() - .fill(Color.blueInfo500) - .frame(width: 105, height: 105) - } - - if callViewModel.remoteAddress != nil { - let addressFriend = contactsManager.getFriendWithAddress(address: callViewModel.remoteAddress!) - - let contactAvatarModel = addressFriend != nil - ? ContactsManager.shared.avatarListModel.first(where: { - ($0.friend!.consolidatedPresence == .Online || $0.friend!.consolidatedPresence == .Busy) - && $0.friend!.name == addressFriend!.name - && $0.friend!.address!.asStringUriOnly() == addressFriend!.address!.asStringUriOnly() - }) - : ContactAvatarModel(friend: nil, withPresence: false) - - if addressFriend != nil && addressFriend!.photo != nil && !addressFriend!.photo!.isEmpty { - if contactAvatarModel != nil { - Avatar(contactAvatarModel: contactAvatarModel!, avatarSize: 100, hidePresence: true) - } - } else { - if callViewModel.remoteAddress!.displayName != nil { - Image(uiImage: contactsManager.textToImage( - firstName: callViewModel.remoteAddress!.displayName!, - lastName: callViewModel.remoteAddress!.displayName!.components(separatedBy: " ").count > 1 - ? callViewModel.remoteAddress!.displayName!.components(separatedBy: " ")[1] - : "")) - .resizable() - .frame(width: 100, height: 100) - .clipShape(Circle()) - - } else { - Image(uiImage: contactsManager.textToImage( - firstName: callViewModel.remoteAddress!.username ?? "Username Error", - lastName: callViewModel.remoteAddress!.username!.components(separatedBy: " ").count > 1 - ? callViewModel.remoteAddress!.username!.components(separatedBy: " ")[1] - : "")) - .resizable() - .frame(width: 100, height: 100) - .clipShape(Circle()) - } - - } - } else { - Image("profil-picture-default") - .resizable() - .frame(width: 100, height: 100) - .clipShape(Circle()) - } - - if callViewModel.isRemoteDeviceTrusted { + HStack { + Spacer() VStack { Spacer() - HStack { - Image("trusted") - .resizable() - .frame(width: 25, height: 25) - 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(width: 100, height: 100) } + .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 + ) } - Text(callViewModel.displayName) - .padding(.top) - .foregroundStyle(.white) + 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 + ) + } - Text(callViewModel.remoteAddressString) - .foregroundStyle(.white) - - Spacer() - } - - LinphoneVideoViewHolder { view in - coreContext.doOnCoreQueue { core in - core.nativeVideoWindow = view + if telecomManager.outgoingCallStarted { + VStack { + ActivityIndicator() + .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 + ) } } .frame( - width: - angleDegree == 0 - ? 120 * ((geometry.size.height + geometry.safeAreaInsets.top + geometry.safeAreaInsets.bottom) / 160) - : 120 * ((geometry.size.width + geometry.safeAreaInsets.leading + geometry.safeAreaInsets.trailing) / 120), - height: - angleDegree == 0 - ? 160 * ((geometry.size.height + geometry.safeAreaInsets.top + geometry.safeAreaInsets.bottom) / 160) - : 160 * ((geometry.size.width + geometry.safeAreaInsets.leading + geometry.safeAreaInsets.trailing) / 120) + 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 ) - .scaledToFill() - .clipped() - .onTapGesture { - if telecomManager.remoteVideo { - fullscreenVideo.toggle() - } - } - - if telecomManager.remoteVideo { - 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)) + .background(Color.gray900) + .cornerRadius(20) + .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 } } - .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 - (0.18 * geometry.size.height) - geometry.safeAreaInsets.bottom - ) - } - - 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() + + if (oldOrientation != orientation && oldOrientation != .faceUp) || (oldOrientation == .faceUp && (orientation == .landscapeLeft || orientation == .landscapeRight)) { + telecomManager.callStarted = false + + DispatchQueue.global().asyncAfter(deadline: .now() + 0.5) { + telecomManager.callStarted = true } - 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 - (0.18 * geometry.size.height) - geometry.safeAreaInsets.bottom - ) + + callViewModel.orientationUpdate(orientation: orientation) } - - if telecomManager.outgoingCallStarted { - VStack { - ActivityIndicator() - .frame(width: 20, height: 20) - .padding(.top, 100) - - 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() + .onAppear { + if orientation == .portrait && orientation == .portraitUpsideDown { + angleDegree = 0 + } else { + if orientation == .landscapeLeft { + angleDegree = -90 + } else if orientation == .landscapeRight { + angleDegree = 90 + } } - .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 - (0.18 * geometry.size.height) - geometry.safeAreaInsets.bottom - ) - .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 - (0.18 * geometry.size.height) - geometry.safeAreaInsets.bottom - ) - .background(Color.gray900) - .cornerRadius(20) - .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 - } - } - - if (oldOrientation != orientation && oldOrientation != .faceUp) || (oldOrientation == .faceUp && (orientation == .landscapeLeft || orientation == .landscapeRight)) { + telecomManager.callStarted = false DispatchQueue.global().asyncAfter(deadline: .now() + 0.5) { telecomManager.callStarted = true } + + callViewModel.orientationUpdate(orientation: orientation) } - callViewModel.orientationUpdate(orientation: orientation) + Spacer() } - .onAppear { - if orientation == .portrait && orientation == .portraitUpsideDown { - angleDegree = 0 - } else { - if orientation == .landscapeLeft { - angleDegree = -90 - } else if orientation == .landscapeRight { - angleDegree = 90 - } - } - - telecomManager.callStarted = false - - DispatchQueue.global().asyncAfter(deadline: .now() + 0.5) { - telecomManager.callStarted = true - } - - callViewModel.orientationUpdate(orientation: orientation) + .frame(height: geometry.size.height) + .frame(maxWidth: .infinity) + .background(Color.gray900) + .if(fullscreenVideo && !telecomManager.isPausedByRemote) { view in + view.ignoresSafeArea(.all) } if !fullscreenVideo || (fullscreenVideo && telecomManager.isPausedByRemote) { @@ -586,74 +603,19 @@ struct CallView: View { BottomSheetView( content: bottomSheetContent(geo: geometry), - minHeight: 0.18 * geometry.size.height, - maxHeight: 0.5 * geometry.size.height, + 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 = 0.18 * geometry.size.height - pointingUp = 1 - ((currentOffset - 0.18 * geometry.size.height) / (0.5 * geometry.size.height - 0.18 * geometry.size.height)) + 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 } - } else { -#if targetEnvironment(simulator) - HStack(spacing: 12) { - HStack { - Spacer() - - Button { - callViewModel.terminateCall() - } label: { - Image("phone-disconnect") - .renderingMode(.template) - .resizable() - .foregroundStyle(.white) - .frame(width: 32, height: 32) - - } - .frame(width: 90, height: 60) - .background(Color.redDanger500) - .cornerRadius(40) - - Button { - callViewModel.acceptCall() - } label: { - Image("phone") - .renderingMode(.template) - .resizable() - .foregroundStyle(.white) - .frame(width: 32, height: 32) - - } - .frame(width: 90, height: 60) - .background(Color.greenSuccess500) - .cornerRadius(40) - - Spacer() - } - .frame(height: 60) - } - .padding(.horizontal, 25) - .padding(.top, 20) -#else - HStack(spacing: 12) { - HStack { - } - .frame(height: 60) - } - .padding(.horizontal, 25) - .padding(.top, 20) -#endif + .edgesIgnoringSafeArea(.bottom) } } - - Spacer() - } - .frame(maxWidth: .infinity) - .background(Color.gray900) - .if(fullscreenVideo && !telecomManager.isPausedByRemote) { view in - view.ignoresSafeArea(.all) } } @@ -662,18 +624,18 @@ struct CallView: View { VStack(spacing: 0) { Button { withAnimation { - if currentOffset < (0.5 * geo.size.height) { - currentOffset = 0.5 * geo.size.height + if currentOffset < (maxBottomSheetHeight * geo.size.height) { + currentOffset = (maxBottomSheetHeight * geo.size.height) } else { - currentOffset = 0.18 * geo.size.height + currentOffset = (minBottomSheetHeight * geo.size.height > 80 ? minBottomSheetHeight * geo.size.height : 78) } - pointingUp = 1 - ((currentOffset - 0.18 * geo.size.height) / (0.5 * geo.size.height - 0.18 * geo.size.height)) + 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: 50, height: 10) + .frame(width: 40, height: 6) .foregroundStyle(.white) .contentShape(Rectangle()) .padding(.top, 15) @@ -699,30 +661,34 @@ struct CallView: View { Button { callViewModel.toggleVideo() } label: { - Image(telecomManager.remoteVideo ? "video-camera" : "video-camera-slash") - .renderingMode(.template) - .resizable() - .foregroundStyle((callViewModel.isPaused || telecomManager.isPausedByRemote) ? Color.gray500 : .white) - .frame(width: 32, height: 32) - + HStack { + Image(telecomManager.remoteVideo ? "video-camera" : "video-camera-slash") + .renderingMode(.template) + .resizable() + .foregroundStyle((callViewModel.isPaused || telecomManager.isPausedByRemote) ? Color.gray500 : .white) + .frame(width: 32, height: 32) + } } + .buttonStyle(PressedButtonStyle()) .frame(width: 60, height: 60) - .background((callViewModel.isPaused || telecomManager.isPausedByRemote) ? Color.gray600 : Color.gray500) + .background((callViewModel.isPaused || telecomManager.isPausedByRemote) ? .white : Color.gray500) .cornerRadius(40) .disabled(callViewModel.isPaused || telecomManager.isPausedByRemote) Button { callViewModel.toggleMuteMicrophone() } label: { - Image(callViewModel.micMutted ? "microphone-slash" : "microphone") - .renderingMode(.template) - .resizable() - .foregroundStyle(callViewModel.micMutted ? .black : .white) - .frame(width: 32, height: 32) - + HStack { + Image(callViewModel.micMutted ? "microphone-slash" : "microphone") + .renderingMode(.template) + .resizable() + .foregroundStyle(.white) + .frame(width: 32, height: 32) + } } + .buttonStyle(PressedButtonStyle()) .frame(width: 60, height: 60) - .background(callViewModel.micMutted ? .white : Color.gray500) + .background(callViewModel.micMutted ? Color.redDanger500 : Color.gray500) .cornerRadius(40) Button { @@ -743,23 +709,26 @@ struct CallView: View { } } label: { - Image(imageAudioRoute) - .renderingMode(.template) - .resizable() - .foregroundStyle(.white) - .frame(width: 32, height: 32) - .onAppear(perform: getAudioRouteImage) - .onReceive(pub) { _ in - self.getAudioRouteImage() - } - + HStack { + Image(imageAudioRoute) + .renderingMode(.template) + .resizable() + .foregroundStyle(.white) + .frame(width: 32, height: 32) + .onAppear(perform: getAudioRouteImage) + .onReceive(pub) { _ in + self.getAudioRouteImage() + } + } } + .buttonStyle(PressedButtonStyle()) .frame(width: 60, height: 60) .background(Color.gray500) .cornerRadius(40) } .frame(height: geo.size.height * 0.15) .padding(.horizontal, 20) + .padding(.top, -12) if orientation != .landscapeLeft && orientation != .landscapeRight { HStack(spacing: 0) { @@ -775,12 +744,15 @@ struct CallView: View { callViewModel.transferClicked() } } label: { - Image("phone-transfer") - .renderingMode(.template) - .resizable() - .foregroundStyle(.white) - .frame(width: 32, height: 32) + HStack { + Image("phone-transfer") + .renderingMode(.template) + .resizable() + .foregroundStyle(.white) + .frame(width: 32, height: 32) + } } + .buttonStyle(PressedButtonStyle()) .frame(width: 60, height: 60) .background(Color.gray500) .cornerRadius(40) @@ -798,12 +770,15 @@ struct CallView: View { isShowStartCallFragment.toggle() } } label: { - Image("phone-plus") - .renderingMode(.template) - .resizable() - .foregroundStyle(.white) - .frame(width: 32, height: 32) + HStack { + Image("phone-plus") + .renderingMode(.template) + .resizable() + .foregroundStyle(.white) + .frame(width: 32, height: 32) + } } + .buttonStyle(PressedButtonStyle()) .frame(width: 60, height: 60) .background(Color.gray500) .cornerRadius(40) @@ -822,12 +797,15 @@ struct CallView: View { isShowCallsListFragment.toggle() } } label: { - Image("phone-list") - .renderingMode(.template) - .resizable() - .foregroundStyle(.white) - .frame(width: 32, height: 32) + HStack { + Image("phone-list") + .renderingMode(.template) + .resizable() + .foregroundStyle(.white) + .frame(width: 32, height: 32) + } } + .buttonStyle(PressedButtonStyle()) .frame(width: 60, height: 60) .background(Color.gray500) .cornerRadius(40) @@ -863,12 +841,15 @@ struct CallView: View { Button { showingDialer.toggle() } label: { - Image("dialer") - .renderingMode(.template) - .resizable() - .foregroundStyle(.white) - .frame(width: 32, height: 32) + HStack { + Image("dialer") + .renderingMode(.template) + .resizable() + .foregroundStyle(.white) + .frame(width: 32, height: 32) + } } + .buttonStyle(PressedButtonStyle()) .frame(width: 60, height: 60) .background(Color.gray500) .cornerRadius(40) @@ -878,31 +859,26 @@ struct CallView: View { .default_text_style(styleSize: 15) } .frame(width: geo.size.width * 0.25, height: geo.size.width * 0.25) - - } .frame(height: geo.size.height * 0.15) HStack(spacing: 0) { VStack { Button { - withAnimation { - telecomManager.callDisplayed = false - } } label: { - Image("chat-teardrop-text") - .renderingMode(.template) - .resizable() - //.foregroundStyle((callViewModel.isPaused || telecomManager.isPausedByRemote) ? Color.gray500 : .white) - .foregroundStyle(Color.gray500) - .frame(width: 32, height: 32) + HStack { + Image("chat-teardrop-text") + .renderingMode(.template) + .resizable() + .foregroundStyle(Color.gray500) + .frame(width: 32, height: 32) + } } + .buttonStyle(PressedButtonStyle()) .frame(width: 60, height: 60) - //.background((callViewModel.isPaused || telecomManager.isPausedByRemote) ? Color.gray600 : Color.gray500) - .background(Color.gray600) + .background(.white) .cornerRadius(40) - //.disabled(callViewModel.isPaused || telecomManager.isPausedByRemote) - //.disabled(true) + .disabled(true) Text("Messages") .foregroundStyle(.white) @@ -914,14 +890,17 @@ struct CallView: View { Button { callViewModel.togglePause() } label: { - Image(callViewModel.isPaused ? "play" : "pause") - .renderingMode(.template) - .resizable() - .foregroundStyle(telecomManager.isPausedByRemote ? Color.gray500 : .white) - .frame(width: 32, height: 32) + HStack { + Image(callViewModel.isPaused ? "play" : "pause") + .renderingMode(.template) + .resizable() + .foregroundStyle(telecomManager.isPausedByRemote ? Color.gray500 : .white) + .frame(width: 32, height: 32) + } } + .buttonStyle(PressedButtonStyle()) .frame(width: 60, height: 60) - .background(telecomManager.isPausedByRemote ? Color.gray600 : (callViewModel.isPaused ? Color.greenSuccess500 : Color.gray500)) + .background(telecomManager.isPausedByRemote ? .white : (callViewModel.isPaused ? Color.greenSuccess500 : Color.gray500)) .cornerRadius(40) .disabled(telecomManager.isPausedByRemote) @@ -935,14 +914,17 @@ struct CallView: View { Button { callViewModel.toggleRecording() } label: { - Image("record-fill") - .renderingMode(.template) - .resizable() - .foregroundStyle((callViewModel.isPaused || telecomManager.isPausedByRemote) ? Color.gray500 : .white) - .frame(width: 32, height: 32) + HStack { + Image("record-fill") + .renderingMode(.template) + .resizable() + .foregroundStyle((callViewModel.isPaused || telecomManager.isPausedByRemote) ? Color.gray500 : .white) + .frame(width: 32, height: 32) + } } + .buttonStyle(PressedButtonStyle()) .frame(width: 60, height: 60) - .background((callViewModel.isPaused || telecomManager.isPausedByRemote) ? Color.gray600 : (callViewModel.isRecording ? Color.redDanger500 : Color.gray500)) + .background((callViewModel.isPaused || telecomManager.isPausedByRemote) ? .white : (callViewModel.isRecording ? Color.redDanger500 : Color.gray500)) .cornerRadius(40) .disabled(callViewModel.isPaused || telecomManager.isPausedByRemote) @@ -955,12 +937,15 @@ struct CallView: View { VStack { Button { } label: { - Image("video-camera") - .renderingMode(.template) - .resizable() - .foregroundStyle(.white) - .frame(width: 32, height: 32) + HStack { + Image("video-camera") + .renderingMode(.template) + .resizable() + .foregroundStyle(.white) + .frame(width: 32, height: 32) + } } + .buttonStyle(PressedButtonStyle()) .frame(width: 60, height: 60) .background(Color.gray500) .cornerRadius(40) @@ -983,12 +968,15 @@ struct CallView: View { isShowStartCallFragment.toggle() } } label: { - Image("phone-transfer") - .renderingMode(.template) - .resizable() - .foregroundStyle(.white) - .frame(width: 32, height: 32) + HStack { + Image("phone-transfer") + .renderingMode(.template) + .resizable() + .foregroundStyle(.white) + .frame(width: 32, height: 32) + } } + .buttonStyle(PressedButtonStyle()) .frame(width: 60, height: 60) .background(Color.gray500) .cornerRadius(40) @@ -1006,12 +994,15 @@ struct CallView: View { isShowStartCallFragment.toggle() } } label: { - Image("phone-plus") - .renderingMode(.template) - .resizable() - .foregroundStyle(.white) - .frame(width: 32, height: 32) + HStack { + Image("phone-plus") + .renderingMode(.template) + .resizable() + .foregroundStyle(.white) + .frame(width: 32, height: 32) + } } + .buttonStyle(PressedButtonStyle()) .frame(width: 60, height: 60) .background(Color.gray500) .cornerRadius(40) @@ -1026,12 +1017,15 @@ struct CallView: View { ZStack { Button { } label: { - Image("phone-list") - .renderingMode(.template) - .resizable() - .foregroundStyle(.white) - .frame(width: 32, height: 32) + HStack { + Image("phone-list") + .renderingMode(.template) + .resizable() + .foregroundStyle(.white) + .frame(width: 32, height: 32) + } } + .buttonStyle(PressedButtonStyle()) .frame(width: 60, height: 60) .background(Color.gray500) .cornerRadius(40) @@ -1067,12 +1061,15 @@ struct CallView: View { Button { showingDialer.toggle() } label: { - Image("dialer") - .renderingMode(.template) - .resizable() - .foregroundStyle(.white) - .frame(width: 32, height: 32) + HStack { + Image("dialer") + .renderingMode(.template) + .resizable() + .foregroundStyle(.white) + .frame(width: 32, height: 32) + } } + .buttonStyle(PressedButtonStyle()) .frame(width: 60, height: 60) .background(Color.gray500) .cornerRadius(40) @@ -1086,18 +1083,18 @@ struct CallView: View { VStack { Button { } label: { - Image("chat-teardrop-text") - .renderingMode(.template) - .resizable() - //.foregroundStyle((callViewModel.isPaused || telecomManager.isPausedByRemote) ? Color.gray500 : .white) - .foregroundStyle(Color.gray500) - .frame(width: 32, height: 32) + HStack { + Image("chat-teardrop-text") + .renderingMode(.template) + .resizable() + .foregroundStyle(Color.gray500) + .frame(width: 32, height: 32) + } } + .buttonStyle(PressedButtonStyle()) .frame(width: 60, height: 60) - //.background((callViewModel.isPaused || telecomManager.isPausedByRemote) ? Color.gray600 : Color.gray500) - .background(Color.gray600) + .background(.white) .cornerRadius(40) - //.disabled(callViewModel.isPaused || telecomManager.isPausedByRemote) .disabled(true) Text("Messages") @@ -1110,14 +1107,17 @@ struct CallView: View { Button { callViewModel.togglePause() } label: { - Image(callViewModel.isPaused ? "play" : "pause") - .renderingMode(.template) - .resizable() - .foregroundStyle(telecomManager.isPausedByRemote ? Color.gray500 : .white) - .frame(width: 32, height: 32) + HStack { + Image(callViewModel.isPaused ? "play" : "pause") + .renderingMode(.template) + .resizable() + .foregroundStyle(telecomManager.isPausedByRemote ? Color.gray500 : .white) + .frame(width: 32, height: 32) + } } + .buttonStyle(PressedButtonStyle()) .frame(width: 60, height: 60) - .background(telecomManager.isPausedByRemote ? Color.gray600 : (callViewModel.isPaused ? Color.greenSuccess500 : Color.gray500)) + .background(telecomManager.isPausedByRemote ? .white : (callViewModel.isPaused ? Color.greenSuccess500 : Color.gray500)) .cornerRadius(40) .disabled(telecomManager.isPausedByRemote) @@ -1131,14 +1131,17 @@ struct CallView: View { Button { callViewModel.toggleRecording() } label: { - Image("record-fill") - .renderingMode(.template) - .resizable() - .foregroundStyle((callViewModel.isPaused || telecomManager.isPausedByRemote) ? Color.gray500 : .white) - .frame(width: 32, height: 32) + HStack { + Image("record-fill") + .renderingMode(.template) + .resizable() + .foregroundStyle((callViewModel.isPaused || telecomManager.isPausedByRemote) ? Color.gray500 : .white) + .frame(width: 32, height: 32) + } } + .buttonStyle(PressedButtonStyle()) .frame(width: 60, height: 60) - .background((callViewModel.isPaused || telecomManager.isPausedByRemote) ? Color.gray600 : (callViewModel.isRecording ? Color.redDanger500 : Color.gray500)) + .background((callViewModel.isPaused || telecomManager.isPausedByRemote) ? .white : (callViewModel.isRecording ? Color.redDanger500 : Color.gray500)) .cornerRadius(40) .disabled(callViewModel.isPaused || telecomManager.isPausedByRemote) @@ -1152,6 +1155,7 @@ struct CallView: View { .padding(.horizontal, 20) .padding(.top, 30) } + Spacer() } .background(Color.gray600) @@ -1214,16 +1218,16 @@ struct BottomSheetView: View { .onChanged { value in currentOffset -= value.translation.height currentOffset = min(max(currentOffset, minHeight), maxHeight) - pointingUp = 1 - ((currentOffset - minHeight) / (maxHeight - minHeight)) + pointingUp = -(((currentOffset - minHeight) / (maxHeight - minHeight)) - 0.5) * 2 } .onEnded { _ in withAnimation { currentOffset = (currentOffset - minHeight <= maxHeight - currentOffset) ? minHeight : maxHeight - pointingUp = 1 - ((currentOffset - minHeight) / (maxHeight - minHeight)) + pointingUp = -(((currentOffset - minHeight) / (maxHeight - minHeight)) - 0.5) * 2 } } ) - .offset(y: maxHeight - currentOffset + bottomSafeArea) + .offset(y: maxHeight - currentOffset) } } } @@ -1247,7 +1251,6 @@ struct ChevronShape: Shape { let arrowTipStartingPoint = height - pointingUp * height * 0.9 path.move(to: .init(x: 0, y: height)) - //path.addLine(to: .init(x: horizontalCenter, y: arrowTipStartingPoint)) 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))) @@ -1258,6 +1261,15 @@ struct ChevronShape: Shape { } } +struct PressedButtonStyle: ButtonStyle { + func makeBody(configuration: Self.Configuration) -> some View { + configuration.label + .frame(width: 60, height: 60) + .background(configuration.isPressed ? .white : .clear) + .cornerRadius(40) + } +} + #Preview { CallView(callViewModel: CallViewModel(), fullscreenVideo: .constant(false), isShowCallsListFragment: .constant(false), isShowStartCallFragment: .constant(false)) } From dc84803a178cf462f4ae7d9ebadf342aa9496ada Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Mon, 12 Feb 2024 17:27:21 +0100 Subject: [PATCH 117/486] Add conversations list --- Linphone.xcodeproj/project.pbxproj | 44 +++ Linphone/Core/CoreContext.swift | 2 + Linphone/LinphoneApp.swift | 5 +- Linphone/Localizable.xcstrings | 6 + .../Viewmodel/AccountLoginViewModel.swift | 6 +- .../UI/Call/Fragments/CallsListFragment.swift | 8 +- .../Fragments/ContactsListFragment.swift | 4 +- .../FavoriteContactsListFragment.swift | 4 +- Linphone/UI/Main/ContentView.swift | 250 ++++++++++++----- .../Conversations/ConversationsView.swift | 51 ++++ .../Fragments/ConversationsFragment.swift | 68 +++++ .../Fragments/ConversationsListFragment.swift | 254 ++++++++++++++++++ .../ConversationsListViewModel.swift | 112 ++++++++ .../Fragments/HistoryListFragment.swift | 2 +- .../ViewModel/HistoryListViewModel.swift | 1 + Linphone/Utils/Avatar.swift | 4 +- Linphone/Utils/LinphoneUtils.swift | 46 ++++ 17 files changed, 787 insertions(+), 80 deletions(-) create mode 100644 Linphone/UI/Main/Conversations/ConversationsView.swift create mode 100644 Linphone/UI/Main/Conversations/Fragments/ConversationsFragment.swift create mode 100644 Linphone/UI/Main/Conversations/Fragments/ConversationsListFragment.swift create mode 100644 Linphone/UI/Main/Conversations/ViewModel/ConversationsListViewModel.swift create mode 100644 Linphone/Utils/LinphoneUtils.swift diff --git a/Linphone.xcodeproj/project.pbxproj b/Linphone.xcodeproj/project.pbxproj index 87d0917df..7fbd4f875 100644 --- a/Linphone.xcodeproj/project.pbxproj +++ b/Linphone.xcodeproj/project.pbxproj @@ -19,6 +19,7 @@ D70C93DE2AC2D0F60063CA3B /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = D70C93DD2AC2D0F60063CA3B /* Localizable.xcstrings */; }; 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 */; }; 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 */; }; @@ -80,6 +81,10 @@ 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 */; }; @@ -112,6 +117,7 @@ D70C93DD2AC2D0F60063CA3B /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; 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 = ""; }; 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 = ""; }; @@ -176,6 +182,10 @@ 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 = ""; }; @@ -248,6 +258,7 @@ D7C3650D2AF15BF200FE6142 /* PhotoPicker.swift */, D732A9082AFD235500DB42BA /* ShareSheetController.swift */, D7B99E9A2B29F7C200BE7BF2 /* ActivityIndicator.swift */, + D7173EBD2B7A5C0A00BCC481 /* LinphoneUtils.swift */, ); path = Utils; sourceTree = ""; @@ -313,6 +324,7 @@ D719ABC62ABC6F0200B41C10 /* Main */ = { isa = PBXGroup; children = ( + D7CEE0332B7A20A400FD79B7 /* Conversations */, D7A03FBB2ACC2D850081A588 /* Contacts */, D74C9CFD2ACAEC150021626A /* Fragments */, D7A03FBE2ACC2E010081A588 /* History */, @@ -518,6 +530,33 @@ path = ViewModel; sourceTree = ""; }; + D7CEE0332B7A20A400FD79B7 /* Conversations */ = { + isa = PBXGroup; + children = ( + D7CEE0392B7A232200FD79B7 /* Fragments */, + D7CEE0362B7A212C00FD79B7 /* ViewModel */, + D7CEE0342B7A210300FD79B7 /* ConversationsView.swift */, + ); + path = Conversations; + sourceTree = ""; + }; + D7CEE0362B7A212C00FD79B7 /* ViewModel */ = { + isa = PBXGroup; + children = ( + D7CEE0372B7A214F00FD79B7 /* ConversationsListViewModel.swift */, + ); + path = ViewModel; + sourceTree = ""; + }; + D7CEE0392B7A232200FD79B7 /* Fragments */ = { + isa = PBXGroup; + children = ( + D7CEE03A2B7A234200FD79B7 /* ConversationsFragment.swift */, + D7CEE03C2B7A23B200FD79B7 /* ConversationsListFragment.swift */, + ); + path = Fragments; + sourceTree = ""; + }; D7D24D0C2AC1B4C700C6F35B /* Fonts */ = { isa = PBXGroup; children = ( @@ -676,6 +715,7 @@ 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 */, @@ -722,8 +762,10 @@ D7A03FC62ACC458A0081A588 /* SplashScreen.swift in Sources */, D7A03FC02ACC2E390081A588 /* HistoryView.swift in Sources */, D748BF2E2ACD82E7004844EB /* ThirdPartySipAccountWarningFragment.swift in Sources */, + D7173EBE2B7A5C0A00BCC481 /* LinphoneUtils.swift in Sources */, 66C492012B24DB6900CEA16D /* Log.swift in Sources */, D748BF2C2ACD82D2004844EB /* ThirdPartySipAccountLoginFragment.swift in Sources */, + D7CEE0382B7A214F00FD79B7 /* ConversationsListViewModel.swift in Sources */, D74C9CF82ACACECE0021626A /* WelcomePage1Fragment.swift in Sources */, D7E6D0552AEBFCCE00A57AAF /* ContactsInnerFragment.swift in Sources */, D732A9092AFD235500DB42BA /* ShareSheetController.swift in Sources */, @@ -740,9 +782,11 @@ D726E43F2B19E56F0083C415 /* StartCallViewModel.swift in Sources */, D7D1698C2AE66FA500109A5C /* MagicSearchSingleton.swift in Sources */, D72250692ADFBF2D008FB426 /* SideMenu.swift in Sources */, + D7CEE0352B7A210300FD79B7 /* ConversationsView.swift in Sources */, D717071E2AC5922E0037746F /* ColorExtension.swift in Sources */, D78290B82ADD3910004AA85C /* ContactsFragment.swift in Sources */, D7DA67642ACCB31700E95002 /* ProfileModeFragment.swift in Sources */, + D7CEE03D2B7A23B200FD79B7 /* ConversationsListFragment.swift in Sources */, D74C9CFC2ACACF370021626A /* WelcomePage3Fragment.swift in Sources */, D719ABCC2ABC769C00B41C10 /* AssistantView.swift in Sources */, D7C365082AEFAB7F00FE6142 /* ContactListBottomSheet.swift in Sources */, diff --git a/Linphone/Core/CoreContext.swift b/Linphone/Core/CoreContext.swift index 173e36237..bb19caad9 100644 --- a/Linphone/Core/CoreContext.swift +++ b/Linphone/Core/CoreContext.swift @@ -135,10 +135,12 @@ final class CoreContext: ObservableObject { self.mCore.removeLinphoneSpec(spec: "conference") Log.info("Removing spec 'ephemeral' from core for this version") self.mCore.removeLinphoneSpec(spec: "ephemeral") + /* Log.info("Removing spec 'groupchat' from core for this version") self.mCore.removeLinphoneSpec(spec: "groupchat") Log.info("Removing spec 'lime' from core for this version") self.mCore.removeLinphoneSpec(spec: "lime") + */ } }) diff --git a/Linphone/LinphoneApp.swift b/Linphone/LinphoneApp.swift index 95378929c..f3a1fc3fa 100644 --- a/Linphone/LinphoneApp.swift +++ b/Linphone/LinphoneApp.swift @@ -45,6 +45,7 @@ struct LinphoneApp: App { @State private var historyListViewModel: HistoryListViewModel? @State private var startCallViewModel: StartCallViewModel? @State private var callViewModel: CallViewModel? + @State private var conversationsListViewModel: ConversationsListViewModel? var body: some Scene { WindowGroup { @@ -72,7 +73,8 @@ struct LinphoneApp: App { historyViewModel: historyViewModel!, historyListViewModel: historyListViewModel!, startCallViewModel: startCallViewModel!, - callViewModel: callViewModel! + callViewModel: callViewModel!, + conversationsListViewModel: conversationsListViewModel! ) } else { SplashScreen() @@ -86,6 +88,7 @@ struct LinphoneApp: App { historyListViewModel = HistoryListViewModel() startCallViewModel = StartCallViewModel() callViewModel = CallViewModel() + conversationsListViewModel = ConversationsListViewModel() } } } diff --git a/Linphone/Localizable.xcstrings b/Linphone/Localizable.xcstrings index 589a8fa68..279f2ae7b 100644 --- a/Linphone/Localizable.xcstrings +++ b/Linphone/Localizable.xcstrings @@ -250,6 +250,9 @@ }, "Continue" : { + }, + "Conversations" : { + }, "Copy address" : { @@ -424,6 +427,9 @@ }, "No contacts for the moment..." : { + }, + "No conversation for the moment..." : { + }, "Not account yet?" : { diff --git a/Linphone/UI/Assistant/Viewmodel/AccountLoginViewModel.swift b/Linphone/UI/Assistant/Viewmodel/AccountLoginViewModel.swift index 98a4f43f9..2d6f9bffb 100644 --- a/Linphone/UI/Assistant/Viewmodel/AccountLoginViewModel.swift +++ b/Linphone/UI/Assistant/Viewmodel/AccountLoginViewModel.swift @@ -99,10 +99,10 @@ class AccountLoginViewModel: ObservableObject { accountParams.pushNotificationConfig?.provider = "apns" + pushEnvironment // Temporary disable these features are they are not used for 6.0 first version - accountParams.conferenceFactoryUri = nil - accountParams.conferenceFactoryAddress = nil + //accountParams.conferenceFactoryUri = nil + //accountParams.conferenceFactoryAddress = nil accountParams.audioVideoConferenceFactoryAddress = nil - accountParams.limeServerUrl = nil + //accountParams.limeServerUrl = nil // Now that our AccountParams is configured, we can create the Account object let account = try core.createAccount(params: accountParams) diff --git a/Linphone/UI/Call/Fragments/CallsListFragment.swift b/Linphone/UI/Call/Fragments/CallsListFragment.swift index 60efa482a..1d2d76aea 100644 --- a/Linphone/UI/Call/Fragments/CallsListFragment.swift +++ b/Linphone/UI/Call/Fragments/CallsListFragment.swift @@ -230,11 +230,11 @@ struct CallsListFragment: View { if addressFriend != nil && addressFriend!.photo != nil && !addressFriend!.photo!.isEmpty { if contactAvatarModel != nil { - Avatar(contactAvatarModel: contactAvatarModel!, avatarSize: 45) + Avatar(contactAvatarModel: contactAvatarModel!, avatarSize: 50) } else { Image("profil-picture-default") .resizable() - .frame(width: 45, height: 45) + .frame(width: 50, height: 50) .clipShape(Circle()) } } else { @@ -245,7 +245,7 @@ struct CallsListFragment: View { ? callViewModel.calls[index].callLog!.remoteAddress!.displayName!.components(separatedBy: " ")[1] : "")) .resizable() - .frame(width: 45, height: 45) + .frame(width: 50, height: 50) .clipShape(Circle()) } else { @@ -255,7 +255,7 @@ struct CallsListFragment: View { ? callViewModel.calls[index].callLog!.remoteAddress!.username!.components(separatedBy: " ")[1] : "")) .resizable() - .frame(width: 45, height: 45) + .frame(width: 50, height: 50) .clipShape(Circle()) } } diff --git a/Linphone/UI/Main/Contacts/Fragments/ContactsListFragment.swift b/Linphone/UI/Main/Contacts/Fragments/ContactsListFragment.swift index 3f594f977..8d9db12e0 100644 --- a/Linphone/UI/Main/Contacts/Fragments/ContactsListFragment.swift +++ b/Linphone/UI/Main/Contacts/Fragments/ContactsListFragment.swift @@ -65,11 +65,11 @@ struct ContactsListFragment: View { if index < contactsManager.avatarListModel.count && contactsManager.avatarListModel[index].friend!.photo != nil && !contactsManager.avatarListModel[index].friend!.photo!.isEmpty { - Avatar(contactAvatarModel: contactsManager.avatarListModel[index], avatarSize: 45) + Avatar(contactAvatarModel: contactsManager.avatarListModel[index], avatarSize: 50) } else { Image("profil-picture-default") .resizable() - .frame(width: 45, height: 45) + .frame(width: 50, height: 50) .clipShape(Circle()) } Text((contactsManager.lastSearch[index].friend?.name)!) diff --git a/Linphone/UI/Main/Contacts/Fragments/FavoriteContactsListFragment.swift b/Linphone/UI/Main/Contacts/Fragments/FavoriteContactsListFragment.swift index 9e2796bfb..7faec743d 100644 --- a/Linphone/UI/Main/Contacts/Fragments/FavoriteContactsListFragment.swift +++ b/Linphone/UI/Main/Contacts/Fragments/FavoriteContactsListFragment.swift @@ -38,11 +38,11 @@ struct FavoriteContactsListFragment: View { VStack { if contactsManager.lastSearch[index].friend!.photo != nil && !contactsManager.lastSearch[index].friend!.photo!.isEmpty { - Avatar(contactAvatarModel: contactsManager.avatarListModel[index], avatarSize: 45) + Avatar(contactAvatarModel: contactsManager.avatarListModel[index], avatarSize: 50) } else { Image("profil-picture-default") .resizable() - .frame(width: 45, height: 45) + .frame(width: 50, height: 50) .clipShape(Circle()) } Text((contactsManager.lastSearch[index].friend?.name)!) diff --git a/Linphone/UI/Main/ContentView.swift b/Linphone/UI/Main/ContentView.swift index aef8efb45..2a90ad2cb 100644 --- a/Linphone/UI/Main/ContentView.swift +++ b/Linphone/UI/Main/ContentView.swift @@ -40,6 +40,7 @@ struct ContentView: View { @ObservedObject var historyListViewModel: HistoryListViewModel @ObservedObject var startCallViewModel: StartCallViewModel @ObservedObject var callViewModel: CallViewModel + @ObservedObject var conversationsListViewModel: ConversationsListViewModel @State var index = 0 @State private var orientation = UIDevice.current.orientation @@ -128,6 +129,53 @@ struct ContentView: View { }) 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 + }, 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) + } + + Spacer() } } .frame(width: 75) @@ -148,7 +196,7 @@ struct ContentView: View { openMenu() } - Text(index == 0 ? "Contacts" : "Calls") + Text(index == 0 ? "Contacts" : (index == 1 ? "Calls" : "Conversations")) .default_text_style_white_800(styleSize: 20) .padding(.leading, 10) @@ -166,72 +214,75 @@ struct ContentView: View { .frame(width: 25, height: 25, alignment: .leading) .padding(.all, 10) } + .padding(.trailing, index == 2 ? 10 : 0) - 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") + 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) } } } - - 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 } - } 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) @@ -254,8 +305,10 @@ struct ContentView: View { magicSearch.currentFilter = "" MagicSearchSingleton.shared.searchForContacts( sourceFlags: MagicSearch.Source.Friends.rawValue | MagicSearch.Source.LdapServers.rawValue) - } else { + } else if index == 1 { historyListViewModel.resetFilterCallLogs() + } else { + //TODO Conversations List reset } } label: { Image("caret-left") @@ -293,8 +346,10 @@ struct ContentView: View { magicSearch.currentFilter = newValue MagicSearchSingleton.shared.searchForContacts( sourceFlags: MagicSearch.Source.Friends.rawValue | MagicSearch.Source.LdapServers.rawValue) - } else { + } else if index == 1 { historyListViewModel.filterCallLogs(filter: text) + } else { + //TODO Conversations List Filter } } } else { @@ -317,9 +372,15 @@ struct ContentView: View { self.focusedField = true } .onChange(of: text) { newValue in - magicSearch.currentFilter = newValue - MagicSearchSingleton.shared.searchForContacts( - sourceFlags: MagicSearch.Source.Friends.rawValue | MagicSearch.Source.LdapServers.rawValue) + 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 { + //TODO Conversations List Filter + } } } @@ -360,6 +421,8 @@ struct ContentView: View { isShowStartCallFragment: $isShowStartCallFragment, isShowEditContactFragment: $isShowEditContactFragment ) + } else if self.index == 2 { + ConversationsView(conversationsListViewModel: conversationsListViewModel) } } .frame(maxWidth: @@ -408,6 +471,7 @@ struct ContentView: View { } }) .padding(.top) + .frame(width: 100) Spacer() @@ -431,6 +495,56 @@ struct ContentView: View { } }) .padding(.top) + .frame(width: 100) + + 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 + }, 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(width: 100) + } + Spacer() } } @@ -491,6 +605,11 @@ struct ContentView: View { .background(Color.gray100) .ignoresSafeArea(.keyboard) } + } else if self.index == 2 { + ConversationsView(conversationsListViewModel: conversationsListViewModel) + .frame(maxWidth: .infinity) + .background(Color.gray100) + .ignoresSafeArea(.keyboard) } } .onAppear { @@ -745,7 +864,8 @@ struct ContentView: View { historyViewModel: HistoryViewModel(), historyListViewModel: HistoryListViewModel(), startCallViewModel: StartCallViewModel(), - callViewModel: CallViewModel() + callViewModel: CallViewModel(), + conversationsListViewModel: ConversationsListViewModel() ) } // swiftlint:enable type_body_length diff --git a/Linphone/UI/Main/Conversations/ConversationsView.swift b/Linphone/UI/Main/Conversations/ConversationsView.swift new file mode 100644 index 000000000..9a79142f3 --- /dev/null +++ b/Linphone/UI/Main/Conversations/ConversationsView.swift @@ -0,0 +1,51 @@ +/* + * 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 conversationsListViewModel: ConversationsListViewModel + + var body: some View { + NavigationView { + ZStack(alignment: .bottomTrailing) { + ConversationsFragment(conversationsListViewModel: conversationsListViewModel) + + Button { + } label: { + Image("plus-circle") + .renderingMode(.template) + .foregroundStyle(.white) + .padding() + .background(Color.orangeMain500) + .clipShape(Circle()) + .shadow(color: .black.opacity(0.2), radius: 4) + + } + .padding() + } + } + .navigationViewStyle(.stack) + } +} + +#Preview { + ConversationsListFragment(conversationsListViewModel: ConversationsListViewModel()) +} diff --git a/Linphone/UI/Main/Conversations/Fragments/ConversationsFragment.swift b/Linphone/UI/Main/Conversations/Fragments/ConversationsFragment.swift new file mode 100644 index 000000000..90ae2cd1a --- /dev/null +++ b/Linphone/UI/Main/Conversations/Fragments/ConversationsFragment.swift @@ -0,0 +1,68 @@ +/* + * 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 conversationsListViewModel: ConversationsListViewModel + + private var idiom: UIUserInterfaceIdiom { UIDevice.current.userInterfaceIdiom } + + var body: some View { + ZStack { + if #available(iOS 16.0, *), idiom != .pad { + ConversationsListFragment(conversationsListViewModel: conversationsListViewModel) + /* + .sheet(isPresented: $showingSheet) { + HistoryListBottomSheet( + historyViewModel: historyViewModel, + contactViewModel: contactViewModel, + editContactViewModel: editContactViewModel, + historyListViewModel: historyListViewModel, + showingSheet: $showingSheet, + index: $index, + isShowEditContactFragment: $isShowEditContactFragment + ) + .presentationDetents([.fraction(0.2)]) + } + */ + } else { + ConversationsListFragment(conversationsListViewModel: conversationsListViewModel) + /* + .halfSheet(showSheet: $showingSheet) { + HistoryListBottomSheet( + historyViewModel: historyViewModel, + contactViewModel: contactViewModel, + editContactViewModel: editContactViewModel, + historyListViewModel: historyListViewModel, + showingSheet: $showingSheet, + index: $index, + isShowEditContactFragment: $isShowEditContactFragment + ) + } onDismiss: {} + */ + } + } + } +} + +#Preview { + ConversationsFragment(conversationsListViewModel: ConversationsListViewModel()) +} diff --git a/Linphone/UI/Main/Conversations/Fragments/ConversationsListFragment.swift b/Linphone/UI/Main/Conversations/Fragments/ConversationsListFragment.swift new file mode 100644 index 000000000..38b047733 --- /dev/null +++ b/Linphone/UI/Main/Conversations/Fragments/ConversationsListFragment.swift @@ -0,0 +1,254 @@ +/* + * 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 { + + @ObservedObject var contactsManager = ContactsManager.shared + + @ObservedObject var conversationsListViewModel: ConversationsListViewModel + + var body: some View { + VStack { + List { + ForEach(0.. 1 + ? conversationsListViewModel.conversationsList[index].subject!.components(separatedBy: " ")[1] + : "")) + .resizable() + .frame(width: 50, height: 50) + .clipShape(Circle()) + } else if addressFriend != nil && addressFriend!.photo != nil && !addressFriend!.photo!.isEmpty { + if contactAvatarModel != nil { + Avatar(contactAvatarModel: contactAvatarModel!, avatarSize: 50) + } else { + Image("profil-picture-default") + .resizable() + .frame(width: 50, height: 50) + .clipShape(Circle()) + } + } else { + if conversationsListViewModel.conversationsList[index].participants.first != nil + && conversationsListViewModel.conversationsList[index].participants.first!.address != nil { + if conversationsListViewModel.conversationsList[index].participants.first!.address!.displayName != nil { + Image(uiImage: contactsManager.textToImage( + firstName: conversationsListViewModel.conversationsList[index].participants.first!.address!.displayName!, + lastName: conversationsListViewModel.conversationsList[index].participants.first!.address!.displayName!.components(separatedBy: " ").count > 1 + ? conversationsListViewModel.conversationsList[index].participants.first!.address!.displayName!.components(separatedBy: " ")[1] + : "")) + .resizable() + .frame(width: 50, height: 50) + .clipShape(Circle()) + + } else { + Image(uiImage: contactsManager.textToImage( + firstName: conversationsListViewModel.conversationsList[index].participants.first!.address!.username ?? "Username Error", + lastName: conversationsListViewModel.conversationsList[index].participants.first!.address!.username!.components(separatedBy: " ").count > 1 + ? conversationsListViewModel.conversationsList[index].participants.first!.address!.username!.components(separatedBy: " ")[1] + : "")) + .resizable() + .frame(width: 50, height: 50) + .clipShape(Circle()) + } + + } else { + Image("profil-picture-default") + .resizable() + .frame(width: 50, height: 50) + .clipShape(Circle()) + } + } + + VStack(spacing: 0) { + Spacer() + + if LinphoneUtils.isChatRoomAGroup(chatRoom: conversationsListViewModel.conversationsList[index]) { + Text(conversationsListViewModel.conversationsList[index].subject ?? "No Subject") + .foregroundStyle(Color.grayMain2c800) + .if(conversationsListViewModel.conversationsList[index].unreadMessagesCount > 0) { view in + view.default_text_style_700(styleSize: 14) + } + .default_text_style(styleSize: 14) + .frame(maxWidth: .infinity, alignment: .leading) + .lineLimit(1) + } else if addressFriend != nil { + Text(addressFriend!.name!) + .foregroundStyle(Color.grayMain2c800) + .if(conversationsListViewModel.conversationsList[index].unreadMessagesCount > 0) { view in + view.default_text_style_700(styleSize: 14) + } + .default_text_style(styleSize: 14) + .frame(maxWidth: .infinity, alignment: .leading) + .lineLimit(1) + } else { + if conversationsListViewModel.conversationsList[index].participants.first != nil && conversationsListViewModel.conversationsList[index].participants.first!.address != nil { + Text(conversationsListViewModel.conversationsList[index].participants.first!.address!.displayName != nil + ? conversationsListViewModel.conversationsList[index].participants.first!.address!.displayName! + : conversationsListViewModel.conversationsList[index].participants.first!.address!.username!) + .foregroundStyle(Color.grayMain2c800) + .if(conversationsListViewModel.conversationsList[index].unreadMessagesCount > 0) { view in + view.default_text_style_700(styleSize: 14) + } + .default_text_style(styleSize: 14) + .frame(maxWidth: .infinity, alignment: .leading) + .lineLimit(1) + } + } + + let text = conversationsListViewModel.conversationsList[index].lastMessageInHistory?.contents.first(where: {$0.isText == true})?.utf8Text ?? "" + + Text(text) + .foregroundStyle(Color.grayMain2c400) + .if(conversationsListViewModel.conversationsList[index].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 conversationsListViewModel.conversationsList[index].currentParams != nil + && !conversationsListViewModel.conversationsList[index].currentParams!.encryptionEnabled { + Image("warning-circle") + .renderingMode(.template) + .resizable() + .foregroundStyle(Color.redDanger500) + .frame(width: 18, height: 18, alignment: .trailing) + } + + if conversationsListViewModel.conversationsList[index].lastMessageInHistory != nil { + Text(conversationsListViewModel.getCallTime(startDate: conversationsListViewModel.conversationsList[index].lastMessageInHistory!.time)) + .foregroundStyle(Color.grayMain2c400) + .default_text_style(styleSize: 14) + .lineLimit(1) + } else { + Text(conversationsListViewModel.getCallTime(startDate: conversationsListViewModel.conversationsList[index].lastUpdateTime)) + .foregroundStyle(Color.grayMain2c400) + .default_text_style(styleSize: 14) + .lineLimit(1) + } + } + + Spacer() + + HStack { + if conversationsListViewModel.conversationsList[index].lastMessageInHistory != nil + && conversationsListViewModel.conversationsList[index].lastMessageInHistory!.isOutgoing == true { + let imageName = LinphoneUtils.getChatIconState(chatState: conversationsListViewModel.conversationsList[index].lastMessageInHistory!.state) + Image(imageName) + .renderingMode(.template) + .resizable() + .foregroundStyle(Color.orangeMain500) + .frame(width: 18, height: 18, alignment: .trailing) + } + + if conversationsListViewModel.conversationsList[index].unreadMessagesCount > 0 { + HStack { + Text( + conversationsListViewModel.conversationsList[index].unreadMessagesCount < 99 + ? String(conversationsListViewModel.conversationsList[index].unreadMessagesCount) + : "99+" + ) + .foregroundStyle(.white) + .default_text_style(styleSize: 10) + .lineLimit(1) + } + .frame(width: 18, height: 18) + .background(Color.redDanger500) + .cornerRadius(50) + } + + if !(conversationsListViewModel.conversationsList[index].lastMessageInHistory != nil + && conversationsListViewModel.conversationsList[index].lastMessageInHistory!.isOutgoing == true) + && conversationsListViewModel.conversationsList[index].unreadMessagesCount == 0 { + Text("") + .frame(width: 18, height: 18, alignment: .trailing) + } + } + + Spacer() + } + .padding(.trailing, 10) + } + } + .buttonStyle(.borderless) + .listRowInsets(EdgeInsets(top: 6, leading: 20, bottom: 6, trailing: 20)) + .listRowSeparator(.hidden) + .background(.white) + .onTapGesture { + } + .onLongPressGesture(minimumDuration: 0.2) { + } + } + } + .listStyle(.plain) + .overlay( + VStack { + if conversationsListViewModel.conversationsList.isEmpty { + Spacer() + Image("illus-belledonne") + .resizable() + .scaledToFit() + .clipped() + .padding(.all) + Text("No conversation for the moment...") + .default_text_style_800(styleSize: 16) + Spacer() + Spacer() + } + } + .padding(.all) + ) + } + .navigationTitle("") + .navigationBarHidden(true) + } +} + +#Preview { + ConversationsListFragment(conversationsListViewModel: ConversationsListViewModel()) +} diff --git a/Linphone/UI/Main/Conversations/ViewModel/ConversationsListViewModel.swift b/Linphone/UI/Main/Conversations/ViewModel/ConversationsListViewModel.swift new file mode 100644 index 000000000..8b7bac6c6 --- /dev/null +++ b/Linphone/UI/Main/Conversations/ViewModel/ConversationsListViewModel.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 Foundation +import linphonesw + +class ConversationsListViewModel: ObservableObject { + + private var coreContext = CoreContext.shared + + @Published var conversationsList: [ChatRoom] = [] + @Published var unreadMessages: Int = 0 + + init() { + computeChatRoomsList(filter: "") + updateUnreadMessagesCount() + } + + func computeChatRoomsList(filter: String) { + coreContext.doOnCoreQueue { core in + let account = core.defaultAccount + let chatRooms = account?.chatRooms != nil ? account!.chatRooms : core.chatRooms + + chatRooms.forEach { chatRoom in + //let disabledBecauseNotSecured = (account?.isInSecureMode() == true && !chatRoom.hasCapability) ? Capabilities.Encrypted.toInt() : 0 + + if filter.isEmpty { + //val model = ConversationModel(chatRoom, disabledBecauseNotSecured) + self.conversationsList.append(chatRoom) + } + /* + else { + val participants = chatRoom.participants + val found = participants.find { + // Search in address but also in contact name if exists + val model = + coreContext.contactsManager.getContactAvatarModelForAddress(it.address) + model.contactName?.contains( + filter, + ignoreCase = true + ) == true || it.address.asStringUriOnly().contains( + filter, + ignoreCase = true + ) + } + if ( + found != null || + chatRoom.peerAddress.asStringUriOnly().contains(filter, ignoreCase = true) || + chatRoom.subject.orEmpty().contains(filter, ignoreCase = true) + ) { + val model = ConversationModel(chatRoom, disabledBecauseNotSecured) + list.add(model) + count += 1 + } + } + */ + } + } + } + + 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 updateUnreadMessagesCount() { + coreContext.doOnCoreQueue { core in + let account = core.defaultAccount + if account != nil { + let count = account?.unreadChatMessageCount != nil ? account!.unreadChatMessageCount : core.unreadChatMessageCount + self.unreadMessages = count + } else { + self.unreadMessages = 0 + } + } + } +} diff --git a/Linphone/UI/Main/History/Fragments/HistoryListFragment.swift b/Linphone/UI/Main/History/Fragments/HistoryListFragment.swift index 5858db1b7..9d219ff30 100644 --- a/Linphone/UI/Main/History/Fragments/HistoryListFragment.swift +++ b/Linphone/UI/Main/History/Fragments/HistoryListFragment.swift @@ -52,7 +52,7 @@ struct HistoryListFragment: View { if addressFriend != nil && addressFriend!.photo != nil && !addressFriend!.photo!.isEmpty { if contactAvatarModel != nil { - Avatar(contactAvatarModel: contactAvatarModel!, avatarSize: 45) + Avatar(contactAvatarModel: contactAvatarModel!, avatarSize: 50) } else { Image("profil-picture-default") .resizable() diff --git a/Linphone/UI/Main/History/ViewModel/HistoryListViewModel.swift b/Linphone/UI/Main/History/ViewModel/HistoryListViewModel.swift index 9bd9c0a05..f82122892 100644 --- a/Linphone/UI/Main/History/ViewModel/HistoryListViewModel.swift +++ b/Linphone/UI/Main/History/ViewModel/HistoryListViewModel.swift @@ -29,6 +29,7 @@ class HistoryListViewModel: ObservableObject { var callLogsAddressToDelete = "" var callLogSubscription: AnyCancellable? + init() { computeCallLogsList() } diff --git a/Linphone/Utils/Avatar.swift b/Linphone/Utils/Avatar.swift index 8d44fa061..5143790a5 100644 --- a/Linphone/Utils/Avatar.swift +++ b/Linphone/Utils/Avatar.swift @@ -55,8 +55,8 @@ struct Avatar: View { Image(contactAvatarModel.presenceStatus == .Online ? "presence-online" : "presence-busy") .resizable() .frame(width: avatarSize/4, height: avatarSize/4) - .padding(.trailing, avatarSize == 45 ? 1 : 3) - .padding(.bottom, avatarSize == 45 ? 1 : 3) + .padding(.trailing, avatarSize == 50 ? 1 : 3) + .padding(.bottom, avatarSize == 50 ? 1 : 3) } } } diff --git a/Linphone/Utils/LinphoneUtils.swift b/Linphone/Utils/LinphoneUtils.swift new file mode 100644 index 000000000..579b4cdde --- /dev/null +++ b/Linphone/Utils/LinphoneUtils.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 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: ChatMessage.State) -> String { + return switch chatState { + case ChatMessage.State.Displayed, ChatMessage.State.FileTransferDone: + "checks" + case ChatMessage.State.DeliveredToUser: + "check" + case ChatMessage.State.Delivered: + "envelope-simple" + case ChatMessage.State.NotDelivered, ChatMessage.State.FileTransferError: + "warning-circle" + case ChatMessage.State.InProgress, ChatMessage.State.FileTransferInProgress: + "animated-in-progress" + default: + "animated-in-progress" + } + } +} From da9d90e368f6e2c8de140cba7fa56d57fc513f6c Mon Sep 17 00:00:00 2001 From: "benoit.martins" Date: Tue, 13 Feb 2024 17:04:36 +0100 Subject: [PATCH 118/486] Add chat room callbacks --- Linphone/Contacts/ContactsManager.swift | 7 +- Linphone/Localizable.xcstrings | 6 - .../Fragments/ConversationsListFragment.swift | 41 ++--- .../ConversationsListViewModel.swift | 167 +++++++++++++----- 4 files changed, 145 insertions(+), 76 deletions(-) diff --git a/Linphone/Contacts/ContactsManager.swift b/Linphone/Contacts/ContactsManager.swift index 6808a6b49..91af15221 100644 --- a/Linphone/Contacts/ContactsManager.swift +++ b/Linphone/Contacts/ContactsManager.swift @@ -295,10 +295,13 @@ final class ContactsManager: ObservableObject { } func getFriendWithAddress(address: Address) -> Friend? { + let clonedAddress = address.clone() + clonedAddress!.clean() + let sipUri = clonedAddress!.asStringUriOnly() if friendList != nil { - var friend = friendList!.friends.first(where: {$0.addresses.contains(where: {$0.asStringUriOnly() == address.asStringUriOnly()})}) + var friend = friendList!.friends.first(where: {$0.addresses.contains(where: {$0.asStringUriOnly() == sipUri})}) if friend == nil { - friend = linphoneFriendList!.friends.first(where: {$0.addresses.contains(where: {$0.asStringUriOnly() == address.asStringUriOnly()})}) + friend = linphoneFriendList!.friends.first(where: {$0.addresses.contains(where: {$0.asStringUriOnly() == sipUri})}) } return friend } else { diff --git a/Linphone/Localizable.xcstrings b/Linphone/Localizable.xcstrings index 279f2ae7b..750aa39b8 100644 --- a/Linphone/Localizable.xcstrings +++ b/Linphone/Localizable.xcstrings @@ -244,9 +244,6 @@ }, "Contacts" : { - }, - "Content" : { - }, "Continue" : { @@ -573,9 +570,6 @@ }, "This contact will be deleted definitively." : { - }, - "Title" : { - }, "TLS" : { diff --git a/Linphone/UI/Main/Conversations/Fragments/ConversationsListFragment.swift b/Linphone/UI/Main/Conversations/Fragments/ConversationsListFragment.swift index 38b047733..c582fea2a 100644 --- a/Linphone/UI/Main/Conversations/Fragments/ConversationsListFragment.swift +++ b/Linphone/UI/Main/Conversations/Fragments/ConversationsListFragment.swift @@ -44,7 +44,6 @@ struct ConversationsListFragment: View { }) : ContactAvatarModel(friend: nil, withPresence: false) - if LinphoneUtils.isChatRoomAGroup(chatRoom: conversationsListViewModel.conversationsList[index]) { Image(uiImage: contactsManager.textToImage( firstName: conversationsListViewModel.conversationsList[index].subject!, @@ -117,7 +116,8 @@ struct ConversationsListFragment: View { .frame(maxWidth: .infinity, alignment: .leading) .lineLimit(1) } else { - if conversationsListViewModel.conversationsList[index].participants.first != nil && conversationsListViewModel.conversationsList[index].participants.first!.address != nil { + if conversationsListViewModel.conversationsList[index].participants.first != nil + && conversationsListViewModel.conversationsList[index].participants.first!.address != nil { Text(conversationsListViewModel.conversationsList[index].participants.first!.address!.displayName != nil ? conversationsListViewModel.conversationsList[index].participants.first!.address!.displayName! : conversationsListViewModel.conversationsList[index].participants.first!.address!.username!) @@ -131,16 +131,18 @@ struct ConversationsListFragment: View { } } - let text = conversationsListViewModel.conversationsList[index].lastMessageInHistory?.contents.first(where: {$0.isText == true})?.utf8Text ?? "" - - Text(text) - .foregroundStyle(Color.grayMain2c400) - .if(conversationsListViewModel.conversationsList[index].unreadMessagesCount > 0) { view in - view.default_text_style_700(styleSize: 14) - } - .default_text_style(styleSize: 14) - .frame(maxWidth: .infinity, alignment: .leading) - .lineLimit(1) + Text( + conversationsListViewModel.conversationsList[index].lastMessageInHistory != nil + ? conversationsListViewModel.getContentTextMessage(message: conversationsListViewModel.conversationsList[index].lastMessageInHistory!) + : "" + ) + .foregroundStyle(Color.grayMain2c400) + .if(conversationsListViewModel.conversationsList[index].unreadMessagesCount > 0) { view in + view.default_text_style_700(styleSize: 14) + } + .default_text_style(styleSize: 14) + .frame(maxWidth: .infinity, alignment: .leading) + .lineLimit(1) Spacer() } @@ -160,17 +162,10 @@ struct ConversationsListFragment: View { .frame(width: 18, height: 18, alignment: .trailing) } - if conversationsListViewModel.conversationsList[index].lastMessageInHistory != nil { - Text(conversationsListViewModel.getCallTime(startDate: conversationsListViewModel.conversationsList[index].lastMessageInHistory!.time)) - .foregroundStyle(Color.grayMain2c400) - .default_text_style(styleSize: 14) - .lineLimit(1) - } else { - Text(conversationsListViewModel.getCallTime(startDate: conversationsListViewModel.conversationsList[index].lastUpdateTime)) - .foregroundStyle(Color.grayMain2c400) - .default_text_style(styleSize: 14) - .lineLimit(1) - } + Text(conversationsListViewModel.getCallTime(startDate: conversationsListViewModel.conversationsList[index].lastUpdateTime)) + .foregroundStyle(Color.grayMain2c400) + .default_text_style(styleSize: 14) + .lineLimit(1) } Spacer() diff --git a/Linphone/UI/Main/Conversations/ViewModel/ConversationsListViewModel.swift b/Linphone/UI/Main/Conversations/ViewModel/ConversationsListViewModel.swift index 8b7bac6c6..5d4e1633d 100644 --- a/Linphone/UI/Main/Conversations/ViewModel/ConversationsListViewModel.swift +++ b/Linphone/UI/Main/Conversations/ViewModel/ConversationsListViewModel.swift @@ -19,17 +19,21 @@ import Foundation import linphonesw +import Combine class ConversationsListViewModel: ObservableObject { private var coreContext = CoreContext.shared + private var contactsManager = ContactsManager.shared + + private var mCoreSuscriptions = Set() @Published var conversationsList: [ChatRoom] = [] @Published var unreadMessages: Int = 0 init() { computeChatRoomsList(filter: "") - updateUnreadMessagesCount() + addConversationDelegate() } func computeChatRoomsList(filter: String) { @@ -37,43 +41,128 @@ class ConversationsListViewModel: ObservableObject { let account = core.defaultAccount let chatRooms = account?.chatRooms != nil ? account!.chatRooms : core.chatRooms - chatRooms.forEach { chatRoom in - //let disabledBecauseNotSecured = (account?.isInSecureMode() == true && !chatRoom.hasCapability) ? Capabilities.Encrypted.toInt() : 0 + DispatchQueue.main.async { + self.conversationsList = [] + chatRooms.forEach { chatRoom in + //let disabledBecauseNotSecured = (account?.isInSecureMode() == true && !chatRoom.hasCapability) ? Capabilities.Encrypted.toInt() : 0 + + if filter.isEmpty { + //val model = ConversationModel(chatRoom, disabledBecauseNotSecured) + self.conversationsList.append(chatRoom) + } + /* + else { + val participants = chatRoom.participants + val found = participants.find { + // Search in address but also in contact name if exists + val model = + coreContext.contactsManager.getContactAvatarModelForAddress(it.address) + model.contactName?.contains( + filter, + ignoreCase = true + ) == true || it.address.asStringUriOnly().contains( + filter, + ignoreCase = true + ) + } + if ( + found != null || + chatRoom.peerAddress.asStringUriOnly().contains(filter, ignoreCase = true) || + chatRoom.subject.orEmpty().contains(filter, ignoreCase = true) + ) { + val model = ConversationModel(chatRoom, disabledBecauseNotSecured) + list.add(model) + count += 1 + } + } + */ + } - if filter.isEmpty { - //val model = ConversationModel(chatRoom, disabledBecauseNotSecured) - self.conversationsList.append(chatRoom) - } - /* - else { - val participants = chatRoom.participants - val found = participants.find { - // Search in address but also in contact name if exists - val model = - coreContext.contactsManager.getContactAvatarModelForAddress(it.address) - model.contactName?.contains( - filter, - ignoreCase = true - ) == true || it.address.asStringUriOnly().contains( - filter, - ignoreCase = true - ) - } - if ( - found != null || - chatRoom.peerAddress.asStringUriOnly().contains(filter, ignoreCase = true) || - chatRoom.subject.orEmpty().contains(filter, ignoreCase = true) - ) { - val model = ConversationModel(chatRoom, disabledBecauseNotSecured) - list.add(model) - count += 1 - } - } - */ + self.updateUnreadMessagesCount() } } } + func addConversationDelegate() { + coreContext.doOnCoreQueue { core in + self.mCoreSuscriptions.insert(core.publisher?.onChatRoomStateChanged?.postOnMainQueue { (cbValue: (_: Core, chatRoom: ChatRoom, state: ChatRoom.State)) in + //Log.info("[ConversationsListViewModel] Conversation [${LinphoneUtils.getChatRoomId(chatRoom)}] state changed [$state]") + switch cbValue.state { + case ChatRoom.State.Created: + self.computeChatRoomsList(filter: "") + case ChatRoom.State.Deleted: + self.computeChatRoomsList(filter: "") + //ToastViewModel.shared.toastMessage = "toast_conversation_deleted" + //ToastViewModel.shared.displayToast = true + default: + break + } + }) + + self.mCoreSuscriptions.insert(core.publisher?.onMessageSent?.postOnMainQueue { _ in + self.reorderChatRooms() + }) + + self.mCoreSuscriptions.insert(core.publisher?.onMessagesReceived?.postOnMainQueue { _ in + self.reorderChatRooms() + }) + } + } + + func reorderChatRooms() { + Log.info("[ConversationsListViewModel] Re-ordering conversations") + var sortedList: [ChatRoom] = [] + 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 + } + } else { + DispatchQueue.main.async { + self.unreadMessages = 0 + } + } + } + } + + func getContentTextMessage(message: ChatMessage) -> String { + var fromAddressFriend = message.fromAddress != nil + ? contactsManager.getFriendWithAddress(address: message.fromAddress!)?.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 + } + + return (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) @@ -97,16 +186,4 @@ class ConversationsListViewModel: ObservableObject { return formatter.string(from: myNSDate) } } - - func updateUnreadMessagesCount() { - coreContext.doOnCoreQueue { core in - let account = core.defaultAccount - if account != nil { - let count = account?.unreadChatMessageCount != nil ? account!.unreadChatMessageCount : core.unreadChatMessageCount - self.unreadMessages = count - } else { - self.unreadMessages = 0 - } - } - } } From cacc61252d847a6d55287cabc2b5ce35c5eb926d Mon Sep 17 00:00:00 2001 From: "benoit.martins" Date: Wed, 14 Feb 2024 17:59:21 +0100 Subject: [PATCH 119/486] Add ConversationsList bottom sheet --- Linphone.xcodeproj/project.pbxproj | 8 + .../bell-slash.imageset/Contents.json | 21 ++ .../bell-slash.imageset/bell-slash.svg | 1 + Linphone/Localizable.xcstrings | 12 + .../Conversations/ConversationsView.swift | 2 +- .../Fragments/ConversationsFragment.swift | 35 +-- .../ConversationsListBottomSheet.swift | 231 ++++++++++++++++++ .../Fragments/ConversationsListFragment.swift | 7 +- .../ViewModel/ConversationViewModel.swift | 30 +++ 9 files changed, 321 insertions(+), 26 deletions(-) create mode 100644 Linphone/Assets.xcassets/bell-slash.imageset/Contents.json create mode 100644 Linphone/Assets.xcassets/bell-slash.imageset/bell-slash.svg create mode 100644 Linphone/UI/Main/Conversations/Fragments/ConversationsListBottomSheet.swift create mode 100644 Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift diff --git a/Linphone.xcodeproj/project.pbxproj b/Linphone.xcodeproj/project.pbxproj index 7fbd4f875..d7d34d2da 100644 --- a/Linphone.xcodeproj/project.pbxproj +++ b/Linphone.xcodeproj/project.pbxproj @@ -16,6 +16,8 @@ 66C491FF2B24D4AC00CEA16D /* FileUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = 66C491FE2B24D4AC00CEA16D /* FileUtils.swift */; }; 66C492012B24DB6900CEA16D /* Log.swift in Sources */ = {isa = PBXBuildFile; fileRef = 66C492002B24DB6900CEA16D /* Log.swift */; }; D706BA822ADD72D100278F45 /* DeviceRotationViewModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = D706BA812ADD72D100278F45 /* DeviceRotationViewModifier.swift */; }; + D70A26EE2B7CF60B006CC8FC /* ConversationsListBottomSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = D70A26ED2B7CF60B006CC8FC /* ConversationsListBottomSheet.swift */; }; + D70A26F02B7D02E6006CC8FC /* ConversationViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D70A26EF2B7D02E6006CC8FC /* ConversationViewModel.swift */; }; D70C93DE2AC2D0F60063CA3B /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = D70C93DD2AC2D0F60063CA3B /* Localizable.xcstrings */; }; D717071E2AC5922E0037746F /* ColorExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = D717071D2AC5922E0037746F /* ColorExtension.swift */; }; D71707202AC5989C0037746F /* TextExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = D717071F2AC5989C0037746F /* TextExtension.swift */; }; @@ -114,6 +116,8 @@ 66C491FE2B24D4AC00CEA16D /* FileUtils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileUtils.swift; sourceTree = ""; }; 66C492002B24DB6900CEA16D /* Log.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Log.swift; sourceTree = ""; }; D706BA812ADD72D100278F45 /* DeviceRotationViewModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceRotationViewModifier.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 = ""; }; D70C93DD2AC2D0F60063CA3B /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; 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 = ""; }; @@ -544,6 +548,7 @@ isa = PBXGroup; children = ( D7CEE0372B7A214F00FD79B7 /* ConversationsListViewModel.swift */, + D70A26EF2B7D02E6006CC8FC /* ConversationViewModel.swift */, ); path = ViewModel; sourceTree = ""; @@ -553,6 +558,7 @@ children = ( D7CEE03A2B7A234200FD79B7 /* ConversationsFragment.swift */, D7CEE03C2B7A23B200FD79B7 /* ConversationsListFragment.swift */, + D70A26ED2B7CF60B006CC8FC /* ConversationsListBottomSheet.swift */, ); path = Fragments; sourceTree = ""; @@ -741,6 +747,7 @@ D78290BB2ADD40B2004AA85C /* ContactViewModel.swift in Sources */, D7F4D9CB2B5FD27200CDCD76 /* CallsListFragment.swift in Sources */, D72992392ADD7F68003AF125 /* HistoryContactFragment.swift in Sources */, + D70A26F02B7D02E6006CC8FC /* ConversationViewModel.swift in Sources */, D7B5066D2AEFA9B900CEB4E9 /* ContactInnerFragment.swift in Sources */, D7E6D04D2AEBD77600A57AAF /* CustomBottomSheet.swift in Sources */, D7C48DF42AFA66F900D938CB /* EditContactController.swift in Sources */, @@ -780,6 +787,7 @@ D7B99E9B2B29F7C300BE7BF2 /* ActivityIndicator.swift in Sources */, D72343302ACEFEF8009AA24E /* QrCodeScannerFragment.swift in Sources */, D726E43F2B19E56F0083C415 /* StartCallViewModel.swift in Sources */, + D70A26EE2B7CF60B006CC8FC /* ConversationsListBottomSheet.swift in Sources */, D7D1698C2AE66FA500109A5C /* MagicSearchSingleton.swift in Sources */, D72250692ADFBF2D008FB426 /* SideMenu.swift in Sources */, D7CEE0352B7A210300FD79B7 /* ConversationsView.swift in Sources */, 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/Localizable.xcstrings b/Linphone/Localizable.xcstrings index 750aa39b8..561647542 100644 --- a/Linphone/Localizable.xcstrings +++ b/Linphone/Localizable.xcstrings @@ -400,12 +400,18 @@ }, "Logs URL copied into clipboard" : { + }, + "Marquer comme non lu" : { + }, "Message" : { }, "Messages" : { + }, + "Mettre en sourdine" : { + }, "Missed call" : { @@ -489,6 +495,9 @@ }, "QR code validated!" : { + }, + "Quitter la conversation" : { + }, "Record" : { @@ -558,6 +567,9 @@ }, "Suggestions" : { + }, + "Supprimer la conversation" : { + }, "TCP" : { diff --git a/Linphone/UI/Main/Conversations/ConversationsView.swift b/Linphone/UI/Main/Conversations/ConversationsView.swift index 9a79142f3..35ee68224 100644 --- a/Linphone/UI/Main/Conversations/ConversationsView.swift +++ b/Linphone/UI/Main/Conversations/ConversationsView.swift @@ -47,5 +47,5 @@ struct ConversationsView: View { } #Preview { - ConversationsListFragment(conversationsListViewModel: ConversationsListViewModel()) + ConversationsListFragment(conversationsListViewModel: ConversationsListViewModel(), conversationViewModel: ConversationViewModel(), showingSheet: .constant(false)) } diff --git a/Linphone/UI/Main/Conversations/Fragments/ConversationsFragment.swift b/Linphone/UI/Main/Conversations/Fragments/ConversationsFragment.swift index 90ae2cd1a..2f375edd9 100644 --- a/Linphone/UI/Main/Conversations/Fragments/ConversationsFragment.swift +++ b/Linphone/UI/Main/Conversations/Fragments/ConversationsFragment.swift @@ -22,47 +22,34 @@ import SwiftUI struct ConversationsFragment: View { @ObservedObject var conversationsListViewModel: ConversationsListViewModel + @ObservedObject var conversationViewModel: ConversationViewModel private var idiom: UIUserInterfaceIdiom { UIDevice.current.userInterfaceIdiom } + @State var showingSheet: Bool = false + var body: some View { ZStack { if #available(iOS 16.0, *), idiom != .pad { - ConversationsListFragment(conversationsListViewModel: conversationsListViewModel) - /* + ConversationsListFragment(conversationsListViewModel: conversationsListViewModel, conversationViewModel: conversationViewModel,showingSheet: $showingSheet) .sheet(isPresented: $showingSheet) { - HistoryListBottomSheet( - historyViewModel: historyViewModel, - contactViewModel: contactViewModel, - editContactViewModel: editContactViewModel, - historyListViewModel: historyListViewModel, - showingSheet: $showingSheet, - index: $index, - isShowEditContactFragment: $isShowEditContactFragment + ConversationsListBottomSheet( + showingSheet: $showingSheet ) - .presentationDetents([.fraction(0.2)]) + .presentationDetents([.fraction(0.4)]) } - */ } else { - ConversationsListFragment(conversationsListViewModel: conversationsListViewModel) - /* + ConversationsListFragment(conversationsListViewModel: conversationsListViewModel, conversationViewModel: conversationViewModel,showingSheet: $showingSheet) .halfSheet(showSheet: $showingSheet) { - HistoryListBottomSheet( - historyViewModel: historyViewModel, - contactViewModel: contactViewModel, - editContactViewModel: editContactViewModel, - historyListViewModel: historyListViewModel, - showingSheet: $showingSheet, - index: $index, - isShowEditContactFragment: $isShowEditContactFragment + ConversationsListBottomSheet( + showingSheet: $showingSheet ) } onDismiss: {} - */ } } } } #Preview { - ConversationsFragment(conversationsListViewModel: ConversationsListViewModel()) + ConversationsFragment(conversationsListViewModel: ConversationsListViewModel(), conversationViewModel: ConversationViewModel()) } diff --git a/Linphone/UI/Main/Conversations/Fragments/ConversationsListBottomSheet.swift b/Linphone/UI/Main/Conversations/Fragments/ConversationsListBottomSheet.swift new file mode 100644 index 000000000..4bd5954c1 --- /dev/null +++ b/Linphone/UI/Main/Conversations/Fragments/ConversationsListBottomSheet.swift @@ -0,0 +1,231 @@ +/* + * 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 ConversationsListBottomSheet: View { + + @Environment(\.dismiss) var dismiss + + private var idiom: UIUserInterfaceIdiom { UIDevice.current.userInterfaceIdiom } + + @State private var orientation = UIDevice.current.orientation + + @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 #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 #available(iOS 16.0, *) { + if idiom != .pad { + showingSheet.toggle() + } else { + showingSheet.toggle() + dismiss() + } + } else { + showingSheet.toggle() + dismiss() + } + } label: { + HStack { + Image("bell-slash") + .renderingMode(.template) + .resizable() + .foregroundStyle(Color.grayMain2c500) + .frame(width: 25, height: 25, alignment: .leading) + .padding(.all, 10) + Text("Mettre en sourdine") + .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() + } + + } 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 { + 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 #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(showingSheet: .constant(true)) +} diff --git a/Linphone/UI/Main/Conversations/Fragments/ConversationsListFragment.swift b/Linphone/UI/Main/Conversations/Fragments/ConversationsListFragment.swift index c582fea2a..8ffa2a1f0 100644 --- a/Linphone/UI/Main/Conversations/Fragments/ConversationsListFragment.swift +++ b/Linphone/UI/Main/Conversations/Fragments/ConversationsListFragment.swift @@ -25,6 +25,9 @@ struct ConversationsListFragment: View { @ObservedObject var contactsManager = ContactsManager.shared @ObservedObject var conversationsListViewModel: ConversationsListViewModel + @ObservedObject var conversationViewModel: ConversationViewModel + + @Binding var showingSheet: Bool var body: some View { VStack { @@ -217,6 +220,8 @@ struct ConversationsListFragment: View { .onTapGesture { } .onLongPressGesture(minimumDuration: 0.2) { + conversationViewModel.selectedConversation = conversationsListViewModel.conversationsList[index] + showingSheet.toggle() } } } @@ -245,5 +250,5 @@ struct ConversationsListFragment: View { } #Preview { - ConversationsListFragment(conversationsListViewModel: ConversationsListViewModel()) + ConversationsListFragment(conversationsListViewModel: ConversationsListViewModel(), conversationViewModel: ConversationViewModel(), showingSheet: .constant(false)) } diff --git a/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift b/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift new file mode 100644 index 000000000..98707fa02 --- /dev/null +++ b/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.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 Foundation +import linphonesw + +class ConversationViewModel: ObservableObject { + + @Published var displayedConversation: ChatRoom? + + var selectedConversation: ChatRoom? + + init() {} +} From 61c2c048bbf89979629f3035e633517214611fa0 Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Thu, 15 Feb 2024 15:19:46 +0100 Subject: [PATCH 120/486] Fix conversations list view when receiving a message or a new chat room --- .../bell.imageset/Contents.json | 21 +++++ .../Assets.xcassets/bell.imageset/bell.svg | 1 + Linphone/LinphoneApp.swift | 3 +- Linphone/Localizable.xcstrings | 9 ++ .../Conversations/ConversationsView.swift | 2 +- .../Fragments/ConversationsFragment.swift | 9 +- .../ConversationsListBottomSheet.swift | 92 +++++++++++++------ .../Fragments/ConversationsListFragment.swift | 28 ++++-- .../ViewModel/ConversationViewModel.swift | 4 - .../ConversationsListViewModel.swift | 27 +++++- 10 files changed, 143 insertions(+), 53 deletions(-) create mode 100644 Linphone/Assets.xcassets/bell.imageset/Contents.json create mode 100644 Linphone/Assets.xcassets/bell.imageset/bell.svg 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/LinphoneApp.swift b/Linphone/LinphoneApp.swift index f3a1fc3fa..92b748694 100644 --- a/Linphone/LinphoneApp.swift +++ b/Linphone/LinphoneApp.swift @@ -66,7 +66,8 @@ struct LinphoneApp: App { && historyViewModel != nil && historyListViewModel != nil && startCallViewModel != nil - && callViewModel != nil { + && callViewModel != nil + && conversationsListViewModel != nil{ ContentView( contactViewModel: contactViewModel!, editContactViewModel: editContactViewModel!, diff --git a/Linphone/Localizable.xcstrings b/Linphone/Localizable.xcstrings index 561647542..7c0dabc93 100644 --- a/Linphone/Localizable.xcstrings +++ b/Linphone/Localizable.xcstrings @@ -244,6 +244,9 @@ }, "Contacts" : { + }, + "Content" : { + }, "Continue" : { @@ -498,6 +501,9 @@ }, "Quitter la conversation" : { + }, + "Réactiver les notifications" : { + }, "Record" : { @@ -582,6 +588,9 @@ }, "This contact will be deleted definitively." : { + }, + "Title" : { + }, "TLS" : { diff --git a/Linphone/UI/Main/Conversations/ConversationsView.swift b/Linphone/UI/Main/Conversations/ConversationsView.swift index 35ee68224..2119f0ab6 100644 --- a/Linphone/UI/Main/Conversations/ConversationsView.swift +++ b/Linphone/UI/Main/Conversations/ConversationsView.swift @@ -47,5 +47,5 @@ struct ConversationsView: View { } #Preview { - ConversationsListFragment(conversationsListViewModel: ConversationsListViewModel(), conversationViewModel: ConversationViewModel(), showingSheet: .constant(false)) + ConversationsListFragment(conversationsListViewModel: ConversationsListViewModel(), showingSheet: .constant(false)) } diff --git a/Linphone/UI/Main/Conversations/Fragments/ConversationsFragment.swift b/Linphone/UI/Main/Conversations/Fragments/ConversationsFragment.swift index 2f375edd9..c4ab574ef 100644 --- a/Linphone/UI/Main/Conversations/Fragments/ConversationsFragment.swift +++ b/Linphone/UI/Main/Conversations/Fragments/ConversationsFragment.swift @@ -22,7 +22,6 @@ import SwiftUI struct ConversationsFragment: View { @ObservedObject var conversationsListViewModel: ConversationsListViewModel - @ObservedObject var conversationViewModel: ConversationViewModel private var idiom: UIUserInterfaceIdiom { UIDevice.current.userInterfaceIdiom } @@ -31,17 +30,19 @@ struct ConversationsFragment: View { var body: some View { ZStack { if #available(iOS 16.0, *), idiom != .pad { - ConversationsListFragment(conversationsListViewModel: conversationsListViewModel, conversationViewModel: conversationViewModel,showingSheet: $showingSheet) + ConversationsListFragment(conversationsListViewModel: conversationsListViewModel, showingSheet: $showingSheet) .sheet(isPresented: $showingSheet) { ConversationsListBottomSheet( + conversationsListViewModel: conversationsListViewModel, showingSheet: $showingSheet ) .presentationDetents([.fraction(0.4)]) } } else { - ConversationsListFragment(conversationsListViewModel: conversationsListViewModel, conversationViewModel: conversationViewModel,showingSheet: $showingSheet) + ConversationsListFragment(conversationsListViewModel: conversationsListViewModel, showingSheet: $showingSheet) .halfSheet(showSheet: $showingSheet) { ConversationsListBottomSheet( + conversationsListViewModel: conversationsListViewModel, showingSheet: $showingSheet ) } onDismiss: {} @@ -51,5 +52,5 @@ struct ConversationsFragment: View { } #Preview { - ConversationsFragment(conversationsListViewModel: ConversationsListViewModel(), conversationViewModel: ConversationViewModel()) + ConversationsFragment(conversationsListViewModel: ConversationsListViewModel()) } diff --git a/Linphone/UI/Main/Conversations/Fragments/ConversationsListBottomSheet.swift b/Linphone/UI/Main/Conversations/Fragments/ConversationsListBottomSheet.swift index 4bd5954c1..c51310d32 100644 --- a/Linphone/UI/Main/Conversations/Fragments/ConversationsListBottomSheet.swift +++ b/Linphone/UI/Main/Conversations/Fragments/ConversationsListBottomSheet.swift @@ -18,6 +18,7 @@ */ import SwiftUI +import linphonesw struct ConversationsListBottomSheet: View { @@ -27,6 +28,8 @@ struct ConversationsListBottomSheet: View { @State private var orientation = UIDevice.current.orientation + @ObservedObject var conversationsListViewModel: ConversationsListViewModel + @Binding var showingSheet: Bool var body: some View { @@ -52,6 +55,12 @@ struct ConversationsListBottomSheet: View { Spacer() Button { + if conversationsListViewModel.selectedConversation != nil { + conversationsListViewModel.objectWillChange.send() + conversationsListViewModel.selectedConversation!.markAsRead() + conversationsListViewModel.updateUnreadMessagesCount() + } + if #available(iOS 16.0, *) { if idiom != .pad { showingSheet.toggle() @@ -86,6 +95,11 @@ struct ConversationsListBottomSheet: View { .frame(maxWidth: .infinity) Button { + if conversationsListViewModel.selectedConversation != nil { + conversationsListViewModel.objectWillChange.send() + conversationsListViewModel.selectedConversation!.muted.toggle() + } + if #available(iOS 16.0, *) { if idiom != .pad { showingSheet.toggle() @@ -99,13 +113,13 @@ struct ConversationsListBottomSheet: View { } } label: { HStack { - Image("bell-slash") + Image(conversationsListViewModel.selectedConversation!.muted ? "bell" : "bell-slash") .renderingMode(.template) .resizable() .foregroundStyle(Color.grayMain2c500) .frame(width: 25, height: 25, alignment: .leading) .padding(.all, 10) - Text("Mettre en sourdine") + Text(conversationsListViewModel.selectedConversation!.muted ? "Réactiver les notifications" : "Mettre en sourdine") .default_text_style(styleSize: 16) Spacer() } @@ -119,42 +133,58 @@ struct ConversationsListBottomSheet: View { } .frame(maxWidth: .infinity) - Button { - if #available(iOS 16.0, *) { - if idiom != .pad { - showingSheet.toggle() + if conversationsListViewModel.selectedConversation != nil + && conversationsListViewModel.selectedConversation!.hasCapability(mask: ChatRoom.Capabilities.OneToOne.rawValue) { + Button { + if conversationsListViewModel.selectedConversation!.participants.first != nil { + TelecomManager.shared.doCallWithCore( + addr: conversationsListViewModel.selectedConversation!.participants.first!.address!, isVideo: false + ) + } + + if #available(iOS 16.0, *) { + if idiom != .pad { + showingSheet.toggle() + } else { + showingSheet.toggle() + dismiss() + } } 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) - } 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() + VStack { + Divider() } - .frame(maxHeight: .infinity) + .frame(maxWidth: .infinity) } - .padding(.horizontal, 30) - .background(Color.gray100) - - VStack { - Divider() - } - .frame(maxWidth: .infinity) Button { + if conversationsListViewModel.selectedConversation != nil { + CoreContext.shared.doOnCoreQueue { core in + core.deleteChatRoom(chatRoom: conversationsListViewModel.selectedConversation!) + //conversationsListViewModel.computeChatRoomsList(filter: "") + } + } + if #available(iOS 16.0, *) { if idiom != .pad { showingSheet.toggle() @@ -190,6 +220,10 @@ struct ConversationsListBottomSheet: View { .frame(maxWidth: .infinity) Button { + if conversationsListViewModel.selectedConversation != nil { + conversationsListViewModel.selectedConversation!.leave() + } + if #available(iOS 16.0, *) { if idiom != .pad { showingSheet.toggle() @@ -227,5 +261,5 @@ struct ConversationsListBottomSheet: View { } #Preview { - ConversationsListBottomSheet(showingSheet: .constant(true)) + ConversationsListBottomSheet(conversationsListViewModel: ConversationsListViewModel(), showingSheet: .constant(true)) } diff --git a/Linphone/UI/Main/Conversations/Fragments/ConversationsListFragment.swift b/Linphone/UI/Main/Conversations/Fragments/ConversationsListFragment.swift index 8ffa2a1f0..5958ac9a0 100644 --- a/Linphone/UI/Main/Conversations/Fragments/ConversationsListFragment.swift +++ b/Linphone/UI/Main/Conversations/Fragments/ConversationsListFragment.swift @@ -25,7 +25,6 @@ struct ConversationsListFragment: View { @ObservedObject var contactsManager = ContactsManager.shared @ObservedObject var conversationsListViewModel: ConversationsListViewModel - @ObservedObject var conversationViewModel: ConversationViewModel @Binding var showingSheet: Bool @@ -174,6 +173,22 @@ struct ConversationsListFragment: View { Spacer() HStack { + if conversationsListViewModel.conversationsList[index].muted == false + && !(conversationsListViewModel.conversationsList[index].lastMessageInHistory != nil + && conversationsListViewModel.conversationsList[index].lastMessageInHistory!.isOutgoing == true) + && conversationsListViewModel.conversationsList[index].unreadMessagesCount == 0 { + Text("") + .frame(width: 18, height: 18, alignment: .trailing) + } + + if conversationsListViewModel.conversationsList[index].muted { + Image("bell-slash") + .renderingMode(.template) + .resizable() + .foregroundStyle(Color.orangeMain500) + .frame(width: 18, height: 18, alignment: .trailing) + } + if conversationsListViewModel.conversationsList[index].lastMessageInHistory != nil && conversationsListViewModel.conversationsList[index].lastMessageInHistory!.isOutgoing == true { let imageName = LinphoneUtils.getChatIconState(chatState: conversationsListViewModel.conversationsList[index].lastMessageInHistory!.state) @@ -199,13 +214,6 @@ struct ConversationsListFragment: View { .background(Color.redDanger500) .cornerRadius(50) } - - if !(conversationsListViewModel.conversationsList[index].lastMessageInHistory != nil - && conversationsListViewModel.conversationsList[index].lastMessageInHistory!.isOutgoing == true) - && conversationsListViewModel.conversationsList[index].unreadMessagesCount == 0 { - Text("") - .frame(width: 18, height: 18, alignment: .trailing) - } } Spacer() @@ -220,7 +228,7 @@ struct ConversationsListFragment: View { .onTapGesture { } .onLongPressGesture(minimumDuration: 0.2) { - conversationViewModel.selectedConversation = conversationsListViewModel.conversationsList[index] + conversationsListViewModel.selectedConversation = conversationsListViewModel.conversationsList[index] showingSheet.toggle() } } @@ -250,5 +258,5 @@ struct ConversationsListFragment: View { } #Preview { - ConversationsListFragment(conversationsListViewModel: ConversationsListViewModel(), conversationViewModel: ConversationViewModel(), showingSheet: .constant(false)) + ConversationsListFragment(conversationsListViewModel: ConversationsListViewModel(), showingSheet: .constant(false)) } diff --git a/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift b/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift index 98707fa02..d71a061b1 100644 --- a/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift +++ b/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift @@ -22,9 +22,5 @@ import linphonesw class ConversationViewModel: ObservableObject { - @Published var displayedConversation: ChatRoom? - - var selectedConversation: ChatRoom? - init() {} } diff --git a/Linphone/UI/Main/Conversations/ViewModel/ConversationsListViewModel.swift b/Linphone/UI/Main/Conversations/ViewModel/ConversationsListViewModel.swift index 5d4e1633d..5db41c604 100644 --- a/Linphone/UI/Main/Conversations/ViewModel/ConversationsListViewModel.swift +++ b/Linphone/UI/Main/Conversations/ViewModel/ConversationsListViewModel.swift @@ -31,6 +31,10 @@ class ConversationsListViewModel: ObservableObject { @Published var conversationsList: [ChatRoom] = [] @Published var unreadMessages: Int = 0 + @Published var displayedConversation: ChatRoom? + + var selectedConversation: ChatRoom? + init() { computeChatRoomsList(filter: "") addConversationDelegate() @@ -45,6 +49,8 @@ class ConversationsListViewModel: ObservableObject { self.conversationsList = [] chatRooms.forEach { chatRoom in //let disabledBecauseNotSecured = (account?.isInSecureMode() == true && !chatRoom.hasCapability) ? Capabilities.Encrypted.toInt() : 0 + if chatRoom.hasCapability(mask: ChatRoom.Capabilities.OneToOne.rawValue) { + } if filter.isEmpty { //val model = ConversationModel(chatRoom, disabledBecauseNotSecured) @@ -85,11 +91,11 @@ class ConversationsListViewModel: ObservableObject { func addConversationDelegate() { coreContext.doOnCoreQueue { core in - self.mCoreSuscriptions.insert(core.publisher?.onChatRoomStateChanged?.postOnMainQueue { (cbValue: (_: Core, chatRoom: ChatRoom, state: ChatRoom.State)) in + self.mCoreSuscriptions.insert(core.publisher?.onChatRoomStateChanged?.postOnMainQueue { (cbValue: (core: Core, chatRoom: ChatRoom, state: ChatRoom.State)) in //Log.info("[ConversationsListViewModel] Conversation [${LinphoneUtils.getChatRoomId(chatRoom)}] state changed [$state]") switch cbValue.state { case ChatRoom.State.Created: - self.computeChatRoomsList(filter: "") + self.addChatRoom(cbChatRoom: cbValue.chatRoom) case ChatRoom.State.Deleted: self.computeChatRoomsList(filter: "") //ToastViewModel.shared.toastMessage = "toast_conversation_deleted" @@ -100,15 +106,28 @@ class ConversationsListViewModel: ObservableObject { }) self.mCoreSuscriptions.insert(core.publisher?.onMessageSent?.postOnMainQueue { _ in - self.reorderChatRooms() + self.computeChatRoomsList(filter: "") }) self.mCoreSuscriptions.insert(core.publisher?.onMessagesReceived?.postOnMainQueue { _ in - self.reorderChatRooms() + self.computeChatRoomsList(filter: "") }) } } + func addChatRoom(cbChatRoom: ChatRoom) { + Log.info("[ConversationsListViewModel] Re-ordering conversations") + var sortedList: [ChatRoom] = [] + sortedList.append(cbChatRoom) + sortedList.append(contentsOf: self.conversationsList) + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + self.conversationsList = sortedList.sorted { $0.lastUpdateTime > $1.lastUpdateTime } + } + + updateUnreadMessagesCount() + } + func reorderChatRooms() { Log.info("[ConversationsListViewModel] Re-ordering conversations") var sortedList: [ChatRoom] = [] From d91996c351f409125d1a78b5ac411a1967393104 Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Thu, 15 Feb 2024 16:49:06 +0100 Subject: [PATCH 121/486] Missed calls counter added to bottom navigation bar --- Linphone/UI/Main/ContentView.swift | 119 +++++++++++++----- .../ViewModel/HistoryListViewModel.swift | 38 ++++++ 2 files changed, 123 insertions(+), 34 deletions(-) diff --git a/Linphone/UI/Main/ContentView.swift b/Linphone/UI/Main/ContentView.swift index 2a90ad2cb..938aa7b42 100644 --- a/Linphone/UI/Main/ContentView.swift +++ b/Linphone/UI/Main/ContentView.swift @@ -108,25 +108,51 @@ struct ContentView: View { Spacer() - Button(action: { - self.index = 1 - contactViewModel.indexDisplayedFriend = nil - }, 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") + 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 + 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) + } Spacer() @@ -475,27 +501,52 @@ struct ContentView: View { Spacer() - Button(action: { - self.index = 1 - contactViewModel.indexDisplayedFriend = nil - }, 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") + 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) } - }) - .padding(.top) - .frame(width: 100) + + Button(action: { + self.index = 1 + contactViewModel.indexDisplayedFriend = 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(width: 100) + } Spacer() diff --git a/Linphone/UI/Main/History/ViewModel/HistoryListViewModel.swift b/Linphone/UI/Main/History/ViewModel/HistoryListViewModel.swift index f82122892..cf9132158 100644 --- a/Linphone/UI/Main/History/ViewModel/HistoryListViewModel.swift +++ b/Linphone/UI/Main/History/ViewModel/HistoryListViewModel.swift @@ -30,8 +30,11 @@ class HistoryListViewModel: ObservableObject { var callLogsAddressToDelete = "" var callLogSubscription: AnyCancellable? + @Published var missedCallsCount: Int = 0 + init() { computeCallLogsList() + updateMissedCallsCount() } func computeCallLogsList() { @@ -62,6 +65,41 @@ class HistoryListViewModel: ObservableObject { self.callLogsTmp.append(log) } } + + self.updateMissedCallsCount() + } + } + } + + 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 + } } } } From 56e93a77a704b194be42d83e26e348cdc1fb2a3e Mon Sep 17 00:00:00 2001 From: "benoit.martins" Date: Thu, 15 Feb 2024 16:49:06 +0100 Subject: [PATCH 122/486] Missed calls counter added to bottom navigation bar --- Linphone.xcodeproj/project.pbxproj | 4 + Linphone/Localizable.xcstrings | 6 - Linphone/UI/Main/ContentView.swift | 119 +++++++++++++----- .../Fragments/ConversationFragment.swift | 18 +++ .../ViewModel/HistoryListViewModel.swift | 38 ++++++ 5 files changed, 145 insertions(+), 40 deletions(-) create mode 100644 Linphone/UI/Main/Conversations/Fragments/ConversationFragment.swift diff --git a/Linphone.xcodeproj/project.pbxproj b/Linphone.xcodeproj/project.pbxproj index d7d34d2da..46ca823f7 100644 --- a/Linphone.xcodeproj/project.pbxproj +++ b/Linphone.xcodeproj/project.pbxproj @@ -18,6 +18,7 @@ D706BA822ADD72D100278F45 /* DeviceRotationViewModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = D706BA812ADD72D100278F45 /* DeviceRotationViewModifier.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 */; }; D70C93DE2AC2D0F60063CA3B /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = D70C93DD2AC2D0F60063CA3B /* Localizable.xcstrings */; }; D717071E2AC5922E0037746F /* ColorExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = D717071D2AC5922E0037746F /* ColorExtension.swift */; }; D71707202AC5989C0037746F /* TextExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = D717071F2AC5989C0037746F /* TextExtension.swift */; }; @@ -118,6 +119,7 @@ D706BA812ADD72D100278F45 /* DeviceRotationViewModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceRotationViewModifier.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 = ""; }; D70C93DD2AC2D0F60063CA3B /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; 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 = ""; }; @@ -559,6 +561,7 @@ D7CEE03A2B7A234200FD79B7 /* ConversationsFragment.swift */, D7CEE03C2B7A23B200FD79B7 /* ConversationsListFragment.swift */, D70A26ED2B7CF60B006CC8FC /* ConversationsListBottomSheet.swift */, + D70A26F12B7F5D95006CC8FC /* ConversationFragment.swift */, ); path = Fragments; sourceTree = ""; @@ -735,6 +738,7 @@ D706BA822ADD72D100278F45 /* DeviceRotationViewModifier.swift in Sources */, D732A9132B04C7A300DB42BA /* HistoryListFragment.swift in Sources */, D719ABC92ABC6FD700B41C10 /* CoreContext.swift in Sources */, + D70A26F22B7F5D95006CC8FC /* ConversationFragment.swift in Sources */, 66C491FD2B24D36500CEA16D /* AudioRouteUtils.swift in Sources */, D7EAACCF2AD6ED8000AA6A8A /* PermissionsFragment.swift in Sources */, D777DBB32AE12C5900565A99 /* ContactsManager.swift in Sources */, diff --git a/Linphone/Localizable.xcstrings b/Linphone/Localizable.xcstrings index 7c0dabc93..3b9a04413 100644 --- a/Linphone/Localizable.xcstrings +++ b/Linphone/Localizable.xcstrings @@ -244,9 +244,6 @@ }, "Contacts" : { - }, - "Content" : { - }, "Continue" : { @@ -588,9 +585,6 @@ }, "This contact will be deleted definitively." : { - }, - "Title" : { - }, "TLS" : { diff --git a/Linphone/UI/Main/ContentView.swift b/Linphone/UI/Main/ContentView.swift index 2a90ad2cb..938aa7b42 100644 --- a/Linphone/UI/Main/ContentView.swift +++ b/Linphone/UI/Main/ContentView.swift @@ -108,25 +108,51 @@ struct ContentView: View { Spacer() - Button(action: { - self.index = 1 - contactViewModel.indexDisplayedFriend = nil - }, 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") + 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 + 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) + } Spacer() @@ -475,27 +501,52 @@ struct ContentView: View { Spacer() - Button(action: { - self.index = 1 - contactViewModel.indexDisplayedFriend = nil - }, 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") + 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) } - }) - .padding(.top) - .frame(width: 100) + + Button(action: { + self.index = 1 + contactViewModel.indexDisplayedFriend = 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(width: 100) + } Spacer() diff --git a/Linphone/UI/Main/Conversations/Fragments/ConversationFragment.swift b/Linphone/UI/Main/Conversations/Fragments/ConversationFragment.swift new file mode 100644 index 000000000..86db6c6e1 --- /dev/null +++ b/Linphone/UI/Main/Conversations/Fragments/ConversationFragment.swift @@ -0,0 +1,18 @@ +// +// ConversationFragment.swift +// Linphone +// +// Created by Martins Benoît on 16/02/2024. +// + +import SwiftUI + +struct ConversationFragment: View { + var body: some View { + Text(/*@START_MENU_TOKEN@*/"Hello, World!"/*@END_MENU_TOKEN@*/) + } +} + +#Preview { + ConversationFragment() +} diff --git a/Linphone/UI/Main/History/ViewModel/HistoryListViewModel.swift b/Linphone/UI/Main/History/ViewModel/HistoryListViewModel.swift index f82122892..cf9132158 100644 --- a/Linphone/UI/Main/History/ViewModel/HistoryListViewModel.swift +++ b/Linphone/UI/Main/History/ViewModel/HistoryListViewModel.swift @@ -30,8 +30,11 @@ class HistoryListViewModel: ObservableObject { var callLogsAddressToDelete = "" var callLogSubscription: AnyCancellable? + @Published var missedCallsCount: Int = 0 + init() { computeCallLogsList() + updateMissedCallsCount() } func computeCallLogsList() { @@ -62,6 +65,41 @@ class HistoryListViewModel: ObservableObject { self.callLogsTmp.append(log) } } + + self.updateMissedCallsCount() + } + } + } + + 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 + } } } } From 6c77fe7850d202824b07695e0c678b99e0efadab Mon Sep 17 00:00:00 2001 From: QuentinArguillere Date: Mon, 19 Feb 2024 17:08:10 +0100 Subject: [PATCH 123/486] Add msgNotificationService app extension --- Linphone.xcodeproj/project.pbxproj | 185 ++++++++++++++++++ Linphone/Linphone.entitlements | 4 + Linphone/Localizable.xcstrings | 6 - Podfile | 9 + .../GoogleService-Info.plist | 36 ++++ msgNotificationService/Info.plist | 31 +++ .../NotificationService.swift | 83 ++++++++ .../msgNotificationService.entitlements | 14 ++ 8 files changed, 362 insertions(+), 6 deletions(-) create mode 100644 msgNotificationService/GoogleService-Info.plist create mode 100644 msgNotificationService/Info.plist create mode 100644 msgNotificationService/NotificationService.swift create mode 100644 msgNotificationService/msgNotificationService.entitlements diff --git a/Linphone.xcodeproj/project.pbxproj b/Linphone.xcodeproj/project.pbxproj index 87d0917df..67231e24a 100644 --- a/Linphone.xcodeproj/project.pbxproj +++ b/Linphone.xcodeproj/project.pbxproj @@ -7,9 +7,11 @@ objects = { /* Begin PBXBuildFile section */ + 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 */; }; 662B69D92B25DE18007118BF /* TelecomManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 662B69D82B25DE18007118BF /* TelecomManager.swift */; }; 662B69DB2B25DE25007118BF /* ProviderDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 662B69DA2B25DE25007118BF /* ProviderDelegate.swift */; }; + 6691CA7E2B839C2D00B2A7B8 /* NotificationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6691CA7D2B839C2D00B2A7B8 /* NotificationService.swift */; }; 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 */; }; @@ -99,10 +101,37 @@ 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 = ""; }; 662B69D82B25DE18007118BF /* TelecomManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TelecomManager.swift; sourceTree = ""; }; 662B69DA2B25DE25007118BF /* ProviderDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProviderDelegate.swift; 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 = ""; }; @@ -196,6 +225,13 @@ /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ + 660AAF782B839271004C0FA6 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; D719ABB02ABC67BF00B41C10 /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; @@ -206,6 +242,15 @@ /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + 660AAF7C2B839272004C0FA6 /* msgNotificationService */ = { + isa = PBXGroup; + children = ( + 6691CA7D2B839C2D00B2A7B8 /* NotificationService.swift */, + 660AAF842B8392E0004C0FA6 /* msgNotificationService.entitlements */, + ); + path = msgNotificationService; + sourceTree = ""; + }; 662B69D72B25DDF6007118BF /* TelecomManager */ = { isa = PBXGroup; children = ( @@ -257,6 +302,7 @@ children = ( 660D8A702B517D260092694D /* GoogleService-Info.plist */, D719ABB52ABC67BF00B41C10 /* Linphone */, + 660AAF7C2B839272004C0FA6 /* msgNotificationService */, D719ABB42ABC67BF00B41C10 /* Products */, A31AF2AB8C6A3D7B7EA3B424 /* Pods */, ); @@ -266,6 +312,7 @@ isa = PBXGroup; children = ( D719ABB32ABC67BF00B41C10 /* Linphone.app */, + 660AAF7B2B839271004C0FA6 /* msgNotificationService.appex */, ); name = Products; sourceTree = ""; @@ -548,12 +595,30 @@ /* 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 */, + ); + 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 */, @@ -561,6 +626,7 @@ buildRules = ( ); dependencies = ( + 660AAF7E2B839272004C0FA6 /* PBXTargetDependency */, ); name = Linphone; productName = Linphone; @@ -577,6 +643,10 @@ LastSwiftUpdateCheck = 1430; LastUpgradeCheck = 1430; TargetAttributes = { + 660AAF7A2B839271004C0FA6 = { + CreatedOnToolsVersion = 15.0.1; + LastSwiftMigration = 1500; + }; D719ABB22ABC67BF00B41C10 = { CreatedOnToolsVersion = 14.3.1; }; @@ -596,11 +666,19 @@ projectRoot = ""; targets = ( D719ABB22ABC67BF00B41C10 /* Linphone */, + 660AAF7A2B839271004C0FA6 /* msgNotificationService */, ); }; /* End PBXProject section */ /* Begin PBXResourcesBuildPhase section */ + 660AAF792B839271004C0FA6 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; D719ABB12ABC67BF00B41C10 /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; @@ -670,6 +748,14 @@ /* End PBXShellScriptBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ + 660AAF772B839271004C0FA6 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 6691CA7E2B839C2D00B2A7B8 /* NotificationService.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; D719ABAF2ABC67BF00B41C10 /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; @@ -755,7 +841,95 @@ }; /* 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 = 1; + DEVELOPMENT_TEAM = Z2V957B3D6; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_PREPROCESSOR_DEFINITIONS = ( + "$(inherited)", + "DEBUG=1", + ); + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = msgNotificationService/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = msgNotificationService; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MARKETING_VERSION = 1.0; + OTHER_SWIFT_FLAGS = "$(inherited)"; + 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 = 1; + DEVELOPMENT_TEAM = Z2V957B3D6; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_PREPROCESSOR_DEFINITIONS = "$(inherited)"; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = msgNotificationService/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = msgNotificationService; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MARKETING_VERSION = 1.0; + OTHER_SWIFT_FLAGS = "$(inherited)"; + 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 = { @@ -874,6 +1048,7 @@ 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; @@ -929,6 +1104,7 @@ 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; @@ -980,6 +1156,15 @@ /* 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 = ( diff --git a/Linphone/Linphone.entitlements b/Linphone/Linphone.entitlements index f88b0a974..6a0769bbd 100644 --- a/Linphone/Linphone.entitlements +++ b/Linphone/Linphone.entitlements @@ -17,5 +17,9 @@ com.apple.security.files.user-selected.read-only + keychain-access-groups + + $(AppIdentifierPrefix)org.linphone.phone + diff --git a/Linphone/Localizable.xcstrings b/Linphone/Localizable.xcstrings index 589a8fa68..b5592f0d7 100644 --- a/Linphone/Localizable.xcstrings +++ b/Linphone/Localizable.xcstrings @@ -244,9 +244,6 @@ }, "Contacts" : { - }, - "Content" : { - }, "Continue" : { @@ -567,9 +564,6 @@ }, "This contact will be deleted definitively." : { - }, - "Title" : { - }, "TLS" : { diff --git a/Podfile b/Podfile index 696a25e90..cc53ba162 100644 --- a/Podfile +++ b/Podfile @@ -30,6 +30,15 @@ target 'Linphone' do 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| diff --git a/msgNotificationService/GoogleService-Info.plist b/msgNotificationService/GoogleService-Info.plist new file mode 100644 index 000000000..98867592f --- /dev/null +++ b/msgNotificationService/GoogleService-Info.plist @@ -0,0 +1,36 @@ + + + + + CLIENT_ID + + REVERSED_CLIENT_ID + + API_KEY + + GCM_SENDER_ID + + 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 + + DATABASE_URL + + + 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..de2092700 --- /dev/null +++ b/msgNotificationService/NotificationService.swift @@ -0,0 +1,83 @@ +/* +* 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 APP_GROUP_ID = "group.org.linphone.phone.msgNotification" +var LINPHONE_DUMMY_SUBJECT = "dummy subject" + +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? +// static var logDelegate: LinphoneLoggingServiceManager! +// static var log: LoggingService! + + override init() { + super.init() +#if USE_CRASHLYTICS + FirebaseApp.configure() +#endif + } + + override func didReceive(_ request: UNNotificationRequest, withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) { + + } + + 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. + //NotificationService.log.warning(message: "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")) // TODO : temporary fix, to be removed after flexisip release + } else { + bestAttemptContent.title = NSLocalizedString("Message received", comment: "") + bestAttemptContent.body = NSLocalizedString("IM_MSG", comment: "") + } + contentHandler(bestAttemptContent) + } + } + +} + +// swiftlint:enable identifier_name diff --git a/msgNotificationService/msgNotificationService.entitlements b/msgNotificationService/msgNotificationService.entitlements new file mode 100644 index 000000000..b63a67474 --- /dev/null +++ b/msgNotificationService/msgNotificationService.entitlements @@ -0,0 +1,14 @@ + + + + + com.apple.security.application-groups + + group.org.linphone.phone.msgNotification + + keychain-access-groups + + $(AppIdentifierPrefix)org.linphone.phone + + + From fd61bca29f45b9ad765e02f14c5ee4d2432ad6e2 Mon Sep 17 00:00:00 2001 From: QuentinArguillere Date: Fri, 26 Jan 2024 15:37:46 +0100 Subject: [PATCH 124/486] Add remote push notification support, which will be required to receive and process the account creation token --- Linphone/LinphoneApp.swift | 25 +++++++++++++++++++ .../Viewmodel/AccountLoginViewModel.swift | 2 +- 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/Linphone/LinphoneApp.swift b/Linphone/LinphoneApp.swift index 95378929c..e82ea23f1 100644 --- a/Linphone/LinphoneApp.swift +++ b/Linphone/LinphoneApp.swift @@ -22,6 +22,8 @@ import SwiftUI import Firebase #endif +let accountTokenNotification = Notification.Name("AccountCreationTokenReceived") + class AppDelegate: NSObject, UIApplicationDelegate { func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool { @@ -30,6 +32,29 @@ class AppDelegate: NSObject, UIApplicationDelegate { #endif return true } + + 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.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("debugtrace -- 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) + } } @main diff --git a/Linphone/UI/Assistant/Viewmodel/AccountLoginViewModel.swift b/Linphone/UI/Assistant/Viewmodel/AccountLoginViewModel.swift index 98a4f43f9..5c2a3f1ad 100644 --- a/Linphone/UI/Assistant/Viewmodel/AccountLoginViewModel.swift +++ b/Linphone/UI/Assistant/Viewmodel/AccountLoginViewModel.swift @@ -90,7 +90,7 @@ class AccountLoginViewModel: ObservableObject { // And we ensure the account will start the registration process accountParams.registerEnabled = true accountParams.pushNotificationAllowed = true - accountParams.remotePushNotificationAllowed = false + accountParams.remotePushNotificationAllowed = true #if DEBUG let pushEnvironment = ".dev" #else From 3461b096ebd49f6bc410c5e9f4c9777ace5e97a2 Mon Sep 17 00:00:00 2001 From: QuentinArguillere Date: Fri, 26 Jan 2024 16:33:16 +0100 Subject: [PATCH 125/486] Add push notification permission request --- Linphone/Utils/PermissionManager.swift | 36 +++++++++++++++++++------- 1 file changed, 26 insertions(+), 10 deletions(-) diff --git a/Linphone/Utils/PermissionManager.swift b/Linphone/Utils/PermissionManager.swift index bad8532a5..e20bc2d51 100644 --- a/Linphone/Utils/PermissionManager.swift +++ b/Linphone/Utils/PermissionManager.swift @@ -20,25 +20,41 @@ import Foundation import Photos import Contacts +import UserNotifications +import SwiftUI 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 contactsPermissionGranted = false @Published var microphonePermissionGranted = false private init() {} func getPermissions() { + pushNotificationRequestPermission() microphoneRequestPermission() photoLibraryRequestPermission() cameraRequestPermission() contactsRequestPermission() } + func pushNotificationRequestPermission() { + 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 + } + } + } + func microphoneRequestPermission() { AVAudioSession.sharedInstance().requestRecordPermission({ granted in DispatchQueue.main.async { @@ -62,13 +78,13 @@ class PermissionManager: ObservableObject { } }) } - - func contactsRequestPermission() { - let store = CNContactStore() - store.requestAccess(for: .contacts) { success, _ in - DispatchQueue.main.async { - self.contactsPermissionGranted = success - } - } - } + + func contactsRequestPermission() { + let store = CNContactStore() + store.requestAccess(for: .contacts) { success, _ in + DispatchQueue.main.async { + self.contactsPermissionGranted = success + } + } + } } From 89d4926798b302536a68a75ba1c22804b7b2c2fa Mon Sep 17 00:00:00 2001 From: QuentinArguillere Date: Fri, 26 Jan 2024 16:34:01 +0100 Subject: [PATCH 126/486] Enable remote push notification by default in the assistant file --- Linphone/Ressources/assistant_linphone_default_values | 1 + 1 file changed, 1 insertion(+) diff --git a/Linphone/Ressources/assistant_linphone_default_values b/Linphone/Ressources/assistant_linphone_default_values index f3723d7a9..c1e26073c 100644 --- a/Linphone/Ressources/assistant_linphone_default_values +++ b/Linphone/Ressources/assistant_linphone_default_values @@ -18,6 +18,7 @@ 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 From b999f2f1e3fcb41c7ebcfe3965edf151359f7535 Mon Sep 17 00:00:00 2001 From: QuentinArguillere Date: Tue, 20 Feb 2024 10:33:02 +0100 Subject: [PATCH 127/486] Import linphone 5.2 msgNotificationService implementation --- Linphone.xcodeproj/project.pbxproj | 8 + .../NotificationService.swift | 304 +++++++++++++++--- 2 files changed, 264 insertions(+), 48 deletions(-) diff --git a/Linphone.xcodeproj/project.pbxproj b/Linphone.xcodeproj/project.pbxproj index 67231e24a..8dfb1c299 100644 --- a/Linphone.xcodeproj/project.pbxproj +++ b/Linphone.xcodeproj/project.pbxproj @@ -17,6 +17,10 @@ 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 */; }; + 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 */; }; D706BA822ADD72D100278F45 /* DeviceRotationViewModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = D706BA812ADD72D100278F45 /* DeviceRotationViewModifier.swift */; }; D70C93DE2AC2D0F60063CA3B /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = D70C93DD2AC2D0F60063CA3B /* Localizable.xcstrings */; }; D717071E2AC5922E0037746F /* ColorExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = D717071D2AC5922E0037746F /* ColorExtension.swift */; }; @@ -752,7 +756,11 @@ 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; }; diff --git a/msgNotificationService/NotificationService.swift b/msgNotificationService/NotificationService.swift index de2092700..45a81b621 100644 --- a/msgNotificationService/NotificationService.swift +++ b/msgNotificationService/NotificationService.swift @@ -1,21 +1,21 @@ /* -* 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 . -*/ + * 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 @@ -28,23 +28,52 @@ import Firebase var APP_GROUP_ID = "group.org.linphone.phone.msgNotification" 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: APP_GROUP_ID) + 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? + 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? -// static var logDelegate: LinphoneLoggingServiceManager! -// static var log: LoggingService! + + var contentHandler: ((UNNotificationContent) -> Void)? + var bestAttemptContent: UNMutableNotificationContent? + + var lc: Core? override init() { super.init() @@ -52,20 +81,117 @@ class NotificationService: UNNotificationServiceExtension { FirebaseApp.configure() #endif } - - override func didReceive(_ request: UNNotificationRequest, withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) { - - } - - 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. - //NotificationService.log.warning(message: "serviceExtensionTimeWillExpire") - //stopCore() - if let contentHandler = contentHandler, let bestAttemptContent = bestAttemptContent { - NSLog("[msgNotificationService] serviceExtensionTimeWillExpire") - bestAttemptContent.categoryIdentifier = "app_active" - + + override func didReceive(_ request: UNNotificationRequest, withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) { + + self.contentHandler = contentHandler + bestAttemptContent = (request.content.mutableCopy() as? UNMutableNotificationContent) + Log.info("[msgNotificationService] start msgNotificationService extension") + /* + if (VFSUtil.vfsEnabled(groupName: APP_GROUP_ID) && !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: true) { + Log.info("received push payload : \(bestAttemptContent.userInfo.debugDescription)") + + /* + let defaults = UserDefaults.init(suiteName: APP_GROUP_ID) + 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?.displayName?.isEmpty != true { + bestAttemptContent.body = chatRoom.peerAddress!.displayName! + } else { + bestAttemptContent.body = chatRoom.peerAddress!.username! + } + } else { + bestAttemptContent.body = chatRoom.subject! + } + + bestAttemptContent.sound = UNNotificationSound(named: UNNotificationSoundName("msg.caf")) // TODO : temporary fix, to be removed after flexisip release + 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 = "" @@ -74,10 +200,92 @@ class NotificationService: UNNotificationServiceExtension { bestAttemptContent.title = NSLocalizedString("Message received", comment: "") bestAttemptContent.body = NSLocalizedString("IM_MSG", comment: "") } - contentHandler(bestAttemptContent) - } - } - + 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 { + if let subject = message.subject as String?, !subject.isEmpty { + msgData.subtitle = subject + if reactionContent == nil { + msgData.body = from + " : " + content + } else { + msgData.body = from + NSLocalizedString(" has reacted by ", comment: "") + reactionContent! + NSLocalizedString(" to: ", comment: "") + content + } + } else { + msgData.subtitle = from + msgData.body = content + } + } else { + if let subject = message.subject as String?, !subject.isEmpty { + 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") + + let config = Config.newForSharedCore(appGroupId: APP_GROUP_ID, configFilename: "linphonerc", factoryConfigFilename: "") + + LoggingService.Instance.logLevel = LogLevel.Debug + let configDir = Factory.Instance.getConfigDir(context: nil) + + Factory.Instance.logCollectionPath = configDir + Factory.Instance.enableLogCollection(state: LogCollectionState.Enabled) + + lc = try? Factory.Instance.createSharedCoreWithConfig(config: config!, systemContext: nil, appGroupId: APP_GROUP_ID, 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 From 3d6888b8ba52ba9ad013f8b2844931cf0398db6e Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Mon, 19 Feb 2024 16:51:02 +0100 Subject: [PATCH 128/486] Init Conversation (Chat room) view --- Linphone.xcodeproj/project.pbxproj | 4 + Linphone/LinphoneApp.swift | 8 +- Linphone/Localizable.xcstrings | 12 + Linphone/UI/Main/ContentView.swift | 14 +- .../Fragments/ChatBubbleView.swift | 35 ++ .../Fragments/ConversationFragment.swift | 374 +++++++++++++++++- .../Fragments/ConversationsListFragment.swift | 3 + .../ViewModel/ConversationViewModel.swift | 13 + .../ConversationsListViewModel.swift | 2 - 9 files changed, 446 insertions(+), 19 deletions(-) create mode 100644 Linphone/UI/Main/Conversations/Fragments/ChatBubbleView.swift diff --git a/Linphone.xcodeproj/project.pbxproj b/Linphone.xcodeproj/project.pbxproj index 46ca823f7..ef1a0b262 100644 --- a/Linphone.xcodeproj/project.pbxproj +++ b/Linphone.xcodeproj/project.pbxproj @@ -23,6 +23,7 @@ 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 */; }; + 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 */; }; @@ -124,6 +125,7 @@ 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 = ""; }; + 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 = ""; }; @@ -562,6 +564,7 @@ D7CEE03C2B7A23B200FD79B7 /* ConversationsListFragment.swift */, D70A26ED2B7CF60B006CC8FC /* ConversationsListBottomSheet.swift */, D70A26F12B7F5D95006CC8FC /* ConversationFragment.swift */, + D71968912B86369D00DF4459 /* ChatBubbleView.swift */, ); path = Fragments; sourceTree = ""; @@ -796,6 +799,7 @@ D72250692ADFBF2D008FB426 /* SideMenu.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 */, diff --git a/Linphone/LinphoneApp.swift b/Linphone/LinphoneApp.swift index 92b748694..06435c86a 100644 --- a/Linphone/LinphoneApp.swift +++ b/Linphone/LinphoneApp.swift @@ -46,6 +46,7 @@ struct LinphoneApp: App { @State private var startCallViewModel: StartCallViewModel? @State private var callViewModel: CallViewModel? @State private var conversationsListViewModel: ConversationsListViewModel? + @State private var conversationViewModel: ConversationViewModel? var body: some Scene { WindowGroup { @@ -67,7 +68,8 @@ struct LinphoneApp: App { && historyListViewModel != nil && startCallViewModel != nil && callViewModel != nil - && conversationsListViewModel != nil{ + && conversationsListViewModel != nil + && conversationViewModel != nil { ContentView( contactViewModel: contactViewModel!, editContactViewModel: editContactViewModel!, @@ -75,7 +77,8 @@ struct LinphoneApp: App { historyListViewModel: historyListViewModel!, startCallViewModel: startCallViewModel!, callViewModel: callViewModel!, - conversationsListViewModel: conversationsListViewModel! + conversationsListViewModel: conversationsListViewModel!, + conversationViewModel: conversationViewModel! ) } else { SplashScreen() @@ -90,6 +93,7 @@ struct LinphoneApp: App { startCallViewModel = StartCallViewModel() callViewModel = CallViewModel() conversationsListViewModel = ConversationsListViewModel() + conversationViewModel = ConversationViewModel() } } } diff --git a/Linphone/Localizable.xcstrings b/Linphone/Localizable.xcstrings index 3b9a04413..068eac355 100644 --- a/Linphone/Localizable.xcstrings +++ b/Linphone/Localizable.xcstrings @@ -244,6 +244,9 @@ }, "Contacts" : { + }, + "Content" : { + }, "Continue" : { @@ -346,6 +349,9 @@ }, "Headphones" : { + }, + "Hello, World!" : { + }, "History has been deleted" : { @@ -522,6 +528,9 @@ }, "Say %@ and click on the letters given by your correspondent:" : { + }, + "Say something..." : { + }, "Scan QR code" : { @@ -585,6 +594,9 @@ }, "This contact will be deleted definitively." : { + }, + "Title" : { + }, "TLS" : { diff --git a/Linphone/UI/Main/ContentView.swift b/Linphone/UI/Main/ContentView.swift index 938aa7b42..696570e98 100644 --- a/Linphone/UI/Main/ContentView.swift +++ b/Linphone/UI/Main/ContentView.swift @@ -41,6 +41,7 @@ struct ContentView: View { @ObservedObject var startCallViewModel: StartCallViewModel @ObservedObject var callViewModel: CallViewModel @ObservedObject var conversationsListViewModel: ConversationsListViewModel + @ObservedObject var conversationViewModel: ConversationViewModel @State var index = 0 @State private var orientation = UIDevice.current.orientation @@ -89,6 +90,7 @@ struct ContentView: View { Button(action: { self.index = 0 historyViewModel.displayedCall = nil + conversationsListViewModel.displayedConversation = nil }, label: { VStack { Image("address-book") @@ -132,6 +134,7 @@ struct ContentView: View { Button(action: { self.index = 1 contactViewModel.indexDisplayedFriend = nil + conversationsListViewModel.displayedConversation = nil if historyListViewModel.missedCallsCount > 0 { historyListViewModel.resetMissedCallsCount() } @@ -480,6 +483,7 @@ struct ContentView: View { Button(action: { self.index = 0 historyViewModel.displayedCall = nil + conversationsListViewModel.displayedConversation = nil }, label: { VStack { Image("address-book") @@ -525,6 +529,7 @@ struct ContentView: View { Button(action: { self.index = 1 contactViewModel.indexDisplayedFriend = nil + conversationsListViewModel.displayedConversation = nil if historyListViewModel.missedCallsCount > 0 { historyListViewModel.resetMissedCallsCount() } @@ -608,7 +613,7 @@ struct ContentView: View { } } - if contactViewModel.indexDisplayedFriend != nil || historyViewModel.displayedCall != nil { + if contactViewModel.indexDisplayedFriend != nil || historyViewModel.displayedCall != nil || conversationsListViewModel.displayedConversation != nil { HStack(spacing: 0) { Spacer() .frame(maxWidth: @@ -657,7 +662,7 @@ struct ContentView: View { .ignoresSafeArea(.keyboard) } } else if self.index == 2 { - ConversationsView(conversationsListViewModel: conversationsListViewModel) + ConversationFragment(conversationViewModel: conversationViewModel, conversationsListViewModel: conversationsListViewModel) .frame(maxWidth: .infinity) .background(Color.gray100) .ignoresSafeArea(.keyboard) @@ -873,7 +878,7 @@ struct ContentView: View { } } .onRotate { newOrientation in - if (contactViewModel.indexDisplayedFriend != nil || historyViewModel.displayedCall != nil) && searchIsActive { + if (contactViewModel.indexDisplayedFriend != nil || historyViewModel.displayedCall != nil || conversationsListViewModel.displayedConversation != nil) && searchIsActive { self.focusedField = false } else if searchIsActive { self.focusedField = true @@ -916,7 +921,8 @@ struct ContentView: View { historyListViewModel: HistoryListViewModel(), startCallViewModel: StartCallViewModel(), callViewModel: CallViewModel(), - conversationsListViewModel: ConversationsListViewModel() + conversationsListViewModel: ConversationsListViewModel(), + conversationViewModel: ConversationViewModel() ) } // swiftlint:enable type_body_length diff --git a/Linphone/UI/Main/Conversations/Fragments/ChatBubbleView.swift b/Linphone/UI/Main/Conversations/Fragments/ChatBubbleView.swift new file mode 100644 index 000000000..2f4d7f7fc --- /dev/null +++ b/Linphone/UI/Main/Conversations/Fragments/ChatBubbleView.swift @@ -0,0 +1,35 @@ +/* + * 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 ChatBubbleView: View { + + @ObservedObject var conversationViewModel: ConversationViewModel + + let index: Int + + var body: some View { + Text(conversationViewModel.getMessage(index: index)) + } +} + +#Preview { + ChatBubbleView(conversationViewModel: ConversationViewModel(), index: 0) +} diff --git a/Linphone/UI/Main/Conversations/Fragments/ConversationFragment.swift b/Linphone/UI/Main/Conversations/Fragments/ConversationFragment.swift index 86db6c6e1..02c6e259f 100644 --- a/Linphone/UI/Main/Conversations/Fragments/ConversationFragment.swift +++ b/Linphone/UI/Main/Conversations/Fragments/ConversationFragment.swift @@ -1,18 +1,370 @@ -// -// ConversationFragment.swift -// Linphone -// -// Created by Martins Benoît on 16/02/2024. -// +/* + * 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 ConversationFragment: View { - var body: some View { - Text(/*@START_MENU_TOKEN@*/"Hello, World!"/*@END_MENU_TOKEN@*/) - } + + @State private var orientation = UIDevice.current.orientation + + @ObservedObject var contactsManager = ContactsManager.shared + + @ObservedObject var conversationViewModel: ConversationViewModel + @ObservedObject var conversationsListViewModel: ConversationsListViewModel + + @State var isMenuOpen = false + + @FocusState var isMessageTextFocused: Bool + + var body: some View { + NavigationView { + GeometryReader { geometry in + 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) { + Image("caret-left") + .renderingMode(.template) + .resizable() + .foregroundStyle(Color.grayMain2c500) + .frame(width: 25, height: 25, alignment: .leading) + .padding(.all, 10) + .padding(.top, 4) + .padding(.leading, -10) + .onTapGesture { + withAnimation { + conversationsListViewModel.displayedConversation = nil + } + } + } + + let addressFriend = + (conversationsListViewModel.displayedConversation!.participants.first != nil && conversationsListViewModel.displayedConversation!.participants.first!.address != nil) + ? contactsManager.getFriendWithAddress(address: conversationsListViewModel.displayedConversation!.participants.first!.address!) + : nil + + let contactAvatarModel = addressFriend != nil + ? ContactsManager.shared.avatarListModel.first(where: { + ($0.friend!.consolidatedPresence == .Online || $0.friend!.consolidatedPresence == .Busy) + && $0.friend!.name == addressFriend!.name + && $0.friend!.address!.asStringUriOnly() == addressFriend!.address!.asStringUriOnly() + }) + : ContactAvatarModel(friend: nil, withPresence: false) + + if LinphoneUtils.isChatRoomAGroup(chatRoom: conversationsListViewModel.displayedConversation!) { + Image(uiImage: contactsManager.textToImage( + firstName: conversationsListViewModel.displayedConversation!.subject!, + lastName: conversationsListViewModel.displayedConversation!.subject!.components(separatedBy: " ").count > 1 + ? conversationsListViewModel.displayedConversation!.subject!.components(separatedBy: " ")[1] + : "")) + .resizable() + .frame(width: 50, height: 50) + .clipShape(Circle()) + .padding(.top, 4) + } else if addressFriend != nil && addressFriend!.photo != nil && !addressFriend!.photo!.isEmpty { + if contactAvatarModel != nil { + Avatar(contactAvatarModel: contactAvatarModel!, avatarSize: 50) + .padding(.top, 4) + } else { + Image("profil-picture-default") + .resizable() + .frame(width: 50, height: 50) + .clipShape(Circle()) + .padding(.top, 4) + } + } else { + if conversationsListViewModel.displayedConversation!.participants.first != nil + && conversationsListViewModel.displayedConversation!.participants.first!.address != nil { + if conversationsListViewModel.displayedConversation!.participants.first!.address!.displayName != nil { + Image(uiImage: contactsManager.textToImage( + firstName: conversationsListViewModel.displayedConversation!.participants.first!.address!.displayName!, + lastName: conversationsListViewModel.displayedConversation!.participants.first!.address!.displayName!.components(separatedBy: " ").count > 1 + ? conversationsListViewModel.displayedConversation!.participants.first!.address!.displayName!.components(separatedBy: " ")[1] + : "")) + .resizable() + .frame(width: 50, height: 50) + .clipShape(Circle()) + .padding(.top, 4) + + } else { + Image(uiImage: contactsManager.textToImage( + firstName: conversationsListViewModel.displayedConversation!.participants.first!.address!.username ?? "Username Error", + lastName: conversationsListViewModel.displayedConversation!.participants.first!.address!.username!.components(separatedBy: " ").count > 1 + ? conversationsListViewModel.displayedConversation!.participants.first!.address!.username!.components(separatedBy: " ")[1] + : "")) + .resizable() + .frame(width: 50, height: 50) + .clipShape(Circle()) + .padding(.top, 4) + } + + } else { + Image("profil-picture-default") + .resizable() + .frame(width: 50, height: 50) + .clipShape(Circle()) + .padding(.top, 4) + } + } + + if LinphoneUtils.isChatRoomAGroup(chatRoom: conversationsListViewModel.displayedConversation!) { + Text(conversationsListViewModel.displayedConversation!.subject ?? "No Subject") + .default_text_style(styleSize: 16) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.top, 4) + .lineLimit(1) + } else if addressFriend != nil { + Text(addressFriend!.name!) + .default_text_style(styleSize: 16) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.top, 4) + .lineLimit(1) + } else { + if conversationsListViewModel.displayedConversation!.participants.first != nil + && conversationsListViewModel.displayedConversation!.participants.first!.address != nil { + Text(conversationsListViewModel.displayedConversation!.participants.first!.address!.displayName != nil + ? conversationsListViewModel.displayedConversation!.participants.first!.address!.displayName! + : conversationsListViewModel.displayedConversation!.participants.first!.address!.username!) + .default_text_style(styleSize: 16) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.top, 4) + .lineLimit(1) + } + } + + Spacer() + + Button { + } 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("See contact") + Spacer() + Image("user-circle") + .resizable() + .frame(width: 25, height: 25, alignment: .leading) + .padding(.all, 10) + } + } + + Button { + isMenuOpen = false + } 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 + } 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.grayMain2c500) + .frame(width: 25, height: 25, alignment: .leading) + .padding(.all, 10) + .padding(.top, 4) + } + .onTapGesture { + isMenuOpen = true + } + } + .frame(maxWidth: .infinity) + .frame(height: 50) + .padding(.horizontal) + .padding(.bottom, 4) + .background(.white) + + List { + if conversationsListViewModel.displayedConversation != nil { + ForEach(0.. 0 ? (isMessageTextFocused ? 12 : 0) : 12) + .padding(.horizontal, 10) + .background(Color.gray100) + } + } + .background(.white) + .navigationBarHidden(true) + .onRotate { newOrientation in + orientation = newOrientation + } + } + } + .navigationViewStyle(.stack) + } } -#Preview { - ConversationFragment() +extension UIApplication { + func endEditing() { + sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil) + } +} + +#Preview { + ConversationFragment(conversationViewModel: ConversationViewModel(), conversationsListViewModel: ConversationsListViewModel()) } diff --git a/Linphone/UI/Main/Conversations/Fragments/ConversationsListFragment.swift b/Linphone/UI/Main/Conversations/Fragments/ConversationsListFragment.swift index 5958ac9a0..6c5f6a757 100644 --- a/Linphone/UI/Main/Conversations/Fragments/ConversationsListFragment.swift +++ b/Linphone/UI/Main/Conversations/Fragments/ConversationsListFragment.swift @@ -226,6 +226,9 @@ struct ConversationsListFragment: View { .listRowSeparator(.hidden) .background(.white) .onTapGesture { + withAnimation { + conversationsListViewModel.displayedConversation = conversationsListViewModel.conversationsList[index] + } } .onLongPressGesture(minimumDuration: 0.2) { conversationsListViewModel.selectedConversation = conversationsListViewModel.conversationsList[index] diff --git a/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift b/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift index d71a061b1..f0d5cc7cb 100644 --- a/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift +++ b/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift @@ -22,5 +22,18 @@ import linphonesw class ConversationViewModel: ObservableObject { + @Published var displayedConversation: ChatRoom? + + @Published var messageText: String = "" + init() {} + + func getMessage(index: Int) -> String { + if self.displayedConversation != nil { + return displayedConversation!.getHistoryRangeEvents(begin: index, end: index+1).first?.chatMessage?.utf8Text ?? "" + } + else { + return "" + } + } } diff --git a/Linphone/UI/Main/Conversations/ViewModel/ConversationsListViewModel.swift b/Linphone/UI/Main/Conversations/ViewModel/ConversationsListViewModel.swift index 5db41c604..094aa5b04 100644 --- a/Linphone/UI/Main/Conversations/ViewModel/ConversationsListViewModel.swift +++ b/Linphone/UI/Main/Conversations/ViewModel/ConversationsListViewModel.swift @@ -31,8 +31,6 @@ class ConversationsListViewModel: ObservableObject { @Published var conversationsList: [ChatRoom] = [] @Published var unreadMessages: Int = 0 - @Published var displayedConversation: ChatRoom? - var selectedConversation: ChatRoom? init() { From fd9ede62f8ff709092d4edb276bbfc9eee3ec567 Mon Sep 17 00:00:00 2001 From: QuentinArguillere Date: Mon, 26 Feb 2024 18:04:32 +0100 Subject: [PATCH 129/486] Add ApplicationWillTerminate and ApplicationWillResignActive App delegate functions --- Linphone/LinphoneApp.swift | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/Linphone/LinphoneApp.swift b/Linphone/LinphoneApp.swift index e82ea23f1..a9468c755 100644 --- a/Linphone/LinphoneApp.swift +++ b/Linphone/LinphoneApp.swift @@ -55,6 +55,30 @@ class AppDelegate: NSObject, UIApplicationDelegate { } completionHandler(UIBackgroundFetchResult.newData) } + + func applicationWillTerminate(_ application: UIApplication) { + Log.info("debugtrace -- applicationWillTerminate") + CoreContext.shared.doOnCoreQueue { core in + Log.info("debugtrace COREQUEUE -- applicationWillTerminate") + core.stop() + } + } + + func applicationWillResignActive(_ application: UIApplication) { + Log.info("debugtrace -- applicationWillResignActive") + CoreContext.shared.doOnCoreQueue { core in + Log.info("debugtrace COREQUEUE -- applicationWillResignActive") + if let userDefaults = UserDefaults(suiteName: Config.appGroupName) { + userDefaults.set(false, forKey: "appactive") + } + try? Config.get().sync() + core.enterBackground() + if core.callsNb == 0 { + core.stop() + } + } + } + } @main From ea1420356d322b6d3cc8e5a31b38adfa4cfb98f4 Mon Sep 17 00:00:00 2001 From: QuentinArguillere Date: Mon, 26 Feb 2024 18:05:19 +0100 Subject: [PATCH 130/486] Move firebase initialisation from appdelegate didFinishLaunchingWithOptionsto Corecontext init, which is called earlier --- Linphone/Core/CoreContext.swift | 7 +++++++ Linphone/LinphoneApp.swift | 8 ++------ 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/Linphone/Core/CoreContext.swift b/Linphone/Core/CoreContext.swift index 173e36237..1cadfdcbd 100644 --- a/Linphone/Core/CoreContext.swift +++ b/Linphone/Core/CoreContext.swift @@ -26,6 +26,10 @@ import Combine import UniformTypeIdentifiers import Network +#if USE_CRASHLYTICS +import Firebase +#endif + final class CoreContext: ObservableObject { static let shared = CoreContext() @@ -67,6 +71,9 @@ final class CoreContext: ObservableObject { func initialiseCore() throws { +#if USE_CRASHLYTICS + FirebaseApp.configure() +#endif coreQueue.async { LoggingService.Instance.logLevel = LogLevel.Debug diff --git a/Linphone/LinphoneApp.swift b/Linphone/LinphoneApp.swift index a9468c755..dd68f6b7b 100644 --- a/Linphone/LinphoneApp.swift +++ b/Linphone/LinphoneApp.swift @@ -18,18 +18,14 @@ */ import SwiftUI -#if USE_CRASHLYTICS -import Firebase -#endif +import linphonesw let accountTokenNotification = Notification.Name("AccountCreationTokenReceived") class AppDelegate: NSObject, UIApplicationDelegate { func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool { -#if USE_CRASHLYTICS - FirebaseApp.configure() -#endif + return true } From c0a16e62be9a718149f31cbc45dfd2f4af078f2e Mon Sep 17 00:00:00 2001 From: QuentinArguillere Date: Mon, 26 Feb 2024 18:06:29 +0100 Subject: [PATCH 131/486] Switch app Core to SharedCore, which will be required to have a working AppExtension --- Linphone/Core/CoreContext.swift | 39 +++++++++---------- Linphone/Linphone.entitlements | 1 - .../Utils/Extensions/ConfigExtension.swift | 5 ++- 3 files changed, 21 insertions(+), 24 deletions(-) diff --git a/Linphone/Core/CoreContext.swift b/Linphone/Core/CoreContext.swift index 1cadfdcbd..3746a3f2c 100644 --- a/Linphone/Core/CoreContext.swift +++ b/Linphone/Core/CoreContext.swift @@ -70,38 +70,35 @@ final class CoreContext: ObservableObject { } func initialiseCore() throws { - #if USE_CRASHLYTICS FirebaseApp.configure() #endif coreQueue.async { - LoggingService.Instance.logLevel = LogLevel.Debug - let configDir = Factory.Instance.getConfigDir(context: nil) - - Factory.Instance.logCollectionPath = configDir + Factory.Instance.logCollectionPath = Factory.Instance.getConfigDir(context: nil) Factory.Instance.enableLogCollection(state: LogCollectionState.Enabled) - Log.info("Initialising core") - let url = NSURL(fileURLWithPath: configDir) - if let pathComponent = url.appendingPathComponent("linphonerc") { - let filePath = pathComponent.path - let fileManager = FileManager.default - if !fileManager.fileExists(atPath: filePath) { - let path = Bundle.main.path(forResource: "linphonerc-default", ofType: nil) - if path != nil { - try? FileManager.default.copyItem(at: NSURL(fileURLWithPath: path!) as URL, to: pathComponent) + 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") } } - let config = try? Factory.Instance.createConfigWithFactory( - path: "\(configDir)/linphonerc", - factoryPath: Bundle.main.path(forResource: "linphonerc-factory", ofType: nil) - ) - if config != nil { - self.mCore = try? Factory.Instance.createCoreWithConfig(config: config!, systemContext: nil) - } + Log.info("Initialising core") + self.mCore = try? Factory.Instance.createSharedCoreWithConfig(config: Config.get(), systemContext: nil, appGroupId: Config.appGroupName, mainCore: true) linphone_core_set_push_registry_dispatch_queue(self.mCore.getCobject, Unmanaged.passUnretained(coreQueue).toOpaque()) self.mCore.autoIterateEnabled = false diff --git a/Linphone/Linphone.entitlements b/Linphone/Linphone.entitlements index 6a0769bbd..5ac776606 100644 --- a/Linphone/Linphone.entitlements +++ b/Linphone/Linphone.entitlements @@ -13,7 +13,6 @@ group.belledonne-communications.linphone group.org.linphone.phone.linphoneExtension group.org.linphone.phone.msgNotification - group.org.linphone.phone.logs com.apple.security.files.user-selected.read-only diff --git a/Linphone/Utils/Extensions/ConfigExtension.swift b/Linphone/Utils/Extensions/ConfigExtension.swift index 9d83cd891..b7b308c0a 100644 --- a/Linphone/Utils/Extensions/ConfigExtension.swift +++ b/Linphone/Utils/Extensions/ConfigExtension.swift @@ -36,7 +36,8 @@ extension Config { public static func get() -> Config { if _instance == nil { - let factoryPath = FileUtil.bundleFilePath(Core.runsInsideExtension() ? "linphonerc-factory-appex" : "linphonerc-factory-app")! + let factoryPath = FileUtil.bundleFilePath("linphonerc-factory")! + let configDir = Factory.Instance.getConfigDir(context: nil) _instance = Config.newForSharedCore(appGroupId: Config.appGroupName, configFilename: "linphonerc", factoryConfigFilename: factoryPath)! } return _instance! @@ -46,7 +47,7 @@ extension Config { return hasEntry(section: section, key: key) == 1 ? getString(section: section, key: key, defaultString: "") : nil } - static let appGroupName = "group.org.linphone.phone.logs" + 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: "") From 0d4efd7a198a246f0416a5d611825ecf5715ccdb Mon Sep 17 00:00:00 2001 From: QuentinArguillere Date: Mon, 26 Feb 2024 18:12:09 +0100 Subject: [PATCH 132/486] Restore groupchat and lime spec --- Linphone/Core/CoreContext.swift | 4 ---- 1 file changed, 4 deletions(-) diff --git a/Linphone/Core/CoreContext.swift b/Linphone/Core/CoreContext.swift index 3746a3f2c..d7b838985 100644 --- a/Linphone/Core/CoreContext.swift +++ b/Linphone/Core/CoreContext.swift @@ -139,10 +139,6 @@ final class CoreContext: ObservableObject { self.mCore.removeLinphoneSpec(spec: "conference") Log.info("Removing spec 'ephemeral' from core for this version") self.mCore.removeLinphoneSpec(spec: "ephemeral") - Log.info("Removing spec 'groupchat' from core for this version") - self.mCore.removeLinphoneSpec(spec: "groupchat") - Log.info("Removing spec 'lime' from core for this version") - self.mCore.removeLinphoneSpec(spec: "lime") } }) From 4196fed865fe470d193a4ef7ae021920a6946183 Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Thu, 22 Feb 2024 17:46:35 +0100 Subject: [PATCH 133/486] Add message bubbles --- Linphone/Localizable.xcstrings | 3 - Linphone/UI/Main/ContentView.swift | 14 +- .../Conversations/ConversationsView.swift | 9 +- .../Fragments/ChatBubbleView.swift | 24 ++- .../Fragments/ConversationFragment.swift | 162 +++++++++++++++--- .../Fragments/ConversationsFragment.swift | 7 +- .../Fragments/ConversationsListFragment.swift | 10 +- .../ViewModel/ConversationViewModel.swift | 144 +++++++++++++++- .../ConversationsListViewModel.swift | 1 + 9 files changed, 323 insertions(+), 51 deletions(-) diff --git a/Linphone/Localizable.xcstrings b/Linphone/Localizable.xcstrings index 068eac355..55e8fd566 100644 --- a/Linphone/Localizable.xcstrings +++ b/Linphone/Localizable.xcstrings @@ -349,9 +349,6 @@ }, "Headphones" : { - }, - "Hello, World!" : { - }, "History has been deleted" : { diff --git a/Linphone/UI/Main/ContentView.swift b/Linphone/UI/Main/ContentView.swift index 696570e98..195f43d48 100644 --- a/Linphone/UI/Main/ContentView.swift +++ b/Linphone/UI/Main/ContentView.swift @@ -90,7 +90,7 @@ struct ContentView: View { Button(action: { self.index = 0 historyViewModel.displayedCall = nil - conversationsListViewModel.displayedConversation = nil + conversationViewModel.displayedConversation = nil }, label: { VStack { Image("address-book") @@ -134,7 +134,7 @@ struct ContentView: View { Button(action: { self.index = 1 contactViewModel.indexDisplayedFriend = nil - conversationsListViewModel.displayedConversation = nil + conversationViewModel.displayedConversation = nil if historyListViewModel.missedCallsCount > 0 { historyListViewModel.resetMissedCallsCount() } @@ -451,7 +451,7 @@ struct ContentView: View { isShowEditContactFragment: $isShowEditContactFragment ) } else if self.index == 2 { - ConversationsView(conversationsListViewModel: conversationsListViewModel) + ConversationsView(conversationViewModel: conversationViewModel, conversationsListViewModel: conversationsListViewModel) } } .frame(maxWidth: @@ -483,7 +483,7 @@ struct ContentView: View { Button(action: { self.index = 0 historyViewModel.displayedCall = nil - conversationsListViewModel.displayedConversation = nil + conversationViewModel.displayedConversation = nil }, label: { VStack { Image("address-book") @@ -529,7 +529,7 @@ struct ContentView: View { Button(action: { self.index = 1 contactViewModel.indexDisplayedFriend = nil - conversationsListViewModel.displayedConversation = nil + conversationViewModel.displayedConversation = nil if historyListViewModel.missedCallsCount > 0 { historyListViewModel.resetMissedCallsCount() } @@ -613,7 +613,7 @@ struct ContentView: View { } } - if contactViewModel.indexDisplayedFriend != nil || historyViewModel.displayedCall != nil || conversationsListViewModel.displayedConversation != nil { + if contactViewModel.indexDisplayedFriend != nil || historyViewModel.displayedCall != nil || conversationViewModel.displayedConversation != nil { HStack(spacing: 0) { Spacer() .frame(maxWidth: @@ -878,7 +878,7 @@ struct ContentView: View { } } .onRotate { newOrientation in - if (contactViewModel.indexDisplayedFriend != nil || historyViewModel.displayedCall != nil || conversationsListViewModel.displayedConversation != nil) && searchIsActive { + if (contactViewModel.indexDisplayedFriend != nil || historyViewModel.displayedCall != nil || conversationViewModel.displayedConversation != nil) && searchIsActive { self.focusedField = false } else if searchIsActive { self.focusedField = true diff --git a/Linphone/UI/Main/Conversations/ConversationsView.swift b/Linphone/UI/Main/Conversations/ConversationsView.swift index 2119f0ab6..161368b2c 100644 --- a/Linphone/UI/Main/Conversations/ConversationsView.swift +++ b/Linphone/UI/Main/Conversations/ConversationsView.swift @@ -21,12 +21,13 @@ import SwiftUI struct ConversationsView: View { + @ObservedObject var conversationViewModel: ConversationViewModel @ObservedObject var conversationsListViewModel: ConversationsListViewModel var body: some View { NavigationView { ZStack(alignment: .bottomTrailing) { - ConversationsFragment(conversationsListViewModel: conversationsListViewModel) + ConversationsFragment(conversationViewModel: conversationViewModel, conversationsListViewModel: conversationsListViewModel) Button { } label: { @@ -47,5 +48,9 @@ struct ConversationsView: View { } #Preview { - ConversationsListFragment(conversationsListViewModel: ConversationsListViewModel(), showingSheet: .constant(false)) + ConversationsListFragment( + conversationViewModel: ConversationViewModel(), + conversationsListViewModel: ConversationsListViewModel(), + showingSheet: .constant(false) + ) } diff --git a/Linphone/UI/Main/Conversations/Fragments/ChatBubbleView.swift b/Linphone/UI/Main/Conversations/Fragments/ChatBubbleView.swift index 2f4d7f7fc..7789984ae 100644 --- a/Linphone/UI/Main/Conversations/Fragments/ChatBubbleView.swift +++ b/Linphone/UI/Main/Conversations/Fragments/ChatBubbleView.swift @@ -26,7 +26,29 @@ struct ChatBubbleView: View { let index: Int var body: some View { - Text(conversationViewModel.getMessage(index: index)) + if index < conversationViewModel.conversationMessagesList.count + && conversationViewModel.conversationMessagesList[index].eventLog.chatMessage != nil { + HStack { + if conversationViewModel.conversationMessagesList[index].eventLog.chatMessage!.isOutgoing { + Spacer() + } + + VStack { + Text(conversationViewModel.conversationMessagesList[index].eventLog.chatMessage!.utf8Text ?? "") + .foregroundStyle(Color.grayMain2c700) + .default_text_style(styleSize: 16) + } + .padding(.all, 15) + .background(conversationViewModel.conversationMessagesList[index].eventLog.chatMessage!.isOutgoing ? Color.orangeMain100 : Color.grayMain2c100) + .clipShape(RoundedRectangle(cornerRadius: 16)) + + if !conversationViewModel.conversationMessagesList[index].eventLog.chatMessage!.isOutgoing { + Spacer() + } + } + .padding(.leading, conversationViewModel.conversationMessagesList[index].eventLog.chatMessage!.isOutgoing ? 40 : 0) + .padding(.trailing, !conversationViewModel.conversationMessagesList[index].eventLog.chatMessage!.isOutgoing ? 40 : 0) + } } } diff --git a/Linphone/UI/Main/Conversations/Fragments/ConversationFragment.swift b/Linphone/UI/Main/Conversations/Fragments/ConversationFragment.swift index 02c6e259f..95e1c90b4 100644 --- a/Linphone/UI/Main/Conversations/Fragments/ConversationFragment.swift +++ b/Linphone/UI/Main/Conversations/Fragments/ConversationFragment.swift @@ -55,14 +55,14 @@ struct ConversationFragment: View { .padding(.leading, -10) .onTapGesture { withAnimation { - conversationsListViewModel.displayedConversation = nil + conversationViewModel.displayedConversation = nil } } } let addressFriend = - (conversationsListViewModel.displayedConversation!.participants.first != nil && conversationsListViewModel.displayedConversation!.participants.first!.address != nil) - ? contactsManager.getFriendWithAddress(address: conversationsListViewModel.displayedConversation!.participants.first!.address!) + (conversationViewModel.displayedConversation!.participants.first != nil && conversationViewModel.displayedConversation!.participants.first!.address != nil) + ? contactsManager.getFriendWithAddress(address: conversationViewModel.displayedConversation!.participants.first!.address!) : nil let contactAvatarModel = addressFriend != nil @@ -73,11 +73,11 @@ struct ConversationFragment: View { }) : ContactAvatarModel(friend: nil, withPresence: false) - if LinphoneUtils.isChatRoomAGroup(chatRoom: conversationsListViewModel.displayedConversation!) { + if LinphoneUtils.isChatRoomAGroup(chatRoom: conversationViewModel.displayedConversation!) { Image(uiImage: contactsManager.textToImage( - firstName: conversationsListViewModel.displayedConversation!.subject!, - lastName: conversationsListViewModel.displayedConversation!.subject!.components(separatedBy: " ").count > 1 - ? conversationsListViewModel.displayedConversation!.subject!.components(separatedBy: " ")[1] + firstName: conversationViewModel.displayedConversation!.subject!, + lastName: conversationViewModel.displayedConversation!.subject!.components(separatedBy: " ").count > 1 + ? conversationViewModel.displayedConversation!.subject!.components(separatedBy: " ")[1] : "")) .resizable() .frame(width: 50, height: 50) @@ -95,13 +95,13 @@ struct ConversationFragment: View { .padding(.top, 4) } } else { - if conversationsListViewModel.displayedConversation!.participants.first != nil - && conversationsListViewModel.displayedConversation!.participants.first!.address != nil { - if conversationsListViewModel.displayedConversation!.participants.first!.address!.displayName != nil { + if conversationViewModel.displayedConversation!.participants.first != nil + && conversationViewModel.displayedConversation!.participants.first!.address != nil { + if conversationViewModel.displayedConversation!.participants.first!.address!.displayName != nil { Image(uiImage: contactsManager.textToImage( - firstName: conversationsListViewModel.displayedConversation!.participants.first!.address!.displayName!, - lastName: conversationsListViewModel.displayedConversation!.participants.first!.address!.displayName!.components(separatedBy: " ").count > 1 - ? conversationsListViewModel.displayedConversation!.participants.first!.address!.displayName!.components(separatedBy: " ")[1] + firstName: conversationViewModel.displayedConversation!.participants.first!.address!.displayName!, + lastName: conversationViewModel.displayedConversation!.participants.first!.address!.displayName!.components(separatedBy: " ").count > 1 + ? conversationViewModel.displayedConversation!.participants.first!.address!.displayName!.components(separatedBy: " ")[1] : "")) .resizable() .frame(width: 50, height: 50) @@ -110,9 +110,9 @@ struct ConversationFragment: View { } else { Image(uiImage: contactsManager.textToImage( - firstName: conversationsListViewModel.displayedConversation!.participants.first!.address!.username ?? "Username Error", - lastName: conversationsListViewModel.displayedConversation!.participants.first!.address!.username!.components(separatedBy: " ").count > 1 - ? conversationsListViewModel.displayedConversation!.participants.first!.address!.username!.components(separatedBy: " ")[1] + firstName: conversationViewModel.displayedConversation!.participants.first!.address!.username ?? "Username Error", + lastName: conversationViewModel.displayedConversation!.participants.first!.address!.username!.components(separatedBy: " ").count > 1 + ? conversationViewModel.displayedConversation!.participants.first!.address!.username!.components(separatedBy: " ")[1] : "")) .resizable() .frame(width: 50, height: 50) @@ -129,8 +129,8 @@ struct ConversationFragment: View { } } - if LinphoneUtils.isChatRoomAGroup(chatRoom: conversationsListViewModel.displayedConversation!) { - Text(conversationsListViewModel.displayedConversation!.subject ?? "No Subject") + if LinphoneUtils.isChatRoomAGroup(chatRoom: conversationViewModel.displayedConversation!) { + Text(conversationViewModel.displayedConversation!.subject ?? "No Subject") .default_text_style(styleSize: 16) .frame(maxWidth: .infinity, alignment: .leading) .padding(.top, 4) @@ -142,11 +142,11 @@ struct ConversationFragment: View { .padding(.top, 4) .lineLimit(1) } else { - if conversationsListViewModel.displayedConversation!.participants.first != nil - && conversationsListViewModel.displayedConversation!.participants.first!.address != nil { - Text(conversationsListViewModel.displayedConversation!.participants.first!.address!.displayName != nil - ? conversationsListViewModel.displayedConversation!.participants.first!.address!.displayName! - : conversationsListViewModel.displayedConversation!.participants.first!.address!.username!) + if conversationViewModel.displayedConversation!.participants.first != nil + && conversationViewModel.displayedConversation!.participants.first!.address != nil { + Text(conversationViewModel.displayedConversation!.participants.first!.address!.displayName != nil + ? conversationViewModel.displayedConversation!.participants.first!.address!.displayName! + : conversationViewModel.displayedConversation!.participants.first!.address!.username!) .default_text_style(styleSize: 16) .frame(maxWidth: .infinity, alignment: .leading) .padding(.top, 4) @@ -225,17 +225,118 @@ struct ConversationFragment: View { .padding(.bottom, 4) .background(.white) + + + + + + List { - if conversationsListViewModel.displayedConversation != nil { - ForEach(0..() + + @Published var conversationMessagesList: [LinphoneCustomEventLog] = [] + init() {} - func getMessage(index: Int) -> String { - if self.displayedConversation != nil { - return displayedConversation!.getHistoryRangeEvents(begin: index, end: index+1).first?.chatMessage?.utf8Text ?? "" - } - else { - return "" + func addConversationDelegate() { + if displayedConversation != nil { + self.chatRoomSuscriptions.insert(displayedConversation!.publisher?.onChatMessageSent?.postOnMainQueue { (cbValue: (chatRoom: ChatRoom, eventLog: EventLog)) in + self.getNewMessages(eventLogs: [cbValue.eventLog]) + }) + + self.chatRoomSuscriptions.insert(displayedConversation!.publisher?.onChatMessagesReceived?.postOnMainQueue { (cbValue: (chatRoom: ChatRoom, eventLogs: [EventLog])) in + self.getNewMessages(eventLogs: cbValue.eventLogs) + }) } } + + func removeConversationDelegate() { + self.chatRoomSuscriptions.removeAll() + } + + func getMessage() { + if self.displayedConversation != nil { + let historyEvents = displayedConversation!.getHistoryRangeEvents(begin: conversationMessagesList.count, end: conversationMessagesList.count + 30) + + historyEvents.reversed().forEach { eventLog in + conversationMessagesList.append(LinphoneCustomEventLog(eventLog: eventLog)) + } + } + } + + func getNewMessages(eventLogs: [EventLog]) { + withAnimation { + eventLogs.forEach { eventLog in + conversationMessagesList.insert(LinphoneCustomEventLog(eventLog: eventLog), at: 0) + //conversationMessagesList.append(LinphoneCustomEventLog(eventLog: eventLog)) + } + } + } + + func resetMessage() { + conversationMessagesList = [] + } + + func sendMessage() { + //val messageToReplyTo = chatMessageToReplyTo + //val message = if (messageToReplyTo != null) { + //Log.i("$TAG Sending message as reply to [${messageToReplyTo.messageId}]") + //chatRoom.createReplyMessage(messageToReplyTo) + //} else { + let message = try? self.displayedConversation!.createEmptyMessage() + //} + + let toSend = self.messageText.trimmingCharacters(in: .whitespacesAndNewlines) + if !toSend.isEmpty { + if message != nil { + message!.addUtf8TextContent(text: toSend) + } + } + + /* + if (isVoiceRecording.value == true && voiceMessageRecorder.file != null) { + stopVoiceRecorder() + val content = voiceMessageRecorder.createContent() + if (content != null) { + Log.i( + "$TAG Voice recording content created, file name is ${content.name} and duration is ${content.fileDuration}" + ) + message.addContent(content) + } else { + Log.e("$TAG Voice recording content couldn't be created!") + } + } else { + for (attachment in attachments.value.orEmpty()) { + val content = Factory.instance().createContent() + + content.type = when (attachment.mimeType) { + FileUtils.MimeType.Image -> "image" + FileUtils.MimeType.Audio -> "audio" + FileUtils.MimeType.Video -> "video" + FileUtils.MimeType.Pdf -> "application" + FileUtils.MimeType.PlainText -> "text" + else -> "file" + } + content.subtype = if (attachment.mimeType == FileUtils.MimeType.PlainText) { + "plain" + } else { + FileUtils.getExtensionFromFileName(attachment.fileName) + } + content.name = attachment.fileName + // Let the file body handler take care of the upload + content.filePath = attachment.file + + message.addFileContent(content) + } + } + */ + + if message != nil && !message!.contents.isEmpty { + Log.info("[ConversationViewModel] Sending message") + message!.send() + } + + Log.info("[ConversationViewModel] Message sent, re-setting defaults") + 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 + */ + } +} +struct LinphoneCustomEventLog: Hashable { + var id = UUID() + var eventLog: EventLog + + func hash(into hasher: inout Hasher) { + hasher.combine(id) + } +} + +extension LinphoneCustomEventLog { + static func ==(lhs: LinphoneCustomEventLog, rhs: LinphoneCustomEventLog) -> Bool { + return lhs.id == rhs.id + } } diff --git a/Linphone/UI/Main/Conversations/ViewModel/ConversationsListViewModel.swift b/Linphone/UI/Main/Conversations/ViewModel/ConversationsListViewModel.swift index 094aa5b04..f3a3c8da7 100644 --- a/Linphone/UI/Main/Conversations/ViewModel/ConversationsListViewModel.swift +++ b/Linphone/UI/Main/Conversations/ViewModel/ConversationsListViewModel.swift @@ -109,6 +109,7 @@ class ConversationsListViewModel: ObservableObject { self.mCoreSuscriptions.insert(core.publisher?.onMessagesReceived?.postOnMainQueue { _ in self.computeChatRoomsList(filter: "") + }) } } From be09968a31592270f4a491efa176fcd0e92c5093 Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Tue, 27 Feb 2024 17:23:25 +0100 Subject: [PATCH 134/486] Replace ChatRoom class with ConversationModel to update conversation views --- Linphone.xcodeproj/project.pbxproj | 12 + Linphone/UI/Call/CallView.swift | 2 +- .../UI/Call/Fragments/CallsListFragment.swift | 2 +- .../Fragments/ContactInnerFragment.swift | 2 +- .../Fragments/EditContactFragment.swift | 4 +- .../Contacts/Model/ContactAvatarModel.swift | 5 +- Linphone/UI/Main/ContentView.swift | 11 +- .../Fragments/ConversationFragment.swift | 99 +------- .../ConversationsListBottomSheet.swift | 21 +- .../Fragments/ConversationsListFragment.swift | 132 ++-------- .../Model/ConversationModel.swift | 231 ++++++++++++++++++ .../ViewModel/ConversationViewModel.swift | 208 +++++++++------- .../ConversationsListViewModel.swift | 113 +++++---- .../Fragments/HistoryContactFragment.swift | 2 +- .../Fragments/HistoryListFragment.swift | 2 +- Linphone/Utils/Avatar.swift | 11 + Linphone/Utils/LinphoneUtils.swift | 28 ++- Linphone/Utils/MagicSearchSingleton.swift | 4 +- 18 files changed, 520 insertions(+), 369 deletions(-) create mode 100644 Linphone/UI/Main/Conversations/Model/ConversationModel.swift diff --git a/Linphone.xcodeproj/project.pbxproj b/Linphone.xcodeproj/project.pbxproj index ef1a0b262..43bab449b 100644 --- a/Linphone.xcodeproj/project.pbxproj +++ b/Linphone.xcodeproj/project.pbxproj @@ -16,6 +16,7 @@ 66C491FF2B24D4AC00CEA16D /* FileUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = 66C491FE2B24D4AC00CEA16D /* FileUtils.swift */; }; 66C492012B24DB6900CEA16D /* Log.swift in Sources */ = {isa = PBXBuildFile; fileRef = 66C492002B24DB6900CEA16D /* Log.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 */; }; @@ -118,6 +119,7 @@ 66C491FE2B24D4AC00CEA16D /* FileUtils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileUtils.swift; sourceTree = ""; }; 66C492002B24DB6900CEA16D /* Log.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Log.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 = ""; }; @@ -252,6 +254,14 @@ path = Pods; sourceTree = ""; }; + D70959EF2B8DF33B0014AC0B /* Model */ = { + isa = PBXGroup; + children = ( + D70959F02B8DF3EC0014AC0B /* ConversationModel.swift */, + ); + path = Model; + sourceTree = ""; + }; D717071C2AC591EF0037746F /* Utils */ = { isa = PBXGroup; children = ( @@ -542,6 +552,7 @@ isa = PBXGroup; children = ( D7CEE0392B7A232200FD79B7 /* Fragments */, + D70959EF2B8DF33B0014AC0B /* Model */, D7CEE0362B7A212C00FD79B7 /* ViewModel */, D7CEE0342B7A210300FD79B7 /* ConversationsView.swift */, ); @@ -743,6 +754,7 @@ D719ABC92ABC6FD700B41C10 /* CoreContext.swift in Sources */, D70A26F22B7F5D95006CC8FC /* ConversationFragment.swift in Sources */, 66C491FD2B24D36500CEA16D /* AudioRouteUtils.swift in Sources */, + D70959F12B8DF3EC0014AC0B /* ConversationModel.swift in Sources */, D7EAACCF2AD6ED8000AA6A8A /* PermissionsFragment.swift in Sources */, D777DBB32AE12C5900565A99 /* ContactsManager.swift in Sources */, D796F2002B0BB61A0041115F /* ToastViewModel.swift in Sources */, diff --git a/Linphone/UI/Call/CallView.swift b/Linphone/UI/Call/CallView.swift index 0a587abae..d198a1a7e 100644 --- a/Linphone/UI/Call/CallView.swift +++ b/Linphone/UI/Call/CallView.swift @@ -380,7 +380,7 @@ struct CallView: View { && $0.friend!.name == addressFriend!.name && $0.friend!.address!.asStringUriOnly() == addressFriend!.address!.asStringUriOnly() }) - : ContactAvatarModel(friend: nil, withPresence: false) + : ContactAvatarModel(friend: nil, name: "", withPresence: false) if addressFriend != nil && addressFriend!.photo != nil && !addressFriend!.photo!.isEmpty { if contactAvatarModel != nil { diff --git a/Linphone/UI/Call/Fragments/CallsListFragment.swift b/Linphone/UI/Call/Fragments/CallsListFragment.swift index 1d2d76aea..83abbd81e 100644 --- a/Linphone/UI/Call/Fragments/CallsListFragment.swift +++ b/Linphone/UI/Call/Fragments/CallsListFragment.swift @@ -226,7 +226,7 @@ struct CallsListFragment: View { && $0.friend!.name == addressFriend!.name && $0.friend!.address!.asStringUriOnly() == addressFriend!.address!.asStringUriOnly() }) - : ContactAvatarModel(friend: nil, withPresence: false) + : ContactAvatarModel(friend: nil, name: "", withPresence: false) if addressFriend != nil && addressFriend!.photo != nil && !addressFriend!.photo!.isEmpty { if contactAvatarModel != nil { diff --git a/Linphone/UI/Main/Contacts/Fragments/ContactInnerFragment.swift b/Linphone/UI/Main/Contacts/Fragments/ContactInnerFragment.swift index aa8ac1e27..5dd1b1787 100644 --- a/Linphone/UI/Main/Contacts/Fragments/ContactInnerFragment.swift +++ b/Linphone/UI/Main/Contacts/Fragments/ContactInnerFragment.swift @@ -286,7 +286,7 @@ struct ContactInnerFragment: View { #Preview { ContactInnerFragment( - contactAvatarModel: ContactAvatarModel(friend: nil, withPresence: true), + contactAvatarModel: ContactAvatarModel(friend: nil, name: "", withPresence: true), contactViewModel: ContactViewModel(), editContactViewModel: EditContactViewModel(), isShowDeletePopup: .constant(false), diff --git a/Linphone/UI/Main/Contacts/Fragments/EditContactFragment.swift b/Linphone/UI/Main/Contacts/Fragments/EditContactFragment.swift index 92c4ed7ff..38cc9e87c 100644 --- a/Linphone/UI/Main/Contacts/Fragments/EditContactFragment.swift +++ b/Linphone/UI/Main/Contacts/Fragments/EditContactFragment.swift @@ -154,7 +154,9 @@ struct EditContactFragment: View { && editContactViewModel.selectedEditFriend!.photo != nil && !editContactViewModel.selectedEditFriend!.photo!.isEmpty && selectedImage == nil && !removedImage { - Avatar(contactAvatarModel: ContactAvatarModel(friend: editContactViewModel.selectedEditFriend!, withPresence: false), avatarSize: 100) + Avatar(contactAvatarModel: + ContactAvatarModel(friend: editContactViewModel.selectedEditFriend!, name: editContactViewModel.selectedEditFriend?.name ?? "", withPresence: false), avatarSize: 100 + ) } else if selectedImage == nil { Image("profil-picture-default") diff --git a/Linphone/UI/Main/Contacts/Model/ContactAvatarModel.swift b/Linphone/UI/Main/Contacts/Model/ContactAvatarModel.swift index efd2e1426..950e431c6 100644 --- a/Linphone/UI/Main/Contacts/Model/ContactAvatarModel.swift +++ b/Linphone/UI/Main/Contacts/Model/ContactAvatarModel.swift @@ -25,6 +25,8 @@ class ContactAvatarModel: ObservableObject { let friend: Friend? + let name: String + let withPresence: Bool? @Published var lastPresenceInfo: String @@ -33,8 +35,9 @@ class ContactAvatarModel: ObservableObject { private var friendSuscription: AnyCancellable? - init(friend: Friend?, withPresence: Bool?) { + init(friend: Friend?, name: String, withPresence: Bool?) { self.friend = friend + self.name = name self.withPresence = withPresence if friend != nil && withPresence == true { diff --git a/Linphone/UI/Main/ContentView.swift b/Linphone/UI/Main/ContentView.swift index 195f43d48..eaa9ed2f9 100644 --- a/Linphone/UI/Main/ContentView.swift +++ b/Linphone/UI/Main/ContentView.swift @@ -62,6 +62,9 @@ struct ContentView: View { @State var isShowCallsListFragment = 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.calls.count == 1) || callViewModel.calls.count > 1) { @@ -644,7 +647,7 @@ struct ContentView: View { && $0.friend!.name == addressFriend!.name && $0.friend!.address!.asStringUriOnly() == addressFriend!.address!.asStringUriOnly() }) - : ContactAvatarModel(friend: nil, withPresence: false) + : ContactAvatarModel(friend: nil, name: "", withPresence: false) if contactAvatarModel != nil { HistoryContactFragment( @@ -866,6 +869,12 @@ struct ContentView: View { .zIndex(3) } } + .onAppear { + MagicSearchSingleton.shared.searchForContacts(sourceFlags: MagicSearch.Source.Friends.rawValue | MagicSearch.Source.LdapServers.rawValue) + } + .onReceive(pub) { _ in + conversationsListViewModel.refreshContactAvatarModel() + } } .overlay { if isMenuOpen { diff --git a/Linphone/UI/Main/Conversations/Fragments/ConversationFragment.swift b/Linphone/UI/Main/Conversations/Fragments/ConversationFragment.swift index 95e1c90b4..05df3a93f 100644 --- a/Linphone/UI/Main/Conversations/Fragments/ConversationFragment.swift +++ b/Linphone/UI/Main/Conversations/Fragments/ConversationFragment.swift @@ -60,99 +60,14 @@ struct ConversationFragment: View { } } - let addressFriend = - (conversationViewModel.displayedConversation!.participants.first != nil && conversationViewModel.displayedConversation!.participants.first!.address != nil) - ? contactsManager.getFriendWithAddress(address: conversationViewModel.displayedConversation!.participants.first!.address!) - : nil - - let contactAvatarModel = addressFriend != nil - ? ContactsManager.shared.avatarListModel.first(where: { - ($0.friend!.consolidatedPresence == .Online || $0.friend!.consolidatedPresence == .Busy) - && $0.friend!.name == addressFriend!.name - && $0.friend!.address!.asStringUriOnly() == addressFriend!.address!.asStringUriOnly() - }) - : ContactAvatarModel(friend: nil, withPresence: false) - - if LinphoneUtils.isChatRoomAGroup(chatRoom: conversationViewModel.displayedConversation!) { - Image(uiImage: contactsManager.textToImage( - firstName: conversationViewModel.displayedConversation!.subject!, - lastName: conversationViewModel.displayedConversation!.subject!.components(separatedBy: " ").count > 1 - ? conversationViewModel.displayedConversation!.subject!.components(separatedBy: " ")[1] - : "")) - .resizable() - .frame(width: 50, height: 50) - .clipShape(Circle()) + Avatar(contactAvatarModel: conversationViewModel.displayedConversation!.avatarModel, avatarSize: 50) .padding(.top, 4) - } else if addressFriend != nil && addressFriend!.photo != nil && !addressFriend!.photo!.isEmpty { - if contactAvatarModel != nil { - Avatar(contactAvatarModel: contactAvatarModel!, avatarSize: 50) - .padding(.top, 4) - } else { - Image("profil-picture-default") - .resizable() - .frame(width: 50, height: 50) - .clipShape(Circle()) - .padding(.top, 4) - } - } else { - if conversationViewModel.displayedConversation!.participants.first != nil - && conversationViewModel.displayedConversation!.participants.first!.address != nil { - if conversationViewModel.displayedConversation!.participants.first!.address!.displayName != nil { - Image(uiImage: contactsManager.textToImage( - firstName: conversationViewModel.displayedConversation!.participants.first!.address!.displayName!, - lastName: conversationViewModel.displayedConversation!.participants.first!.address!.displayName!.components(separatedBy: " ").count > 1 - ? conversationViewModel.displayedConversation!.participants.first!.address!.displayName!.components(separatedBy: " ")[1] - : "")) - .resizable() - .frame(width: 50, height: 50) - .clipShape(Circle()) - .padding(.top, 4) - - } else { - Image(uiImage: contactsManager.textToImage( - firstName: conversationViewModel.displayedConversation!.participants.first!.address!.username ?? "Username Error", - lastName: conversationViewModel.displayedConversation!.participants.first!.address!.username!.components(separatedBy: " ").count > 1 - ? conversationViewModel.displayedConversation!.participants.first!.address!.username!.components(separatedBy: " ")[1] - : "")) - .resizable() - .frame(width: 50, height: 50) - .clipShape(Circle()) - .padding(.top, 4) - } - - } else { - Image("profil-picture-default") - .resizable() - .frame(width: 50, height: 50) - .clipShape(Circle()) - .padding(.top, 4) - } - } - if LinphoneUtils.isChatRoomAGroup(chatRoom: conversationViewModel.displayedConversation!) { - Text(conversationViewModel.displayedConversation!.subject ?? "No Subject") - .default_text_style(styleSize: 16) - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.top, 4) - .lineLimit(1) - } else if addressFriend != nil { - Text(addressFriend!.name!) - .default_text_style(styleSize: 16) - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.top, 4) - .lineLimit(1) - } else { - if conversationViewModel.displayedConversation!.participants.first != nil - && conversationViewModel.displayedConversation!.participants.first!.address != nil { - Text(conversationViewModel.displayedConversation!.participants.first!.address!.displayName != nil - ? conversationViewModel.displayedConversation!.participants.first!.address!.displayName! - : conversationViewModel.displayedConversation!.participants.first!.address!.username!) - .default_text_style(styleSize: 16) - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.top, 4) - .lineLimit(1) - } - } + Text(conversationViewModel.displayedConversation!.subject) + .default_text_style(styleSize: 16) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.top, 4) + .lineLimit(1) Spacer() @@ -238,7 +153,7 @@ struct ConversationFragment: View { .scaleEffect(x: 1, y: -1, anchor: .center) .listRowInsets(EdgeInsets(top: 2, leading: 10, bottom: 2, trailing: 10)) .listRowSeparator(.hidden) - .transition(.move(edge: .top)) + .transition(.move(edge: .top)) } } .scaleEffect(x: 1, y: -1, anchor: .center) diff --git a/Linphone/UI/Main/Conversations/Fragments/ConversationsListBottomSheet.swift b/Linphone/UI/Main/Conversations/Fragments/ConversationsListBottomSheet.swift index c51310d32..14a81e55e 100644 --- a/Linphone/UI/Main/Conversations/Fragments/ConversationsListBottomSheet.swift +++ b/Linphone/UI/Main/Conversations/Fragments/ConversationsListBottomSheet.swift @@ -97,7 +97,7 @@ struct ConversationsListBottomSheet: View { Button { if conversationsListViewModel.selectedConversation != nil { conversationsListViewModel.objectWillChange.send() - conversationsListViewModel.selectedConversation!.muted.toggle() + conversationsListViewModel.selectedConversation!.toggleMute() } if #available(iOS 16.0, *) { @@ -113,13 +113,13 @@ struct ConversationsListBottomSheet: View { } } label: { HStack { - Image(conversationsListViewModel.selectedConversation!.muted ? "bell" : "bell-slash") + 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!.muted ? "Réactiver les notifications" : "Mettre en sourdine") + Text(conversationsListViewModel.selectedConversation!.isMuted ? "Réactiver les notifications" : "Mettre en sourdine") .default_text_style(styleSize: 16) Spacer() } @@ -134,12 +134,10 @@ struct ConversationsListBottomSheet: View { .frame(maxWidth: .infinity) if conversationsListViewModel.selectedConversation != nil - && conversationsListViewModel.selectedConversation!.hasCapability(mask: ChatRoom.Capabilities.OneToOne.rawValue) { + && !conversationsListViewModel.selectedConversation!.isGroup { Button { - if conversationsListViewModel.selectedConversation!.participants.first != nil { - TelecomManager.shared.doCallWithCore( - addr: conversationsListViewModel.selectedConversation!.participants.first!.address!, isVideo: false - ) + if !conversationsListViewModel.selectedConversation!.isGroup { + conversationsListViewModel.selectedConversation!.call() } if #available(iOS 16.0, *) { @@ -178,12 +176,7 @@ struct ConversationsListBottomSheet: View { } Button { - if conversationsListViewModel.selectedConversation != nil { - CoreContext.shared.doOnCoreQueue { core in - core.deleteChatRoom(chatRoom: conversationsListViewModel.selectedConversation!) - //conversationsListViewModel.computeChatRoomsList(filter: "") - } - } + conversationsListViewModel.computeChatRoomsList(filter: "") if #available(iOS 16.0, *) { if idiom != .pad { diff --git a/Linphone/UI/Main/Conversations/Fragments/ConversationsListFragment.swift b/Linphone/UI/Main/Conversations/Fragments/ConversationsListFragment.swift index 6f51b17a4..ee2ba6a2c 100644 --- a/Linphone/UI/Main/Conversations/Fragments/ConversationsListFragment.swift +++ b/Linphone/UI/Main/Conversations/Fragments/ConversationsListFragment.swift @@ -22,8 +22,6 @@ import linphonesw struct ConversationsListFragment: View { - @ObservedObject var contactsManager = ContactsManager.shared - @ObservedObject var conversationViewModel: ConversationViewModel @ObservedObject var conversationsListViewModel: ConversationsListViewModel @@ -34,111 +32,21 @@ struct ConversationsListFragment: View { List { ForEach(0.. 1 - ? conversationsListViewModel.conversationsList[index].subject!.components(separatedBy: " ")[1] - : "")) - .resizable() - .frame(width: 50, height: 50) - .clipShape(Circle()) - } else if addressFriend != nil && addressFriend!.photo != nil && !addressFriend!.photo!.isEmpty { - if contactAvatarModel != nil { - Avatar(contactAvatarModel: contactAvatarModel!, avatarSize: 50) - } else { - Image("profil-picture-default") - .resizable() - .frame(width: 50, height: 50) - .clipShape(Circle()) - } - } else { - if conversationsListViewModel.conversationsList[index].participants.first != nil - && conversationsListViewModel.conversationsList[index].participants.first!.address != nil { - if conversationsListViewModel.conversationsList[index].participants.first!.address!.displayName != nil { - Image(uiImage: contactsManager.textToImage( - firstName: conversationsListViewModel.conversationsList[index].participants.first!.address!.displayName!, - lastName: conversationsListViewModel.conversationsList[index].participants.first!.address!.displayName!.components(separatedBy: " ").count > 1 - ? conversationsListViewModel.conversationsList[index].participants.first!.address!.displayName!.components(separatedBy: " ")[1] - : "")) - .resizable() - .frame(width: 50, height: 50) - .clipShape(Circle()) - - } else { - Image(uiImage: contactsManager.textToImage( - firstName: conversationsListViewModel.conversationsList[index].participants.first!.address!.username ?? "Username Error", - lastName: conversationsListViewModel.conversationsList[index].participants.first!.address!.username!.components(separatedBy: " ").count > 1 - ? conversationsListViewModel.conversationsList[index].participants.first!.address!.username!.components(separatedBy: " ")[1] - : "")) - .resizable() - .frame(width: 50, height: 50) - .clipShape(Circle()) - } - - } else { - Image("profil-picture-default") - .resizable() - .frame(width: 50, height: 50) - .clipShape(Circle()) - } - } + Avatar(contactAvatarModel: conversationsListViewModel.conversationsList[index].avatarModel, avatarSize: 50) VStack(spacing: 0) { Spacer() - - if LinphoneUtils.isChatRoomAGroup(chatRoom: conversationsListViewModel.conversationsList[index]) { - Text(conversationsListViewModel.conversationsList[index].subject ?? "No Subject") - .foregroundStyle(Color.grayMain2c800) - .if(conversationsListViewModel.conversationsList[index].unreadMessagesCount > 0) { view in - view.default_text_style_700(styleSize: 14) - } - .default_text_style(styleSize: 14) - .frame(maxWidth: .infinity, alignment: .leading) - .lineLimit(1) - } else if addressFriend != nil { - Text(addressFriend!.name!) - .foregroundStyle(Color.grayMain2c800) - .if(conversationsListViewModel.conversationsList[index].unreadMessagesCount > 0) { view in - view.default_text_style_700(styleSize: 14) - } - .default_text_style(styleSize: 14) - .frame(maxWidth: .infinity, alignment: .leading) - .lineLimit(1) - } else { - if conversationsListViewModel.conversationsList[index].participants.first != nil - && conversationsListViewModel.conversationsList[index].participants.first!.address != nil { - Text(conversationsListViewModel.conversationsList[index].participants.first!.address!.displayName != nil - ? conversationsListViewModel.conversationsList[index].participants.first!.address!.displayName! - : conversationsListViewModel.conversationsList[index].participants.first!.address!.username!) - .foregroundStyle(Color.grayMain2c800) - .if(conversationsListViewModel.conversationsList[index].unreadMessagesCount > 0) { view in - view.default_text_style_700(styleSize: 14) - } - .default_text_style(styleSize: 14) - .frame(maxWidth: .infinity, alignment: .leading) - .lineLimit(1) + + Text(conversationsListViewModel.conversationsList[index].subject) + .foregroundStyle(Color.grayMain2c800) + .if(conversationsListViewModel.conversationsList[index].unreadMessagesCount > 0) { view in + view.default_text_style_700(styleSize: 14) } - } + .default_text_style(styleSize: 14) + .frame(maxWidth: .infinity, alignment: .leading) + .lineLimit(1) - Text( - conversationsListViewModel.conversationsList[index].lastMessageInHistory != nil - ? conversationsListViewModel.getContentTextMessage(message: conversationsListViewModel.conversationsList[index].lastMessageInHistory!) - : "" - ) + Text(conversationsListViewModel.conversationsList[index].lastMessageText) .foregroundStyle(Color.grayMain2c400) .if(conversationsListViewModel.conversationsList[index].unreadMessagesCount > 0) { view in view.default_text_style_700(styleSize: 14) @@ -156,8 +64,7 @@ struct ConversationsListFragment: View { Spacer() HStack { - if conversationsListViewModel.conversationsList[index].currentParams != nil - && !conversationsListViewModel.conversationsList[index].currentParams!.encryptionEnabled { + if !conversationsListViewModel.conversationsList[index].encryptionEnabled { Image("warning-circle") .renderingMode(.template) .resizable() @@ -174,15 +81,15 @@ struct ConversationsListFragment: View { Spacer() HStack { - if conversationsListViewModel.conversationsList[index].muted == false - && !(conversationsListViewModel.conversationsList[index].lastMessageInHistory != nil - && conversationsListViewModel.conversationsList[index].lastMessageInHistory!.isOutgoing == true) + if conversationsListViewModel.conversationsList[index].isMuted == false + && !(!conversationsListViewModel.conversationsList[index].lastMessageText.isEmpty + && conversationsListViewModel.conversationsList[index].lastMessageIsOutgoing == true) && conversationsListViewModel.conversationsList[index].unreadMessagesCount == 0 { Text("") .frame(width: 18, height: 18, alignment: .trailing) } - if conversationsListViewModel.conversationsList[index].muted { + if conversationsListViewModel.conversationsList[index].isMuted { Image("bell-slash") .renderingMode(.template) .resizable() @@ -190,9 +97,9 @@ struct ConversationsListFragment: View { .frame(width: 18, height: 18, alignment: .trailing) } - if conversationsListViewModel.conversationsList[index].lastMessageInHistory != nil - && conversationsListViewModel.conversationsList[index].lastMessageInHistory!.isOutgoing == true { - let imageName = LinphoneUtils.getChatIconState(chatState: conversationsListViewModel.conversationsList[index].lastMessageInHistory!.state) + if !conversationsListViewModel.conversationsList[index].lastMessageText.isEmpty + && conversationsListViewModel.conversationsList[index].lastMessageIsOutgoing == true { + let imageName = LinphoneUtils.getChatIconState(chatState: conversationsListViewModel.conversationsList[index].lastMessageState) Image(imageName) .renderingMode(.template) .resizable() @@ -220,7 +127,6 @@ struct ConversationsListFragment: View { Spacer() } .padding(.trailing, 10) - } } .buttonStyle(.borderless) .listRowInsets(EdgeInsets(top: 6, leading: 20, bottom: 6, trailing: 20)) @@ -228,7 +134,7 @@ struct ConversationsListFragment: View { .background(.white) .onTapGesture { withAnimation { - conversationViewModel.displayedConversation = conversationsListViewModel.conversationsList[index] + conversationViewModel.changeDisplayedChatRoom(conversationModel: conversationsListViewModel.conversationsList[index]) conversationViewModel.getMessage() } } diff --git a/Linphone/UI/Main/Conversations/Model/ConversationModel.swift b/Linphone/UI/Main/Conversations/Model/ConversationModel.swift new file mode 100644 index 000000000..e04ce5b39 --- /dev/null +++ b/Linphone/UI/Main/Conversations/Model/ConversationModel.swift @@ -0,0 +1,231 @@ +/* + * 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 + +class ConversationModel: ObservableObject { + + 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 isComposing: Bool + @Published var lastUpdateTime: time_t + //@Published var composingLabel: String + @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 dateTime: String + @Published var unreadMessagesCount: Int + @Published var avatarModel: ContactAvatarModel + //@Published var isBeingDeleted: Bool + + //private let lastMessage: ChatMessage? = nil + + 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.composingLabel = chatRoom.compo + + 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.dateTime = chatRoom.date + + self.unreadMessagesCount = chatRoom.unreadMessagesCount + + self.avatarModel = ContactAvatarModel(friend: nil, name: "", withPresence: false) + + //self.isBeingDeleted = MutableLiveData() + + //self.lastMessage: ChatMessage? = null + + getContentTextMessage() + getChatRoomSubject() + } + + func leave(){ + coreContext.doOnCoreQueue { _ in + self.chatRoom.leave() + } + } + + func markAsRead() { + coreContext.doOnCoreQueue { _ in + self.chatRoom.markAsRead() + } + } + + func toggleMute() { + coreContext.doOnCoreQueue { _ in + self.chatRoom.muted.toggle() + self.isMuted = self.chatRoom.muted + } + } + + func call() { + coreContext.doOnCoreQueue { _ in + if self.chatRoom.peerAddress != nil { + TelecomManager.shared.doCallWithCore( + addr: self.chatRoom.peerAddress!, isVideo: false + ) + } + } + } + + 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 + + if self.isGroup { + self.subject = self.chatRoom.subject! + } else if addressFriend != nil { + self.subject = addressFriend!.name! + } else { + if self.chatRoom.participants.first != nil + && self.chatRoom.participants.first!.address != nil { + + self.subject = self.chatRoom.participants.first!.address!.displayName != nil + ? self.chatRoom.participants.first!.address!.displayName! + : self.chatRoom.participants.first!.address!.username! + + } + } + + 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: self.subject, withPresence: false) + : ContactAvatarModel(friend: nil, name: self.subject, withPresence: false) + + DispatchQueue.main.async { + self.avatarModel = avatarModelTmp + } + } + } + + func refreshAvatarModel() { + 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 + + if addressFriend != nil && !self.isGroup { + let avatarModelTmp = ContactsManager.shared.avatarListModel.first(where: { + $0.friend!.name == addressFriend!.name + && $0.friend!.address!.asStringUriOnly() == addressFriend!.address!.asStringUriOnly() + }) ?? ContactAvatarModel(friend: nil, name: self.subject, withPresence: false) + + DispatchQueue.main.async { + self.avatarModel = avatarModelTmp + } + } + } + } +} diff --git a/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift b/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift index e34c268e3..40005d702 100644 --- a/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift +++ b/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift @@ -26,7 +26,7 @@ class ConversationViewModel: ObservableObject { private var coreContext = CoreContext.shared - @Published var displayedConversation: ChatRoom? + @Published var displayedConversation: ConversationModel? @Published var messageText: String = "" @@ -37,14 +37,16 @@ class ConversationViewModel: ObservableObject { init() {} func addConversationDelegate() { - if displayedConversation != nil { - self.chatRoomSuscriptions.insert(displayedConversation!.publisher?.onChatMessageSent?.postOnMainQueue { (cbValue: (chatRoom: ChatRoom, eventLog: EventLog)) in - self.getNewMessages(eventLogs: [cbValue.eventLog]) - }) - - self.chatRoomSuscriptions.insert(displayedConversation!.publisher?.onChatMessagesReceived?.postOnMainQueue { (cbValue: (chatRoom: ChatRoom, eventLogs: [EventLog])) in - self.getNewMessages(eventLogs: cbValue.eventLogs) - }) + coreContext.doOnCoreQueue { _ in + if self.displayedConversation != nil { + self.chatRoomSuscriptions.insert(self.displayedConversation?.chatRoom.publisher?.onChatMessageSent?.postOnCoreQueue { (cbValue: (chatRoom: ChatRoom, eventLog: EventLog)) in + self.getNewMessages(eventLogs: [cbValue.eventLog]) + }) + + self.chatRoomSuscriptions.insert(self.displayedConversation?.chatRoom.publisher?.onChatMessagesReceived?.postOnMainQueue { (cbValue: (chatRoom: ChatRoom, eventLogs: [EventLog])) in + self.getNewMessages(eventLogs: cbValue.eventLogs) + }) + } } } @@ -53,20 +55,36 @@ class ConversationViewModel: ObservableObject { } func getMessage() { - if self.displayedConversation != nil { - let historyEvents = displayedConversation!.getHistoryRangeEvents(begin: conversationMessagesList.count, end: conversationMessagesList.count + 30) - - historyEvents.reversed().forEach { eventLog in - conversationMessagesList.append(LinphoneCustomEventLog(eventLog: eventLog)) + coreContext.doOnCoreQueue { _ in + if self.displayedConversation != nil { + let historyEvents = self.displayedConversation!.chatRoom.getHistoryRangeEvents(begin: self.conversationMessagesList.count, end: self.conversationMessagesList.count + 30) + + historyEvents.reversed().forEach { eventLog in + DispatchQueue.main.async { + self.conversationMessagesList.append(LinphoneCustomEventLog(eventLog: eventLog)) + } + } } } } func getNewMessages(eventLogs: [EventLog]) { - withAnimation { - eventLogs.forEach { eventLog in - conversationMessagesList.insert(LinphoneCustomEventLog(eventLog: eventLog), at: 0) - //conversationMessagesList.append(LinphoneCustomEventLog(eventLog: eventLog)) + + //let conversationMessagesListTmp = self.conversationMessagesList + //self.conversationMessagesList = [] + + eventLogs.forEach { eventLog in + DispatchQueue.main.async { + /* + withAnimation { + self.conversationMessagesList.append(LinphoneCustomEventLog(eventLog: eventLog)) + } + + self.conversationMessagesList.append(contentsOf: conversationMessagesListTmp) + */ + withAnimation(.spring(duration: 2)) { + self.conversationMessagesList.insert(LinphoneCustomEventLog(eventLog: eventLog), at: 0) + } } } } @@ -76,83 +94,93 @@ class ConversationViewModel: ObservableObject { } func sendMessage() { - //val messageToReplyTo = chatMessageToReplyTo - //val message = if (messageToReplyTo != null) { + coreContext.doOnCoreQueue { _ in + //val messageToReplyTo = chatMessageToReplyTo + //val message = if (messageToReplyTo != null) { //Log.i("$TAG Sending message as reply to [${messageToReplyTo.messageId}]") //chatRoom.createReplyMessage(messageToReplyTo) - //} else { - let message = try? self.displayedConversation!.createEmptyMessage() - //} - - let toSend = self.messageText.trimmingCharacters(in: .whitespacesAndNewlines) - if !toSend.isEmpty { - if message != nil { - message!.addUtf8TextContent(text: toSend) - } - } - - /* - if (isVoiceRecording.value == true && voiceMessageRecorder.file != null) { - stopVoiceRecorder() - val content = voiceMessageRecorder.createContent() - if (content != null) { - Log.i( - "$TAG Voice recording content created, file name is ${content.name} and duration is ${content.fileDuration}" - ) - message.addContent(content) - } else { - Log.e("$TAG Voice recording content couldn't be created!") - } - } else { - for (attachment in attachments.value.orEmpty()) { - val content = Factory.instance().createContent() - - content.type = when (attachment.mimeType) { - FileUtils.MimeType.Image -> "image" - FileUtils.MimeType.Audio -> "audio" - FileUtils.MimeType.Video -> "video" - FileUtils.MimeType.Pdf -> "application" - FileUtils.MimeType.PlainText -> "text" - else -> "file" + //} else { + let message = try? self.displayedConversation!.chatRoom.createEmptyMessage() + //} + + let toSend = self.messageText.trimmingCharacters(in: .whitespacesAndNewlines) + if !toSend.isEmpty { + if message != nil { + message!.addUtf8TextContent(text: toSend) } - content.subtype = if (attachment.mimeType == FileUtils.MimeType.PlainText) { - "plain" - } else { - FileUtils.getExtensionFromFileName(attachment.fileName) - } - content.name = attachment.fileName - // Let the file body handler take care of the upload - content.filePath = attachment.file - - message.addFileContent(content) } + + /* + if (isVoiceRecording.value == true && voiceMessageRecorder.file != null) { + stopVoiceRecorder() + val content = voiceMessageRecorder.createContent() + if (content != null) { + Log.i( + "$TAG Voice recording content created, file name is ${content.name} and duration is ${content.fileDuration}" + ) + message.addContent(content) + } else { + Log.e("$TAG Voice recording content couldn't be created!") + } + } else { + for (attachment in attachments.value.orEmpty()) { + val content = Factory.instance().createContent() + + content.type = when (attachment.mimeType) { + FileUtils.MimeType.Image -> "image" + FileUtils.MimeType.Audio -> "audio" + FileUtils.MimeType.Video -> "video" + FileUtils.MimeType.Pdf -> "application" + FileUtils.MimeType.PlainText -> "text" + else -> "file" + } + content.subtype = if (attachment.mimeType == FileUtils.MimeType.PlainText) { + "plain" + } else { + FileUtils.getExtensionFromFileName(attachment.fileName) + } + content.name = attachment.fileName + // Let the file body handler take care of the upload + content.filePath = attachment.file + + message.addFileContent(content) + } + } + */ + + 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.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 + */ } - */ - - if message != nil && !message!.contents.isEmpty { - Log.info("[ConversationViewModel] Sending message") - message!.send() - } - - Log.info("[ConversationViewModel] Message sent, re-setting defaults") - 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 - */ + } + + func changeDisplayedChatRoom(conversationModel: ConversationModel) { + self.displayedConversation = conversationModel } } struct LinphoneCustomEventLog: Hashable { diff --git a/Linphone/UI/Main/Conversations/ViewModel/ConversationsListViewModel.swift b/Linphone/UI/Main/Conversations/ViewModel/ConversationsListViewModel.swift index f3a3c8da7..41553ff9b 100644 --- a/Linphone/UI/Main/Conversations/ViewModel/ConversationsListViewModel.swift +++ b/Linphone/UI/Main/Conversations/ViewModel/ConversationsListViewModel.swift @@ -28,10 +28,10 @@ class ConversationsListViewModel: ObservableObject { private var mCoreSuscriptions = Set() - @Published var conversationsList: [ChatRoom] = [] + @Published var conversationsList: [ConversationModel] = [] @Published var unreadMessages: Int = 0 - var selectedConversation: ChatRoom? + var selectedConversation: ConversationModel? init() { computeChatRoomsList(filter: "") @@ -43,47 +43,64 @@ class ConversationsListViewModel: ObservableObject { let account = core.defaultAccount let chatRooms = account?.chatRooms != nil ? account!.chatRooms : core.chatRooms - DispatchQueue.main.async { - self.conversationsList = [] - chatRooms.forEach { chatRoom in - //let disabledBecauseNotSecured = (account?.isInSecureMode() == true && !chatRoom.hasCapability) ? Capabilities.Encrypted.toInt() : 0 - if chatRoom.hasCapability(mask: ChatRoom.Capabilities.OneToOne.rawValue) { - } - - if filter.isEmpty { - //val model = ConversationModel(chatRoom, disabledBecauseNotSecured) - self.conversationsList.append(chatRoom) - } - /* - else { - val participants = chatRoom.participants - val found = participants.find { - // Search in address but also in contact name if exists - val model = - coreContext.contactsManager.getContactAvatarModelForAddress(it.address) - model.contactName?.contains( - filter, - ignoreCase = true - ) == true || it.address.asStringUriOnly().contains( - filter, - ignoreCase = true - ) - } - if ( - found != null || - chatRoom.peerAddress.asStringUriOnly().contains(filter, ignoreCase = true) || - chatRoom.subject.orEmpty().contains(filter, ignoreCase = true) - ) { - val model = ConversationModel(chatRoom, disabledBecauseNotSecured) - list.add(model) - count += 1 - } - } - */ + var conversationsListTmp: [ConversationModel] = [] + + chatRooms.forEach { chatRoom in + //let disabledBecauseNotSecured = (account?.isInSecureMode() == true && !chatRoom.hasCapability) ? Capabilities.Encrypted.toInt() : 0 + if chatRoom.hasCapability(mask: ChatRoom.Capabilities.OneToOne.rawValue) { } - self.updateUnreadMessagesCount() + if filter.isEmpty { + let model = ConversationModel(chatRoom: chatRoom) + conversationsListTmp.append(model) + } + /* + else { + val participants = chatRoom.participants + val found = participants.find { + // Search in address but also in contact name if exists + val model = + coreContext.contactsManager.getContactAvatarModelForAddress(it.address) + model.contactName?.contains( + filter, + ignoreCase = true + ) == true || it.address.asStringUriOnly().contains( + filter, + ignoreCase = true + ) + } + if ( + found != null || + chatRoom.peerAddress.asStringUriOnly().contains(filter, ignoreCase = true) || + chatRoom.subject.orEmpty().contains(filter, ignoreCase = true) + ) { + val model = ConversationModel(chatRoom, disabledBecauseNotSecured) + list.add(model) + count += 1 + } + } + */ } + + if !self.conversationsList.isEmpty { + for (index, element) in conversationsListTmp.enumerated() { + if index > 0 && element.id != self.conversationsList[index].id { + DispatchQueue.main.async { + self.conversationsList[index] = element + } + } + } + + DispatchQueue.main.async { + self.conversationsList[0] = conversationsListTmp.first! + } + } else { + DispatchQueue.main.async { + self.conversationsList = conversationsListTmp + } + } + + self.updateUnreadMessagesCount() } } @@ -93,7 +110,8 @@ class ConversationsListViewModel: ObservableObject { //Log.info("[ConversationsListViewModel] Conversation [${LinphoneUtils.getChatRoomId(chatRoom)}] state changed [$state]") switch cbValue.state { case ChatRoom.State.Created: - self.addChatRoom(cbChatRoom: cbValue.chatRoom) + let model = ConversationModel(chatRoom: cbValue.chatRoom) + self.addChatRoom(cbChatRoom: model) case ChatRoom.State.Deleted: self.computeChatRoomsList(filter: "") //ToastViewModel.shared.toastMessage = "toast_conversation_deleted" @@ -109,14 +127,13 @@ class ConversationsListViewModel: ObservableObject { self.mCoreSuscriptions.insert(core.publisher?.onMessagesReceived?.postOnMainQueue { _ in self.computeChatRoomsList(filter: "") - }) } } - func addChatRoom(cbChatRoom: ChatRoom) { + func addChatRoom(cbChatRoom: ConversationModel) { Log.info("[ConversationsListViewModel] Re-ordering conversations") - var sortedList: [ChatRoom] = [] + var sortedList: [ConversationModel] = [] sortedList.append(cbChatRoom) sortedList.append(contentsOf: self.conversationsList) @@ -129,7 +146,7 @@ class ConversationsListViewModel: ObservableObject { func reorderChatRooms() { Log.info("[ConversationsListViewModel] Re-ordering conversations") - var sortedList: [ChatRoom] = [] + var sortedList: [ConversationModel] = [] sortedList.append(contentsOf: conversationsList) DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { @@ -204,4 +221,10 @@ class ConversationsListViewModel: ObservableObject { return formatter.string(from: myNSDate) } } + + func refreshContactAvatarModel() { + conversationsList.forEach { conversationModel in + conversationModel.refreshAvatarModel() + } + } } diff --git a/Linphone/UI/Main/History/Fragments/HistoryContactFragment.swift b/Linphone/UI/Main/History/Fragments/HistoryContactFragment.swift index a5021b64a..9c427a317 100644 --- a/Linphone/UI/Main/History/Fragments/HistoryContactFragment.swift +++ b/Linphone/UI/Main/History/Fragments/HistoryContactFragment.swift @@ -563,7 +563,7 @@ struct HistoryContactFragment: View { #Preview { HistoryContactFragment( - contactAvatarModel: ContactAvatarModel(friend: nil, withPresence: false), + contactAvatarModel: ContactAvatarModel(friend: nil, name: "", withPresence: false), historyViewModel: HistoryViewModel(), historyListViewModel: HistoryListViewModel(), contactViewModel: ContactViewModel(), diff --git a/Linphone/UI/Main/History/Fragments/HistoryListFragment.swift b/Linphone/UI/Main/History/Fragments/HistoryListFragment.swift index 9d219ff30..0c4bc0d05 100644 --- a/Linphone/UI/Main/History/Fragments/HistoryListFragment.swift +++ b/Linphone/UI/Main/History/Fragments/HistoryListFragment.swift @@ -48,7 +48,7 @@ struct HistoryListFragment: View { && $0.friend!.name == addressFriend!.name && $0.friend!.address!.asStringUriOnly() == addressFriend!.address!.asStringUriOnly() }) - : ContactAvatarModel(friend: nil, withPresence: false) + : ContactAvatarModel(friend: nil, name: "", withPresence: false) if addressFriend != nil && addressFriend!.photo != nil && !addressFriend!.photo!.isEmpty { if contactAvatarModel != nil { diff --git a/Linphone/Utils/Avatar.swift b/Linphone/Utils/Avatar.swift index 5143790a5..aca33765f 100644 --- a/Linphone/Utils/Avatar.swift +++ b/Linphone/Utils/Avatar.swift @@ -22,6 +22,8 @@ import linphonesw struct Avatar: View { + private var contactsManager = ContactsManager.shared + @ObservedObject var contactAvatarModel: ContactAvatarModel let avatarSize: CGFloat @@ -71,6 +73,15 @@ struct Avatar: View { EmptyView() } } + } 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: 50, height: 50) + .clipShape(Circle()) } else { Image("profil-picture-default") .resizable() diff --git a/Linphone/Utils/LinphoneUtils.swift b/Linphone/Utils/LinphoneUtils.swift index 579b4cdde..ded14ef87 100644 --- a/Linphone/Utils/LinphoneUtils.swift +++ b/Linphone/Utils/LinphoneUtils.swift @@ -27,20 +27,36 @@ class LinphoneUtils: NSObject { return !oneToOne && conference } - public class func getChatIconState(chatState: ChatMessage.State) -> String { + public class func getChatIconState(chatState: Int) -> String { return switch chatState { - case ChatMessage.State.Displayed, ChatMessage.State.FileTransferDone: + case ChatMessage.State.Displayed.rawValue, ChatMessage.State.FileTransferDone.rawValue: "checks" - case ChatMessage.State.DeliveredToUser: + case ChatMessage.State.DeliveredToUser.rawValue: "check" - case ChatMessage.State.Delivered: + case ChatMessage.State.Delivered.rawValue: "envelope-simple" - case ChatMessage.State.NotDelivered, ChatMessage.State.FileTransferError: + case ChatMessage.State.NotDelivered.rawValue, ChatMessage.State.FileTransferError.rawValue: "warning-circle" - case ChatMessage.State.InProgress, ChatMessage.State.FileTransferInProgress: + 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)" + } } diff --git a/Linphone/Utils/MagicSearchSingleton.swift b/Linphone/Utils/MagicSearchSingleton.swift index b65c09bf5..4a58ca4c8 100644 --- a/Linphone/Utils/MagicSearchSingleton.swift +++ b/Linphone/Utils/MagicSearchSingleton.swift @@ -82,9 +82,11 @@ final class MagicSearchSingleton: ObservableObject { self.contactsManager.lastSearch.forEach { searchResult in if searchResult.friend != nil { - self.contactsManager.avatarListModel.append(ContactAvatarModel(friend: searchResult.friend!, withPresence: true)) + self.contactsManager.avatarListModel.append(ContactAvatarModel(friend: searchResult.friend!, name: searchResult.friend?.name ?? "", withPresence: true)) } } + + NotificationCenter.default.post(name: NSNotification.Name("ContactLoaded"), object: nil) } } } From d8d867d798f4423bfab0a7e69d397751121ee37d Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Thu, 29 Feb 2024 17:28:11 +0100 Subject: [PATCH 135/486] Fix messages list in conversation --- .../Fragments/ChatBubbleView.swift | 40 ++-- .../Fragments/ConversationFragment.swift | 198 ++++++++++++++---- .../ConversationsListBottomSheet.swift | 3 +- .../Fragments/ConversationsListFragment.swift | 3 +- .../Model/ConversationModel.swift | 11 + .../ViewModel/ConversationViewModel.swift | 72 +++++-- 6 files changed, 250 insertions(+), 77 deletions(-) diff --git a/Linphone/UI/Main/Conversations/Fragments/ChatBubbleView.swift b/Linphone/UI/Main/Conversations/Fragments/ChatBubbleView.swift index 7789984ae..0c029a7dc 100644 --- a/Linphone/UI/Main/Conversations/Fragments/ChatBubbleView.swift +++ b/Linphone/UI/Main/Conversations/Fragments/ChatBubbleView.swift @@ -28,26 +28,34 @@ struct ChatBubbleView: View { var body: some View { if index < conversationViewModel.conversationMessagesList.count && conversationViewModel.conversationMessagesList[index].eventLog.chatMessage != nil { - HStack { - if conversationViewModel.conversationMessagesList[index].eventLog.chatMessage!.isOutgoing { - Spacer() + VStack { + if index == 0 && conversationViewModel.displayedConversationHistorySize > conversationViewModel.conversationMessagesList.count { + ProgressView() + .frame(idealWidth: .infinity, maxWidth: .infinity, alignment: .center) + .id(UUID()) } - VStack { - Text(conversationViewModel.conversationMessagesList[index].eventLog.chatMessage!.utf8Text ?? "") - .foregroundStyle(Color.grayMain2c700) - .default_text_style(styleSize: 16) - } - .padding(.all, 15) - .background(conversationViewModel.conversationMessagesList[index].eventLog.chatMessage!.isOutgoing ? Color.orangeMain100 : Color.grayMain2c100) - .clipShape(RoundedRectangle(cornerRadius: 16)) - - if !conversationViewModel.conversationMessagesList[index].eventLog.chatMessage!.isOutgoing { - Spacer() + HStack { + if conversationViewModel.conversationMessagesList[index].eventLog.chatMessage!.isOutgoing { + Spacer() + } + + VStack { + Text(conversationViewModel.conversationMessagesList[index].eventLog.chatMessage!.utf8Text ?? "") + .foregroundStyle(Color.grayMain2c700) + .default_text_style(styleSize: 16) + } + .padding(.all, 15) + .background(conversationViewModel.conversationMessagesList[index].eventLog.chatMessage!.isOutgoing ? Color.orangeMain100 : Color.grayMain2c100) + .clipShape(RoundedRectangle(cornerRadius: 16)) + + if !conversationViewModel.conversationMessagesList[index].eventLog.chatMessage!.isOutgoing { + Spacer() + } } + .padding(.leading, conversationViewModel.conversationMessagesList[index].eventLog.chatMessage!.isOutgoing ? 40 : 0) + .padding(.trailing, !conversationViewModel.conversationMessagesList[index].eventLog.chatMessage!.isOutgoing ? 40 : 0) } - .padding(.leading, conversationViewModel.conversationMessagesList[index].eventLog.chatMessage!.isOutgoing ? 40 : 0) - .padding(.trailing, !conversationViewModel.conversationMessagesList[index].eventLog.chatMessage!.isOutgoing ? 40 : 0) } } } diff --git a/Linphone/UI/Main/Conversations/Fragments/ConversationFragment.swift b/Linphone/UI/Main/Conversations/Fragments/ConversationFragment.swift index 05df3a93f..adf475fbd 100644 --- a/Linphone/UI/Main/Conversations/Fragments/ConversationFragment.swift +++ b/Linphone/UI/Main/Conversations/Fragments/ConversationFragment.swift @@ -32,6 +32,8 @@ struct ConversationFragment: View { @FocusState var isMessageTextFocused: Bool + @State var offset: CGPoint = .zero + var body: some View { NavigationView { GeometryReader { geometry in @@ -140,64 +142,169 @@ struct ConversationFragment: View { .padding(.bottom, 4) .background(.white) + /* + List { + ForEach(0.. conversationViewModel.conversationMessagesList.count { + //DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + conversationViewModel.getOldMessages() + //} + } + } + } + } + .listStyle(.plain) + .onTapGesture { + UIApplication.shared.endEditing() + } + .onAppear { + conversationViewModel.getMessages() + } + .onChange(of: conversationViewModel.conversationMessagesList) { _ in + if conversationViewModel.conversationMessagesList.count <= 30 { + proxy.scrollTo( + conversationViewModel.conversationMessagesList.last, anchor: .top + ) + } else if conversationViewModel.conversationMessagesList.count >= conversationViewModel.displayedConversationHistorySize { + print("ChatBubbleViewChatBubbleView 1 " + + "\(conversationViewModel.conversationMessagesList.count) " + + "\(conversationViewModel.displayedConversationHistorySize - 30) " + + "\(conversationViewModel.conversationMessagesList.first?.eventLog.chatMessage!.utf8Text ?? "") " + + "\(conversationViewModel.conversationMessagesList[29].eventLog.chatMessage!.utf8Text ?? "")" + ) + + proxy.scrollTo( + conversationViewModel.conversationMessagesList[conversationViewModel.displayedConversationHistorySize%30], anchor: .top + ) + } else { + print("ChatBubbleViewChatBubbleView 2 " + + "\(conversationViewModel.conversationMessagesList.count) " + + "\(conversationViewModel.displayedConversationHistorySize - 30) " + + "\(conversationViewModel.conversationMessagesList.first?.eventLog.chatMessage!.utf8Text ?? "") " + + "\(conversationViewModel.conversationMessagesList[29].eventLog.chatMessage!.utf8Text ?? "")" + ) + + proxy.scrollTo(30, anchor: .top) + } + } + .onDisappear { + conversationViewModel.resetMessage() } } - .scaleEffect(x: 1, y: -1, anchor: .center) - .listStyle(.plain) - .frame(maxWidth: .infinity) - .background(.white) - .onTapGesture { - UIApplication.shared.endEditing() - } - .onDisappear { - conversationViewModel.resetMessage() - } - - - /* - ScrollViewReader { proxy in - ScrollView { - LazyVStack { - ForEach(0.. Color in + DispatchQueue.main.async { + //self.offset = -geometry.frame(in: .named("scroll")).origin.y + let offsetMax = geometry.size.height - reader.size.height + //print("ScrollOffsetPreferenceKey >> \(self.offset) \(offsetMax)") + if -geometry.frame(in: .named("scroll")).origin.y <= 0 && self.offset > 0 { + conversationViewModel.getOldMessages() + print("ScrollOffsetPreferenceKey >> \(self.offset) \(-geometry.frame(in: .named("scroll")).origin.y) \(offsetMax)") + //proxy.scrollTo(conversationViewModel.conversationMessagesList[19], anchor: .top) + } + self.offset = -geometry.frame(in: .named("scroll")).origin.y + } + return Color.clear + }) + /*/ + .background(GeometryReader { geometry in + Color.clear + .preference(key: ScrollOffsetPreferenceKey.self, value: (geometry.frame(in: .named("scroll")).origin)) + }) + .onPreferenceChange(ScrollOffsetPreferenceKey.self) { value in + //self.scrollOffset = value + print("ScrollOffsetPreferenceKey \(value)") + if value.y > 0 { + print("ScrollOffsetPreferenceKey \(value) \(conversationViewModel.conversationMessagesList.count)") + conversationViewModel.getOldMessages() + } + } + */ } - } - .frame(maxWidth: .infinity) - .background(.white) - .onTapGesture { - UIApplication.shared.endEditing() - } - .onAppear { - if conversationViewModel.conversationMessagesList.last != nil { - proxy.scrollTo(conversationViewModel.conversationMessagesList.last!, anchor: .bottom) + .coordinateSpace(name: "scroll") + .onTapGesture { + UIApplication.shared.endEditing() + } + .onAppear { + conversationViewModel.getMessages() + } + .onDisappear { + conversationViewModel.resetMessage() + } + .defaultScrollAnchor(.bottom) + } else { + ScrollView(.vertical) { + VStack { + ForEach(0.. Date: Tue, 5 Mar 2024 16:17:23 +0100 Subject: [PATCH 136/486] Add linphonerc-factory and GoogleService-Info.plist to msgNotificationService app extension --- Linphone.xcodeproj/project.pbxproj | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/Linphone.xcodeproj/project.pbxproj b/Linphone.xcodeproj/project.pbxproj index 8dfb1c299..6a8dd8b81 100644 --- a/Linphone.xcodeproj/project.pbxproj +++ b/Linphone.xcodeproj/project.pbxproj @@ -11,6 +11,8 @@ 660D8A712B517D260092694D /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = 660D8A702B517D260092694D /* GoogleService-Info.plist */; }; 662B69D92B25DE18007118BF /* TelecomManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 662B69D82B25DE18007118BF /* TelecomManager.swift */; }; 662B69DB2B25DE25007118BF /* ProviderDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 662B69DA2B25DE25007118BF /* ProviderDelegate.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 */; }; 66C491F92B24D25B00CEA16D /* ConfigExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 66C491F82B24D25A00CEA16D /* ConfigExtension.swift */; }; 66C491FB2B24D32600CEA16D /* CoreExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 66C491FA2B24D32600CEA16D /* CoreExtension.swift */; }; @@ -135,6 +137,7 @@ 660D8A702B517D260092694D /* GoogleService-Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = "GoogleService-Info.plist"; 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 = ""; }; + 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 = ""; }; @@ -249,6 +252,7 @@ 660AAF7C2B839272004C0FA6 /* msgNotificationService */ = { isa = PBXGroup; children = ( + 667E5D802B8E444D00EBCFC4 /* GoogleService-Info.plist */, 6691CA7D2B839C2D00B2A7B8 /* NotificationService.swift */, 660AAF842B8392E0004C0FA6 /* msgNotificationService.entitlements */, ); @@ -680,6 +684,8 @@ isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( + 667E5D7F2B8E430C00EBCFC4 /* linphonerc-factory in Resources */, + 667E5D812B8E444E00EBCFC4 /* GoogleService-Info.plist in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; From a0fdd54b70c8a3fb85262b3bf46de296f6694136 Mon Sep 17 00:00:00 2001 From: QuentinArguillere Date: Tue, 5 Mar 2024 16:17:57 +0100 Subject: [PATCH 137/486] Add NSSupportsSuddentTermination key set to FALSE in info.plist --- Linphone/Info.plist | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Linphone/Info.plist b/Linphone/Info.plist index 0cc800b37..1639aabd3 100644 --- a/Linphone/Info.plist +++ b/Linphone/Info.plist @@ -6,6 +6,8 @@ ITSEncryptionExportComplianceCode b5cb085f-772a-4a4f-8c77-5d1332b1f93f + NSSupportsSuddenTermination + UIAppFonts NotoSans-Light.ttf From c2a1f7bc28605b38d99f7454591dcd9608dffe37 Mon Sep 17 00:00:00 2001 From: QuentinArguillere Date: Tue, 5 Mar 2024 16:19:39 +0100 Subject: [PATCH 138/486] Now stop/starts the core when entering background/foreground. Also moved the presence related code inside this new trigger for phase change --- Linphone/Core/CoreContext.swift | 48 +++++++++++++---------- Linphone/LinphoneApp.swift | 41 +++++++++---------- Linphone/UI/Main/ContentView.swift | 19 --------- Linphone/Utils/MagicSearchSingleton.swift | 4 ++ 4 files changed, 50 insertions(+), 62 deletions(-) diff --git a/Linphone/Core/CoreContext.swift b/Linphone/Core/CoreContext.swift index d7b838985..df718cd9e 100644 --- a/Linphone/Core/CoreContext.swift +++ b/Linphone/Core/CoreContext.swift @@ -166,9 +166,6 @@ final class CoreContext: ObservableObject { if cbVal.state == .Ok { self.loggingInProgress = false self.loggedIn = true - if self.mCore.consolidatedPresence != ConsolidatedPresence.Online { - self.onForeground() - } } else if cbVal.state == .Progress { self.loggingInProgress = true } else { @@ -195,8 +192,11 @@ final class CoreContext: ObservableObject { }) self.mCoreSuscriptions.insert(self.mCore.publisher?.onAccountRegistrationStateChanged?.postOnCoreQueue { (cbVal: (core: Core, account: Account, state: RegistrationState, message: String)) in - // If registration failed, remove account from core - if cbVal.state != .Ok && cbVal.state != .Progress { + if cbVal.state == .Ok { + if self.mCore.consolidatedPresence != ConsolidatedPresence.Online { + self.updatePresence(core: self.mCore, presence: ConsolidatedPresence.Online) + } + } else if cbVal.state != .Ok && cbVal.state != .Progress { // If registration failed, remove account from core let params = cbVal.account.params let clonedParams = params?.clone() clonedParams?.registerEnabled = false @@ -263,27 +263,35 @@ final class CoreContext: ObservableObject { try? self.mCore.start() } } - func onForeground() { - coreQueue.async { - // We can't rely on defaultAccount?.params?.isPublishEnabled - // as it will be modified by the SDK when changing the presence status - if self.mCore.config!.getBool(section: "app", key: "publish_presence", defaultValue: true) { - Log.info("App is in foreground, PUBLISHING presence as Online") - self.mCore.consolidatedPresence = ConsolidatedPresence.Online - } + + func updatePresence(core : Core, presence : ConsolidatedPresence) { + if core.config!.getBool(section: "app", key: "publish_presence", defaultValue: true) { + core.consolidatedPresence = presence } } - func onBackground() { + func onEnterForeground() { coreQueue.async { // We can't rely on defaultAccount?.params?.isPublishEnabled // as it will be modified by the SDK when changing the presence status - if self.mCore.config!.getBool(section: "app", key: "publish_presence", defaultValue: true) { - 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.mCore.consolidatedPresence = ConsolidatedPresence.Offline - } + + Log.info("App is in foreground, PUBLISHING presence as Online") + self.updatePresence(core: self.mCore, presence: ConsolidatedPresence.Online) + try? self.mCore.start() + } + } + + func onEnterBackground() { + coreQueue.async { + // 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() + self.mCore.stop() } } diff --git a/Linphone/LinphoneApp.swift b/Linphone/LinphoneApp.swift index dd68f6b7b..dcc46dea3 100644 --- a/Linphone/LinphoneApp.swift +++ b/Linphone/LinphoneApp.swift @@ -23,11 +23,6 @@ import linphonesw let accountTokenNotification = Notification.Name("AccountCreationTokenReceived") class AppDelegate: NSObject, UIApplicationDelegate { - func application(_ application: UIApplication, - didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool { - - return true - } func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) { let tokenStr = deviceToken.map { String(format: "%02.2hhx", $0) }.joined() @@ -43,7 +38,7 @@ class AppDelegate: NSObject, UIApplicationDelegate { } func application(_ application: UIApplication, didReceiveRemoteNotification userInfo: [AnyHashable: Any], fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void) { - Log.info("debugtrace -- Received background push notification, payload = \(userInfo.description)") + Log.info("Received background push notification, payload = \(userInfo.description)") let creationToken = (userInfo["customPayload"] as? NSDictionary)?["token"] as? String if let creationToken = creationToken { @@ -53,24 +48,14 @@ class AppDelegate: NSObject, UIApplicationDelegate { } func applicationWillTerminate(_ application: UIApplication) { - Log.info("debugtrace -- applicationWillTerminate") - CoreContext.shared.doOnCoreQueue { core in - Log.info("debugtrace COREQUEUE -- applicationWillTerminate") - core.stop() - } - } - - func applicationWillResignActive(_ application: UIApplication) { - Log.info("debugtrace -- applicationWillResignActive") - CoreContext.shared.doOnCoreQueue { core in - Log.info("debugtrace COREQUEUE -- applicationWillResignActive") - if let userDefaults = UserDefaults(suiteName: Config.appGroupName) { - userDefaults.set(false, forKey: "appactive") - } - try? Config.get().sync() - core.enterBackground() - if core.callsNb == 0 { + 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") } } } @@ -80,6 +65,7 @@ class AppDelegate: NSObject, UIApplicationDelegate { @main struct LinphoneApp: App { + @Environment(\.scenePhase) var scenePhase @UIApplicationDelegateAdaptor(AppDelegate.self) var delegate @ObservedObject private var coreContext = CoreContext.shared @ObservedObject private var sharedMainViewModel = SharedMainViewModel.shared @@ -133,6 +119,15 @@ struct LinphoneApp: App { callViewModel = CallViewModel() } } + }.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/UI/Main/ContentView.swift b/Linphone/UI/Main/ContentView.swift index aef8efb45..33529d2b3 100644 --- a/Linphone/UI/Main/ContentView.swift +++ b/Linphone/UI/Main/ContentView.swift @@ -710,25 +710,6 @@ struct ContentView: View { } orientation = newOrientation } - .onChange(of: scenePhase) { newPhase in - if newPhase == .active { - coreContext.onForeground() - /* - if !isShowStartCallFragment { - contactsManager.fetchContacts() - DispatchQueue.global().asyncAfter(deadline: .now() + 0.5) { - historyListViewModel.computeCallLogsList() - } - } - */ - print("Active") - } else if newPhase == .inactive { - print("Inactive") - } else if newPhase == .background { - coreContext.onBackground() - print("Background") - } - } } func openMenu() { diff --git a/Linphone/Utils/MagicSearchSingleton.swift b/Linphone/Utils/MagicSearchSingleton.swift index b65c09bf5..3593e26a2 100644 --- a/Linphone/Utils/MagicSearchSingleton.swift +++ b/Linphone/Utils/MagicSearchSingleton.swift @@ -43,6 +43,10 @@ final class MagicSearchSingleton: ObservableObject { var searchSubscription: AnyCancellable? + func destroyMagicSearch() { + magicSearch = nil + } + private init() { coreContext.doOnCoreQueue { core in self.domainDefaultAccount = core.defaultAccount?.params?.domain ?? "" From 3b20c47f1d342f9031f1a32b00effc8dcac27cd5 Mon Sep 17 00:00:00 2001 From: QuentinArguillere Date: Tue, 5 Mar 2024 16:20:00 +0100 Subject: [PATCH 139/486] For crashlytics : add informations in the msgNotificationService googleService-info.plist file --- .../GoogleService-Info.plist | 24 +++++++++---------- 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/msgNotificationService/GoogleService-Info.plist b/msgNotificationService/GoogleService-Info.plist index 98867592f..b493c102e 100644 --- a/msgNotificationService/GoogleService-Info.plist +++ b/msgNotificationService/GoogleService-Info.plist @@ -3,13 +3,13 @@ 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 @@ -19,18 +19,18 @@ 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 From 8e5a3b703f586c853a1f456d65d13970a1fa29a9 Mon Sep 17 00:00:00 2001 From: QuentinArguillere Date: Tue, 5 Mar 2024 16:21:39 +0100 Subject: [PATCH 140/486] Add "disable_chat_feature=0" key to rc files --- Linphone/Ressources/linphonerc-default | 1 + Linphone/Ressources/linphonerc-factory | 1 + 2 files changed, 2 insertions(+) diff --git a/Linphone/Ressources/linphonerc-default b/Linphone/Ressources/linphonerc-default index ea5356429..d0fb013ee 100644 --- a/Linphone/Ressources/linphonerc-default +++ b/Linphone/Ressources/linphonerc-default @@ -24,6 +24,7 @@ size=vga tunnel=disabled auto_start=1 record_aware=1 +disable_chat_feature=0 [tunnel] host= diff --git a/Linphone/Ressources/linphonerc-factory b/Linphone/Ressources/linphonerc-factory index 0abf8269d..eb16b25f2 100644 --- a/Linphone/Ressources/linphonerc-factory +++ b/Linphone/Ressources/linphonerc-factory @@ -45,6 +45,7 @@ store_friends=0 activation_code_length=4 prefer_basic_chat_room=1 record_aware=1 +disable_chat_feature=0 [account_creator] backend=1 From 66361d730941cab9049700dee8a7e782a0be5683 Mon Sep 17 00:00:00 2001 From: QuentinArguillere Date: Tue, 5 Mar 2024 16:22:23 +0100 Subject: [PATCH 141/486] Use centralised Config file in app extension --- .../NotificationService.swift | 25 +++++++------------ 1 file changed, 9 insertions(+), 16 deletions(-) diff --git a/msgNotificationService/NotificationService.swift b/msgNotificationService/NotificationService.swift index 45a81b621..ef3f6439e 100644 --- a/msgNotificationService/NotificationService.swift +++ b/msgNotificationService/NotificationService.swift @@ -25,14 +25,13 @@ import linphonesw import Firebase #endif -var APP_GROUP_ID = "group.org.linphone.phone.msgNotification" 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: APP_GROUP_ID) + let defaults = UserDefaults.init(suiteName: Config.appGroupName) let addressBook = defaults?.dictionary(forKey: "addressBook") if addressBook == nil { @@ -86,9 +85,13 @@ class NotificationService: UNNotificationServiceExtension { 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: APP_GROUP_ID) && !VFSUtil.activateVFS()) { + if (VFSUtil.vfsEnabled(groupName: Config.appGroupName) && !VFSUtil.activateVFS()) { VFSUtil.log("[VFS] Error unable to activate.", .error) } */ @@ -99,7 +102,7 @@ class NotificationService: UNNotificationServiceExtension { Log.info("received push payload : \(bestAttemptContent.userInfo.debugDescription)") /* - let defaults = UserDefaults.init(suiteName: APP_GROUP_ID) + 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 @@ -130,8 +133,6 @@ class NotificationService: UNNotificationServiceExtension { } else { bestAttemptContent.body = chatRoom.subject! } - - bestAttemptContent.sound = UNNotificationSound(named: UNNotificationSoundName("msg.caf")) // TODO : temporary fix, to be removed after flexisip release contentHandler(bestAttemptContent) return } @@ -257,16 +258,8 @@ class NotificationService: UNNotificationServiceExtension { func createCore() { Log.info("[msgNotificationService] create core") - - let config = Config.newForSharedCore(appGroupId: APP_GROUP_ID, configFilename: "linphonerc", factoryConfigFilename: "") - - LoggingService.Instance.logLevel = LogLevel.Debug - let configDir = Factory.Instance.getConfigDir(context: nil) - - Factory.Instance.logCollectionPath = configDir - Factory.Instance.enableLogCollection(state: LogCollectionState.Enabled) - - lc = try? Factory.Instance.createSharedCoreWithConfig(config: config!, systemContext: nil, appGroupId: APP_GROUP_ID, mainCore: false) + + lc = try? Factory.Instance.createSharedCoreWithConfig(config: Config.get(), systemContext: nil, appGroupId: Config.appGroupName, mainCore: false) } func stopCore() { From 56caacfe1c9b9fe6a8eabf3296c7223acf8fdcf7 Mon Sep 17 00:00:00 2001 From: QuentinArguillere Date: Tue, 5 Mar 2024 17:39:33 +0100 Subject: [PATCH 142/486] =?UTF-8?q?Ajout=20des=20traductions=20anglaises?= =?UTF-8?q?=20et=20fran=C3=A7aises=20pour=20les=20clefs=20IM=5FMSG=20et=20?= =?UTF-8?q?GC=5FMSG?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Linphone/Localizable.xcstrings | 37 ++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/Linphone/Localizable.xcstrings b/Linphone/Localizable.xcstrings index b5592f0d7..d2f5f86f4 100644 --- a/Linphone/Localizable.xcstrings +++ b/Linphone/Localizable.xcstrings @@ -337,6 +337,23 @@ }, "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" : { @@ -352,6 +369,23 @@ }, "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" + } + } + } }, "Incoming call" : { @@ -376,6 +410,9 @@ }, "Job title" : { + }, + "Key" : { + "extractionState" : "manual" }, "Last name" : { From 533bc26d6db60d6df4c7bd09c60fb16b56ea12ce Mon Sep 17 00:00:00 2001 From: QuentinArguillere Date: Tue, 5 Mar 2024 17:40:02 +0100 Subject: [PATCH 143/486] =?UTF-8?q?R=C3=A9activation=20des=20settings=20de?= =?UTF-8?q?=20lime=20et=20de=20conf=C3=A9rence=20au=20login=20des=20compte?= =?UTF-8?q?s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Linphone/UI/Assistant/Viewmodel/AccountLoginViewModel.swift | 6 ------ 1 file changed, 6 deletions(-) diff --git a/Linphone/UI/Assistant/Viewmodel/AccountLoginViewModel.swift b/Linphone/UI/Assistant/Viewmodel/AccountLoginViewModel.swift index 5c2a3f1ad..57dd6688d 100644 --- a/Linphone/UI/Assistant/Viewmodel/AccountLoginViewModel.swift +++ b/Linphone/UI/Assistant/Viewmodel/AccountLoginViewModel.swift @@ -98,12 +98,6 @@ class AccountLoginViewModel: ObservableObject { #endif accountParams.pushNotificationConfig?.provider = "apns" + pushEnvironment - // Temporary disable these features are they are not used for 6.0 first version - accountParams.conferenceFactoryUri = nil - accountParams.conferenceFactoryAddress = nil - accountParams.audioVideoConferenceFactoryAddress = nil - accountParams.limeServerUrl = nil - // Now that our AccountParams is configured, we can create the Account object let account = try core.createAccount(params: accountParams) From 8d6f09658251f463af731c2f38a8c5f5bb85acd9 Mon Sep 17 00:00:00 2001 From: QuentinArguillere Date: Thu, 7 Mar 2024 12:01:04 +0100 Subject: [PATCH 144/486] Disable background notification process for now (will be used later for account creation token processing) --- Linphone/LinphoneApp.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Linphone/LinphoneApp.swift b/Linphone/LinphoneApp.swift index dcc46dea3..a5135b9c1 100644 --- a/Linphone/LinphoneApp.swift +++ b/Linphone/LinphoneApp.swift @@ -39,12 +39,12 @@ class AppDelegate: NSObject, UIApplicationDelegate { 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) + completionHandler(UIBackgroundFetchResult.newData)*/ } func applicationWillTerminate(_ application: UIApplication) { From 73d6f805d37cc9946abe26228b468a21066bca33 Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Tue, 5 Mar 2024 14:40:14 +0100 Subject: [PATCH 145/486] Test Table view for messages list --- Linphone.xcodeproj/project.pbxproj | 12 + .../Fragments/ChatBubbleView.swift | 60 ++- .../Fragments/ConversationFragment.swift | 347 +++++-------- .../Fragments/ConversationsListFragment.swift | 9 +- .../Conversations/Fragments/MessageMenu.swift | 30 ++ .../Main/Conversations/Fragments/UIList.swift | 487 ++++++++++++++++++ .../Main/Conversations/Model/Attachment.swift | 61 +++ .../Model/ConversationModel.swift | 12 +- .../UI/Main/Conversations/Model/Message.swift | 296 +++++++++++ .../ViewModel/ConversationViewModel.swift | 115 ++++- .../ConversationsListViewModel.swift | 2 +- 11 files changed, 1188 insertions(+), 243 deletions(-) create mode 100644 Linphone/UI/Main/Conversations/Fragments/MessageMenu.swift create mode 100644 Linphone/UI/Main/Conversations/Fragments/UIList.swift create mode 100644 Linphone/UI/Main/Conversations/Model/Attachment.swift create mode 100644 Linphone/UI/Main/Conversations/Model/Message.swift diff --git a/Linphone.xcodeproj/project.pbxproj b/Linphone.xcodeproj/project.pbxproj index 43bab449b..329a09722 100644 --- a/Linphone.xcodeproj/project.pbxproj +++ b/Linphone.xcodeproj/project.pbxproj @@ -46,6 +46,7 @@ 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 */; }; @@ -99,6 +100,8 @@ 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 */; }; @@ -151,6 +154,7 @@ 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 = ""; }; @@ -205,6 +209,8 @@ 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 = ""; }; @@ -258,6 +264,8 @@ isa = PBXGroup; children = ( D70959F02B8DF3EC0014AC0B /* ConversationModel.swift */, + D7E6ADF22B9875C20009A2BC /* Message.swift */, + D7E6ADF42B9876ED0009A2BC /* Attachment.swift */, ); path = Model; sourceTree = ""; @@ -576,6 +584,7 @@ D70A26ED2B7CF60B006CC8FC /* ConversationsListBottomSheet.swift */, D70A26F12B7F5D95006CC8FC /* ConversationFragment.swift */, D71968912B86369D00DF4459 /* ChatBubbleView.swift */, + D72A9A042B9750A1000DC093 /* UIList.swift */, ); path = Fragments; sourceTree = ""; @@ -786,6 +795,7 @@ D75759322B56D40900E7AC10 /* ZRTPPopup.swift in Sources */, D7A2EDD62AC18115005D90FC /* SharedMainViewModel.swift in Sources */, D7A03FC62ACC458A0081A588 /* SplashScreen.swift in Sources */, + D7E6ADF52B9876ED0009A2BC /* Attachment.swift in Sources */, D7A03FC02ACC2E390081A588 /* HistoryView.swift in Sources */, D748BF2E2ACD82E7004844EB /* ThirdPartySipAccountWarningFragment.swift in Sources */, D7173EBE2B7A5C0A00BCC481 /* LinphoneUtils.swift in Sources */, @@ -797,11 +807,13 @@ 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 */, 66C491FB2B24D32600CEA16D /* CoreExtension.swift in Sources */, + D72A9A052B9750A1000DC093 /* UIList.swift in Sources */, D726E43D2B19E4FE0083C415 /* StartCallFragment.swift in Sources */, D7B99E9B2B29F7C300BE7BF2 /* ActivityIndicator.swift in Sources */, D72343302ACEFEF8009AA24E /* QrCodeScannerFragment.swift in Sources */, diff --git a/Linphone/UI/Main/Conversations/Fragments/ChatBubbleView.swift b/Linphone/UI/Main/Conversations/Fragments/ChatBubbleView.swift index 0c029a7dc..e1e8ef98b 100644 --- a/Linphone/UI/Main/Conversations/Fragments/ChatBubbleView.swift +++ b/Linphone/UI/Main/Conversations/Fragments/ChatBubbleView.swift @@ -23,13 +23,17 @@ struct ChatBubbleView: View { @ObservedObject var conversationViewModel: ConversationViewModel - let index: Int + //let index: IndexPath + + let message: Message var body: some View { - if index < conversationViewModel.conversationMessagesList.count + /* + if index < conversationViewModel.conversationMessagesList.count && conversationViewModel.conversationMessagesList[index].eventLog.chatMessage != nil { VStack { if index == 0 && conversationViewModel.displayedConversationHistorySize > conversationViewModel.conversationMessagesList.count { + //if index % 30 == 29 && conversationViewModel.displayedConversationHistorySize > conversationViewModel.conversationMessagesList.count { ProgressView() .frame(idealWidth: .infinity, maxWidth: .infinity, alignment: .center) .id(UUID()) @@ -57,9 +61,61 @@ struct ChatBubbleView: View { .padding(.trailing, !conversationViewModel.conversationMessagesList[index].eventLog.chatMessage!.isOutgoing ? 40 : 0) } } + if conversationViewModel.conversationMessagesSection.count > index.section && conversationViewModel.conversationMessagesSection[index.section].rows.count > index.row { + VStack { + HStack { + if message.isOutgoing { + Spacer() + } + + VStack { + Text(message.text + ) + .foregroundStyle(Color.grayMain2c700) + .default_text_style(styleSize: 16) + } + .padding(.all, 15) + .background(message.isOutgoing ? Color.orangeMain100 : Color.grayMain2c100) + .clipShape(RoundedRectangle(cornerRadius: 16)) + + if !message.isOutgoing { + Spacer() + } + } + .padding(.leading, message.isOutgoing ? 40 : 0) + .padding(.trailing, !message.isOutgoing ? 40 : 0) + } + } + */ + + VStack { + HStack { + if message.isOutgoing { + Spacer() + } + + VStack { + Text(message.text + ) + .foregroundStyle(Color.grayMain2c700) + .default_text_style(styleSize: 16) + } + .padding(.all, 15) + .background(message.isOutgoing ? Color.orangeMain100 : Color.grayMain2c100) + .clipShape(RoundedRectangle(cornerRadius: 16)) + + if !message.isOutgoing { + Spacer() + } + } + .padding(.leading, message.isOutgoing ? 40 : 0) + .padding(.trailing, !message.isOutgoing ? 40 : 0) + } } } +/* #Preview { ChatBubbleView(conversationViewModel: ConversationViewModel(), index: 0) } +*/ diff --git a/Linphone/UI/Main/Conversations/Fragments/ConversationFragment.swift b/Linphone/UI/Main/Conversations/Fragments/ConversationFragment.swift index adf475fbd..46dba158c 100644 --- a/Linphone/UI/Main/Conversations/Fragments/ConversationFragment.swift +++ b/Linphone/UI/Main/Conversations/Fragments/ConversationFragment.swift @@ -34,6 +34,14 @@ struct ConversationFragment: View { @State var offset: CGPoint = .zero + private let ids: [String] = [] + + @State private var isScrolledToBottom: Bool = true + var showMessageMenuOnLongPress: Bool = true + + @StateObject private var viewModel = ChatViewModel() + @StateObject private var paginationState = PaginationState() + var body: some View { NavigationView { GeometryReader { geometry in @@ -142,223 +150,106 @@ struct ConversationFragment: View { .padding(.bottom, 4) .background(.white) - /* - List { - ForEach(0.. conversationViewModel.conversationMessagesList.count { - //DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { - conversationViewModel.getOldMessages() - //} + if #available(iOS 16.0, *) { + ZStack(alignment: .bottomTrailing) { + list + + if !isScrolledToBottom { + Button { + NotificationCenter.default.post(name: .onScrollToBottom, object: nil) + } 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() } } - .listStyle(.plain) .onTapGesture { UIApplication.shared.endEditing() } .onAppear { conversationViewModel.getMessages() } - .onChange(of: conversationViewModel.conversationMessagesList) { _ in - if conversationViewModel.conversationMessagesList.count <= 30 { - proxy.scrollTo( - conversationViewModel.conversationMessagesList.last, anchor: .top - ) - } else if conversationViewModel.conversationMessagesList.count >= conversationViewModel.displayedConversationHistorySize { - print("ChatBubbleViewChatBubbleView 1 " - + "\(conversationViewModel.conversationMessagesList.count) " - + "\(conversationViewModel.displayedConversationHistorySize - 30) " - + "\(conversationViewModel.conversationMessagesList.first?.eventLog.chatMessage!.utf8Text ?? "") " - + "\(conversationViewModel.conversationMessagesList[29].eventLog.chatMessage!.utf8Text ?? "")" - ) - - proxy.scrollTo( - conversationViewModel.conversationMessagesList[conversationViewModel.displayedConversationHistorySize%30], anchor: .top - ) - } else { - print("ChatBubbleViewChatBubbleView 2 " - + "\(conversationViewModel.conversationMessagesList.count) " - + "\(conversationViewModel.displayedConversationHistorySize - 30) " - + "\(conversationViewModel.conversationMessagesList.first?.eventLog.chatMessage!.utf8Text ?? "") " - + "\(conversationViewModel.conversationMessagesList[29].eventLog.chatMessage!.utf8Text ?? "")" - ) - - proxy.scrollTo(30, anchor: .top) - } - } .onDisappear { conversationViewModel.resetMessage() } - } - - - /* - GeometryReader { reader in + } else { ScrollViewReader { proxy in - if #available(iOS 17.0, *) { - ScrollView(.vertical) { - VStack(spacing: 4) { - Spacer() - ForEach(0.. Color in - DispatchQueue.main.async { - //self.offset = -geometry.frame(in: .named("scroll")).origin.y - let offsetMax = geometry.size.height - reader.size.height - //print("ScrollOffsetPreferenceKey >> \(self.offset) \(offsetMax)") - if -geometry.frame(in: .named("scroll")).origin.y <= 0 && self.offset > 0 { + List { + ForEach(0.. conversationViewModel.conversationMessagesList.count { + //DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { conversationViewModel.getOldMessages() - print("ScrollOffsetPreferenceKey >> \(self.offset) \(-geometry.frame(in: .named("scroll")).origin.y) \(offsetMax)") - //proxy.scrollTo(conversationViewModel.conversationMessagesList[19], anchor: .top) + //} } - self.offset = -geometry.frame(in: .named("scroll")).origin.y } - return Color.clear - }) - /*/ - .background(GeometryReader { geometry in - Color.clear - .preference(key: ScrollOffsetPreferenceKey.self, value: (geometry.frame(in: .named("scroll")).origin)) - }) - .onPreferenceChange(ScrollOffsetPreferenceKey.self) { value in - //self.scrollOffset = value - print("ScrollOffsetPreferenceKey \(value)") - if value.y > 0 { - print("ScrollOffsetPreferenceKey \(value) \(conversationViewModel.conversationMessagesList.count)") - conversationViewModel.getOldMessages() - } - } - */ } - .coordinateSpace(name: "scroll") - .onTapGesture { - UIApplication.shared.endEditing() - } - .onAppear { - conversationViewModel.getMessages() - } - .onDisappear { - conversationViewModel.resetMessage() - } - .defaultScrollAnchor(.bottom) - } else { - ScrollView(.vertical) { - VStack { - ForEach(0..= conversationViewModel.displayedConversationHistorySize { + proxy.scrollTo( + conversationViewModel.conversationMessagesList[conversationViewModel.displayedConversationHistorySize%30], anchor: .top + ) + } else { + proxy.scrollTo(30, anchor: .top) } } + .onDisappear { + conversationViewModel.resetMessage() + } } } - */ - - /* - ScrollViewReader { proxy in - if #available(iOS 17.0, *) { - ScrollView { - LazyVStack { - ForEach(0... + */ + +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/UIList.swift b/Linphone/UI/Main/Conversations/Fragments/UIList.swift new file mode 100644 index 000000000..702f0a857 --- /dev/null +++ b/Linphone/UI/Main/Conversations/Fragments/UIList.swift @@ -0,0 +1,487 @@ +/* + * 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 +import SwiftUI + +public extension Notification.Name { + static let onScrollToBottom = Notification.Name("onScrollToBottom") +} + +struct UIList: UIViewRepresentable { + + @ObservedObject var viewModel: ChatViewModel + @ObservedObject var paginationState: PaginationState + @ObservedObject var conversationViewModel: ConversationViewModel + + @Binding var isScrolledToBottom: Bool + + let showMessageMenuOnLongPress: Bool + let sections: [MessagesSection] + let ids: [String] + + @State private var isScrolledToTop = false + + private let updatesQueue = DispatchQueue(label: "updatesQueue", qos: .utility) + @State private var updateSemaphore = DispatchSemaphore(value: 1) + @State private var tableSemaphore = DispatchSemaphore(value: 0) + + func makeUIView(context: Context) -> UITableView { + let tableView = UITableView(frame: .zero, style: .grouped) + tableView.contentInset = UIEdgeInsets(top: -10, left: 0, bottom: -20, 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.backgroundColor = UIColor(.white) + tableView.scrollsToTop = true + + NotificationCenter.default.addObserver(forName: .onScrollToBottom, object: nil, queue: nil) { _ in + DispatchQueue.main.async { + if !context.coordinator.sections.isEmpty { + tableView.scrollToRow(at: IndexPath(row: 0, section: 0), at: .bottom, animated: true) + } + } + } + + return tableView + } + + func updateUIView(_ tableView: UITableView, context: Context) { + if context.coordinator.sections == sections { + return + } + updatesQueue.async { + updateSemaphore.wait() + + if context.coordinator.sections == sections { + updateSemaphore.signal() + return + } + + let prevSections = context.coordinator.sections + let (appliedDeletes, appliedDeletesSwapsAndEdits, deleteOperations, swapOperations, editOperations, insertOperations) = operationsSplit(oldSections: prevSections, newSections: sections) + + // step 1 + // preapare intermediate sections and operations + //print("1 updateUIView sections:", "\n") + //print("whole previous:\n", formatSections(prevSections), "\n") + //print("whole appliedDeletes:\n", formatSections(appliedDeletes), "\n") + //print("whole appliedDeletesSwapsAndEdits:\n", formatSections(appliedDeletesSwapsAndEdits), "\n") + //print("whole final sections:\n", formatSections(sections), "\n") + + //print("operations delete:\n", deleteOperations) + //print("operations swap:\n", swapOperations) + //print("operations edit:\n", editOperations) + //print("operations insert:\n", insertOperations) + + DispatchQueue.main.async { + tableView.performBatchUpdates { + // step 2 + // delete sections and rows if necessary + //print("2 apply delete") + context.coordinator.sections = appliedDeletes + for operation in deleteOperations { + applyOperation(operation, tableView: tableView) + } + } completion: { _ in + tableSemaphore.signal() + //print("2 finished delete") + } + } + tableSemaphore.wait() + + DispatchQueue.main.async { + tableView.performBatchUpdates { + // step 3 + // swap places for rows that moved inside the table + // (example of how this happens. send two messages: first m1, then m2. if m2 is delivered to server faster, then it should jump above m1 even though it was sent later) + //print("3 apply swaps") + 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) + } + } completion: { _ in + tableSemaphore.signal() + //print("3 finished swaps") + } + } + tableSemaphore.wait() + + DispatchQueue.main.async { + tableView.performBatchUpdates { + // step 4 + // check only sections that are already in the table for existing rows that changed and apply only them to table's dataSource without animation + //print("4 apply edits") + context.coordinator.sections = appliedDeletesSwapsAndEdits + for operation in editOperations { + applyOperation(operation, tableView: tableView) + } + } completion: { _ in + tableSemaphore.signal() + //print("4 finished edits") + } + } + tableSemaphore.wait() + + if isScrolledToBottom || isScrolledToTop { + DispatchQueue.main.sync { + // step 5 + // apply the rest of the changes to table's dataSource, i.e. inserts + //print("5 apply inserts") + context.coordinator.sections = sections + context.coordinator.ids = ids + + tableView.beginUpdates() + for operation in insertOperations { + applyOperation(operation, tableView: tableView) + } + tableView.endUpdates() + + updateSemaphore.signal() + } + } else { + context.coordinator.ids = ids + updateSemaphore.signal() + } + } + } + + // 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: .top) + 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 // start with old sections, remove rows that need to be deleted + var appliedDeletesSwapsAndEdits = newSections // take new sections and remove rows that need to be inserted for now, then we'll get array with all the changes except for inserts + // appliedDeletesSwapsEditsAndInserts == newSection + + var deleteOperations = [Operation]() + var swapOperations = [Operation]() + var editOperations = [Operation]() + var insertOperations = [Operation]() + + // 1 compare sections + + 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 { + // operationIndex is not the same as newIndex because appliedDeletesSwapsAndEdits is being changed as we go, but to apply changes to UITableView we should have initial index + 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 } + + // 2 compare section rows + // isolate deletes and inserts, and remove them from row arrays, leaving only rows that are in both arrays: 'duplicates' + // this will allow to compare relative position changes of rows - swaps + + var oldRows = appliedDeletes[oldIndex].rows + var newRows = appliedDeletesSwapsAndEdits[newIndex].rows + let oldRowIDs = Set(oldRows.map { $0.id }) + let newRowIDs = Set(newRows.map { $0.id }) + let rowIDsToDelete = oldRowIDs.subtracting(newRowIDs) + let rowIDsToInsert = newRowIDs.subtracting(oldRowIDs) // TODO is order important? + for rowId in rowIDsToDelete { + if let index = oldRows.firstIndex(where: { $0.id == rowId }) { + oldRows.remove(at: index) + deleteOperations.append(.delete(oldIndex, index)) // this row was in old section, should not be in final result + } + } + for rowId in rowIDsToInsert { + if let index = newRows.firstIndex(where: { $0.id == rowId }) { + // this row was not in old section, should add it to final result + insertOperations.append(.insert(newIndex, index)) + } + } + + for rowId in rowIDsToInsert { + if let index = newRows.firstIndex(where: { $0.id == rowId }) { + // remove for now, leaving only 'duplicates' + newRows.remove(at: index) + } + } + + // 3 isolate swaps and edits + + 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 { + Coordinator( + conversationViewModel: conversationViewModel, + viewModel: viewModel, + paginationState: paginationState, + isScrolledToBottom: $isScrolledToBottom, + isScrolledToTop: $isScrolledToTop, + showMessageMenuOnLongPress: showMessageMenuOnLongPress, + sections: sections, + ids: ids + ) + } + + class Coordinator: NSObject, UITableViewDataSource, UITableViewDelegate { + + @ObservedObject var viewModel: ChatViewModel + @ObservedObject var paginationState: PaginationState + @ObservedObject var conversationViewModel: ConversationViewModel + + @Binding var isScrolledToBottom: Bool + @Binding var isScrolledToTop: Bool + + let showMessageMenuOnLongPress: Bool + var sections: [MessagesSection] + var ids: [String] + + init(conversationViewModel: ConversationViewModel, viewModel: ChatViewModel, paginationState: PaginationState, isScrolledToBottom: Binding, isScrolledToTop: Binding, showMessageMenuOnLongPress: Bool, sections: [MessagesSection], ids: [String]) { + self.conversationViewModel = conversationViewModel + self.viewModel = viewModel + self.paginationState = paginationState + self._isScrolledToBottom = isScrolledToBottom + self._isScrolledToTop = isScrolledToTop + self.showMessageMenuOnLongPress = showMessageMenuOnLongPress + self.sections = sections + self.ids = ids + } + + 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 > conversationViewModel.conversationMessagesSection.count + && conversationViewModel.conversationMessagesSection[section].rows.count < 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: conversationViewModel, message: row) + .padding(.vertical, 1) + .padding(.horizontal, 10) + .onTapGesture { } + } + .minSize(width: 0, height: 0) + .margins(.all, 0) + } else { + // Fallback on earlier versions + } + + 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] + paginationState.handle(row, ids: ids) + } + + func scrollViewDidScroll(_ scrollView: UIScrollView) { + isScrolledToBottom = scrollView.contentOffset.y <= 10 + + if isScrolledToBottom && conversationViewModel.displayedConversationUnreadMessagesCount > 0 { + conversationViewModel.markAsRead() + } + + if !isScrolledToTop && scrollView.contentOffset.y >= scrollView.contentSize.height - scrollView.frame.height - 200 { + self.conversationViewModel.getOldMessages() + } + isScrolledToTop = scrollView.contentOffset.y >= scrollView.contentSize.height - scrollView.frame.height - 200 + } + } +} + +struct MessagesSection: Equatable { + + let date: Date + var rows: [Message] + + static var formatter = { + let formatter = DateFormatter() + formatter.dateFormat = "EEEE, MMMM d" + return formatter + }() + + init(date: Date, rows: [Message]) { + self.date = date + 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 + } + +} + +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, ids: [String]) { + guard shouldHandlePagination else { + return + } + if ids.prefix(offset + 1).contains(message.id) { + onEvent?(message) + } + } +} + +public typealias ChatPaginationClosure = (Message) -> Void + +final class ChatViewModel: ObservableObject { + + @Published private(set) var fullscreenAttachmentItem: Optional = nil + @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 diff --git a/Linphone/UI/Main/Conversations/Model/Attachment.swift b/Linphone/UI/Main/Conversations/Model/Attachment.swift new file mode 100644 index 000000000..e9973cbaf --- /dev/null +++ b/Linphone/UI/Main/Conversations/Model/Attachment.swift @@ -0,0 +1,61 @@ +/* + * 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 video + + public var title: String { + switch self { + case .image: + return "Image" + default: + return "Video" + } + } + + public init(mediaType: MediaType) { + switch mediaType { + case .image: + self = .image + default: + self = .video + } + } +} + +public struct Attachment: Codable, Identifiable, Hashable { + public let id: String + public let thumbnail: URL + public let full: URL + public let type: AttachmentType + + public init(id: String, thumbnail: URL, full: URL, type: AttachmentType) { + self.id = id + self.thumbnail = thumbnail + self.full = full + self.type = type + } + + public init(id: String, url: URL, type: AttachmentType) { + self.init(id: id, thumbnail: url, full: url, type: type) + } +} diff --git a/Linphone/UI/Main/Conversations/Model/ConversationModel.swift b/Linphone/UI/Main/Conversations/Model/ConversationModel.swift index 9044b7a1f..22fb9ede6 100644 --- a/Linphone/UI/Main/Conversations/Model/ConversationModel.swift +++ b/Linphone/UI/Main/Conversations/Model/ConversationModel.swift @@ -88,7 +88,7 @@ class ConversationModel: ObservableObject { //self.dateTime = chatRoom.date - self.unreadMessagesCount = chatRoom.unreadMessagesCount + self.unreadMessagesCount = 0 self.avatarModel = ContactAvatarModel(friend: nil, name: "", withPresence: false) @@ -98,9 +98,10 @@ class ConversationModel: ObservableObject { getContentTextMessage() getChatRoomSubject() + getUnreadMessagesCount() } - func leave(){ + func leave() { coreContext.doOnCoreQueue { _ in self.chatRoom.leave() } @@ -214,6 +215,13 @@ class ConversationModel: ObservableObject { } } + + func getUnreadMessagesCount() { + coreContext.doOnCoreQueue { _ in + self.unreadMessagesCount = self.chatRoom.unreadMessagesCount + } + } + func refreshAvatarModel() { coreContext.doOnCoreQueue { _ in let addressFriend = diff --git a/Linphone/UI/Main/Conversations/Model/Message.swift b/Linphone/UI/Main/Conversations/Model/Message.swift new file mode 100644 index 000000000..3865cf26c --- /dev/null +++ b/Linphone/UI/Main/Conversations/Model/Message.swift @@ -0,0 +1,296 @@ +/* + * 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 + +public struct Message: Identifiable, Hashable { + + public enum Status: Equatable, Hashable { + case sending + case sent + case read + case error(DraftMessage) + + public func hash(into hasher: inout Hasher) { + switch self { + case .sending: + return hasher.combine("sending") + case .sent: + return hasher.combine("sent") + 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 (.read, .read): + return true + case ( .error(_), .error(_)): + return true + default: + return false + } + } + } + + public var id: String + public var status: Status? + public var createdAt: Date + public var isOutgoing: Bool + + public var text: String + public var attachments: [Attachment] + public var recording: Recording? + public var replyMessage: ReplyMessage? + + public init(id: String, + status: Status? = nil, + createdAt: Date = Date(), + isOutgoing: Bool, + text: String = "", + attachments: [Attachment] = [], + recording: Recording? = nil, + replyMessage: ReplyMessage? = nil) { + + self.id = id + self.status = status + self.createdAt = createdAt + self.isOutgoing = isOutgoing + self.text = text + self.attachments = attachments + self.recording = recording + self.replyMessage = replyMessage + } + + 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, url: thumbnailURL, type: .image) + case .video: + guard let fullURL = await media.getURL() else { + return nil + } + return Attachment(id: UUID().uuidString, thumbnail: thumbnailURL, full: fullURL, type: .video) + } + } + + return Message( + id: id, + status: status, + createdAt: draft.createdAt, + isOutgoing: draft.isOutgoing, + text: draft.text, + attachments: attachments, + recording: draft.recording, + replyMessage: draft.replyMessage + ) + } +} + +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 + } +} + +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 text: String + public var isOutgoing: Bool + public var attachments: [Attachment] + public var recording: Recording? + + public init(id: String, + text: String = "", + isOutgoing: Bool, + attachments: [Attachment] = [], + recording: Recording? = nil) { + + self.id = id + self.text = text + self.isOutgoing = isOutgoing + self.attachments = attachments + self.recording = recording + } + + func toMessage() -> Message { + Message(id: id, isOutgoing: isOutgoing, text: text, attachments: attachments, recording: recording) + } +} + +public extension Message { + + func toReplyMessage() -> ReplyMessage { + ReplyMessage(id: id, text: text, isOutgoing: isOutgoing, attachments: attachments, recording: recording) + } +} + +public struct DraftMessage { + public var id: String? + public let isOutgoing: Bool + public let text: String + public let medias: [Media] + public let recording: Recording? + public let replyMessage: ReplyMessage? + public let createdAt: Date + + public init(id: String? = nil, + isOutgoing: Bool, + text: String, + medias: [Media], + recording: Recording?, + replyMessage: ReplyMessage?, + createdAt: Date) { + self.id = id + self.isOutgoing = isOutgoing + self.text = text + self.medias = medias + self.recording = recording + self.replyMessage = replyMessage + self.createdAt = createdAt + } +} + +public enum MediaType { + case image + case video +} + +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 el = try await transform(element) { + values.append(el) + } + } + + 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) + } +} diff --git a/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift b/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift index b4fa2f73e..50cf29a2a 100644 --- a/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift +++ b/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift @@ -28,12 +28,16 @@ class ConversationViewModel: ObservableObject { @Published var displayedConversation: ConversationModel? @Published var displayedConversationHistorySize: Int = 0 + @Published var displayedConversationUnreadMessagesCount: Int = 0 + @Published var messageText: String = "" private var chatRoomSuscriptions = Set() @Published var conversationMessagesList: [LinphoneCustomEventLog] = [] + @Published var conversationMessagesSection: [MessagesSection] = [] + @Published var conversationMessagesIds: [String] = [] init() {} @@ -66,26 +70,60 @@ class ConversationViewModel: ObservableObject { } } + 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 + self.displayedConversation!.chatRoom.markAsRead() + let unreadMessagesCount = self.displayedConversation!.chatRoom.unreadMessagesCount + DispatchQueue.main.async { + self.displayedConversationUnreadMessagesCount = unreadMessagesCount + } + } + } + func getMessages() { self.getHistorySize() + self.getUnreadMessagesCount() coreContext.doOnCoreQueue { _ in if self.displayedConversation != nil { let historyEvents = self.displayedConversation!.chatRoom.getHistoryRangeEvents(begin: self.conversationMessagesList.count, end: self.conversationMessagesList.count + 30) //For List /* - historyEvents.reversed().forEach { eventLog in - DispatchQueue.main.async { - self.conversationMessagesList.append(LinphoneCustomEventLog(eventLog: eventLog)) - } - } + historyEvents.reversed().forEach { eventLog in + DispatchQueue.main.async { + self.conversationMessagesList.append(LinphoneCustomEventLog(eventLog: eventLog)) + } + } */ //For ScrollView - historyEvents.forEach { eventLog in + var conversationMessage: [Message] = [] + historyEvents.enumerated().forEach { index, eventLog in DispatchQueue.main.async { self.conversationMessagesList.append(LinphoneCustomEventLog(eventLog: eventLog)) } + conversationMessage.append(Message( + id: UUID().uuidString, + isOutgoing: eventLog.chatMessage?.isOutgoing ?? false, + text: eventLog.chatMessage?.utf8Text ?? "")) + + DispatchQueue.main.async { + if index == historyEvents.count - 1 { + self.conversationMessagesSection.append(MessagesSection(date: Date(), rows: conversationMessage.reversed())) + self.conversationMessagesIds.append(UUID().uuidString) + } + } } } } @@ -103,38 +141,83 @@ class ConversationViewModel: ObservableObject { self.conversationMessagesList.append(LinphoneCustomEventLog(eventLog: eventLog)) } } - */ + */ //For ScrollView var conversationMessagesListTmp: [LinphoneCustomEventLog] = [] + var conversationMessagesTmp: [Message] = [] historyEvents.reversed().forEach { eventLog in conversationMessagesListTmp.insert(LinphoneCustomEventLog(eventLog: eventLog), at: 0) + + conversationMessagesTmp.insert( + Message( + id: UUID().uuidString, + isOutgoing: eventLog.chatMessage?.isOutgoing ?? false, + text: eventLog.chatMessage?.utf8Text ?? "" + ), at: 0 + ) } - DispatchQueue.main.async { - self.conversationMessagesList.insert(contentsOf: conversationMessagesListTmp, at: 0) + if !conversationMessagesTmp.isEmpty { + DispatchQueue.main.async { + self.conversationMessagesList.insert(contentsOf: conversationMessagesListTmp, at: 0) + //self.conversationMessagesSection.append(MessagesSection(date: Date(), rows: conversationMessagesTmp.reversed())) + //self.conversationMessagesIds.append(UUID().uuidString) + self.conversationMessagesSection[0].rows.append(contentsOf: conversationMessagesTmp.reversed()) + } } } } } func getNewMessages(eventLogs: [EventLog]) { - eventLogs.forEach { eventLog in + var conversationMessage: [Message] = [] + eventLogs.enumerated().forEach { index, eventLog in DispatchQueue.main.async { - withAnimation { - //For List - //self.conversationMessagesList.insert(LinphoneCustomEventLog(eventLog: eventLog), at: 0) - - //For ScrollView - self.conversationMessagesList.append(LinphoneCustomEventLog(eventLog: eventLog)) + //withAnimation { + //For List + //self.conversationMessagesList.insert(LinphoneCustomEventLog(eventLog: eventLog), at: 0) + + //For ScrollView + self.conversationMessagesList.append(LinphoneCustomEventLog(eventLog: eventLog)) + + /* + conversationMessage.append(Message( + id: UUID().uuidString, + isOutgoing: eventLog.chatMessage?.isOutgoing ?? false, + text: eventLog.chatMessage?.utf8Text ?? "" + ) + ) + */ + } + let message = Message( + id: UUID().uuidString, + isOutgoing: eventLog.chatMessage?.isOutgoing ?? false, + text: eventLog.chatMessage?.utf8Text ?? "" + ) + + DispatchQueue.main.async { + if self.conversationMessagesSection.isEmpty { + self.conversationMessagesSection.append(MessagesSection(date: Date(), rows: [message])) + } else { + self.conversationMessagesSection[0].rows.insert(message, at: 0) } + + if !message.isOutgoing { + self.displayedConversationUnreadMessagesCount += 1 + } + } + + if self.displayedConversation != nil { + self.displayedConversation!.markAsRead() } } } func resetMessage() { conversationMessagesList = [] + conversationMessagesSection = [] } func sendMessage() { diff --git a/Linphone/UI/Main/Conversations/ViewModel/ConversationsListViewModel.swift b/Linphone/UI/Main/Conversations/ViewModel/ConversationsListViewModel.swift index 41553ff9b..6da0a683a 100644 --- a/Linphone/UI/Main/Conversations/ViewModel/ConversationsListViewModel.swift +++ b/Linphone/UI/Main/Conversations/ViewModel/ConversationsListViewModel.swift @@ -84,7 +84,7 @@ class ConversationsListViewModel: ObservableObject { if !self.conversationsList.isEmpty { for (index, element) in conversationsListTmp.enumerated() { - if index > 0 && element.id != self.conversationsList[index].id { + if index > 0 && index < self.conversationsList.count && element.id != self.conversationsList[index].id { DispatchQueue.main.async { self.conversationsList[index] = element } From af3a0fbd31aea25889189a0cff972c6582f8f6a7 Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Fri, 8 Mar 2024 17:30:25 +0100 Subject: [PATCH 146/486] Add image bubble message --- Linphone/Core/CoreContext.swift | 1 + .../Fragments/ChatBubbleView.swift | 22 +++- .../Model/ConversationModel.swift | 8 +- .../UI/Main/Conversations/Model/Message.swift | 19 +-- .../ViewModel/ConversationViewModel.swift | 114 +++++++++++++----- Linphone/Utils/FileUtils.swift | 108 ++++++++++++++++- 6 files changed, 227 insertions(+), 45 deletions(-) diff --git a/Linphone/Core/CoreContext.swift b/Linphone/Core/CoreContext.swift index bb19caad9..676c6caed 100644 --- a/Linphone/Core/CoreContext.swift +++ b/Linphone/Core/CoreContext.swift @@ -92,6 +92,7 @@ final class CoreContext: ObservableObject { path: "\(configDir)/linphonerc", factoryPath: Bundle.main.path(forResource: "linphonerc-factory", ofType: nil) ) + if config != nil { self.mCore = try? Factory.Instance.createCoreWithConfig(config: config!, systemContext: nil) } diff --git a/Linphone/UI/Main/Conversations/Fragments/ChatBubbleView.swift b/Linphone/UI/Main/Conversations/Fragments/ChatBubbleView.swift index e1e8ef98b..f0c10a683 100644 --- a/Linphone/UI/Main/Conversations/Fragments/ChatBubbleView.swift +++ b/Linphone/UI/Main/Conversations/Fragments/ChatBubbleView.swift @@ -95,10 +95,24 @@ struct ChatBubbleView: View { } VStack { - Text(message.text - ) - .foregroundStyle(Color.grayMain2c700) - .default_text_style(styleSize: 16) + if !message.attachments.isEmpty { + AsyncImage(url: message.attachments.first!.full) { image in + image.resizable() + .scaledToFill() + //.aspectRatio(1.5, contentMode: .fill) + //.clipped() + } placeholder: { + ProgressView() + } + .frame(maxHeight: 400) + //.frame(width: 50, height: 50) + } + + if !message.text.isEmpty { + Text(message.text) + .foregroundStyle(Color.grayMain2c700) + .default_text_style(styleSize: 16) + } } .padding(.all, 15) .background(message.isOutgoing ? Color.orangeMain100 : Color.grayMain2c100) diff --git a/Linphone/UI/Main/Conversations/Model/ConversationModel.swift b/Linphone/UI/Main/Conversations/Model/ConversationModel.swift index 22fb9ede6..a1090a043 100644 --- a/Linphone/UI/Main/Conversations/Model/ConversationModel.swift +++ b/Linphone/UI/Main/Conversations/Model/ConversationModel.swift @@ -215,7 +215,6 @@ class ConversationModel: ObservableObject { } } - func getUnreadMessagesCount() { coreContext.doOnCoreQueue { _ in self.unreadMessagesCount = self.chatRoom.unreadMessagesCount @@ -242,6 +241,13 @@ class ConversationModel: ObservableObject { } } + func downloadContent(chatMessage: ChatMessage, content: Content) { + coreContext.doOnCoreQueue { _ in + let result = chatMessage.downloadContent(content: content) + print("resultresult download \(result)") + } + } + func deleteChatRoom() { CoreContext.shared.doOnCoreQueue { core in core.deleteChatRoom(chatRoom: self.chatRoom) diff --git a/Linphone/UI/Main/Conversations/Model/Message.swift b/Linphone/UI/Main/Conversations/Model/Message.swift index 3865cf26c..ecc65b40f 100644 --- a/Linphone/UI/Main/Conversations/Model/Message.swift +++ b/Linphone/UI/Main/Conversations/Model/Message.swift @@ -66,15 +66,16 @@ public struct Message: Identifiable, Hashable { public var recording: Recording? public var replyMessage: ReplyMessage? - public init(id: String, - status: Status? = nil, - createdAt: Date = Date(), - isOutgoing: Bool, - text: String = "", - attachments: [Attachment] = [], - recording: Recording? = nil, - replyMessage: ReplyMessage? = nil) { - + public init( + id: String, + status: Status? = nil, + createdAt: Date = Date(), + isOutgoing: Bool, + text: String = "", + attachments: [Attachment] = [], + recording: Recording? = nil, + replyMessage: ReplyMessage? = nil + ) { self.id = id self.status = status self.createdAt = createdAt diff --git a/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift b/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift index 50cf29a2a..b711fd279 100644 --- a/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift +++ b/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift @@ -21,6 +21,7 @@ import Foundation import linphonesw import Combine import SwiftUI +import AVFoundation class ConversationViewModel: ObservableObject { @@ -113,10 +114,30 @@ class ConversationViewModel: ObservableObject { DispatchQueue.main.async { self.conversationMessagesList.append(LinphoneCustomEventLog(eventLog: eventLog)) } + + 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) + } else { + let attachment = Attachment(id: UUID().uuidString, url: URL(string: "file://" + content.filePath!)!, type: .image) + attachmentList.append(attachment) + } + } + } + } + conversationMessage.append(Message( id: UUID().uuidString, isOutgoing: eventLog.chatMessage?.isOutgoing ?? false, - text: eventLog.chatMessage?.utf8Text ?? "")) + text: contentText, + attachments: attachmentList)) DispatchQueue.main.async { if index == historyEvents.count - 1 { @@ -133,28 +154,29 @@ class ConversationViewModel: ObservableObject { coreContext.doOnCoreQueue { _ in if self.displayedConversation != nil { let historyEvents = self.displayedConversation!.chatRoom.getHistoryRangeEvents(begin: self.conversationMessagesList.count, end: self.conversationMessagesList.count + 30) - - //For List - /* - historyEvents.reversed().forEach { eventLog in - DispatchQueue.main.async { - self.conversationMessagesList.append(LinphoneCustomEventLog(eventLog: eventLog)) - } - } - */ - - //For ScrollView var conversationMessagesListTmp: [LinphoneCustomEventLog] = [] var conversationMessagesTmp: [Message] = [] historyEvents.reversed().forEach { eventLog in conversationMessagesListTmp.insert(LinphoneCustomEventLog(eventLog: eventLog), at: 0) + 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 ?? "" + } + } + } + conversationMessagesTmp.insert( Message( id: UUID().uuidString, isOutgoing: eventLog.chatMessage?.isOutgoing ?? false, - text: eventLog.chatMessage?.utf8Text ?? "" + text: contentText, + attachments: attachmentList ), at: 0 ) } @@ -162,8 +184,6 @@ class ConversationViewModel: ObservableObject { if !conversationMessagesTmp.isEmpty { DispatchQueue.main.async { self.conversationMessagesList.insert(contentsOf: conversationMessagesListTmp, at: 0) - //self.conversationMessagesSection.append(MessagesSection(date: Date(), rows: conversationMessagesTmp.reversed())) - //self.conversationMessagesIds.append(UUID().uuidString) self.conversationMessagesSection[0].rows.append(contentsOf: conversationMessagesTmp.reversed()) } } @@ -175,26 +195,28 @@ class ConversationViewModel: ObservableObject { var conversationMessage: [Message] = [] eventLogs.enumerated().forEach { index, eventLog in DispatchQueue.main.async { - //withAnimation { - //For List - //self.conversationMessagesList.insert(LinphoneCustomEventLog(eventLog: eventLog), at: 0) - - //For ScrollView self.conversationMessagesList.append(LinphoneCustomEventLog(eventLog: eventLog)) - - /* - conversationMessage.append(Message( - id: UUID().uuidString, - isOutgoing: eventLog.chatMessage?.isOutgoing ?? false, - text: eventLog.chatMessage?.utf8Text ?? "" - ) - ) - */ } + + var attachmentList: [Attachment] = [] + var contentText = "" + + if eventLog.chatMessage != nil && !eventLog.chatMessage!.contents.isEmpty { + eventLog.chatMessage!.contents.forEach { content in + if content.isText { + print("contentscontents text") + contentText = content.utf8Text ?? "" + } else { + print("contentscontents \(content.isText)") + } + } + } + let message = Message( id: UUID().uuidString, isOutgoing: eventLog.chatMessage?.isOutgoing ?? false, - text: eventLog.chatMessage?.utf8Text ?? "" + text: contentText, + attachments: attachmentList ) DispatchQueue.main.async { @@ -309,6 +331,38 @@ class ConversationViewModel: ObservableObject { func changeDisplayedChatRoom(conversationModel: ConversationModel) { self.displayedConversation = conversationModel } + + func downloadContent(chatMessage: ChatMessage, content: Content) { + //Log.debug("[ConversationViewModel] Starting downloading content for file \(model.fileName)") + if content.filePath == nil || content.filePath!.isEmpty { + let contentName = content.name + if contentName != nil { + let isImage = FileUtil.isExtensionImage(path: contentName!) + let file = FileUtil.getFileStoragePath(fileName: contentName!, isImage: isImage) + content.filePath = file + Log.info( + "[ConversationViewModel] File \(contentName) will be downloaded at \(content.filePath)" + ) + self.displayedConversation?.downloadContent(chatMessage: chatMessage, content: content) + } else { + Log.error("[ConversationViewModel] Content name is null, can't download it!") + } + } + } + + func generateThumbnail(path: URL) -> UIImage? { + do { + 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) + return thumbnail + } catch let error { + print("*** Error generating thumbnail: \(error.localizedDescription)") + return nil + } + } } struct LinphoneCustomEventLog: Hashable { var id = UUID() diff --git a/Linphone/Utils/FileUtils.swift b/Linphone/Utils/FileUtils.swift index d923462ed..97dc886e1 100644 --- a/Linphone/Utils/FileUtils.swift +++ b/Linphone/Utils/FileUtils.swift @@ -19,8 +19,19 @@ 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) } @@ -82,7 +93,7 @@ class FileUtil: NSObject { return false } } - + public class func write(string: String, toPath: String) { do { try string.write(to: URL(fileURLWithPath: toPath), atomically: true, encoding: String.Encoding.utf8) @@ -122,4 +133,99 @@ class FileUtil: NSObject { } } + 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() + } } From a822a0895dd5076a469832a28715606820fe1157 Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Tue, 12 Mar 2024 17:24:49 +0100 Subject: [PATCH 147/486] Add gifs support --- .../Fragments/ChatBubbleView.swift | 169 ++++++++++-------- .../Main/Conversations/Model/Attachment.swift | 1 + .../ViewModel/ConversationViewModel.swift | 26 +-- 3 files changed, 96 insertions(+), 100 deletions(-) diff --git a/Linphone/UI/Main/Conversations/Fragments/ChatBubbleView.swift b/Linphone/UI/Main/Conversations/Fragments/ChatBubbleView.swift index f0c10a683..907f986bb 100644 --- a/Linphone/UI/Main/Conversations/Fragments/ChatBubbleView.swift +++ b/Linphone/UI/Main/Conversations/Fragments/ChatBubbleView.swift @@ -18,76 +18,15 @@ */ import SwiftUI +import WebKit struct ChatBubbleView: View { @ObservedObject var conversationViewModel: ConversationViewModel - //let index: IndexPath - let message: Message - var body: some View { - /* - if index < conversationViewModel.conversationMessagesList.count - && conversationViewModel.conversationMessagesList[index].eventLog.chatMessage != nil { - VStack { - if index == 0 && conversationViewModel.displayedConversationHistorySize > conversationViewModel.conversationMessagesList.count { - //if index % 30 == 29 && conversationViewModel.displayedConversationHistorySize > conversationViewModel.conversationMessagesList.count { - ProgressView() - .frame(idealWidth: .infinity, maxWidth: .infinity, alignment: .center) - .id(UUID()) - } - - HStack { - if conversationViewModel.conversationMessagesList[index].eventLog.chatMessage!.isOutgoing { - Spacer() - } - - VStack { - Text(conversationViewModel.conversationMessagesList[index].eventLog.chatMessage!.utf8Text ?? "") - .foregroundStyle(Color.grayMain2c700) - .default_text_style(styleSize: 16) - } - .padding(.all, 15) - .background(conversationViewModel.conversationMessagesList[index].eventLog.chatMessage!.isOutgoing ? Color.orangeMain100 : Color.grayMain2c100) - .clipShape(RoundedRectangle(cornerRadius: 16)) - - if !conversationViewModel.conversationMessagesList[index].eventLog.chatMessage!.isOutgoing { - Spacer() - } - } - .padding(.leading, conversationViewModel.conversationMessagesList[index].eventLog.chatMessage!.isOutgoing ? 40 : 0) - .padding(.trailing, !conversationViewModel.conversationMessagesList[index].eventLog.chatMessage!.isOutgoing ? 40 : 0) - } - } - if conversationViewModel.conversationMessagesSection.count > index.section && conversationViewModel.conversationMessagesSection[index.section].rows.count > index.row { - VStack { - HStack { - if message.isOutgoing { - Spacer() - } - - VStack { - Text(message.text - ) - .foregroundStyle(Color.grayMain2c700) - .default_text_style(styleSize: 16) - } - .padding(.all, 15) - .background(message.isOutgoing ? Color.orangeMain100 : Color.grayMain2c100) - .clipShape(RoundedRectangle(cornerRadius: 16)) - - if !message.isOutgoing { - Spacer() - } - } - .padding(.leading, message.isOutgoing ? 40 : 0) - .padding(.trailing, !message.isOutgoing ? 40 : 0) - } - } - */ - + var body: some View { VStack { HStack { if message.isOutgoing { @@ -96,16 +35,45 @@ struct ChatBubbleView: View { VStack { if !message.attachments.isEmpty { - AsyncImage(url: message.attachments.first!.full) { image in - image.resizable() - .scaledToFill() - //.aspectRatio(1.5, contentMode: .fill) - //.clipped() - } placeholder: { - ProgressView() + if message.attachments.count == 1 { + let result = imageDimensions(url: message.attachments.first!.full.absoluteString) + if message.attachments.first!.type != .gif { + AsyncImage(url: message.attachments.first!.full) { image in + image.resizable() + .interpolation(.low) + .scaledToFit() + .clipShape(RoundedRectangle(cornerRadius: 4)) + } placeholder: { + ProgressView() + } + .frame( + height: result.0 > result.1 + ? (result.1 / (result.0 / (UIScreen.main.bounds.height > UIScreen.main.bounds.width ? UIScreen.main.bounds.height / 3 : UIScreen.main.bounds.width / 3))) + : (UIScreen.main.bounds.height > UIScreen.main.bounds.width ? UIScreen.main.bounds.height / 3 : UIScreen.main.bounds.width / 3) + ) + } else { + if result.0 < result.1 { + GifImageView(message.attachments.first!.full) + .clipShape(RoundedRectangle(cornerRadius: 4)) + .frame( + width: result.1 > UIScreen.main.bounds.height / 3 + ? (result.0 / (result.0 / (UIScreen.main.bounds.height > UIScreen.main.bounds.width ? UIScreen.main.bounds.height / 3 : UIScreen.main.bounds.width / 3))) + : result.0, + height: result.1 > UIScreen.main.bounds.height / 3 + ? (result.1 / (result.0 / (UIScreen.main.bounds.height > UIScreen.main.bounds.width ? UIScreen.main.bounds.height / 3 : UIScreen.main.bounds.width / 3))) + : result.1 + ) + } else { + GifImageView(message.attachments.first!.full) + .clipShape(RoundedRectangle(cornerRadius: 4)) + .frame(maxWidth: .infinity) + .frame( + height: result.1 / (result.0 / (UIScreen.main.bounds.height > UIScreen.main.bounds.width ? UIScreen.main.bounds.height / 3 : UIScreen.main.bounds.width / 3)) + ) + } + } + } else { } - .frame(maxHeight: 400) - //.frame(width: 50, height: 50) } if !message.text.isEmpty { @@ -125,11 +93,58 @@ struct ChatBubbleView: View { .padding(.leading, message.isOutgoing ? 40 : 0) .padding(.trailing, !message.isOutgoing ? 40 : 0) } - } + } + + 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 + return (pixelWidth ?? 0, pixelHeight ?? 0) + } + } + return (0, 0) + } +} + +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() + } } /* -#Preview { - ChatBubbleView(conversationViewModel: ConversationViewModel(), index: 0) -} -*/ + #Preview { + ChatBubbleView(conversationViewModel: ConversationViewModel(), index: 0) + } + */ diff --git a/Linphone/UI/Main/Conversations/Model/Attachment.swift b/Linphone/UI/Main/Conversations/Model/Attachment.swift index e9973cbaf..39d456e79 100644 --- a/Linphone/UI/Main/Conversations/Model/Attachment.swift +++ b/Linphone/UI/Main/Conversations/Model/Attachment.swift @@ -22,6 +22,7 @@ import Foundation public enum AttachmentType: String, Codable { case image case video + case gif public var title: String { switch self { diff --git a/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift b/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift index b711fd279..059cb9d10 100644 --- a/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift +++ b/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift @@ -99,16 +99,6 @@ class ConversationViewModel: ObservableObject { if self.displayedConversation != nil { let historyEvents = self.displayedConversation!.chatRoom.getHistoryRangeEvents(begin: self.conversationMessagesList.count, end: self.conversationMessagesList.count + 30) - //For List - /* - historyEvents.reversed().forEach { eventLog in - DispatchQueue.main.async { - self.conversationMessagesList.append(LinphoneCustomEventLog(eventLog: eventLog)) - } - } - */ - - //For ScrollView var conversationMessage: [Message] = [] historyEvents.enumerated().forEach { index, eventLog in DispatchQueue.main.async { @@ -126,7 +116,7 @@ class ConversationViewModel: ObservableObject { if content.filePath == nil || content.filePath!.isEmpty { self.downloadContent(chatMessage: eventLog.chatMessage!, content: content) } else { - let attachment = Attachment(id: UUID().uuidString, url: URL(string: "file://" + content.filePath!)!, type: .image) + let attachment = Attachment(id: UUID().uuidString, url: URL(string: self.getNewFilePath(name: content.name ?? ""))!, type: (content.name?.lowercased().hasSuffix("gif"))! ? .gif : .image) attachmentList.append(attachment) } } @@ -350,18 +340,8 @@ class ConversationViewModel: ObservableObject { } } - func generateThumbnail(path: URL) -> UIImage? { - do { - 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) - return thumbnail - } catch let error { - print("*** Error generating thumbnail: \(error.localizedDescription)") - return nil - } + func getNewFilePath(name: String) -> String { + return "file://" + Factory.Instance.getDownloadDir(context: nil) + name } } struct LinphoneCustomEventLog: Hashable { From 0d74e6651aec2c76060bac5c619ca61a1f2a0f60 Mon Sep 17 00:00:00 2001 From: "benoit.martins" Date: Tue, 12 Mar 2024 17:24:49 +0100 Subject: [PATCH 148/486] Add gifs support --- Linphone/Localizable.xcstrings | 6 - .../Fragments/ChatBubbleView.swift | 187 ++++++++++-------- .../Fragments/ConversationFragment.swift | 24 ++- .../Main/Conversations/Fragments/UIList.swift | 8 +- .../Main/Conversations/Model/Attachment.swift | 1 + .../ViewModel/ConversationViewModel.swift | 26 +-- 6 files changed, 131 insertions(+), 121 deletions(-) diff --git a/Linphone/Localizable.xcstrings b/Linphone/Localizable.xcstrings index 55e8fd566..6bb5ae918 100644 --- a/Linphone/Localizable.xcstrings +++ b/Linphone/Localizable.xcstrings @@ -244,9 +244,6 @@ }, "Contacts" : { - }, - "Content" : { - }, "Continue" : { @@ -591,9 +588,6 @@ }, "This contact will be deleted definitively." : { - }, - "Title" : { - }, "TLS" : { diff --git a/Linphone/UI/Main/Conversations/Fragments/ChatBubbleView.swift b/Linphone/UI/Main/Conversations/Fragments/ChatBubbleView.swift index f0c10a683..c7dd24d4b 100644 --- a/Linphone/UI/Main/Conversations/Fragments/ChatBubbleView.swift +++ b/Linphone/UI/Main/Conversations/Fragments/ChatBubbleView.swift @@ -18,76 +18,17 @@ */ import SwiftUI +import WebKit struct ChatBubbleView: View { @ObservedObject var conversationViewModel: ConversationViewModel - //let index: IndexPath - let message: Message - var body: some View { - /* - if index < conversationViewModel.conversationMessagesList.count - && conversationViewModel.conversationMessagesList[index].eventLog.chatMessage != nil { - VStack { - if index == 0 && conversationViewModel.displayedConversationHistorySize > conversationViewModel.conversationMessagesList.count { - //if index % 30 == 29 && conversationViewModel.displayedConversationHistorySize > conversationViewModel.conversationMessagesList.count { - ProgressView() - .frame(idealWidth: .infinity, maxWidth: .infinity, alignment: .center) - .id(UUID()) - } - - HStack { - if conversationViewModel.conversationMessagesList[index].eventLog.chatMessage!.isOutgoing { - Spacer() - } - - VStack { - Text(conversationViewModel.conversationMessagesList[index].eventLog.chatMessage!.utf8Text ?? "") - .foregroundStyle(Color.grayMain2c700) - .default_text_style(styleSize: 16) - } - .padding(.all, 15) - .background(conversationViewModel.conversationMessagesList[index].eventLog.chatMessage!.isOutgoing ? Color.orangeMain100 : Color.grayMain2c100) - .clipShape(RoundedRectangle(cornerRadius: 16)) - - if !conversationViewModel.conversationMessagesList[index].eventLog.chatMessage!.isOutgoing { - Spacer() - } - } - .padding(.leading, conversationViewModel.conversationMessagesList[index].eventLog.chatMessage!.isOutgoing ? 40 : 0) - .padding(.trailing, !conversationViewModel.conversationMessagesList[index].eventLog.chatMessage!.isOutgoing ? 40 : 0) - } - } - if conversationViewModel.conversationMessagesSection.count > index.section && conversationViewModel.conversationMessagesSection[index.section].rows.count > index.row { - VStack { - HStack { - if message.isOutgoing { - Spacer() - } - - VStack { - Text(message.text - ) - .foregroundStyle(Color.grayMain2c700) - .default_text_style(styleSize: 16) - } - .padding(.all, 15) - .background(message.isOutgoing ? Color.orangeMain100 : Color.grayMain2c100) - .clipShape(RoundedRectangle(cornerRadius: 16)) - - if !message.isOutgoing { - Spacer() - } - } - .padding(.leading, message.isOutgoing ? 40 : 0) - .padding(.trailing, !message.isOutgoing ? 40 : 0) - } - } - */ - + let geometryProxy: GeometryProxy + + var body: some View { VStack { HStack { if message.isOutgoing { @@ -96,16 +37,61 @@ struct ChatBubbleView: View { VStack { if !message.attachments.isEmpty { - AsyncImage(url: message.attachments.first!.full) { image in - image.resizable() - .scaledToFill() - //.aspectRatio(1.5, contentMode: .fill) - //.clipped() - } placeholder: { - ProgressView() + if message.attachments.count == 1 { + if message.attachments.first!.type == .image || message.attachments.first!.type == .gif { + let result = imageDimensions(url: message.attachments.first!.full.absoluteString) + if message.attachments.first!.type != .gif { + AsyncImage(url: message.attachments.first!.full) { image in + image.resizable() + .interpolation(.low) + .scaledToFit() + .clipShape(RoundedRectangle(cornerRadius: 4)) + } placeholder: { + ProgressView() + } + .frame( + height: result.0 > result.1 + ? result.1 / (result.0 / (geometryProxy.size.width - 80)) + : UIScreen.main.bounds.height > UIScreen.main.bounds.width ? UIScreen.main.bounds.height / 2.5 : UIScreen.main.bounds.width / 2.5 + ) + } else { + if result.0 < result.1 { + GifImageView(message.attachments.first!.full) + .clipShape(RoundedRectangle(cornerRadius: 4)) + .frame( + width: result.1 > (UIScreen.main.bounds.height > UIScreen.main.bounds.width ? UIScreen.main.bounds.height / 2.5 : UIScreen.main.bounds.width / 2.5) + ? result.0 / (result.1 / (UIScreen.main.bounds.height > UIScreen.main.bounds.width ? UIScreen.main.bounds.height / 2.5 : UIScreen.main.bounds.width / 2.5)) + : result.0, + height: result.1 > (UIScreen.main.bounds.height > UIScreen.main.bounds.width ? UIScreen.main.bounds.height / 2.5 : UIScreen.main.bounds.width / 2.5) + ? UIScreen.main.bounds.height > UIScreen.main.bounds.width ? UIScreen.main.bounds.height / 2.5 : UIScreen.main.bounds.width / 2.5 + : result.1 + ) + } else { + GifImageView(message.attachments.first!.full) + .clipShape(RoundedRectangle(cornerRadius: 4)) + .frame( + height: result.1 / (result.0 / (geometryProxy.size.width - 80)) + ) + } + } + } else { + let result = imageDimensions(url: message.attachments.first!.full.absoluteString) + AsyncImage(url: message.attachments.first!.full) { image in + image.resizable() + .interpolation(.low) + .scaledToFit() + .clipShape(RoundedRectangle(cornerRadius: 4)) + } placeholder: { + ProgressView() + } + .frame( + height: result.0 > result.1 + ? result.1 / (result.0 / (geometryProxy.size.width - 80)) + : UIScreen.main.bounds.height > UIScreen.main.bounds.width ? UIScreen.main.bounds.height / 2.5 : UIScreen.main.bounds.width / 2.5 + ) + } + } else { } - .frame(maxHeight: 400) - //.frame(width: 50, height: 50) } if !message.text.isEmpty { @@ -125,11 +111,58 @@ struct ChatBubbleView: View { .padding(.leading, message.isOutgoing ? 40 : 0) .padding(.trailing, !message.isOutgoing ? 40 : 0) } - } + } + + 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 + return (pixelWidth ?? 0, pixelHeight ?? 0) + } + } + return (0, 0) + } +} + +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() + } } /* -#Preview { - ChatBubbleView(conversationViewModel: ConversationViewModel(), index: 0) -} -*/ + #Preview { + ChatBubbleView(conversationViewModel: ConversationViewModel(), index: 0) + } + */ diff --git a/Linphone/UI/Main/Conversations/Fragments/ConversationFragment.swift b/Linphone/UI/Main/Conversations/Fragments/ConversationFragment.swift index 46dba158c..c95ecddde 100644 --- a/Linphone/UI/Main/Conversations/Fragments/ConversationFragment.swift +++ b/Linphone/UI/Main/Conversations/Fragments/ConversationFragment.swift @@ -152,7 +152,15 @@ struct ConversationFragment: View { if #available(iOS 16.0, *) { ZStack(alignment: .bottomTrailing) { - list + UIList(viewModel: viewModel, + paginationState: paginationState, + conversationViewModel: conversationViewModel, + isScrolledToBottom: $isScrolledToBottom, + showMessageMenuOnLongPress: showMessageMenuOnLongPress, + geometryProxy: geometry, + sections: conversationViewModel.conversationMessagesSection, + ids: conversationViewModel.conversationMessagesIds + ) if !isScrolledToBottom { Button { @@ -209,6 +217,7 @@ struct ConversationFragment: View { conversationViewModel.resetMessage() } } else { + /* ScrollViewReader { proxy in List { ForEach(0.., isScrolledToTop: Binding, showMessageMenuOnLongPress: Bool, sections: [MessagesSection], ids: [String]) { + init(conversationViewModel: ConversationViewModel, viewModel: ChatViewModel, paginationState: PaginationState, isScrolledToBottom: Binding, isScrolledToTop: Binding, showMessageMenuOnLongPress: Bool, geometryProxy: GeometryProxy, sections: [MessagesSection], ids: [String]) { self.conversationViewModel = conversationViewModel self.viewModel = viewModel self.paginationState = paginationState self._isScrolledToBottom = isScrolledToBottom self._isScrolledToTop = isScrolledToTop self.showMessageMenuOnLongPress = showMessageMenuOnLongPress + self.geometryProxy = geometryProxy self.sections = sections self.ids = ids } @@ -373,7 +377,7 @@ struct UIList: UIViewRepresentable { let row = sections[indexPath.section].rows[indexPath.row] if #available(iOS 16.0, *) { tableViewCell.contentConfiguration = UIHostingConfiguration { - ChatBubbleView(conversationViewModel: conversationViewModel, message: row) + ChatBubbleView(conversationViewModel: conversationViewModel, message: row, geometryProxy: geometryProxy) .padding(.vertical, 1) .padding(.horizontal, 10) .onTapGesture { } diff --git a/Linphone/UI/Main/Conversations/Model/Attachment.swift b/Linphone/UI/Main/Conversations/Model/Attachment.swift index e9973cbaf..39d456e79 100644 --- a/Linphone/UI/Main/Conversations/Model/Attachment.swift +++ b/Linphone/UI/Main/Conversations/Model/Attachment.swift @@ -22,6 +22,7 @@ import Foundation public enum AttachmentType: String, Codable { case image case video + case gif public var title: String { switch self { diff --git a/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift b/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift index b711fd279..059cb9d10 100644 --- a/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift +++ b/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift @@ -99,16 +99,6 @@ class ConversationViewModel: ObservableObject { if self.displayedConversation != nil { let historyEvents = self.displayedConversation!.chatRoom.getHistoryRangeEvents(begin: self.conversationMessagesList.count, end: self.conversationMessagesList.count + 30) - //For List - /* - historyEvents.reversed().forEach { eventLog in - DispatchQueue.main.async { - self.conversationMessagesList.append(LinphoneCustomEventLog(eventLog: eventLog)) - } - } - */ - - //For ScrollView var conversationMessage: [Message] = [] historyEvents.enumerated().forEach { index, eventLog in DispatchQueue.main.async { @@ -126,7 +116,7 @@ class ConversationViewModel: ObservableObject { if content.filePath == nil || content.filePath!.isEmpty { self.downloadContent(chatMessage: eventLog.chatMessage!, content: content) } else { - let attachment = Attachment(id: UUID().uuidString, url: URL(string: "file://" + content.filePath!)!, type: .image) + let attachment = Attachment(id: UUID().uuidString, url: URL(string: self.getNewFilePath(name: content.name ?? ""))!, type: (content.name?.lowercased().hasSuffix("gif"))! ? .gif : .image) attachmentList.append(attachment) } } @@ -350,18 +340,8 @@ class ConversationViewModel: ObservableObject { } } - func generateThumbnail(path: URL) -> UIImage? { - do { - 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) - return thumbnail - } catch let error { - print("*** Error generating thumbnail: \(error.localizedDescription)") - return nil - } + func getNewFilePath(name: String) -> String { + return "file://" + Factory.Instance.getDownloadDir(context: nil) + name } } struct LinphoneCustomEventLog: Hashable { From f3d2f1cf6a8c5aabc4ba27a74770314dbde14613 Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Thu, 14 Mar 2024 15:09:10 +0100 Subject: [PATCH 149/486] Fix messages list in iOS 15 --- .../Fragments/ChatBubbleView.swift | 28 +++++----- .../Fragments/ConversationFragment.swift | 52 +++++++++++-------- .../ViewModel/ConversationViewModel.swift | 6 ++- 3 files changed, 47 insertions(+), 39 deletions(-) diff --git a/Linphone/UI/Main/Conversations/Fragments/ChatBubbleView.swift b/Linphone/UI/Main/Conversations/Fragments/ChatBubbleView.swift index c7dd24d4b..71d7d59bf 100644 --- a/Linphone/UI/Main/Conversations/Fragments/ChatBubbleView.swift +++ b/Linphone/UI/Main/Conversations/Fragments/ChatBubbleView.swift @@ -35,11 +35,11 @@ struct ChatBubbleView: View { Spacer() } - VStack { + VStack(alignment: message.isOutgoing ? .trailing : .leading) { if !message.attachments.isEmpty { if message.attachments.count == 1 { + let result = imageDimensions(url: message.attachments.first!.full.absoluteString) if message.attachments.first!.type == .image || message.attachments.first!.type == .gif { - let result = imageDimensions(url: message.attachments.first!.full.absoluteString) if message.attachments.first!.type != .gif { AsyncImage(url: message.attachments.first!.full) { image in image.resizable() @@ -96,8 +96,8 @@ struct ChatBubbleView: View { if !message.text.isEmpty { Text(message.text) - .foregroundStyle(Color.grayMain2c700) - .default_text_style(styleSize: 16) + .foregroundStyle(Color.grayMain2c700) + .default_text_style(styleSize: 16) } } .padding(.all, 15) @@ -126,17 +126,17 @@ struct ChatBubbleView: View { } 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 + 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 { diff --git a/Linphone/UI/Main/Conversations/Fragments/ConversationFragment.swift b/Linphone/UI/Main/Conversations/Fragments/ConversationFragment.swift index c95ecddde..79ac61416 100644 --- a/Linphone/UI/Main/Conversations/Fragments/ConversationFragment.swift +++ b/Linphone/UI/Main/Conversations/Fragments/ConversationFragment.swift @@ -217,23 +217,26 @@ struct ConversationFragment: View { conversationViewModel.resetMessage() } } else { - /* ScrollViewReader { proxy in List { ForEach(0.. conversationViewModel.conversationMessagesList.count { - //DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { - conversationViewModel.getOldMessages() - //} - } - } + if index < conversationViewModel.conversationMessagesSection.first!.rows.count { + ChatBubbleView(conversationViewModel: conversationViewModel, message: conversationViewModel.conversationMessagesSection.first!.rows[index], geometryProxy: geometry) + .id(conversationViewModel.conversationMessagesList[index]) + .listRowInsets(EdgeInsets(top: 2, leading: 10, bottom: 2, trailing: 10)) + .listRowSeparator(.hidden) + .scaleEffect(x: 1, y: -1, anchor: .center) + .onAppear { + if index == conversationViewModel.conversationMessagesList.count - 1 && conversationViewModel.displayedConversationHistorySize > conversationViewModel.conversationMessagesList.count { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { + conversationViewModel.getOldMessages() + } + } + } + } } } + .scaleEffect(x: 1, y: -1, anchor: .center) .listStyle(.plain) .onTapGesture { UIApplication.shared.endEditing() @@ -242,23 +245,26 @@ struct ConversationFragment: View { conversationViewModel.getMessages() } .onChange(of: conversationViewModel.conversationMessagesList) { _ in - if conversationViewModel.conversationMessagesList.count <= 30 { - proxy.scrollTo( - conversationViewModel.conversationMessagesList.last, anchor: .top - ) - } else if conversationViewModel.conversationMessagesList.count >= conversationViewModel.displayedConversationHistorySize { - proxy.scrollTo( - conversationViewModel.conversationMessagesList[conversationViewModel.displayedConversationHistorySize%30], anchor: .top - ) - } else { - proxy.scrollTo(30, anchor: .top) + /* + DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { + if conversationViewModel.conversationMessagesList.count <= 30 { + proxy.scrollTo( + conversationViewModel.conversationMessagesList.first, anchor: .top + ) + } else if conversationViewModel.conversationMessagesList.count >= conversationViewModel.displayedConversationHistorySize { + proxy.scrollTo( + conversationViewModel.conversationMessagesList[conversationViewModel.displayedConversationHistorySize%30], anchor: .top + ) + } else { + proxy.scrollTo(30, anchor: .top) + } } + */ } .onDisappear { conversationViewModel.resetMessage() } } - */ } HStack(spacing: 0) { diff --git a/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift b/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift index 059cb9d10..84eb57807 100644 --- a/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift +++ b/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift @@ -116,8 +116,10 @@ class ConversationViewModel: ObservableObject { if content.filePath == nil || content.filePath!.isEmpty { self.downloadContent(chatMessage: eventLog.chatMessage!, content: content) } else { - let attachment = Attachment(id: UUID().uuidString, url: URL(string: self.getNewFilePath(name: content.name ?? ""))!, type: (content.name?.lowercased().hasSuffix("gif"))! ? .gif : .image) - attachmentList.append(attachment) + if URL(string: self.getNewFilePath(name: content.name ?? "")) != nil { + let attachment = Attachment(id: UUID().uuidString, url: URL(string: self.getNewFilePath(name: content.name ?? ""))!, type: (content.name?.lowercased().hasSuffix("gif"))! ? .gif : .image) + attachmentList.append(attachment) + } } } } From ab3b88344222a9803af61575eefeb0d44055ffb9 Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Mon, 18 Mar 2024 17:12:46 +0100 Subject: [PATCH 150/486] Init conference call view --- Linphone.xcodeproj/project.pbxproj | 12 + Linphone/Core/CoreContext.swift | 13 +- Linphone/Localizable.xcstrings | 14 +- Linphone/TelecomManager/TelecomManager.swift | 15 +- Linphone/UI/Call/CallView.swift | 829 +++++++++++------- Linphone/UI/Call/Model/ParticipantModel.swift | 58 ++ .../UI/Call/ViewModel/CallViewModel.swift | 73 ++ .../ConversationsListViewModel.swift | 27 - Linphone/Utils/Avatar.swift | 2 +- 9 files changed, 713 insertions(+), 330 deletions(-) create mode 100644 Linphone/UI/Call/Model/ParticipantModel.swift diff --git a/Linphone.xcodeproj/project.pbxproj b/Linphone.xcodeproj/project.pbxproj index 7a2f7f197..a2b10c1f1 100644 --- a/Linphone.xcodeproj/project.pbxproj +++ b/Linphone.xcodeproj/project.pbxproj @@ -44,6 +44,7 @@ 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 */; }; @@ -180,6 +181,7 @@ 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 = ""; }; @@ -444,6 +446,14 @@ path = Viewmodel; sourceTree = ""; }; + D720E6AB2BAD81C800DDFD87 /* Model */ = { + isa = PBXGroup; + children = ( + D720E6AC2BAD822000DDFD87 /* ParticipantModel.swift */, + ); + path = Model; + sourceTree = ""; + }; D72250612ADE95E4008FB426 /* ViewModel */ = { isa = PBXGroup; children = ( @@ -597,6 +607,7 @@ isa = PBXGroup; children = ( D75759302B56D3CE00E7AC10 /* Fragments */, + D720E6AB2BAD81C800DDFD87 /* Model */, D7B99E972B29B37F00BE7BF2 /* ViewModel */, D7B5678D2B28888F00DE63EB /* CallView.swift */, ); @@ -866,6 +877,7 @@ D70959F12B8DF3EC0014AC0B /* ConversationModel.swift in Sources */, D7EAACCF2AD6ED8000AA6A8A /* PermissionsFragment.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 */, diff --git a/Linphone/Core/CoreContext.swift b/Linphone/Core/CoreContext.swift index bde91d495..98cb3105e 100644 --- a/Linphone/Core/CoreContext.swift +++ b/Linphone/Core/CoreContext.swift @@ -270,9 +270,11 @@ final class CoreContext: ObservableObject { } } - func updatePresence(core : Core, presence : ConsolidatedPresence) { + func updatePresence(core: Core, presence: ConsolidatedPresence) { if core.config!.getBool(section: "app", key: "publish_presence", defaultValue: true) { - core.consolidatedPresence = presence + DispatchQueue.main.async { + core.consolidatedPresence = presence + } } } @@ -283,7 +285,7 @@ final class CoreContext: ObservableObject { Log.info("App is in foreground, PUBLISHING presence as Online") self.updatePresence(core: self.mCore, presence: ConsolidatedPresence.Online) - try? self.mCore.start() + //try? self.mCore.start() } } @@ -297,7 +299,10 @@ final class CoreContext: ObservableObject { // Flexisip will handle the Busy status depending on other devices self.updatePresence(core: self.mCore, presence: ConsolidatedPresence.Offline) // self.mCore.iterate() - self.mCore.stop() + + if self.mCore.currentCall == nil { + //self.mCore.stop() + } } } diff --git a/Linphone/Localizable.xcstrings b/Linphone/Localizable.xcstrings index b251f51a8..526a5e932 100644 --- a/Linphone/Localizable.xcstrings +++ b/Linphone/Localizable.xcstrings @@ -244,6 +244,9 @@ }, "Contacts" : { + }, + "Content" : { + }, "Continue" : { @@ -485,6 +488,12 @@ }, "Other actions" : { + }, + "Partage d'écran" : { + + }, + "Participants" : { + }, "password" : { "extractionState" : "manual", @@ -625,6 +634,9 @@ }, "This contact will be deleted definitively." : { + }, + "Title" : { + }, "TLS" : { @@ -693,4 +705,4 @@ } }, "version" : "1.0" -} +} \ No newline at end of file diff --git a/Linphone/TelecomManager/TelecomManager.swift b/Linphone/TelecomManager/TelecomManager.swift index 0bf7b0cbc..3cfd68bc0 100644 --- a/Linphone/TelecomManager/TelecomManager.swift +++ b/Linphone/TelecomManager/TelecomManager.swift @@ -45,6 +45,7 @@ class TelecomManager: ObservableObject { @Published var callStarted: Bool = false @Published var outgoingCallStarted: Bool = false @Published var remoteVideo: Bool = false + @Published var remoteConfVideo: Bool = false @Published var isRecordingByRemote: Bool = false @Published var isPausedByRemote: Bool = false @Published var refreshCallViewModel: Bool = false @@ -374,7 +375,19 @@ class TelecomManager: ObservableObject { DispatchQueue.main.async { let oldRemoteVideo = self.remoteVideo - self.remoteVideo = (core.videoActivationPolicy?.automaticallyAccept ?? false) && (call.remoteParams?.videoEnabled ?? false) + //self.remoteVideo = (core.videoActivationPolicy?.automaticallyAccept ?? false) && (call.remoteParams?.videoEnabled ?? false) + + if call.conference != nil { + if call.conference!.activeSpeakerParticipantDevice != nil { + let direction = call.conference?.activeSpeakerParticipantDevice!.getStreamCapability(streamType: StreamType.Video) + self.remoteConfVideo = direction == MediaDirection.SendRecv || direction == MediaDirection.SendOnly + } else { + self.remoteConfVideo = true + } + } else { + self.remoteVideo = call.currentParams!.videoEnabled && call.currentParams!.videoDirection != MediaDirection.Inactive + self.remoteConfVideo = false + } if self.remoteVideo && self.remoteVideo != oldRemoteVideo { do { diff --git a/Linphone/UI/Call/CallView.swift b/Linphone/UI/Call/CallView.swift index d198a1a7e..f5e01f466 100644 --- a/Linphone/UI/Call/CallView.swift +++ b/Linphone/UI/Call/CallView.swift @@ -17,13 +17,13 @@ * along with this program. If not, see . */ -// swiftlint:disable type_body_length -// swiftlint:disable line_length import SwiftUI import CallKit import AVFAudio import linphonesw +// swiftlint:disable type_body_length +// swiftlint:disable line_length struct CallView: View { @ObservedObject private var coreContext = CoreContext.shared @@ -38,7 +38,6 @@ struct CallView: View { let pub = NotificationCenter.default.publisher(for: AVAudioSession.routeChangeNotification) @State var audioRouteSheet: Bool = false - @State var hideButtonsSheet: Bool = false @State var options: Int = 1 @State var imageAudioRoute: String = "" @State var angleDegree = 0.0 @@ -47,6 +46,7 @@ struct CallView: View { @State var maxBottomSheetHeight: CGFloat = 0.5 @State private var pointingUp: CGFloat = 0.0 @State private var currentOffset: CGFloat = 0.0 + @State private var viewIsDisplayed = false @Binding var fullscreenVideo: Bool @Binding var isShowCallsListFragment: Bool @@ -59,7 +59,6 @@ struct CallView: View { innerView(geometry: geo) .sheet(isPresented: $audioRouteSheet, onDismiss: { audioRouteSheet = false - hideButtonsSheet = false }) { innerBottomSheet() .presentationDetents([.fraction(0.3)]) @@ -77,7 +76,6 @@ struct CallView: View { innerView(geometry: geo) .sheet(isPresented: $audioRouteSheet, onDismiss: { audioRouteSheet = false - hideButtonsSheet = false }) { innerBottomSheet() .presentationDetents([.fraction(0.3)]) @@ -96,7 +94,6 @@ struct CallView: View { innerBottomSheet() } onDismiss: { audioRouteSheet = false - hideButtonsSheet = false } .halfSheet(showSheet: $showingDialer) { DialerBottomSheet( @@ -320,7 +317,18 @@ struct CallView: View { .padding(.all, 10) } - if telecomManager.remoteVideo { + if !callViewModel.isConference && telecomManager.remoteVideo { + Button { + callViewModel.switchCamera() + } label: { + Image("camera-rotate") + .renderingMode(.template) + .resizable() + .foregroundStyle(.white) + .frame(width: 30, height: 30) + .padding(.horizontal) + } + } else if callViewModel.isConference && callViewModel.videoDisplayed { Button { callViewModel.switchCamera() } label: { @@ -360,232 +368,7 @@ struct CallView: View { } } - ZStack { - VStack { - Spacer() - ZStack { - - if callViewModel.isRemoteDeviceTrusted { - Circle() - .fill(Color.blueInfo500) - .frame(width: 206, height: 206) - } - - if callViewModel.remoteAddress != nil { - let addressFriend = contactsManager.getFriendWithAddress(address: callViewModel.remoteAddress!) - - let contactAvatarModel = addressFriend != nil - ? ContactsManager.shared.avatarListModel.first(where: { - ($0.friend!.consolidatedPresence == .Online || $0.friend!.consolidatedPresence == .Busy) - && $0.friend!.name == addressFriend!.name - && $0.friend!.address!.asStringUriOnly() == addressFriend!.address!.asStringUriOnly() - }) - : ContactAvatarModel(friend: nil, name: "", withPresence: false) - - if addressFriend != nil && addressFriend!.photo != nil && !addressFriend!.photo!.isEmpty { - if contactAvatarModel != nil { - Avatar(contactAvatarModel: contactAvatarModel!, avatarSize: 200, hidePresence: true) - } - } else { - if callViewModel.remoteAddress!.displayName != nil { - Image(uiImage: contactsManager.textToImage( - firstName: callViewModel.remoteAddress!.displayName!, - lastName: callViewModel.remoteAddress!.displayName!.components(separatedBy: " ").count > 1 - ? callViewModel.remoteAddress!.displayName!.components(separatedBy: " ")[1] - : "")) - .resizable() - .frame(width: 200, height: 200) - .clipShape(Circle()) - - } else { - Image(uiImage: contactsManager.textToImage( - firstName: callViewModel.remoteAddress!.username ?? "Username Error", - lastName: callViewModel.remoteAddress!.username!.components(separatedBy: " ").count > 1 - ? callViewModel.remoteAddress!.username!.components(separatedBy: " ")[1] - : "")) - .resizable() - .frame(width: 200, height: 200) - .clipShape(Circle()) - } - - } - } else { - Image("profil-picture-default") - .resizable() - .frame(width: 200, height: 200) - .clipShape(Circle()) - } - - 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() - } - - LinphoneVideoViewHolder { view in - coreContext.doOnCoreQueue { core in - core.nativeVideoWindow = view - } - } - .frame( - width: - angleDegree == 0 - ? (fullscreenVideo && !telecomManager.isPausedByRemote ? geometry.size.width : geometry.size.width - 8) - : (fullscreenVideo && !telecomManager.isPausedByRemote ? geometry.size.height + geometry.safeAreaInsets.top + geometry.safeAreaInsets.bottom : geometry.size.height - (minBottomSheetHeight * geometry.size.height > 80 ? minBottomSheetHeight * geometry.size.height : 78) - 40 - 20 + geometry.safeAreaInsets.bottom), - height: - angleDegree == 0 - ? (fullscreenVideo && !telecomManager.isPausedByRemote ? geometry.size.height + geometry.safeAreaInsets.top + geometry.safeAreaInsets.bottom : geometry.size.height - (minBottomSheetHeight * geometry.size.height > 80 ? minBottomSheetHeight * geometry.size.height : 78) - 40 - 20 + geometry.safeAreaInsets.bottom) - : (fullscreenVideo && !telecomManager.isPausedByRemote ? geometry.size.width : geometry.size.width - 8) - ) - .scaledToFill() - .clipped() - .onTapGesture { - if telecomManager.remoteVideo { - fullscreenVideo.toggle() - } - } - - if telecomManager.remoteVideo { - 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 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 - ) - } - - if telecomManager.outgoingCallStarted { - VStack { - ActivityIndicator() - .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 - ) - } - } - .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(.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 - } - } - - if (oldOrientation != orientation && oldOrientation != .faceUp) || (oldOrientation == .faceUp && (orientation == .landscapeLeft || orientation == .landscapeRight)) { - telecomManager.callStarted = false - - DispatchQueue.global().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 - } - } - - telecomManager.callStarted = false - - DispatchQueue.global().asyncAfter(deadline: .now() + 0.5) { - telecomManager.callStarted = true - } - - callViewModel.orientationUpdate(orientation: orientation) - } + simpleCallView(geometry: geometry) Spacer() } @@ -619,6 +402,396 @@ struct CallView: View { } } + func simpleCallView(geometry: GeometryProxy) -> some View { + ZStack { + if !callViewModel.isConference { + VStack { + Spacer() + ZStack { + + if callViewModel.isRemoteDeviceTrusted { + Circle() + .fill(Color.blueInfo500) + .frame(width: 206, height: 206) + } + + if callViewModel.remoteAddress != nil { + let addressFriend = contactsManager.getFriendWithAddress(address: callViewModel.remoteAddress!) + + let contactAvatarModel = addressFriend != nil + ? ContactsManager.shared.avatarListModel.first(where: { + ($0.friend!.consolidatedPresence == .Online || $0.friend!.consolidatedPresence == .Busy) + && $0.friend!.name == addressFriend!.name + && $0.friend!.address!.asStringUriOnly() == addressFriend!.address!.asStringUriOnly() + }) + : ContactAvatarModel(friend: nil, name: "", withPresence: false) + + if addressFriend != nil && addressFriend!.photo != nil && !addressFriend!.photo!.isEmpty { + if contactAvatarModel != nil { + Avatar(contactAvatarModel: contactAvatarModel!, avatarSize: 200, hidePresence: true) + } + } else { + if callViewModel.remoteAddress!.displayName != nil { + Image(uiImage: contactsManager.textToImage( + firstName: callViewModel.remoteAddress!.displayName!, + lastName: callViewModel.remoteAddress!.displayName!.components(separatedBy: " ").count > 1 + ? callViewModel.remoteAddress!.displayName!.components(separatedBy: " ")[1] + : "")) + .resizable() + .frame(width: 200, height: 200) + .clipShape(Circle()) + + } else { + Image(uiImage: contactsManager.textToImage( + firstName: callViewModel.remoteAddress!.username ?? "Username Error", + lastName: callViewModel.remoteAddress!.username!.components(separatedBy: " ").count > 1 + ? callViewModel.remoteAddress!.username!.components(separatedBy: " ")[1] + : "")) + .resizable() + .frame(width: 200, height: 200) + .clipShape(Circle()) + } + + } + } else { + Image("profil-picture-default") + .resizable() + .frame(width: 200, height: 200) + .clipShape(Circle()) + } + + 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() + } + } else { + VStack { + Spacer() + ZStack { + if callViewModel.activeSpeakerParticipant?.address != nil { + let addressFriend = contactsManager.getFriendWithAddress(address: callViewModel.activeSpeakerParticipant!.address) + + let contactAvatarModel = addressFriend != nil + ? ContactsManager.shared.avatarListModel.first(where: { + ($0.friend!.consolidatedPresence == .Online || $0.friend!.consolidatedPresence == .Busy) + && $0.friend!.name == addressFriend!.name + && $0.friend!.address!.asStringUriOnly() == addressFriend!.address!.asStringUriOnly() + }) + : ContactAvatarModel(friend: nil, name: "", withPresence: false) + + if addressFriend != nil && addressFriend!.photo != nil && !addressFriend!.photo!.isEmpty { + if contactAvatarModel != nil { + Avatar(contactAvatarModel: contactAvatarModel!, avatarSize: 200, hidePresence: true) + } + } else { + if callViewModel.activeSpeakerParticipant!.address.displayName != nil { + Image(uiImage: contactsManager.textToImage( + firstName: callViewModel.activeSpeakerParticipant!.address.displayName!, + lastName: callViewModel.activeSpeakerParticipant!.address.displayName!.components(separatedBy: " ").count > 1 + ? callViewModel.activeSpeakerParticipant!.address.displayName!.components(separatedBy: " ")[1] + : "")) + .resizable() + .frame(width: 200, height: 200) + .clipShape(Circle()) + + } else { + Image(uiImage: contactsManager.textToImage( + firstName: callViewModel.activeSpeakerParticipant!.address.username ?? "Username Error", + lastName: callViewModel.activeSpeakerParticipant!.address.username!.components(separatedBy: " ").count > 1 + ? callViewModel.activeSpeakerParticipant!.address.username!.components(separatedBy: " ")[1] + : "")) + .resizable() + .frame(width: 200, height: 200) + .clipShape(Circle()) + } + + } + } else { + Image("profil-picture-default") + .resizable() + .frame(width: 200, height: 200) + .clipShape(Circle()) + } + } + + Spacer() + } + } + + if !callViewModel.isConference { + LinphoneVideoViewHolder { view in + coreContext.doOnCoreQueue { core in + core.nativeVideoWindowId = UnsafeMutableRawPointer(Unmanaged.passRetained(view).toOpaque()) + } + } + .frame( + width: + angleDegree == 0 + ? (fullscreenVideo && !telecomManager.isPausedByRemote ? geometry.size.width : geometry.size.width - 8) + : (fullscreenVideo && !telecomManager.isPausedByRemote ? geometry.size.height + geometry.safeAreaInsets.top + geometry.safeAreaInsets.bottom : geometry.size.height - (minBottomSheetHeight * geometry.size.height > 80 ? minBottomSheetHeight * geometry.size.height : 78) - 40 - 20 + geometry.safeAreaInsets.bottom), + height: + angleDegree == 0 + ? (fullscreenVideo && !telecomManager.isPausedByRemote ? geometry.size.height + geometry.safeAreaInsets.top + geometry.safeAreaInsets.bottom : geometry.size.height - (minBottomSheetHeight * geometry.size.height > 80 ? minBottomSheetHeight * geometry.size.height : 78) - 40 - 20 + geometry.safeAreaInsets.bottom) + : (fullscreenVideo && !telecomManager.isPausedByRemote ? geometry.size.width : geometry.size.width - 8) + ) + .scaledToFill() + .clipped() + .onTapGesture { + if telecomManager.remoteVideo { + fullscreenVideo.toggle() + } + } + + if telecomManager.remoteVideo { + 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 !viewIsDisplayed { + VStack { + + } + .onAppear { + DispatchQueue.main.asyncAfter(deadline: .now() + 2) { + viewIsDisplayed = true + } + } + } + + if viewIsDisplayed && (callViewModel.receiveVideo || telecomManager.remoteConfVideo) { + */ + if (callViewModel.receiveVideo || telecomManager.remoteConfVideo) { + LinphoneVideoViewHolder { view in + coreContext.doOnCoreQueue { core in + //core.nativeVideoWindow = view + core.nativeVideoWindowId = UnsafeMutableRawPointer(Unmanaged.passRetained(view).toOpaque()) + } + } + .frame( + width: + angleDegree == 0 + ? (fullscreenVideo && !telecomManager.isPausedByRemote ? geometry.size.width : geometry.size.width - 8) + : (fullscreenVideo && !telecomManager.isPausedByRemote ? geometry.size.height + geometry.safeAreaInsets.top + geometry.safeAreaInsets.bottom : geometry.size.height - (minBottomSheetHeight * geometry.size.height > 80 ? minBottomSheetHeight * geometry.size.height : 78) - 40 - 20 + geometry.safeAreaInsets.bottom), + height: + angleDegree == 0 + ? (fullscreenVideo && !telecomManager.isPausedByRemote ? geometry.size.height + geometry.safeAreaInsets.top + geometry.safeAreaInsets.bottom : geometry.size.height - (minBottomSheetHeight * geometry.size.height > 80 ? minBottomSheetHeight * geometry.size.height : 78) - 40 - 20 + geometry.safeAreaInsets.bottom) + : (fullscreenVideo && !telecomManager.isPausedByRemote ? geometry.size.width : geometry.size.width - 8) + ) + .scaledToFill() + .clipped() + .onTapGesture { + if telecomManager.remoteVideo { + fullscreenVideo.toggle() + } + } + + HStack { + Spacer() + VStack { + Spacer() + ScrollView(.horizontal) { + HStack { + ForEach(0.. 80 ? minBottomSheetHeight * geometry.size.height : 78) - 40 - 20 + geometry.safeAreaInsets.bottom + ) + .padding(.bottom, 10) + /* + 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 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 + ) + } + + if telecomManager.outgoingCallStarted { + VStack { + ActivityIndicator() + .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 + ) + } + } + .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(.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 + } + } + + if (oldOrientation != orientation && oldOrientation != .faceUp) || (oldOrientation == .faceUp && (orientation == .landscapeLeft || orientation == .landscapeRight)) { + telecomManager.callStarted = false + + DispatchQueue.global().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 + } + } + + telecomManager.callStarted = false + + DispatchQueue.global().asyncAfter(deadline: .now() + 0.5) { + telecomManager.callStarted = true + } + + callViewModel.orientationUpdate(orientation: orientation) + } + } + + // swiftlint:disable function_body_length func bottomSheetContent(geo: GeometryProxy) -> some View { GeometryReader { _ in VStack(spacing: 0) { @@ -658,22 +831,41 @@ struct CallView: View { Spacer() - Button { - callViewModel.toggleVideo() - } label: { - HStack { - Image(telecomManager.remoteVideo ? "video-camera" : "video-camera-slash") - .renderingMode(.template) - .resizable() - .foregroundStyle((callViewModel.isPaused || telecomManager.isPausedByRemote) ? Color.gray500 : .white) - .frame(width: 32, height: 32) + if !callViewModel.isConference { + Button { + callViewModel.toggleVideo() + } label: { + HStack { + Image(telecomManager.remoteVideo ? "video-camera" : "video-camera-slash") + .renderingMode(.template) + .resizable() + .foregroundStyle((callViewModel.isPaused || telecomManager.isPausedByRemote) ? Color.gray500 : .white) + .frame(width: 32, height: 32) + } } + .buttonStyle(PressedButtonStyle()) + .frame(width: 60, height: 60) + .background((callViewModel.isPaused || telecomManager.isPausedByRemote) ? .white : Color.gray500) + .cornerRadius(40) + .disabled(callViewModel.isPaused || telecomManager.isPausedByRemote) + } else { + Button { + 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()) + .frame(width: 60, height: 60) + .background((callViewModel.isPaused || telecomManager.isPausedByRemote) ? .white : Color.gray500) + .cornerRadius(40) + .disabled(callViewModel.isPaused || telecomManager.isPausedByRemote) } - .buttonStyle(PressedButtonStyle()) - .frame(width: 60, height: 60) - .background((callViewModel.isPaused || telecomManager.isPausedByRemote) ? .white : Color.gray500) - .cornerRadius(40) - .disabled(callViewModel.isPaused || telecomManager.isPausedByRemote) Button { callViewModel.toggleMuteMicrophone() @@ -695,8 +887,6 @@ struct CallView: View { if AVAudioSession.sharedInstance().availableInputs != nil && !AVAudioSession.sharedInstance().availableInputs!.filter({ $0.portType.rawValue.contains("Bluetooth") }).isEmpty { - hideButtonsSheet = true - DispatchQueue.global().asyncAfter(deadline: .now() + 0.5) { audioRouteSheet = true } @@ -732,63 +922,109 @@ struct CallView: View { if orientation != .landscapeLeft && orientation != .landscapeRight { HStack(spacing: 0) { - VStack { - Button { - if callViewModel.calls.count < 2 { + if !callViewModel.isConference { + VStack { + Button { + if callViewModel.calls.count < 2 { + withAnimation { + callViewModel.isTransferInsteadCall = true + MagicSearchSingleton.shared.searchForSuggestions() + isShowStartCallFragment.toggle() + } + } else { + callViewModel.transferClicked() + } + } label: { + HStack { + Image("phone-transfer") + .renderingMode(.template) + .resizable() + .foregroundStyle(.white) + .frame(width: 32, height: 32) + } + } + .buttonStyle(PressedButtonStyle()) + .frame(width: 60, height: 60) + .background(Color.gray500) + .cornerRadius(40) + + Text(callViewModel.calls.count < 2 ? "Transfer" : "Attended transfer") + .foregroundStyle(.white) + .default_text_style(styleSize: 15) + } + .frame(width: geo.size.width * 0.25, height: geo.size.width * 0.25) + + VStack { + Button { withAnimation { - callViewModel.isTransferInsteadCall = true MagicSearchSingleton.shared.searchForSuggestions() isShowStartCallFragment.toggle() } - } else { - callViewModel.transferClicked() - } - } label: { - HStack { - Image("phone-transfer") - .renderingMode(.template) - .resizable() - .foregroundStyle(.white) - .frame(width: 32, height: 32) + } label: { + HStack { + Image("phone-plus") + .renderingMode(.template) + .resizable() + .foregroundStyle(.white) + .frame(width: 32, height: 32) + } } + .buttonStyle(PressedButtonStyle()) + .frame(width: 60, height: 60) + .background(Color.gray500) + .cornerRadius(40) + + Text("New call") + .foregroundStyle(.white) + .default_text_style(styleSize: 15) } - .buttonStyle(PressedButtonStyle()) - .frame(width: 60, height: 60) - .background(Color.gray500) - .cornerRadius(40) - - Text(callViewModel.calls.count < 2 ? "Transfer" : "Attended transfer") - .foregroundStyle(.white) - .default_text_style(styleSize: 15) - } - .frame(width: geo.size.width * 0.25, height: geo.size.width * 0.25) - - VStack { - Button { - withAnimation { - MagicSearchSingleton.shared.searchForSuggestions() - isShowStartCallFragment.toggle() - } - } label: { - HStack { - Image("phone-plus") - .renderingMode(.template) - .resizable() - .foregroundStyle(.white) - .frame(width: 32, height: 32) + .frame(width: geo.size.width * 0.25, height: geo.size.width * 0.25) + } else { + VStack { + Button { + } label: { + HStack { + Image("screencast") + .renderingMode(.template) + .resizable() + .foregroundStyle(Color.gray500) + .frame(width: 32, height: 32) + } } + .buttonStyle(PressedButtonStyle()) + .frame(width: 60, height: 60) + .background(.white) + .cornerRadius(40) + .disabled(true) + + Text("Partage d'écran") + .foregroundStyle(.white) + .default_text_style(styleSize: 15) } - .buttonStyle(PressedButtonStyle()) - .frame(width: 60, height: 60) - .background(Color.gray500) - .cornerRadius(40) + .frame(width: geo.size.width * 0.25, height: geo.size.width * 0.25) - Text("New call") - .foregroundStyle(.white) - .default_text_style(styleSize: 15) + VStack { + Button { + } label: { + HStack { + Image("users") + .renderingMode(.template) + .resizable() + .foregroundStyle(.white) + .frame(width: 32, height: 32) + } + } + .buttonStyle(PressedButtonStyle()) + .frame(width: 60, height: 60) + .background(Color.gray500) + .cornerRadius(40) + + Text("Participants") + .foregroundStyle(.white) + .default_text_style(styleSize: 15) + } + .frame(width: geo.size.width * 0.25, height: geo.size.width * 0.25) } - .frame(width: geo.size.width * 0.25, height: geo.size.width * 0.25) - VStack { ZStack { Button { @@ -1162,6 +1398,7 @@ struct CallView: View { .frame(maxHeight: .infinity, alignment: .top) } } + // swiftlint:enable function_body_length func getAudioRouteImage() { imageAudioRoute = AVAudioSession.sharedInstance().currentRoute.outputs.filter({ $0.portType.rawValue == "Speaker" }).isEmpty diff --git a/Linphone/UI/Call/Model/ParticipantModel.swift b/Linphone/UI/Call/Model/ParticipantModel.swift new file mode 100644 index 000000000..9f23870fb --- /dev/null +++ b/Linphone/UI/Call/Model/ParticipantModel.swift @@ -0,0 +1,58 @@ +/* + * 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 + + init(address: Address) { + self.address = address + + self.sipUri = address.asStringUriOnly() + + let addressFriend = ContactsManager.shared.getFriendWithAddress(address: self.address) + + var nameTmp = "" + + if addressFriend != nil { + nameTmp = addressFriend!.name! + } else { + nameTmp = address.displayName != nil + ? address.displayName! + : address.username! + } + + self.name = nameTmp + + self.avatarModel = addressFriend != nil + ? ContactsManager.shared.avatarListModel.first(where: { + $0.friend!.name == addressFriend!.name + && $0.friend!.address!.asStringUriOnly() == address.asStringUriOnly() + }) ?? ContactAvatarModel(friend: nil, name: nameTmp, withPresence: false) + : ContactAvatarModel(friend: nil, name: nameTmp, withPresence: false) + } +} diff --git a/Linphone/UI/Call/ViewModel/CallViewModel.swift b/Linphone/UI/Call/ViewModel/CallViewModel.swift index 95f17f788..8f832665e 100644 --- a/Linphone/UI/Call/ViewModel/CallViewModel.swift +++ b/Linphone/UI/Call/ViewModel/CallViewModel.swift @@ -45,6 +45,14 @@ class CallViewModel: ObservableObject { @Published var isRemoteDeviceTrusted: Bool = false @Published var selectedCall: Call? @Published var isTransferInsteadCall: Bool = false + @Published var isConference: Bool = false + @Published var videoDisplayed: Bool = false + @Published var receiveVideo: Bool = false + @Published var participantList: [ParticipantModel] = [] + @Published var activeSpeakerParticipant: ParticipantModel? = nil + + + private var mConferenceSuscriptions = Set() var calls: [Call] = [] @@ -110,6 +118,7 @@ class CallViewModel: ObservableObject { self.isRemoteDeviceTrusted = self.telecomManager.callInProgress ? isDeviceTrusted : false self.getCallsList() + self.getConference() } self.callSuscriptions.insert(self.currentCall!.publisher?.onEncryptionChanged?.postOnMainQueue {(cbVal: (call: Call, on: Bool, authenticationToken: String?)) in @@ -127,6 +136,43 @@ class CallViewModel: ObservableObject { } } + func getConference() { + coreContext.doOnCoreQueue { core in + //conf = self.currentCall?.conference != nil ? self.currentCall!.conference! : core.findConferenceInformationFromUri(uri: (self.currentCall?.remoteContactAddress)!) + if self.currentCall?.remoteContactAddress != nil { + let conf = core.findConferenceInformationFromUri(uri: (self.currentCall?.remoteContactAddress)!) + DispatchQueue.main.async { + self.isConference = conf != nil + if self.isConference { + self.displayName = conf?.subject ?? "" + self.participantList = [] + conf?.participantInfos.forEach({ participantInfo in + if participantInfo.address != nil { + self.participantList.append(ParticipantModel(address: participantInfo.address!)) + } + }) + self.addConferenceCallBacks() + } + } + } + } + } + + func addConferenceCallBacks() { + coreContext.doOnCoreQueue { core in + self.mConferenceSuscriptions.insert( + self.currentCall?.conference?.publisher?.onActiveSpeakerParticipantDevice?.postOnMainQueue {(cbValue: (conference: Conference, participantDevice: ParticipantDevice)) in + let direction = cbValue.participantDevice.getStreamCapability(streamType: StreamType.Video) + + self.receiveVideo = direction == MediaDirection.SendRecv || direction == MediaDirection.SendOnly + + if cbValue.participantDevice.address != nil { + self.activeSpeakerParticipant = ParticipantModel(address: cbValue.participantDevice.address!) + } + }) + } + } + func terminateCall() { coreContext.doOnCoreQueue { core in if self.currentCall != nil { @@ -186,6 +232,33 @@ class CallViewModel: ObservableObject { } } + func displayMyVideo() { + coreContext.doOnCoreQueue { core in + if self.currentCall != nil { + do { + let params = try core.createCallParams(call: self.currentCall) + + if params.videoEnabled { + if params.videoDirection == MediaDirection.SendRecv { + params.videoDirection = MediaDirection.RecvOnly + } else if params.videoDirection == MediaDirection.RecvOnly { + params.videoDirection = MediaDirection.SendRecv + } + } + + try self.currentCall!.update(params: params) + + let video = params.videoDirection == MediaDirection.SendRecv + DispatchQueue.main.asyncAfter(deadline: .now() + 1) { + self.videoDisplayed = video + } + } catch { + + } + } + } + } + func switchCamera() { coreContext.doOnCoreQueue { core in let currentDevice = core.videoDevice diff --git a/Linphone/UI/Main/Conversations/ViewModel/ConversationsListViewModel.swift b/Linphone/UI/Main/Conversations/ViewModel/ConversationsListViewModel.swift index 6da0a683a..3f7cab4ad 100644 --- a/Linphone/UI/Main/Conversations/ViewModel/ConversationsListViewModel.swift +++ b/Linphone/UI/Main/Conversations/ViewModel/ConversationsListViewModel.swift @@ -46,7 +46,6 @@ class ConversationsListViewModel: ObservableObject { var conversationsListTmp: [ConversationModel] = [] chatRooms.forEach { chatRoom in - //let disabledBecauseNotSecured = (account?.isInSecureMode() == true && !chatRoom.hasCapability) ? Capabilities.Encrypted.toInt() : 0 if chatRoom.hasCapability(mask: ChatRoom.Capabilities.OneToOne.rawValue) { } @@ -54,32 +53,6 @@ class ConversationsListViewModel: ObservableObject { let model = ConversationModel(chatRoom: chatRoom) conversationsListTmp.append(model) } - /* - else { - val participants = chatRoom.participants - val found = participants.find { - // Search in address but also in contact name if exists - val model = - coreContext.contactsManager.getContactAvatarModelForAddress(it.address) - model.contactName?.contains( - filter, - ignoreCase = true - ) == true || it.address.asStringUriOnly().contains( - filter, - ignoreCase = true - ) - } - if ( - found != null || - chatRoom.peerAddress.asStringUriOnly().contains(filter, ignoreCase = true) || - chatRoom.subject.orEmpty().contains(filter, ignoreCase = true) - ) { - val model = ConversationModel(chatRoom, disabledBecauseNotSecured) - list.add(model) - count += 1 - } - } - */ } if !self.conversationsList.isEmpty { diff --git a/Linphone/Utils/Avatar.swift b/Linphone/Utils/Avatar.swift index aca33765f..768122219 100644 --- a/Linphone/Utils/Avatar.swift +++ b/Linphone/Utils/Avatar.swift @@ -80,7 +80,7 @@ struct Avatar: View { ? contactAvatarModel.name.components(separatedBy: " ")[1] : "")) .resizable() - .frame(width: 50, height: 50) + .frame(width: avatarSize, height: avatarSize) .clipShape(Circle()) } else { Image("profil-picture-default") From 027cb1ec2d9e019325094b9c7a8d5a613422cb14 Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Tue, 2 Apr 2024 17:07:54 +0200 Subject: [PATCH 151/486] Add participants list and active speaker to the conference call view --- Linphone/UI/Call/CallView.swift | 88 ++++++++++++++----- .../UI/Call/ViewModel/CallViewModel.swift | 26 +++++- 2 files changed, 88 insertions(+), 26 deletions(-) diff --git a/Linphone/UI/Call/CallView.swift b/Linphone/UI/Call/CallView.swift index f5e01f466..cf65931df 100644 --- a/Linphone/UI/Call/CallView.swift +++ b/Linphone/UI/Call/CallView.swift @@ -402,6 +402,7 @@ struct CallView: View { } } + // swiftlint:disable:next cyclomatic_complexity func simpleCallView(geometry: GeometryProxy) -> some View { ZStack { if !callViewModel.isConference { @@ -598,7 +599,7 @@ struct CallView: View { if viewIsDisplayed && (callViewModel.receiveVideo || telecomManager.remoteConfVideo) { */ - if (callViewModel.receiveVideo || telecomManager.remoteConfVideo) { + if (callViewModel.receiveVideo || telecomManager.remoteConfVideo) && !telecomManager.outgoingCallStarted { LinphoneVideoViewHolder { view in coreContext.doOnCoreQueue { core in //core.nativeVideoWindow = view @@ -623,13 +624,73 @@ struct CallView: View { } } + /* + 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 { HStack { Spacer() VStack { Spacer() ScrollView(.horizontal) { HStack { - ForEach(0.. 80 ? minBottomSheetHeight * geometry.size.height : 78) - 40 - 20 + geometry.safeAreaInsets.bottom ) .padding(.bottom, 10) - /* - 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 - ) - */ } } @@ -711,7 +751,7 @@ struct CallView: View { ) } - if telecomManager.outgoingCallStarted { + if telecomManager.outgoingCallStarted { VStack { ActivityIndicator() .frame(width: 20, height: 20) diff --git a/Linphone/UI/Call/ViewModel/CallViewModel.swift b/Linphone/UI/Call/ViewModel/CallViewModel.swift index 8f832665e..38ef4a84b 100644 --- a/Linphone/UI/Call/ViewModel/CallViewModel.swift +++ b/Linphone/UI/Call/ViewModel/CallViewModel.swift @@ -50,6 +50,7 @@ class CallViewModel: ObservableObject { @Published var receiveVideo: Bool = false @Published var participantList: [ParticipantModel] = [] @Published var activeSpeakerParticipant: ParticipantModel? = nil + @Published var myParticipantModel: ParticipantModel? = nil private var mConferenceSuscriptions = Set() @@ -148,7 +149,13 @@ class CallViewModel: ObservableObject { self.participantList = [] conf?.participantInfos.forEach({ participantInfo in if participantInfo.address != nil { - self.participantList.append(ParticipantModel(address: participantInfo.address!)) + if participantInfo.address!.equal(address2: (self.currentCall?.callLog?.localAddress!)!) { + self.myParticipantModel = ParticipantModel(address: participantInfo.address!) + } else { + if self.activeSpeakerParticipant != nil && !participantInfo.address!.equal(address2: self.activeSpeakerParticipant!.address) { + self.participantList.append(ParticipantModel(address: participantInfo.address!)) + } + } } }) self.addConferenceCallBacks() @@ -167,9 +174,24 @@ class CallViewModel: ObservableObject { self.receiveVideo = direction == MediaDirection.SendRecv || direction == MediaDirection.SendOnly if cbValue.participantDevice.address != nil { + let activeSpeakerParticipantTmp = self.activeSpeakerParticipant self.activeSpeakerParticipant = ParticipantModel(address: cbValue.participantDevice.address!) + + if self.activeSpeakerParticipant != nil + && ((activeSpeakerParticipantTmp != nil && !activeSpeakerParticipantTmp!.address.equal(address2: self.activeSpeakerParticipant!.address)) + || ( activeSpeakerParticipantTmp == nil)) { + + self.participantList = [] + cbValue.conference.participantList.forEach({ participant in + if participant.address != nil && !cbValue.conference.isMe(uri: participant.address!) { + if !cbValue.conference.isMe(uri: participant.address!) && !participant.address!.equal(address2: self.activeSpeakerParticipant!.address) { + self.participantList.append(ParticipantModel(address: participant.address!)) + } + } + }) + } } - }) + }) } } From f771bdc79011ac0a672f090140f26fcd5d644843 Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Thu, 4 Apr 2024 17:58:30 +0200 Subject: [PATCH 152/486] FIx active speaker mode --- Linphone/TelecomManager/TelecomManager.swift | 11 +- Linphone/UI/Call/CallView.swift | 389 ++++++++++-------- .../UI/Call/ViewModel/CallViewModel.swift | 111 +++-- .../ContactInnerActionsFragment.swift | 2 +- .../Fragments/ContactInnerFragment.swift | 4 +- Linphone/UI/Main/ContentView.swift | 2 +- .../Model/ConversationModel.swift | 2 +- .../History/Fragments/DialerBottomSheet.swift | 2 +- .../Fragments/HistoryContactFragment.swift | 8 +- .../Fragments/HistoryListFragment.swift | 43 +- .../History/Fragments/StartCallFragment.swift | 4 +- 11 files changed, 366 insertions(+), 212 deletions(-) diff --git a/Linphone/TelecomManager/TelecomManager.swift b/Linphone/TelecomManager/TelecomManager.swift index 3cfd68bc0..b4655f5bc 100644 --- a/Linphone/TelecomManager/TelecomManager.swift +++ b/Linphone/TelecomManager/TelecomManager.swift @@ -50,6 +50,7 @@ class TelecomManager: ObservableObject { @Published var isPausedByRemote: Bool = false @Published var refreshCallViewModel: Bool = false @Published var remainingCall: Bool = false + @Published var callConnected: Bool = false var actionToFulFill: CXCallAction? var callkitAudioSessionActivated: Bool? @@ -155,10 +156,10 @@ class TelecomManager: ObservableObject { } } - func doCallWithCore(addr: Address, isVideo: Bool) { + 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: false) + 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) ") } @@ -213,6 +214,7 @@ class TelecomManager: ObservableObject { lcallParams.mediaEncryption = .ZRTP } if isConference { + lcallParams.videoEnabled = true /* if (ConferenceWaitingRoomViewModel.sharedModel.joinLayout.value! != .AudioOnly) { lcallParams.videoEnabled = true lcallParams.videoDirection = ConferenceWaitingRoomViewModel.sharedModel.isVideoEnabled.value == true ? .SendRecv : .RecvOnly @@ -439,6 +441,10 @@ class TelecomManager: ObservableObject { default: self.isPausedByRemote = false } + + if (cstate == Call.State.Connected) { + self.callConnected = true + } } if call.userData == nil { @@ -596,6 +602,7 @@ class TelecomManager: ObservableObject { self.callInProgress = false self.callDisplayed = false self.callStarted = false + self.callConnected = false } } else { if core.calls.last != nil { diff --git a/Linphone/UI/Call/CallView.swift b/Linphone/UI/Call/CallView.swift index cf65931df..9b4a27fdd 100644 --- a/Linphone/UI/Call/CallView.swift +++ b/Linphone/UI/Call/CallView.swift @@ -46,7 +46,7 @@ struct CallView: View { @State var maxBottomSheetHeight: CGFloat = 0.5 @State private var pointingUp: CGFloat = 0.0 @State private var currentOffset: CGFloat = 0.0 - @State private var viewIsDisplayed = false + @State var displayVideo = false @Binding var fullscreenVideo: Bool @Binding var isShowCallsListFragment: Bool @@ -485,64 +485,10 @@ struct CallView: View { Spacer() } - } else { - VStack { - Spacer() - ZStack { - if callViewModel.activeSpeakerParticipant?.address != nil { - let addressFriend = contactsManager.getFriendWithAddress(address: callViewModel.activeSpeakerParticipant!.address) - - let contactAvatarModel = addressFriend != nil - ? ContactsManager.shared.avatarListModel.first(where: { - ($0.friend!.consolidatedPresence == .Online || $0.friend!.consolidatedPresence == .Busy) - && $0.friend!.name == addressFriend!.name - && $0.friend!.address!.asStringUriOnly() == addressFriend!.address!.asStringUriOnly() - }) - : ContactAvatarModel(friend: nil, name: "", withPresence: false) - - if addressFriend != nil && addressFriend!.photo != nil && !addressFriend!.photo!.isEmpty { - if contactAvatarModel != nil { - Avatar(contactAvatarModel: contactAvatarModel!, avatarSize: 200, hidePresence: true) - } - } else { - if callViewModel.activeSpeakerParticipant!.address.displayName != nil { - Image(uiImage: contactsManager.textToImage( - firstName: callViewModel.activeSpeakerParticipant!.address.displayName!, - lastName: callViewModel.activeSpeakerParticipant!.address.displayName!.components(separatedBy: " ").count > 1 - ? callViewModel.activeSpeakerParticipant!.address.displayName!.components(separatedBy: " ")[1] - : "")) - .resizable() - .frame(width: 200, height: 200) - .clipShape(Circle()) - - } else { - Image(uiImage: contactsManager.textToImage( - firstName: callViewModel.activeSpeakerParticipant!.address.username ?? "Username Error", - lastName: callViewModel.activeSpeakerParticipant!.address.username!.components(separatedBy: " ").count > 1 - ? callViewModel.activeSpeakerParticipant!.address.username!.components(separatedBy: " ")[1] - : "")) - .resizable() - .frame(width: 200, height: 200) - .clipShape(Circle()) - } - - } - } else { - Image("profil-picture-default") - .resizable() - .frame(width: 200, height: 200) - .clipShape(Circle()) - } - } - - Spacer() - } - } - - if !callViewModel.isConference { + LinphoneVideoViewHolder { view in coreContext.doOnCoreQueue { core in - core.nativeVideoWindowId = UnsafeMutableRawPointer(Unmanaged.passRetained(view).toOpaque()) + core.nativeVideoWindow = view } } .frame( @@ -584,26 +530,80 @@ struct CallView: View { 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 !viewIsDisplayed { - VStack { - - } - .onAppear { - DispatchQueue.main.asyncAfter(deadline: .now() + 2) { - viewIsDisplayed = true + } else if callViewModel.isConference && !telecomManager.outgoingCallStarted && callViewModel.activeSpeakerParticipant != nil { + VStack { + Spacer() + ZStack { + if callViewModel.activeSpeakerParticipant?.address != nil { + let addressFriend = contactsManager.getFriendWithAddress(address: callViewModel.activeSpeakerParticipant!.address) + + let contactAvatarModel = addressFriend != nil + ? ContactsManager.shared.avatarListModel.first(where: { + ($0.friend!.consolidatedPresence == .Online || $0.friend!.consolidatedPresence == .Busy) + && $0.friend!.name == addressFriend!.name + && $0.friend!.address!.asStringUriOnly() == addressFriend!.address!.asStringUriOnly() + }) + : ContactAvatarModel(friend: nil, name: "", withPresence: true) + + if addressFriend != nil && addressFriend!.photo != nil && !addressFriend!.photo!.isEmpty { + if contactAvatarModel != nil { + Avatar(contactAvatarModel: contactAvatarModel!, avatarSize: 200, hidePresence: false) + .onAppear { + DispatchQueue.main.asyncAfter(deadline: .now() + 2) { + displayVideo = true + } + } + } + } else { + if callViewModel.activeSpeakerParticipant!.address.displayName != nil { + Image(uiImage: contactsManager.textToImage( + firstName: callViewModel.activeSpeakerParticipant!.address.displayName!, + lastName: callViewModel.activeSpeakerParticipant!.address.displayName!.components(separatedBy: " ").count > 1 + ? callViewModel.activeSpeakerParticipant!.address.displayName!.components(separatedBy: " ")[1] + : "")) + .resizable() + .frame(width: 200, height: 200) + .clipShape(Circle()) + .onAppear { + DispatchQueue.main.asyncAfter(deadline: .now() + 2) { + displayVideo = true + } + } + + } else { + Image(uiImage: contactsManager.textToImage( + firstName: callViewModel.activeSpeakerParticipant!.address.username ?? "Username Error", + lastName: callViewModel.activeSpeakerParticipant!.address.username!.components(separatedBy: " ").count > 1 + ? callViewModel.activeSpeakerParticipant!.address.username!.components(separatedBy: " ")[1] + : "")) + .resizable() + .frame(width: 200, height: 200) + .clipShape(Circle()) + .onAppear { + DispatchQueue.main.asyncAfter(deadline: .now() + 2) { + displayVideo = true + } + } + } + + } + } else { + Image("profil-picture-default") + .resizable() + .frame(width: 200, height: 200) + .clipShape(Circle()) } } + + Spacer() } - if viewIsDisplayed && (callViewModel.receiveVideo || telecomManager.remoteConfVideo) { - */ - if (callViewModel.receiveVideo || telecomManager.remoteConfVideo) && !telecomManager.outgoingCallStarted { + if telecomManager.remoteConfVideo && !telecomManager.outgoingCallStarted && callViewModel.activeSpeakerParticipant != nil && displayVideo { LinphoneVideoViewHolder { view in - coreContext.doOnCoreQueue { core in - //core.nativeVideoWindow = view - core.nativeVideoWindowId = UnsafeMutableRawPointer(Unmanaged.passRetained(view).toOpaque()) + DispatchQueue.main.asyncAfter(deadline: .now() + 1) { + coreContext.doOnCoreQueue { core in + core.nativeVideoWindow = view + } } } .frame( @@ -623,35 +623,22 @@ struct CallView: View { fullscreenVideo.toggle() } } - - /* - 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 { + 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) + ScrollView(.horizontal) { HStack { ZStack { @@ -664,6 +651,20 @@ struct CallView: View { Spacer() } + .frame(width: 140, height: 140) + + if callViewModel.videoDisplayed { + LinphoneVideoViewHolder { view in + DispatchQueue.main.asyncAfter(deadline: .now() + 1) { + 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() @@ -678,44 +679,50 @@ struct CallView: View { .padding(.bottom, 6) } } - .frame(maxWidth: .infinity) - - LinphoneVideoViewHolder { view in - coreContext.doOnCoreQueue { core in - core.nativePreviewWindow = view - } - } + .frame(width: 140, height: 140) } .frame(width: 140, height: 140) .background(Color.gray600) .cornerRadius(20) ForEach(0.. 80 ? minBottomSheetHeight * geometry.size.height : 78) - 40 - 20 + geometry.safeAreaInsets.bottom ) .padding(.bottom, 10) + .padding(.leading, -10) } } @@ -773,6 +781,11 @@ struct CallView: View { Spacer() } + .onDisappear { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + callViewModel.getConference() + } + } .background(.clear) .frame( maxWidth: fullscreenVideo && !telecomManager.isPausedByRemote ? geometry.size.width : geometry.size.width - 8, @@ -1186,29 +1199,54 @@ struct CallView: View { } .frame(width: geo.size.width * 0.25, height: geo.size.width * 0.25) - 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) + if !callViewModel.isConference { + 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()) + .frame(width: 60, height: 60) + .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) } - .buttonStyle(PressedButtonStyle()) - .frame(width: 60, height: 60) - .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.25, height: geo.size.width * 0.25) + } else { + VStack { + Button { + } label: { + HStack { + Image("record-fill") + .renderingMode(.template) + .resizable() + .foregroundStyle(Color.gray500) + .frame(width: 32, height: 32) + } + } + .buttonStyle(PressedButtonStyle()) + .frame(width: 60, height: 60) + .background(.white) + .cornerRadius(40) + .disabled(true) + + Text("Record") + .foregroundStyle(.white) + .default_text_style(styleSize: 15) + } + .frame(width: geo.size.width * 0.25, height: geo.size.width * 0.25) } - .frame(width: geo.size.width * 0.25, height: geo.size.width * 0.25) VStack { Button { @@ -1403,29 +1441,54 @@ struct CallView: View { } .frame(width: geo.size.width * 0.125, height: geo.size.width * 0.125) - 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) + if !callViewModel.isConference { + 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()) + .frame(width: 60, height: 60) + .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) } - .buttonStyle(PressedButtonStyle()) - .frame(width: 60, height: 60) - .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()) + .frame(width: 60, height: 60) + .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(width: geo.size.width * 0.125, height: geo.size.width * 0.125) } .frame(height: geo.size.height * 0.15) .padding(.horizontal, 20) diff --git a/Linphone/UI/Call/ViewModel/CallViewModel.swift b/Linphone/UI/Call/ViewModel/CallViewModel.swift index 38ef4a84b..eb8b1f1a7 100644 --- a/Linphone/UI/Call/ViewModel/CallViewModel.swift +++ b/Linphone/UI/Call/ViewModel/CallViewModel.swift @@ -47,9 +47,9 @@ class CallViewModel: ObservableObject { @Published var isTransferInsteadCall: Bool = false @Published var isConference: Bool = false @Published var videoDisplayed: Bool = false - @Published var receiveVideo: Bool = false @Published var participantList: [ParticipantModel] = [] @Published var activeSpeakerParticipant: ParticipantModel? = nil + @Published var activeSpeakerName: String = "" @Published var myParticipantModel: ParticipantModel? = nil @@ -80,7 +80,7 @@ class CallViewModel: ObservableObject { } } - func disableAVAudioSession(){ + func disableAVAudioSession() { do { try AVAudioSession.sharedInstance().setActive(false) } catch _ { @@ -117,6 +117,14 @@ class CallViewModel: ObservableObject { let authToken = self.currentCall!.authenticationToken let isDeviceTrusted = self.currentCall!.authenticationTokenVerified && authToken != nil self.isRemoteDeviceTrusted = self.telecomManager.callInProgress ? isDeviceTrusted : false + self.activeSpeakerParticipant = nil + + do { + let params = try core.createCallParams(call: self.currentCall) + self.videoDisplayed = params.videoDirection == MediaDirection.SendRecv + } catch { + + } self.getCallsList() self.getConference() @@ -139,28 +147,69 @@ class CallViewModel: ObservableObject { func getConference() { coreContext.doOnCoreQueue { core in - //conf = self.currentCall?.conference != nil ? self.currentCall!.conference! : core.findConferenceInformationFromUri(uri: (self.currentCall?.remoteContactAddress)!) - if self.currentCall?.remoteContactAddress != nil { - let conf = core.findConferenceInformationFromUri(uri: (self.currentCall?.remoteContactAddress)!) + if self.currentCall?.conference != nil { + let conf = self.currentCall!.conference! + self.isConference = true DispatchQueue.main.async { - self.isConference = conf != nil - if self.isConference { - self.displayName = conf?.subject ?? "" - self.participantList = [] - conf?.participantInfos.forEach({ participantInfo in - if participantInfo.address != nil { - if participantInfo.address!.equal(address2: (self.currentCall?.callLog?.localAddress!)!) { - self.myParticipantModel = ParticipantModel(address: participantInfo.address!) - } else { - if self.activeSpeakerParticipant != nil && !participantInfo.address!.equal(address2: self.activeSpeakerParticipant!.address) { - self.participantList.append(ParticipantModel(address: participantInfo.address!)) - } - } - } - }) - self.addConferenceCallBacks() + self.displayName = conf.subject ?? "" + self.participantList = [] + + if self.currentCall?.callLog?.localAddress != nil { + self.myParticipantModel = ParticipantModel(address: self.currentCall!.callLog!.localAddress!) } + + if conf.activeSpeakerParticipantDevice?.address != nil { + self.activeSpeakerParticipant = ParticipantModel(address: conf.activeSpeakerParticipantDevice!.address!) + } else if conf.participantList.first?.address != nil { + self.activeSpeakerParticipant = ParticipantModel(address: conf.participantList.first!.address!) + } else { + DispatchQueue.main.asyncAfter(deadline: .now() + 1) { + self.getConference() + } + } + + if self.activeSpeakerParticipant != nil { + let friend = ContactsManager.shared.getFriendWithAddress(address: self.activeSpeakerParticipant!.address) + if friend != nil && friend!.address != nil && friend!.address!.displayName != nil { + self.activeSpeakerName = friend!.address!.displayName! + } else { + if self.activeSpeakerParticipant!.address.displayName != nil { + self.activeSpeakerName = self.activeSpeakerParticipant!.address.displayName! + } else if self.activeSpeakerParticipant!.address.username != nil { + self.activeSpeakerName = self.activeSpeakerParticipant!.address.username! + } + } + } + + conf.participantList.forEach({ participant in + self.participantList.append(ParticipantModel(address: participant.address!)) + }) + + self.addConferenceCallBacks() } + } else if self.currentCall?.remoteContactAddress != nil { + let conf = core.findConferenceInformationFromUri(uri: (self.currentCall?.remoteContactAddress)!) + DispatchQueue.main.async { + self.isConference = conf != nil + if self.isConference { + self.displayName = conf?.subject ?? "" + self.participantList = [] + + conf?.participantInfos.forEach({ participantInfo in + if participantInfo.address != nil { + if participantInfo.address!.equal(address2: (self.currentCall?.callLog?.localAddress!)!) { + self.myParticipantModel = ParticipantModel(address: participantInfo.address!) + } else { + if self.activeSpeakerParticipant != nil && !participantInfo.address!.equal(address2: self.activeSpeakerParticipant!.address) { + self.participantList.append(ParticipantModel(address: participantInfo.address!)) + } + } + } + }) + + self.addConferenceCallBacks() + } + } } } } @@ -169,14 +218,24 @@ class CallViewModel: ObservableObject { coreContext.doOnCoreQueue { core in self.mConferenceSuscriptions.insert( self.currentCall?.conference?.publisher?.onActiveSpeakerParticipantDevice?.postOnMainQueue {(cbValue: (conference: Conference, participantDevice: ParticipantDevice)) in - let direction = cbValue.participantDevice.getStreamCapability(streamType: StreamType.Video) - - self.receiveVideo = direction == MediaDirection.SendRecv || direction == MediaDirection.SendOnly - if cbValue.participantDevice.address != nil { let activeSpeakerParticipantTmp = self.activeSpeakerParticipant self.activeSpeakerParticipant = ParticipantModel(address: cbValue.participantDevice.address!) + + if self.activeSpeakerParticipant != nil { + let friend = ContactsManager.shared.getFriendWithAddress(address: self.activeSpeakerParticipant!.address) + if friend != nil && friend!.address != nil && friend!.address!.displayName != nil { + self.activeSpeakerName = friend!.address!.displayName! + } else { + if self.activeSpeakerParticipant!.address.displayName != nil { + self.activeSpeakerName = self.activeSpeakerParticipant!.address.displayName! + } else if self.activeSpeakerParticipant!.address.username != nil { + self.activeSpeakerName = self.activeSpeakerParticipant!.address.username! + } + } + } + if self.activeSpeakerParticipant != nil && ((activeSpeakerParticipantTmp != nil && !activeSpeakerParticipantTmp!.address.equal(address2: self.activeSpeakerParticipant!.address)) || ( activeSpeakerParticipantTmp == nil)) { @@ -184,7 +243,7 @@ class CallViewModel: ObservableObject { self.participantList = [] cbValue.conference.participantList.forEach({ participant in if participant.address != nil && !cbValue.conference.isMe(uri: participant.address!) { - if !cbValue.conference.isMe(uri: participant.address!) && !participant.address!.equal(address2: self.activeSpeakerParticipant!.address) { + if !cbValue.conference.isMe(uri: participant.address!) { self.participantList.append(ParticipantModel(address: participant.address!)) } } diff --git a/Linphone/UI/Main/Contacts/Fragments/ContactInnerActionsFragment.swift b/Linphone/UI/Main/Contacts/Fragments/ContactInnerActionsFragment.swift index be78afc16..9d50cfc1e 100644 --- a/Linphone/UI/Main/Contacts/Fragments/ContactInnerActionsFragment.swift +++ b/Linphone/UI/Main/Contacts/Fragments/ContactInnerActionsFragment.swift @@ -94,7 +94,7 @@ struct ContactInnerActionsFragment: View { .onTapGesture { withAnimation { telecomManager.doCallWithCore( - addr: contactsManager.lastSearch[contactViewModel.indexDisplayedFriend!].friend!.addresses[index], isVideo: false + addr: contactsManager.lastSearch[contactViewModel.indexDisplayedFriend!].friend!.addresses[index], isVideo: false, isConference: false ) } } diff --git a/Linphone/UI/Main/Contacts/Fragments/ContactInnerFragment.swift b/Linphone/UI/Main/Contacts/Fragments/ContactInnerFragment.swift index 5dd1b1787..7974d0a88 100644 --- a/Linphone/UI/Main/Contacts/Fragments/ContactInnerFragment.swift +++ b/Linphone/UI/Main/Contacts/Fragments/ContactInnerFragment.swift @@ -158,7 +158,7 @@ struct ContactInnerFragment: View { Spacer() Button(action: { - telecomManager.doCallWithCore(addr: contactsManager.lastSearch[contactViewModel.indexDisplayedFriend!].friend!.address!, isVideo: false) + telecomManager.doCallWithCore(addr: contactsManager.lastSearch[contactViewModel.indexDisplayedFriend!].friend!.address!, isVideo: false, isConference: false) }, label: { VStack { HStack(alignment: .center) { @@ -208,7 +208,7 @@ struct ContactInnerFragment: View { Spacer() Button(action: { - telecomManager.doCallWithCore(addr: contactsManager.lastSearch[contactViewModel.indexDisplayedFriend!].friend!.address!, isVideo: true) + telecomManager.doCallWithCore(addr: contactsManager.lastSearch[contactViewModel.indexDisplayedFriend!].friend!.address!, isVideo: true, isConference: false) }, label: { VStack { HStack(alignment: .center) { diff --git a/Linphone/UI/Main/ContentView.swift b/Linphone/UI/Main/ContentView.swift index bce817c6e..412541b28 100644 --- a/Linphone/UI/Main/ContentView.swift +++ b/Linphone/UI/Main/ContentView.swift @@ -856,7 +856,7 @@ struct ContentView: View { } } - if telecomManager.callInProgress && telecomManager.callDisplayed { + if telecomManager.callDisplayed && ((telecomManager.callInProgress && telecomManager.outgoingCallStarted) || telecomManager.callConnected) { CallView(callViewModel: callViewModel, fullscreenVideo: $fullscreenVideo, isShowCallsListFragment: $isShowCallsListFragment, isShowStartCallFragment: $isShowStartCallFragment) .zIndex(3) .transition(.scale.combined(with: .move(edge: .top))) diff --git a/Linphone/UI/Main/Conversations/Model/ConversationModel.swift b/Linphone/UI/Main/Conversations/Model/ConversationModel.swift index a1090a043..35aa5dd28 100644 --- a/Linphone/UI/Main/Conversations/Model/ConversationModel.swift +++ b/Linphone/UI/Main/Conversations/Model/ConversationModel.swift @@ -129,7 +129,7 @@ class ConversationModel: ObservableObject { coreContext.doOnCoreQueue { _ in if self.chatRoom.peerAddress != nil { TelecomManager.shared.doCallWithCore( - addr: self.chatRoom.peerAddress!, isVideo: false + addr: self.chatRoom.peerAddress!, isVideo: false, isConference: false ) } } diff --git a/Linphone/UI/Main/History/Fragments/DialerBottomSheet.swift b/Linphone/UI/Main/History/Fragments/DialerBottomSheet.swift index 0fadeb5d0..267c8aded 100644 --- a/Linphone/UI/Main/History/Fragments/DialerBottomSheet.swift +++ b/Linphone/UI/Main/History/Fragments/DialerBottomSheet.swift @@ -434,7 +434,7 @@ struct DialerBottomSheet: View { if !startCallViewModel.searchField.isEmpty { do { let address = try Factory.Instance.createAddress(addr: String("sip:" + startCallViewModel.searchField + "@" + startCallViewModel.domain)) - telecomManager.doCallWithCore(addr: address, isVideo: false) + telecomManager.doCallWithCore(addr: address, isVideo: false, isConference: false) } catch { } diff --git a/Linphone/UI/Main/History/Fragments/HistoryContactFragment.swift b/Linphone/UI/Main/History/Fragments/HistoryContactFragment.swift index 9c427a317..71062797b 100644 --- a/Linphone/UI/Main/History/Fragments/HistoryContactFragment.swift +++ b/Linphone/UI/Main/History/Fragments/HistoryContactFragment.swift @@ -388,11 +388,11 @@ struct HistoryContactFragment: View { Button(action: { if historyViewModel.displayedCall!.dir == .Outgoing && historyViewModel.displayedCall!.toAddress != nil { telecomManager.doCallWithCore( - addr: historyViewModel.displayedCall!.toAddress!, isVideo: false + addr: historyViewModel.displayedCall!.toAddress!, isVideo: false, isConference: false ) } else if historyViewModel.displayedCall!.dir == .Incoming && historyViewModel.displayedCall!.fromAddress != nil { telecomManager.doCallWithCore( - addr: historyViewModel.displayedCall!.fromAddress!, isVideo: false + addr: historyViewModel.displayedCall!.fromAddress!, isVideo: false, isConference: false ) } }, label: { @@ -448,11 +448,11 @@ struct HistoryContactFragment: View { Button(action: { if historyViewModel.displayedCall!.dir == .Outgoing && historyViewModel.displayedCall!.toAddress != nil { telecomManager.doCallWithCore( - addr: historyViewModel.displayedCall!.toAddress!, isVideo: true + addr: historyViewModel.displayedCall!.toAddress!, isVideo: true, isConference: false ) } else if historyViewModel.displayedCall!.dir == .Incoming && historyViewModel.displayedCall!.fromAddress != nil { telecomManager.doCallWithCore( - addr: historyViewModel.displayedCall!.fromAddress!, isVideo: true + addr: historyViewModel.displayedCall!.fromAddress!, isVideo: true, isConference: false ) } }, label: { diff --git a/Linphone/UI/Main/History/Fragments/HistoryListFragment.swift b/Linphone/UI/Main/History/Fragments/HistoryListFragment.swift index 0c4bc0d05..87c80a253 100644 --- a/Linphone/UI/Main/History/Fragments/HistoryListFragment.swift +++ b/Linphone/UI/Main/History/Fragments/HistoryListFragment.swift @@ -165,15 +165,7 @@ struct HistoryListFragment: View { TapGesture() .onEnded { _ in withAnimation { - if historyListViewModel.callLogs[index].dir == .Outgoing && historyListViewModel.callLogs[index].toAddress != nil { - telecomManager.doCallWithCore( - addr: historyListViewModel.callLogs[index].toAddress!, isVideo: false - ) - } else if historyListViewModel.callLogs[index].fromAddress != nil { - telecomManager.doCallWithCore( - addr: historyListViewModel.callLogs[index].fromAddress!, isVideo: false - ) - } + doCall(index: index) historyViewModel.displayedCall = nil } } @@ -217,6 +209,39 @@ struct HistoryListFragment: View { .navigationTitle("") .navigationBarHidden(true) } + + func doCall(index: Int) { + if historyListViewModel.callLogs[index].dir == .Outgoing && historyListViewModel.callLogs[index].toAddress != nil { + if historyListViewModel.callLogs[index].toAddress!.asStringUriOnly().hasPrefix("sip:conference-focus@sip.linphone.org") { + do { + //let reudumatin = try Factory.Instance.createAddress(addr: "sip:conference-focus@sip.linphone.org;conf-id=8~YNkpFOv;gr=0ee3f37f-6df2-0071-bb9a-a4e24be30135") + let reutest = try Factory.Instance.createAddress(addr: "sip:conference-focus@sip.linphone.org;conf-id=iVs8XshC~;gr=0ee3f37f-6df2-0071-bb9a-a4e24be30135") + + telecomManager.doCallWithCore( + addr: reutest, isVideo: false, isConference: true + ) + } catch {} + } else { + telecomManager.doCallWithCore( + addr: historyListViewModel.callLogs[index].toAddress!, isVideo: false, isConference: false + ) + } + } else if historyListViewModel.callLogs[index].fromAddress != nil { + if historyListViewModel.callLogs[index].fromAddress!.asStringUriOnly().hasPrefix("sip:conference-focus@sip.linphone.org") { + do { + //let reudumatin = try Factory.Instance.createAddress(addr: "sip:conference-focus@sip.linphone.org;conf-id=8~YNkpFOv;gr=0ee3f37f-6df2-0071-bb9a-a4e24be30135") + let reutest = try Factory.Instance.createAddress(addr: "sip:conference-focus@sip.linphone.org;conf-id=iVs8XshC~;gr=0ee3f37f-6df2-0071-bb9a-a4e24be30135") + telecomManager.doCallWithCore( + addr: reutest, isVideo: false, isConference: true + ) + } catch {} + } else { + telecomManager.doCallWithCore( + addr: historyListViewModel.callLogs[index].fromAddress!, isVideo: false, isConference: false + ) + } + } + } } #Preview { diff --git a/Linphone/UI/Main/History/Fragments/StartCallFragment.swift b/Linphone/UI/Main/History/Fragments/StartCallFragment.swift index 7eea3b63f..37a5f2a4f 100644 --- a/Linphone/UI/Main/History/Fragments/StartCallFragment.swift +++ b/Linphone/UI/Main/History/Fragments/StartCallFragment.swift @@ -219,7 +219,7 @@ struct StartCallFragment: View { withAnimation { isShowStartCallFragment.toggle() - telecomManager.doCallWithCore(addr: addr, isVideo: false) + telecomManager.doCallWithCore(addr: addr, isVideo: false, isConference: false) } } }) @@ -305,7 +305,7 @@ struct StartCallFragment: View { isShowStartCallFragment.toggle() if contactsManager.lastSearchSuggestions[index].address != nil { telecomManager.doCallWithCore( - addr: contactsManager.lastSearchSuggestions[index].address!, isVideo: false + addr: contactsManager.lastSearchSuggestions[index].address!, isVideo: false, isConference: false ) } } From 6c59bd6581bb03d1e441d301b84d13459185d9e9 Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Tue, 9 Apr 2024 14:00:41 +0200 Subject: [PATCH 153/486] Asymmetrical video call --- Linphone/Ressources/linphonerc-default | 3 + Linphone/TelecomManager/TelecomManager.swift | 26 ++++---- Linphone/UI/Call/CallView.swift | 66 +++++-------------- .../UI/Call/ViewModel/CallViewModel.swift | 40 +++++------ 4 files changed, 51 insertions(+), 84 deletions(-) diff --git a/Linphone/Ressources/linphonerc-default b/Linphone/Ressources/linphonerc-default index d0fb013ee..ce7ae7161 100644 --- a/Linphone/Ressources/linphonerc-default +++ b/Linphone/Ressources/linphonerc-default @@ -19,6 +19,9 @@ upload_bw=0 [video] size=vga +automatically_accept=1 +automatically_initiate=0 +automatically_accept_direction=2 #receive only [app] tunnel=disabled diff --git a/Linphone/TelecomManager/TelecomManager.swift b/Linphone/TelecomManager/TelecomManager.swift index b4655f5bc..fc7eaa832 100644 --- a/Linphone/TelecomManager/TelecomManager.swift +++ b/Linphone/TelecomManager/TelecomManager.swift @@ -44,7 +44,6 @@ class TelecomManager: ObservableObject { @Published var callDisplayed: Bool = true @Published var callStarted: Bool = false @Published var outgoingCallStarted: Bool = false - @Published var remoteVideo: Bool = false @Published var remoteConfVideo: Bool = false @Published var isRecordingByRemote: Bool = false @Published var isPausedByRemote: Bool = false @@ -213,6 +212,7 @@ class TelecomManager: ObservableObject { if isSas { lcallParams.mediaEncryption = .ZRTP } + if isConference { lcallParams.videoEnabled = true /* if (ConferenceWaitingRoomViewModel.sharedModel.joinLayout.value! != .AudioOnly) { @@ -223,7 +223,8 @@ class TelecomManager: ObservableObject { lcallParams.videoEnabled = false }*/ } else { - lcallParams.videoEnabled = isVideo + lcallParams.videoEnabled = true + lcallParams.videoDirection = isVideo ? MediaDirection.SendRecv : MediaDirection.Inactive } if let call = core.inviteAddressWithParams(addr: addr, params: lcallParams) { @@ -256,7 +257,6 @@ class TelecomManager: ObservableObject { 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")) { @@ -283,7 +283,7 @@ class TelecomManager: ObservableObject { // 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 + callParams.videoDirection = core.videoActivationPolicy?.automaticallyInitiate == true ? .SendRecv : .RecvOnly Log.info("[Context] Enabling video on call params to prevent audio-only layout when answering") } @@ -376,8 +376,7 @@ class TelecomManager: ObservableObject { } else { DispatchQueue.main.async { - let oldRemoteVideo = self.remoteVideo - //self.remoteVideo = (core.videoActivationPolicy?.automaticallyAccept ?? false) && (call.remoteParams?.videoEnabled ?? false) + let oldRemoteConfVideo = self.remoteConfVideo if call.conference != nil { if call.conference!.activeSpeakerParticipantDevice != nil { @@ -387,11 +386,14 @@ class TelecomManager: ObservableObject { self.remoteConfVideo = true } } else { - self.remoteVideo = call.currentParams!.videoEnabled && call.currentParams!.videoDirection != MediaDirection.Inactive self.remoteConfVideo = false + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + self.remoteConfVideo = call.currentParams!.videoEnabled && call.currentParams!.videoDirection == MediaDirection.SendRecv || call.currentParams!.videoDirection == MediaDirection.SendOnly + } } - if self.remoteVideo && self.remoteVideo != oldRemoteVideo { + if self.remoteConfVideo && self.remoteConfVideo != oldRemoteConfVideo { do { try AVAudioSession.sharedInstance().overrideOutputAudioPort(.speaker) } catch _ { @@ -399,7 +401,7 @@ class TelecomManager: ObservableObject { } } - if self.remoteVideo { + if self.remoteConfVideo { Log.info("[Call] Remote video is activated") } @@ -484,7 +486,7 @@ class TelecomManager: ObservableObject { providerDelegate.callInfos.updateValue(callInfo!, forKey: uuid!) providerDelegate.uuids.removeValue(forKey: callId) providerDelegate.uuids.updateValue(uuid!, forKey: callInfo!.callId) - providerDelegate.updateCall(uuid: uuid!, handle: addr!.asStringUriOnly(), hasVideo: remoteVideo, displayName: displayName) + providerDelegate.updateCall(uuid: uuid!, handle: addr!.asStringUriOnly(), hasVideo: remoteConfVideo, displayName: displayName) } } else if TelecomManager.callKitEnabled(core: core) { /* @@ -507,9 +509,9 @@ class TelecomManager: ObservableObject { if uuid != nil { // Tha app is now registered, updated the call already existed. - providerDelegate.updateCall(uuid: uuid!, handle: addr!.asStringUriOnly(), hasVideo: remoteVideo, displayName: displayName) + providerDelegate.updateCall(uuid: uuid!, handle: addr!.asStringUriOnly(), hasVideo: remoteConfVideo, displayName: displayName) } else { - displayIncomingCall(call: call, handle: addr!.asStringUriOnly(), hasVideo: remoteVideo, callId: callId, displayName: displayName) + displayIncomingCall(call: call, handle: addr!.asStringUriOnly(), hasVideo: remoteConfVideo, callId: callId, displayName: displayName) } } /* else if UIApplication.shared.applicationState != .active { // not support callkit , use notif diff --git a/Linphone/UI/Call/CallView.swift b/Linphone/UI/Call/CallView.swift index 9b4a27fdd..6e70cbe78 100644 --- a/Linphone/UI/Call/CallView.swift +++ b/Linphone/UI/Call/CallView.swift @@ -317,18 +317,7 @@ struct CallView: View { .padding(.all, 10) } - if !callViewModel.isConference && telecomManager.remoteVideo { - Button { - callViewModel.switchCamera() - } label: { - Image("camera-rotate") - .renderingMode(.template) - .resizable() - .foregroundStyle(.white) - .frame(width: 30, height: 30) - .padding(.horizontal) - } - } else if callViewModel.isConference && callViewModel.videoDisplayed { + if callViewModel.videoDisplayed { Button { callViewModel.switchCamera() } label: { @@ -504,12 +493,12 @@ struct CallView: View { .scaledToFill() .clipped() .onTapGesture { - if telecomManager.remoteVideo { + if callViewModel.videoDisplayed { fullscreenVideo.toggle() } } - if telecomManager.remoteVideo { + if callViewModel.videoDisplayed && telecomManager.remoteConfVideo { HStack { Spacer() VStack { @@ -619,7 +608,7 @@ struct CallView: View { .scaledToFill() .clipped() .onTapGesture { - if telecomManager.remoteVideo { + if callViewModel.videoDisplayed { fullscreenVideo.toggle() } } @@ -884,41 +873,22 @@ struct CallView: View { Spacer() - if !callViewModel.isConference { - Button { - callViewModel.toggleVideo() - } label: { - HStack { - Image(telecomManager.remoteVideo ? "video-camera" : "video-camera-slash") - .renderingMode(.template) - .resizable() - .foregroundStyle((callViewModel.isPaused || telecomManager.isPausedByRemote) ? Color.gray500 : .white) - .frame(width: 32, height: 32) - } + Button { + 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()) - .frame(width: 60, height: 60) - .background((callViewModel.isPaused || telecomManager.isPausedByRemote) ? .white : Color.gray500) - .cornerRadius(40) - .disabled(callViewModel.isPaused || telecomManager.isPausedByRemote) - } else { - Button { - 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()) - .frame(width: 60, height: 60) - .background((callViewModel.isPaused || telecomManager.isPausedByRemote) ? .white : Color.gray500) - .cornerRadius(40) - .disabled(callViewModel.isPaused || telecomManager.isPausedByRemote) } + .buttonStyle(PressedButtonStyle()) + .frame(width: 60, height: 60) + .background((callViewModel.isPaused || telecomManager.isPausedByRemote) ? .white : Color.gray500) + .cornerRadius(40) + .disabled(callViewModel.isPaused || telecomManager.isPausedByRemote) Button { callViewModel.toggleMuteMicrophone() diff --git a/Linphone/UI/Call/ViewModel/CallViewModel.swift b/Linphone/UI/Call/ViewModel/CallViewModel.swift index eb8b1f1a7..b67a797c3 100644 --- a/Linphone/UI/Call/ViewModel/CallViewModel.swift +++ b/Linphone/UI/Call/ViewModel/CallViewModel.swift @@ -295,42 +295,34 @@ class CallViewModel: ObservableObject { } } - func toggleVideo() { - coreContext.doOnCoreQueue { core in - if self.currentCall != nil { - do { - let params = try core.createCallParams(call: self.currentCall) - - params.videoEnabled = !params.videoEnabled - Log.info( - "[CallViewModel] Updating call with video enabled set to \(params.videoEnabled)" - ) - try self.currentCall!.update(params: params) - } catch { - - } - } - } - } - 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 == MediaDirection.SendRecv { - params.videoDirection = MediaDirection.RecvOnly - } else if params.videoDirection == MediaDirection.RecvOnly { - params.videoDirection = MediaDirection.SendRecv + 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 = .SendOnly } } try self.currentCall!.update(params: params) - let video = params.videoDirection == MediaDirection.SendRecv - DispatchQueue.main.asyncAfter(deadline: .now() + 1) { + 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 { From 0299640c2cff43d3262a1be0d40f2ce2192d84e3 Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Tue, 9 Apr 2024 17:46:22 +0200 Subject: [PATCH 154/486] Use participant device in conf call view --- Linphone/UI/Call/CallView.swift | 8 +- .../UI/Call/ViewModel/CallViewModel.swift | 77 +++++++++++-------- 2 files changed, 50 insertions(+), 35 deletions(-) diff --git a/Linphone/UI/Call/CallView.swift b/Linphone/UI/Call/CallView.swift index 6e70cbe78..ed87d1389 100644 --- a/Linphone/UI/Call/CallView.swift +++ b/Linphone/UI/Call/CallView.swift @@ -532,11 +532,11 @@ struct CallView: View { && $0.friend!.name == addressFriend!.name && $0.friend!.address!.asStringUriOnly() == addressFriend!.address!.asStringUriOnly() }) - : ContactAvatarModel(friend: nil, name: "", withPresence: true) + : ContactAvatarModel(friend: nil, name: "", withPresence: false) if addressFriend != nil && addressFriend!.photo != nil && !addressFriend!.photo!.isEmpty { if contactAvatarModel != nil { - Avatar(contactAvatarModel: contactAvatarModel!, avatarSize: 200, hidePresence: false) + Avatar(contactAvatarModel: contactAvatarModel!, avatarSize: 200, hidePresence: true) .onAppear { DispatchQueue.main.asyncAfter(deadline: .now() + 2) { displayVideo = true @@ -635,7 +635,7 @@ struct CallView: View { Spacer() if callViewModel.myParticipantModel != nil { - Avatar(contactAvatarModel: callViewModel.myParticipantModel!.avatarModel, avatarSize: 50) + Avatar(contactAvatarModel: callViewModel.myParticipantModel!.avatarModel, avatarSize: 50, hidePresence: true) } Spacer() @@ -680,7 +680,7 @@ struct CallView: View { VStack { Spacer() - Avatar(contactAvatarModel: callViewModel.participantList[index].avatarModel, avatarSize: 50) + Avatar(contactAvatarModel: callViewModel.participantList[index].avatarModel, avatarSize: 50, hidePresence: true) Spacer() } diff --git a/Linphone/UI/Call/ViewModel/CallViewModel.swift b/Linphone/UI/Call/ViewModel/CallViewModel.swift index b67a797c3..78d0af568 100644 --- a/Linphone/UI/Call/ViewModel/CallViewModel.swift +++ b/Linphone/UI/Call/ViewModel/CallViewModel.swift @@ -22,6 +22,7 @@ import linphonesw import AVFAudio import Combine +// swiftlint:disable type_body_length class CallViewModel: ObservableObject { var coreContext = CoreContext.shared @@ -156,12 +157,15 @@ class CallViewModel: ObservableObject { if self.currentCall?.callLog?.localAddress != nil { self.myParticipantModel = ParticipantModel(address: self.currentCall!.callLog!.localAddress!) + print("ParticipantModelParticipantModel myParticipantModel \(self.currentCall!.callLog!.localAddress!.asStringUriOnly())") } if conf.activeSpeakerParticipantDevice?.address != nil { self.activeSpeakerParticipant = ParticipantModel(address: conf.activeSpeakerParticipantDevice!.address!) + print("ParticipantModelParticipantModel activeSpeakerParticipantDevice 1 \(conf.activeSpeakerParticipantDevice!.address!.asStringUriOnly())") } else if conf.participantList.first?.address != nil { self.activeSpeakerParticipant = ParticipantModel(address: conf.participantList.first!.address!) + print("ParticipantModelParticipantModel activeSpeakerParticipantDevice 2 \(conf.participantList.first!.address!.asStringUriOnly())") } else { DispatchQueue.main.asyncAfter(deadline: .now() + 1) { self.getConference() @@ -181,35 +185,15 @@ class CallViewModel: ObservableObject { } } - conf.participantList.forEach({ participant in - self.participantList.append(ParticipantModel(address: participant.address!)) + conf.participantDeviceList.forEach({ participantDevice in + self.participantList.append(ParticipantModel(address: participantDevice.address!)) + print("ParticipantModelParticipantModel participantDevice \(participantDevice.address!.asStringUriOnly())") }) - self.addConferenceCallBacks() + //self.addConferenceCallBacks() } } else if self.currentCall?.remoteContactAddress != nil { - let conf = core.findConferenceInformationFromUri(uri: (self.currentCall?.remoteContactAddress)!) - DispatchQueue.main.async { - self.isConference = conf != nil - if self.isConference { - self.displayName = conf?.subject ?? "" - self.participantList = [] - - conf?.participantInfos.forEach({ participantInfo in - if participantInfo.address != nil { - if participantInfo.address!.equal(address2: (self.currentCall?.callLog?.localAddress!)!) { - self.myParticipantModel = ParticipantModel(address: participantInfo.address!) - } else { - if self.activeSpeakerParticipant != nil && !participantInfo.address!.equal(address2: self.activeSpeakerParticipant!.address) { - self.participantList.append(ParticipantModel(address: participantInfo.address!)) - } - } - } - }) - - self.addConferenceCallBacks() - } - } + //self.addConferenceCallBacks() } } } @@ -222,7 +206,6 @@ class CallViewModel: ObservableObject { let activeSpeakerParticipantTmp = self.activeSpeakerParticipant self.activeSpeakerParticipant = ParticipantModel(address: cbValue.participantDevice.address!) - if self.activeSpeakerParticipant != nil { let friend = ContactsManager.shared.getFriendWithAddress(address: self.activeSpeakerParticipant!.address) if friend != nil && friend!.address != nil && friend!.address!.displayName != nil { @@ -241,16 +224,47 @@ class CallViewModel: ObservableObject { || ( activeSpeakerParticipantTmp == nil)) { self.participantList = [] - cbValue.conference.participantList.forEach({ participant in - if participant.address != nil && !cbValue.conference.isMe(uri: participant.address!) { - if !cbValue.conference.isMe(uri: participant.address!) { - self.participantList.append(ParticipantModel(address: participant.address!)) + cbValue.conference.participantDeviceList.forEach({ participantDevice in + if participantDevice.address != nil && !cbValue.conference.isMe(uri: participantDevice.address!) { + if !cbValue.conference.isMe(uri: participantDevice.address!) { + self.participantList.append(ParticipantModel(address: participantDevice.address!)) } } }) } } - }) + } + ) + + self.mConferenceSuscriptions.insert( + self.currentCall?.conference?.publisher?.onParticipantAdded?.postOnMainQueue {(cbValue: (conference: Conference, participant: Participant)) in + if cbValue.participant.address != nil { + self.participantList = [] + cbValue.conference.participantDeviceList.forEach({ participantDevice in + if participantDevice.address != nil && !cbValue.conference.isMe(uri: participantDevice.address!) { + if !cbValue.conference.isMe(uri: participantDevice.address!) { + self.participantList.append(ParticipantModel(address: participantDevice.address!)) + } + } + }) + } + } + ) + + self.mConferenceSuscriptions.insert( + self.currentCall?.conference?.publisher?.onParticipantRemoved?.postOnMainQueue {(cbValue: (conference: Conference, participant: Participant)) in + if cbValue.participant.address != nil { + self.participantList = [] + cbValue.conference.participantDeviceList.forEach({ participantDevice in + if participantDevice.address != nil && !cbValue.conference.isMe(uri: participantDevice.address!) { + if !cbValue.conference.isMe(uri: participantDevice.address!) { + self.participantList.append(ParticipantModel(address: participantDevice.address!)) + } + } + }) + } + } + ) } } @@ -607,3 +621,4 @@ class CallViewModel: ObservableObject { } } } +// swiftlint:enable type_body_length From 601be3ebed54c4a12036a62a466145d338649754 Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Wed, 10 Apr 2024 17:24:19 +0200 Subject: [PATCH 155/486] Add Meeting Waiting Room --- Linphone.xcodeproj/project.pbxproj | 8 + Linphone/Core/CoreContext.swift | 1 + Linphone/LinphoneApp.swift | 4 + Linphone/Localizable.xcstrings | 12 + .../TelecomManager/ProviderDelegate.swift | 2 +- Linphone/TelecomManager/TelecomManager.swift | 11 +- Linphone/UI/Call/CallView.swift | 15 +- .../UI/Call/MeetingWaitingRoomFragment.swift | 542 ++++++++++++++++++ .../UI/Call/ViewModel/CallViewModel.swift | 63 +- .../MeetingWaitingRoomViewModel.swift | 261 +++++++++ Linphone/UI/Main/ContentView.swift | 19 +- .../Fragments/HistoryListFragment.swift | 14 + Linphone/Utils/ActivityIndicator.swift | 7 +- 13 files changed, 908 insertions(+), 51 deletions(-) create mode 100644 Linphone/UI/Call/MeetingWaitingRoomFragment.swift create mode 100644 Linphone/UI/Call/ViewModel/MeetingWaitingRoomViewModel.swift diff --git a/Linphone.xcodeproj/project.pbxproj b/Linphone.xcodeproj/project.pbxproj index a2b10c1f1..16879d82e 100644 --- a/Linphone.xcodeproj/project.pbxproj +++ b/Linphone.xcodeproj/project.pbxproj @@ -63,6 +63,8 @@ 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 */; }; @@ -200,6 +202,8 @@ 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 = ""; }; @@ -610,6 +614,7 @@ D720E6AB2BAD81C800DDFD87 /* Model */, D7B99E972B29B37F00BE7BF2 /* ViewModel */, D7B5678D2B28888F00DE63EB /* CallView.swift */, + D73449982BC6932A00778C56 /* MeetingWaitingRoomFragment.swift */, ); path = Call; sourceTree = ""; @@ -618,6 +623,7 @@ isa = PBXGroup; children = ( D7B99E982B29B39000BE7BF2 /* CallViewModel.swift */, + D734499A2BC694C900778C56 /* MeetingWaitingRoomViewModel.swift */, ); path = ViewModel; sourceTree = ""; @@ -890,6 +896,7 @@ D70A26F02B7D02E6006CC8FC /* ConversationViewModel.swift in Sources */, D7B5066D2AEFA9B900CEB4E9 /* ContactInnerFragment.swift in Sources */, D7E6D04D2AEBD77600A57AAF /* CustomBottomSheet.swift in Sources */, + D73449992BC6932A00778C56 /* MeetingWaitingRoomFragment.swift in Sources */, D7C48DF42AFA66F900D938CB /* EditContactController.swift in Sources */, 66C491FF2B24D4AC00CEA16D /* FileUtils.swift in Sources */, D74C9D012ACB098C0021626A /* PermissionManager.swift in Sources */, @@ -898,6 +905,7 @@ D732A9152B04C7FE00DB42BA /* HistoryListViewModel.swift in Sources */, D71FCA7F2AE1397200D2E43E /* ContactsListViewModel.swift in Sources */, D71FCA812AE14CFC00D2E43E /* ContactsListFragment.swift in Sources */, + D734499B2BC694C900778C56 /* MeetingWaitingRoomViewModel.swift in Sources */, D719ABB72ABC67BF00B41C10 /* LinphoneApp.swift in Sources */, D732A91B2B061BD900DB42BA /* HistoryListBottomSheet.swift in Sources */, D72250632ADE9615008FB426 /* HistoryViewModel.swift in Sources */, diff --git a/Linphone/Core/CoreContext.swift b/Linphone/Core/CoreContext.swift index 98cb3105e..30adf40a9 100644 --- a/Linphone/Core/CoreContext.swift +++ b/Linphone/Core/CoreContext.swift @@ -109,6 +109,7 @@ final class CoreContext: ObservableObject { self.mCore.videoCaptureEnabled = true self.mCore.videoDisplayEnabled = true + self.mCore.videoPreviewEnabled = false self.mCoreSuscriptions.insert(self.mCore.publisher?.onGlobalStateChanged?.postOnMainQueue { (cbVal: (core: Core, state: GlobalState, message: String)) in if cbVal.state == GlobalState.On { diff --git a/Linphone/LinphoneApp.swift b/Linphone/LinphoneApp.swift index 6f62860e6..2bc7b715f 100644 --- a/Linphone/LinphoneApp.swift +++ b/Linphone/LinphoneApp.swift @@ -76,6 +76,7 @@ struct LinphoneApp: App { @State private var historyListViewModel: HistoryListViewModel? @State private var startCallViewModel: StartCallViewModel? @State private var callViewModel: CallViewModel? + @State private var meetingWaitingRoomViewModel: MeetingWaitingRoomViewModel? @State private var conversationsListViewModel: ConversationsListViewModel? @State private var conversationViewModel: ConversationViewModel? @@ -99,6 +100,7 @@ struct LinphoneApp: App { && historyListViewModel != nil && startCallViewModel != nil && callViewModel != nil + && meetingWaitingRoomViewModel != nil && conversationsListViewModel != nil && conversationViewModel != nil { ContentView( @@ -108,6 +110,7 @@ struct LinphoneApp: App { historyListViewModel: historyListViewModel!, startCallViewModel: startCallViewModel!, callViewModel: callViewModel!, + meetingWaitingRoomViewModel: meetingWaitingRoomViewModel!, conversationsListViewModel: conversationsListViewModel!, conversationViewModel: conversationViewModel! ) @@ -123,6 +126,7 @@ struct LinphoneApp: App { historyListViewModel = HistoryListViewModel() startCallViewModel = StartCallViewModel() callViewModel = CallViewModel() + meetingWaitingRoomViewModel = MeetingWaitingRoomViewModel() conversationsListViewModel = ConversationsListViewModel() conversationViewModel = ConversationViewModel() } diff --git a/Linphone/Localizable.xcstrings b/Linphone/Localizable.xcstrings index 526a5e932..2f86da3a3 100644 --- a/Linphone/Localizable.xcstrings +++ b/Linphone/Localizable.xcstrings @@ -241,6 +241,9 @@ }, "Conditions de service" : { + }, + "Connexion à la réunion" : { + }, "Contacts" : { @@ -443,6 +446,9 @@ }, "Marquer comme non lu" : { + }, + "Mercredi 10 Avril 2024 | 2:41 PM - 2:42 PM" : { + }, "Message" : { @@ -553,6 +559,9 @@ }, "Register" : { + }, + "Rejoindre" : { + }, "Remove from favourites" : { @@ -696,6 +705,9 @@ }, "Vos communications sont en sécurité grâce aux **Chiffrement de bout en bout**." : { + }, + "Vous allez rejoindre la réunion dans quelques instants..." : { + }, "Welcome" : { diff --git a/Linphone/TelecomManager/ProviderDelegate.swift b/Linphone/TelecomManager/ProviderDelegate.swift index 7a56be324..1c7fb444e 100644 --- a/Linphone/TelecomManager/ProviderDelegate.swift +++ b/Linphone/TelecomManager/ProviderDelegate.swift @@ -329,7 +329,7 @@ extension ProviderDelegate: CXProviderDelegate { 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) + try TelecomManager.shared.doCall(core: core, addr: addr!, isSas: callInfo?.sasEnabled ?? false, isVideo: ((callInfo?.videoEnabled ?? false) && core.videoPreviewEnabled), isConference: callInfo?.isConference ?? false) action.fulfill() } catch { Log.info("CallKit: Call started failed because \(error)") diff --git a/Linphone/TelecomManager/TelecomManager.swift b/Linphone/TelecomManager/TelecomManager.swift index fc7eaa832..92d32421a 100644 --- a/Linphone/TelecomManager/TelecomManager.swift +++ b/Linphone/TelecomManager/TelecomManager.swift @@ -50,6 +50,9 @@ class TelecomManager: ObservableObject { @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? @@ -215,6 +218,7 @@ class TelecomManager: ObservableObject { if isConference { lcallParams.videoEnabled = true + lcallParams.videoDirection = isVideo ? MediaDirection.SendRecv : MediaDirection.RecvOnly /* if (ConferenceWaitingRoomViewModel.sharedModel.joinLayout.value! != .AudioOnly) { lcallParams.videoEnabled = true lcallParams.videoDirection = ConferenceWaitingRoomViewModel.sharedModel.isVideoEnabled.value == true ? .SendRecv : .RecvOnly @@ -393,6 +397,7 @@ class TelecomManager: ObservableObject { } } + /* if self.remoteConfVideo && self.remoteConfVideo != oldRemoteConfVideo { do { try AVAudioSession.sharedInstance().overrideOutputAudioPort(.speaker) @@ -400,6 +405,7 @@ class TelecomManager: ObservableObject { } } + */ if self.remoteConfVideo { Log.info("[Call] Remote video is activated") @@ -444,8 +450,11 @@ class TelecomManager: ObservableObject { self.isPausedByRemote = false } - if (cstate == Call.State.Connected) { + if cstate == Call.State.Connected { self.callConnected = true + + self.meetingWaitingRoomSelected = nil + self.meetingWaitingRoomDisplayed = false } } diff --git a/Linphone/UI/Call/CallView.swift b/Linphone/UI/Call/CallView.swift index ed87d1389..aca0212eb 100644 --- a/Linphone/UI/Call/CallView.swift +++ b/Linphone/UI/Call/CallView.swift @@ -538,7 +538,7 @@ struct CallView: View { if contactAvatarModel != nil { Avatar(contactAvatarModel: contactAvatarModel!, avatarSize: 200, hidePresence: true) .onAppear { - DispatchQueue.main.asyncAfter(deadline: .now() + 2) { + DispatchQueue.main.asyncAfter(deadline: .now() + 1) { displayVideo = true } } @@ -554,7 +554,7 @@ struct CallView: View { .frame(width: 200, height: 200) .clipShape(Circle()) .onAppear { - DispatchQueue.main.asyncAfter(deadline: .now() + 2) { + DispatchQueue.main.asyncAfter(deadline: .now() + 1) { displayVideo = true } } @@ -569,7 +569,7 @@ struct CallView: View { .frame(width: 200, height: 200) .clipShape(Circle()) .onAppear { - DispatchQueue.main.asyncAfter(deadline: .now() + 2) { + DispatchQueue.main.asyncAfter(deadline: .now() + 1) { displayVideo = true } } @@ -750,7 +750,7 @@ struct CallView: View { if telecomManager.outgoingCallStarted { VStack { - ActivityIndicator() + ActivityIndicator(color: .white) .frame(width: 20, height: 20) .padding(.top, 60) @@ -772,7 +772,8 @@ struct CallView: View { } .onDisappear { DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { - callViewModel.getConference() + // callViewModel.getConference() + callViewModel.waitingForCreatedStateConference() } } .background(.clear) @@ -799,6 +800,8 @@ struct CallView: View { angleDegree = -90 } else if orientation == .landscapeRight { angleDegree = 90 + } else if UIScreen.main.bounds.size.width > UIScreen.main.bounds.size.height { + angleDegree = 90 } } @@ -820,6 +823,8 @@ struct CallView: View { angleDegree = -90 } else if orientation == .landscapeRight { angleDegree = 90 + } else if UIScreen.main.bounds.size.width > UIScreen.main.bounds.size.height { + angleDegree = 90 } } diff --git a/Linphone/UI/Call/MeetingWaitingRoomFragment.swift b/Linphone/UI/Call/MeetingWaitingRoomFragment.swift new file mode 100644 index 000000000..dcb9195c1 --- /dev/null +++ b/Linphone/UI/Call/MeetingWaitingRoomFragment.swift @@ -0,0 +1,542 @@ +/* + * 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 + +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 + }) { + innerBottomSheet() + .presentationDetents([.fraction(0.3)]) + } + .onAppear { + meetingWaitingRoomViewModel.enableAVAudioSession() + + do { + try AVAudioSession.sharedInstance().overrideOutputAudioPort(.speaker) + } catch _ { + + } + } + .onDisappear { + meetingWaitingRoomViewModel.disableAVAudioSession() + } + } else { + innerView(geometry: geometry) + .halfSheet(showSheet: $audioRouteSheet) { + innerBottomSheet() + } onDismiss: { + audioRouteSheet = false + } + .onAppear { + meetingWaitingRoomViewModel.enableAVAudioSession() + + do { + try AVAudioSession.sharedInstance().overrideOutputAudioPort(.speaker) + } catch _ { + + } + } + .onDisappear { + meetingWaitingRoomViewModel.disableAVAudioSession() + } + } + } + } + + @ViewBuilder + 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("Mercredi 10 Avril 2024 | 2:41 PM - 2:42 PM") + .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()) + .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()) + .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: meetingWaitingRoomViewModel.getAudioRouteImage) + .onReceive(pub) { _ in + self.meetingWaitingRoomViewModel.getAudioRouteImage() + } + } + } + .buttonStyle(PressedButtonStyle()) + .frame(width: 60, height: 60) + .background(Color.gray500) + .cornerRadius(40) + .padding(.horizontal, 5) + + Spacer() + + if angleDegree != 0 { + Button(action: { + meetingWaitingRoomViewModel.joinMeeting() + }, label: { + Text("Rejoindre") + .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("Rejoindre") + .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() + } + } + + Spacer() + } + .background(Color.gray900) + .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 + } + } + + 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) + } +} + +#Preview { + MeetingWaitingRoomFragment(meetingWaitingRoomViewModel: MeetingWaitingRoomViewModel()) +} diff --git a/Linphone/UI/Call/ViewModel/CallViewModel.swift b/Linphone/UI/Call/ViewModel/CallViewModel.swift index 78d0af568..8d7a86722 100644 --- a/Linphone/UI/Call/ViewModel/CallViewModel.swift +++ b/Linphone/UI/Call/ViewModel/CallViewModel.swift @@ -53,7 +53,6 @@ class CallViewModel: ObservableObject { @Published var activeSpeakerName: String = "" @Published var myParticipantModel: ParticipantModel? = nil - private var mConferenceSuscriptions = Set() var calls: [Call] = [] @@ -98,19 +97,10 @@ class CallViewModel: ObservableObject { self.remoteAddressString = String(self.currentCall!.remoteAddress!.asStringUriOnly().dropFirst(4)) self.remoteAddress = self.currentCall!.remoteAddress! - let friend = ContactsManager.shared.getFriendWithAddress(address: self.currentCall!.remoteAddress!) - if friend != nil && friend!.address != nil && friend!.address!.displayName != nil { - self.displayName = friend!.address!.displayName! - } else { - if self.currentCall!.remoteAddress!.displayName != nil { - self.displayName = self.currentCall!.remoteAddress!.displayName! - } else if self.currentCall!.remoteAddress!.username != nil { - self.displayName = self.currentCall!.remoteAddress!.username! - } - } + self.displayName = self.currentCall?.conference?.subject ?? "" //self.avatarModel = ??? - self.micMutted = self.currentCall!.microphoneMuted + self.micMutted = self.currentCall!.microphoneMuted || !core.micEnabled self.isRecording = self.currentCall!.params!.isRecording self.isPaused = self.isCallPaused() self.timeElapsed = self.currentCall?.duration ?? 0 @@ -128,7 +118,7 @@ class CallViewModel: ObservableObject { } self.getCallsList() - self.getConference() + self.waitingForCreatedStateConference() } self.callSuscriptions.insert(self.currentCall!.publisher?.onEncryptionChanged?.postOnMainQueue {(cbVal: (call: Call, on: Bool, authenticationToken: String?)) in @@ -157,19 +147,15 @@ class CallViewModel: ObservableObject { if self.currentCall?.callLog?.localAddress != nil { self.myParticipantModel = ParticipantModel(address: self.currentCall!.callLog!.localAddress!) - print("ParticipantModelParticipantModel myParticipantModel \(self.currentCall!.callLog!.localAddress!.asStringUriOnly())") + print("ParticipantModelParticipantModel 1 \(conf.me?.address!.asStringUriOnly())") } if conf.activeSpeakerParticipantDevice?.address != nil { self.activeSpeakerParticipant = ParticipantModel(address: conf.activeSpeakerParticipantDevice!.address!) - print("ParticipantModelParticipantModel activeSpeakerParticipantDevice 1 \(conf.activeSpeakerParticipantDevice!.address!.asStringUriOnly())") + print("ParticipantModelParticipantModel 2 \(conf.activeSpeakerParticipantDevice!.address!.asStringUriOnly())") } else if conf.participantList.first?.address != nil { self.activeSpeakerParticipant = ParticipantModel(address: conf.participantList.first!.address!) - print("ParticipantModelParticipantModel activeSpeakerParticipantDevice 2 \(conf.participantList.first!.address!.asStringUriOnly())") - } else { - DispatchQueue.main.asyncAfter(deadline: .now() + 1) { - self.getConference() - } + print("ParticipantModelParticipantModel 3 \(conf.participantList.first!.address!.asStringUriOnly())") } if self.activeSpeakerParticipant != nil { @@ -187,7 +173,7 @@ class CallViewModel: ObservableObject { conf.participantDeviceList.forEach({ participantDevice in self.participantList.append(ParticipantModel(address: participantDevice.address!)) - print("ParticipantModelParticipantModel participantDevice \(participantDevice.address!.asStringUriOnly())") + print("ParticipantModelParticipantModel 4 \(participantDevice.address!.asStringUriOnly()) \(conf.isIn) \(conf.state) \(self.currentCall?.state)") }) //self.addConferenceCallBacks() @@ -198,6 +184,17 @@ class CallViewModel: ObservableObject { } } + func waitingForCreatedStateConference() { + self.mConferenceSuscriptions.insert( + self.currentCall?.conference?.publisher?.onStateChanged?.postOnMainQueue {(cbValue: (conference: Conference, state: Conference.State)) in + if cbValue.state == .Created { + print("ParticipantModelParticipantModel 0 \(cbValue.conference.isIn) \(cbValue.conference.state) \(self.currentCall?.state) \(cbValue.state)") + self.getConference() + } + } + ) + } + func addConferenceCallBacks() { coreContext.doOnCoreQueue { core in self.mConferenceSuscriptions.insert( @@ -298,10 +295,16 @@ class CallViewModel: ObservableObject { } func toggleMuteMicrophone() { - coreContext.doOnCoreQueue { _ in + coreContext.doOnCoreQueue { core in if self.currentCall != nil { - self.currentCall!.microphoneMuted = !self.currentCall!.microphoneMuted - self.micMutted = self.currentCall!.microphoneMuted + if !core.micEnabled && !self.currentCall!.microphoneMuted { + core.micEnabled = true + } else { + self.currentCall!.microphoneMuted = !self.currentCall!.microphoneMuted + } + + self.micMutted = self.currentCall!.microphoneMuted || !core.micEnabled + Log.info( "[CallViewModel] Microphone mute switch \(self.micMutted)" ) @@ -436,18 +439,6 @@ class CallViewModel: ObservableObject { return false } - func getAudioRoute() -> Int { - if AVAudioSession.sharedInstance().currentRoute.outputs.filter({ $0.portType.rawValue == "Speaker" }).isEmpty { - if AVAudioSession.sharedInstance().currentRoute.outputs.filter({ $0.portType.rawValue.contains("Bluetooth") }).isEmpty { - return 1 - } else { - return 3 - } - } else { - return 2 - } - } - func orientationUpdate(orientation: UIDeviceOrientation) { coreContext.doOnCoreQueue { core in let oldLinphoneOrientation = core.deviceRotation diff --git a/Linphone/UI/Call/ViewModel/MeetingWaitingRoomViewModel.swift b/Linphone/UI/Call/ViewModel/MeetingWaitingRoomViewModel.swift new file mode 100644 index 000000000..150233219 --- /dev/null +++ b/Linphone/UI/Call/ViewModel/MeetingWaitingRoomViewModel.swift @@ -0,0 +1,261 @@ +/* + * 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 = "" + + init() { + do { + try AVAudioSession.sharedInstance().setCategory(.playAndRecord, mode: .voiceChat, options: .allowBluetooth) + } catch _ { + + } + if !telecomManager.callStarted { + self.resetMeetingRoomView() + } + } + + func resetMeetingRoomView() { + if self.telecomManager.meetingWaitingRoomSelected != nil { + coreContext.doOnCoreQueue { core in + + let conf = core.findConferenceInformationFromUri(uri: self.telecomManager.meetingWaitingRoomSelected!) + + 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 + + if friend != nil && friend!.address != nil && friend!.address!.displayName != nil { + userNameTmp = friend!.address!.displayName! + } else { + if core.defaultAccount!.contactAddress!.displayName != nil { + userNameTmp = core.defaultAccount!.contactAddress!.displayName! + } else if core.defaultAccount!.contactAddress!.username != nil { + userNameTmp = core.defaultAccount!.contactAddress!.username! + } + } + + 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, withPresence: false) + : ContactAvatarModel(friend: nil, name: userNameTmp, 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 + + 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 + } + + } + } + } + } + + func enableVideoPreview() { + self.coreContext.doOnCoreQueue { core in + if core.videoEnabled { + self.videoDisplayed = true + core.videoPreviewEnabled = true + } + } + } + + func disableVideoPreview() { + coreContext.doOnCoreQueue { core in + if core.videoEnabled { + self.videoDisplayed = false + core.videoPreviewEnabled = false + } + } + } + + func switchCamera() { + coreContext.doOnCoreQueue { core in + let currentDevice = core.videoDevice + Log.info("[CallViewModel] Current camera device is \(currentDevice)") + + 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 getAudioRouteImage() { + print("AVAudioSessionAVAudioSession getAudioRouteImage \(imageAudioRoute)") + imageAudioRoute = AVAudioSession.sharedInstance().currentRoute.outputs.filter({ $0.portType.rawValue == "Speaker" }).isEmpty + ? ( + AVAudioSession.sharedInstance().currentRoute.outputs.filter({ $0.portType.rawValue.contains("Bluetooth") }).isEmpty + ? ( + isHeadPhoneAvailable() + ? "headset" + : "speaker-slash" + ) + : "bluetooth" + ) + : "speaker-high" + + print("AVAudioSessionAVAudioSession getAudioRouteImage \(imageAudioRoute)") + } + + 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 _ { + + } + } + } + } + } +} diff --git a/Linphone/UI/Main/ContentView.swift b/Linphone/UI/Main/ContentView.swift index 412541b28..7f7fcae8e 100644 --- a/Linphone/UI/Main/ContentView.swift +++ b/Linphone/UI/Main/ContentView.swift @@ -40,6 +40,7 @@ struct ContentView: View { @ObservedObject var historyListViewModel: HistoryListViewModel @ObservedObject var startCallViewModel: StartCallViewModel @ObservedObject var callViewModel: CallViewModel + @ObservedObject var meetingWaitingRoomViewModel: MeetingWaitingRoomViewModel @ObservedObject var conversationsListViewModel: ConversationsListViewModel @ObservedObject var conversationViewModel: ConversationViewModel @@ -713,7 +714,7 @@ struct ContentView: View { isShowDismissPopup: $isShowDismissPopup ) .zIndex(3) - .transition(.move(edge: .bottom)) + .transition(.opacity.combined(with: .move(edge: .bottom))) .onAppear { contactViewModel.indexDisplayedFriend = nil } @@ -729,7 +730,7 @@ struct ContentView: View { resetCallView: {callViewModel.resetCallView()} ) .zIndex(4) - .transition(.move(edge: .bottom)) + .transition(.opacity.combined(with: .move(edge: .bottom))) .sheet(isPresented: $showingDialer) { DialerBottomSheet( startCallViewModel: startCallViewModel, @@ -748,7 +749,7 @@ struct ContentView: View { resetCallView: {callViewModel.resetCallView()} ) .zIndex(4) - .transition(.move(edge: .bottom)) + .transition(.opacity.combined(with: .move(edge: .bottom))) .halfSheet(showSheet: $showingDialer) { DialerBottomSheet( startCallViewModel: startCallViewModel, @@ -856,7 +857,16 @@ struct ContentView: View { } } - if telecomManager.callDisplayed && ((telecomManager.callInProgress && telecomManager.outgoingCallStarted) || telecomManager.callConnected) { + 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, fullscreenVideo: $fullscreenVideo, isShowCallsListFragment: $isShowCallsListFragment, isShowStartCallFragment: $isShowStartCallFragment) .zIndex(3) .transition(.scale.combined(with: .move(edge: .top))) @@ -911,6 +921,7 @@ struct ContentView: View { historyListViewModel: HistoryListViewModel(), startCallViewModel: StartCallViewModel(), callViewModel: CallViewModel(), + meetingWaitingRoomViewModel: MeetingWaitingRoomViewModel(), conversationsListViewModel: ConversationsListViewModel(), conversationViewModel: ConversationViewModel() ) diff --git a/Linphone/UI/Main/History/Fragments/HistoryListFragment.swift b/Linphone/UI/Main/History/Fragments/HistoryListFragment.swift index 87c80a253..8fbaac2e5 100644 --- a/Linphone/UI/Main/History/Fragments/HistoryListFragment.swift +++ b/Linphone/UI/Main/History/Fragments/HistoryListFragment.swift @@ -215,11 +215,18 @@ struct HistoryListFragment: View { if historyListViewModel.callLogs[index].toAddress!.asStringUriOnly().hasPrefix("sip:conference-focus@sip.linphone.org") { do { //let reudumatin = try Factory.Instance.createAddress(addr: "sip:conference-focus@sip.linphone.org;conf-id=8~YNkpFOv;gr=0ee3f37f-6df2-0071-bb9a-a4e24be30135") + /* let reutest = try Factory.Instance.createAddress(addr: "sip:conference-focus@sip.linphone.org;conf-id=iVs8XshC~;gr=0ee3f37f-6df2-0071-bb9a-a4e24be30135") telecomManager.doCallWithCore( addr: reutest, isVideo: false, isConference: true ) + */ + + let reutest = try Factory.Instance.createAddress(addr: "sip:conference-focus@sip.linphone.org;conf-id=iVs8XshC~;gr=0ee3f37f-6df2-0071-bb9a-a4e24be30135") + + telecomManager.meetingWaitingRoomDisplayed = true + telecomManager.meetingWaitingRoomSelected = reutest } catch {} } else { telecomManager.doCallWithCore( @@ -230,10 +237,17 @@ struct HistoryListFragment: View { if historyListViewModel.callLogs[index].fromAddress!.asStringUriOnly().hasPrefix("sip:conference-focus@sip.linphone.org") { do { //let reudumatin = try Factory.Instance.createAddress(addr: "sip:conference-focus@sip.linphone.org;conf-id=8~YNkpFOv;gr=0ee3f37f-6df2-0071-bb9a-a4e24be30135") + /* let reutest = try Factory.Instance.createAddress(addr: "sip:conference-focus@sip.linphone.org;conf-id=iVs8XshC~;gr=0ee3f37f-6df2-0071-bb9a-a4e24be30135") telecomManager.doCallWithCore( addr: reutest, isVideo: false, isConference: true ) + */ + + let reutest = try Factory.Instance.createAddress(addr: "sip:conference-focus@sip.linphone.org;conf-id=iVs8XshC~;gr=0ee3f37f-6df2-0071-bb9a-a4e24be30135") + + telecomManager.meetingWaitingRoomDisplayed = true + telecomManager.meetingWaitingRoomSelected = reutest } catch {} } else { telecomManager.doCallWithCore( diff --git a/Linphone/Utils/ActivityIndicator.swift b/Linphone/Utils/ActivityIndicator.swift index 862ff4ed6..d7b386fbe 100644 --- a/Linphone/Utils/ActivityIndicator.swift +++ b/Linphone/Utils/ActivityIndicator.swift @@ -23,15 +23,14 @@ struct ActivityIndicator: View { let style = StrokeStyle(lineWidth: 3, lineCap: .round) @State var animate = false - let color1 = Color.white - let color2 = Color.white.opacity(0.5) + let color: Color var body: some View { ZStack { Circle() .trim(from: 0, to: 0.7) .stroke( - AngularGradient(gradient: .init(colors: [color1, color2]), center: .center), style: style) + 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 { @@ -41,5 +40,5 @@ struct ActivityIndicator: View { } #Preview { - ActivityIndicator() + ActivityIndicator(color: .white) } From 2133934e28c41856d7f0c4db398db19cdfc934fc Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Tue, 16 Apr 2024 17:11:32 +0200 Subject: [PATCH 156/486] Fix participant devices in meeting call view --- Linphone/UI/Call/CallView.swift | 6 -- Linphone/UI/Call/Model/ParticipantModel.swift | 5 +- .../UI/Call/ViewModel/CallViewModel.swift | 74 +++++++++++++------ 3 files changed, 55 insertions(+), 30 deletions(-) diff --git a/Linphone/UI/Call/CallView.swift b/Linphone/UI/Call/CallView.swift index aca0212eb..3005a880d 100644 --- a/Linphone/UI/Call/CallView.swift +++ b/Linphone/UI/Call/CallView.swift @@ -770,12 +770,6 @@ struct CallView: View { Spacer() } - .onDisappear { - DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { - // callViewModel.getConference() - callViewModel.waitingForCreatedStateConference() - } - } .background(.clear) .frame( maxWidth: fullscreenVideo && !telecomManager.isPausedByRemote ? geometry.size.width : geometry.size.width - 8, diff --git a/Linphone/UI/Call/Model/ParticipantModel.swift b/Linphone/UI/Call/Model/ParticipantModel.swift index 9f23870fb..507dc4ba6 100644 --- a/Linphone/UI/Call/Model/ParticipantModel.swift +++ b/Linphone/UI/Call/Model/ParticipantModel.swift @@ -28,8 +28,9 @@ class ParticipantModel: ObservableObject { @Published var sipUri: String @Published var name: String @Published var avatarModel: ContactAvatarModel + @Published var isJoining: Bool - init(address: Address) { + init(address: Address, isJoining: Bool) { self.address = address self.sipUri = address.asStringUriOnly() @@ -54,5 +55,7 @@ class ParticipantModel: ObservableObject { && $0.friend!.address!.asStringUriOnly() == address.asStringUriOnly() }) ?? ContactAvatarModel(friend: nil, name: nameTmp, withPresence: false) : ContactAvatarModel(friend: nil, name: nameTmp, withPresence: false) + + self.isJoining = isJoining } } diff --git a/Linphone/UI/Call/ViewModel/CallViewModel.swift b/Linphone/UI/Call/ViewModel/CallViewModel.swift index 8d7a86722..b2331d500 100644 --- a/Linphone/UI/Call/ViewModel/CallViewModel.swift +++ b/Linphone/UI/Call/ViewModel/CallViewModel.swift @@ -97,7 +97,20 @@ class CallViewModel: ObservableObject { self.remoteAddressString = String(self.currentCall!.remoteAddress!.asStringUriOnly().dropFirst(4)) self.remoteAddress = self.currentCall!.remoteAddress! - self.displayName = self.currentCall?.conference?.subject ?? "" + if self.currentCall?.conference != nil { + self.displayName = 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 { + self.displayName = friend!.address!.displayName! + } else { + if self.currentCall!.remoteAddress!.displayName != nil { + self.displayName = self.currentCall!.remoteAddress!.displayName! + } else if self.currentCall!.remoteAddress!.username != nil { + self.displayName = self.currentCall!.remoteAddress!.username! + } + } + } //self.avatarModel = ??? self.micMutted = self.currentCall!.microphoneMuted || !core.micEnabled @@ -145,17 +158,16 @@ class CallViewModel: ObservableObject { self.displayName = conf.subject ?? "" self.participantList = [] - if self.currentCall?.callLog?.localAddress != nil { - self.myParticipantModel = ParticipantModel(address: self.currentCall!.callLog!.localAddress!) - print("ParticipantModelParticipantModel 1 \(conf.me?.address!.asStringUriOnly())") + if conf.me?.address != nil { + self.myParticipantModel = ParticipantModel(address: conf.me!.address!, isJoining: false) + } else if self.currentCall?.callLog?.localAddress != nil { + self.myParticipantModel = ParticipantModel(address: self.currentCall!.callLog!.localAddress!, isJoining: false) } if conf.activeSpeakerParticipantDevice?.address != nil { - self.activeSpeakerParticipant = ParticipantModel(address: conf.activeSpeakerParticipantDevice!.address!) - print("ParticipantModelParticipantModel 2 \(conf.activeSpeakerParticipantDevice!.address!.asStringUriOnly())") + self.activeSpeakerParticipant = ParticipantModel(address: conf.activeSpeakerParticipantDevice!.address!, isJoining: false) } else if conf.participantList.first?.address != nil { - self.activeSpeakerParticipant = ParticipantModel(address: conf.participantList.first!.address!) - print("ParticipantModelParticipantModel 3 \(conf.participantList.first!.address!.asStringUriOnly())") + self.activeSpeakerParticipant = ParticipantModel(address: conf.participantList.first!.address!, isJoining: false) } if self.activeSpeakerParticipant != nil { @@ -172,14 +184,17 @@ class CallViewModel: ObservableObject { } conf.participantDeviceList.forEach({ participantDevice in - self.participantList.append(ParticipantModel(address: participantDevice.address!)) - print("ParticipantModelParticipantModel 4 \(participantDevice.address!.asStringUriOnly()) \(conf.isIn) \(conf.state) \(self.currentCall?.state)") + if participantDevice.address != nil && !conf.isMe(uri: participantDevice.address!.clone()!) { + if !conf.isMe(uri: participantDevice.address!.clone()!) { + self.participantList.append(ParticipantModel(address: participantDevice.address!, isJoining: participantDevice.state == .Joining)) + } + } }) - //self.addConferenceCallBacks() + self.addConferenceCallBacks() } } else if self.currentCall?.remoteContactAddress != nil { - //self.addConferenceCallBacks() + self.addConferenceCallBacks() } } } @@ -188,7 +203,6 @@ class CallViewModel: ObservableObject { self.mConferenceSuscriptions.insert( self.currentCall?.conference?.publisher?.onStateChanged?.postOnMainQueue {(cbValue: (conference: Conference, state: Conference.State)) in if cbValue.state == .Created { - print("ParticipantModelParticipantModel 0 \(cbValue.conference.isIn) \(cbValue.conference.state) \(self.currentCall?.state) \(cbValue.state)") self.getConference() } } @@ -201,7 +215,7 @@ class CallViewModel: ObservableObject { self.currentCall?.conference?.publisher?.onActiveSpeakerParticipantDevice?.postOnMainQueue {(cbValue: (conference: Conference, participantDevice: ParticipantDevice)) in if cbValue.participantDevice.address != nil { let activeSpeakerParticipantTmp = self.activeSpeakerParticipant - self.activeSpeakerParticipant = ParticipantModel(address: cbValue.participantDevice.address!) + self.activeSpeakerParticipant = ParticipantModel(address: cbValue.participantDevice.address!, isJoining: false) if self.activeSpeakerParticipant != nil { let friend = ContactsManager.shared.getFriendWithAddress(address: self.activeSpeakerParticipant!.address) @@ -222,9 +236,9 @@ class CallViewModel: ObservableObject { self.participantList = [] cbValue.conference.participantDeviceList.forEach({ participantDevice in - if participantDevice.address != nil && !cbValue.conference.isMe(uri: participantDevice.address!) { - if !cbValue.conference.isMe(uri: participantDevice.address!) { - self.participantList.append(ParticipantModel(address: participantDevice.address!)) + if participantDevice.address != nil && !cbValue.conference.isMe(uri: participantDevice.address!.clone()!) { + if !cbValue.conference.isMe(uri: participantDevice.address!.clone()!) { + self.participantList.append(ParticipantModel(address: participantDevice.address!, isJoining: participantDevice.state == .Joining)) } } }) @@ -238,9 +252,9 @@ class CallViewModel: ObservableObject { if cbValue.participant.address != nil { self.participantList = [] cbValue.conference.participantDeviceList.forEach({ participantDevice in - if participantDevice.address != nil && !cbValue.conference.isMe(uri: participantDevice.address!) { - if !cbValue.conference.isMe(uri: participantDevice.address!) { - self.participantList.append(ParticipantModel(address: participantDevice.address!)) + if participantDevice.address != nil && !cbValue.conference.isMe(uri: participantDevice.address!.clone()!) { + if !cbValue.conference.isMe(uri: participantDevice.address!.clone()!) { + self.participantList.append(ParticipantModel(address: participantDevice.address!, isJoining: participantDevice.state == .Joining)) } } }) @@ -253,15 +267,29 @@ class CallViewModel: ObservableObject { if cbValue.participant.address != nil { self.participantList = [] cbValue.conference.participantDeviceList.forEach({ participantDevice in - if participantDevice.address != nil && !cbValue.conference.isMe(uri: participantDevice.address!) { - if !cbValue.conference.isMe(uri: participantDevice.address!) { - self.participantList.append(ParticipantModel(address: participantDevice.address!)) + if participantDevice.address != nil && !cbValue.conference.isMe(uri: participantDevice.address!.clone()!) { + if !cbValue.conference.isMe(uri: participantDevice.address!.clone()!) { + self.participantList.append(ParticipantModel(address: participantDevice.address!, isJoining: participantDevice.state == .Joining)) } } }) } } ) + + self.mConferenceSuscriptions.insert( + self.currentCall?.conference?.publisher?.onParticipantDeviceStateChanged?.postOnMainQueue {(cbValue: (conference: Conference, device: ParticipantDevice, state: ParticipantDevice.State)) in + Log.info( + "[CallViewModel] Participant device \(cbValue.device.address!.asStringUriOnly()) state changed \(cbValue.state)" + ) + + self.participantList.forEach({ participantDevice in + if participantDevice.address.equal(address2: cbValue.device.address!) { + participantDevice.isJoining = cbValue.state == .Joining + } + }) + } + ) } } From c350def61651f2a4acff807e38398c1ba1abb760 Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Wed, 17 Apr 2024 15:48:23 +0200 Subject: [PATCH 157/486] Add muted and joining icons in conf call --- Linphone/Localizable.xcstrings | 3 + Linphone/UI/Call/CallView.swift | 91 ++++++++++++++++--- Linphone/UI/Call/Model/ParticipantModel.swift | 4 +- .../UI/Call/ViewModel/CallViewModel.swift | 50 +++++++--- .../Fragments/HistoryListFragment.swift | 4 +- 5 files changed, 121 insertions(+), 31 deletions(-) diff --git a/Linphone/Localizable.xcstrings b/Linphone/Localizable.xcstrings index 2f86da3a3..1af0329dd 100644 --- a/Linphone/Localizable.xcstrings +++ b/Linphone/Localizable.xcstrings @@ -419,6 +419,9 @@ }, "Job title" : { + }, + "Joining..." : { + }, "Key" : { "extractionState" : "manual" diff --git a/Linphone/UI/Call/CallView.swift b/Linphone/UI/Call/CallView.swift index 3005a880d..03f2cbdcd 100644 --- a/Linphone/UI/Call/CallView.swift +++ b/Linphone/UI/Call/CallView.swift @@ -519,7 +519,7 @@ struct CallView: View { 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 { + } else if callViewModel.isConference && !telecomManager.outgoingCallStarted && callViewModel.activeSpeakerParticipant != nil { VStack { Spacer() ZStack { @@ -613,6 +613,27 @@ struct CallView: View { } } } + 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) + } + Spacer() + } + .frame(maxWidth: .infinity) + .padding(.all, 20) + } if callViewModel.isConference { HStack { @@ -654,6 +675,7 @@ struct CallView: View { .scaledToFill() .clipped() } + VStack(alignment: .leading) { Spacer() @@ -677,21 +699,63 @@ struct CallView: View { ForEach(0.. Date: Fri, 19 Apr 2024 09:46:33 +0200 Subject: [PATCH 158/486] Fix Active speaker view --- Linphone/Contacts/ContactsManager.swift | 10 +- Linphone/Core/CoreContext.swift | 7 +- Linphone/UI/Call/CallView.swift | 224 ++++++++++-------- .../UI/Call/ViewModel/CallViewModel.swift | 89 ++++++- .../Fragments/HistoryListFragment.swift | 4 +- 5 files changed, 208 insertions(+), 126 deletions(-) diff --git a/Linphone/Contacts/ContactsManager.swift b/Linphone/Contacts/ContactsManager.swift index 91af15221..5033d25b4 100644 --- a/Linphone/Contacts/ContactsManager.swift +++ b/Linphone/Contacts/ContactsManager.swift @@ -299,10 +299,14 @@ final class ContactsManager: ObservableObject { clonedAddress!.clean() let sipUri = clonedAddress!.asStringUriOnly() if friendList != nil { - var friend = friendList!.friends.first(where: {$0.addresses.contains(where: {$0.asStringUriOnly() == sipUri})}) - if friend == nil { - friend = linphoneFriendList!.friends.first(where: {$0.addresses.contains(where: {$0.asStringUriOnly() == sipUri})}) + var friend: Friend? + self.coreContext.doOnCoreQueue { _ in + friend = self.friendList!.friends.first(where: {$0.addresses.contains(where: {$0.asStringUriOnly() == sipUri})}) + if friend == nil { + friend = self.linphoneFriendList!.friends.first(where: {$0.addresses.contains(where: {$0.asStringUriOnly() == sipUri})}) + } } + return friend } else { return nil diff --git a/Linphone/Core/CoreContext.swift b/Linphone/Core/CoreContext.swift index 30adf40a9..a886f426e 100644 --- a/Linphone/Core/CoreContext.swift +++ b/Linphone/Core/CoreContext.swift @@ -286,7 +286,7 @@ final class CoreContext: ObservableObject { Log.info("App is in foreground, PUBLISHING presence as Online") self.updatePresence(core: self.mCore, presence: ConsolidatedPresence.Online) - //try? self.mCore.start() + try? self.mCore.start() } } @@ -300,10 +300,7 @@ final class CoreContext: ObservableObject { // 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() - } + self.mCore.stop() } } diff --git a/Linphone/UI/Call/CallView.swift b/Linphone/UI/Call/CallView.swift index 03f2cbdcd..4b4b73faa 100644 --- a/Linphone/UI/Call/CallView.swift +++ b/Linphone/UI/Call/CallView.swift @@ -394,7 +394,7 @@ struct CallView: View { // swiftlint:disable:next cyclomatic_complexity func simpleCallView(geometry: GeometryProxy) -> some View { ZStack { - if !callViewModel.isConference { + if callViewModel.isOneOneCall { VStack { Spacer() ZStack { @@ -519,100 +519,145 @@ struct CallView: View { 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 { VStack { - Spacer() - ZStack { - if callViewModel.activeSpeakerParticipant?.address != nil { - let addressFriend = contactsManager.getFriendWithAddress(address: callViewModel.activeSpeakerParticipant!.address) - - let contactAvatarModel = addressFriend != nil - ? ContactsManager.shared.avatarListModel.first(where: { - ($0.friend!.consolidatedPresence == .Online || $0.friend!.consolidatedPresence == .Busy) - && $0.friend!.name == addressFriend!.name - && $0.friend!.address!.asStringUriOnly() == addressFriend!.address!.asStringUriOnly() - }) - : ContactAvatarModel(friend: nil, name: "", withPresence: false) - - if addressFriend != nil && addressFriend!.photo != nil && !addressFriend!.photo!.isEmpty { - if contactAvatarModel != nil { - Avatar(contactAvatarModel: contactAvatarModel!, avatarSize: 200, hidePresence: true) + VStack { + Spacer() + ZStack { + if callViewModel.activeSpeakerParticipant?.address != nil { + let addressFriend = contactsManager.getFriendWithAddress(address: callViewModel.activeSpeakerParticipant!.address) + + let contactAvatarModel = addressFriend != nil + ? ContactsManager.shared.avatarListModel.first(where: { + ($0.friend!.consolidatedPresence == .Online || $0.friend!.consolidatedPresence == .Busy) + && $0.friend!.name == addressFriend!.name + && $0.friend!.address!.asStringUriOnly() == addressFriend!.address!.asStringUriOnly() + }) + : ContactAvatarModel(friend: nil, name: "", withPresence: false) + + if addressFriend != nil && addressFriend!.photo != nil && !addressFriend!.photo!.isEmpty { + if contactAvatarModel != nil { + Avatar(contactAvatarModel: contactAvatarModel!, avatarSize: 200, hidePresence: true) + .onAppear { + DispatchQueue.main.asyncAfter(deadline: .now() + 1) { + displayVideo = true + } + } + } + } else { + if callViewModel.activeSpeakerParticipant!.address.displayName != nil { + Image(uiImage: contactsManager.textToImage( + firstName: callViewModel.activeSpeakerParticipant!.address.displayName!, + lastName: callViewModel.activeSpeakerParticipant!.address.displayName!.components(separatedBy: " ").count > 1 + ? callViewModel.activeSpeakerParticipant!.address.displayName!.components(separatedBy: " ")[1] + : "")) + .resizable() + .frame(width: 200, height: 200) + .clipShape(Circle()) .onAppear { DispatchQueue.main.asyncAfter(deadline: .now() + 1) { displayVideo = true } } - } - } else { - if callViewModel.activeSpeakerParticipant!.address.displayName != nil { - Image(uiImage: contactsManager.textToImage( - firstName: callViewModel.activeSpeakerParticipant!.address.displayName!, - lastName: callViewModel.activeSpeakerParticipant!.address.displayName!.components(separatedBy: " ").count > 1 - ? callViewModel.activeSpeakerParticipant!.address.displayName!.components(separatedBy: " ")[1] - : "")) - .resizable() - .frame(width: 200, height: 200) - .clipShape(Circle()) - .onAppear { - DispatchQueue.main.asyncAfter(deadline: .now() + 1) { - displayVideo = true + + } else { + Image(uiImage: contactsManager.textToImage( + firstName: callViewModel.activeSpeakerParticipant!.address.username ?? "Username Error", + lastName: callViewModel.activeSpeakerParticipant!.address.username!.components(separatedBy: " ").count > 1 + ? callViewModel.activeSpeakerParticipant!.address.username!.components(separatedBy: " ")[1] + : "")) + .resizable() + .frame(width: 200, height: 200) + .clipShape(Circle()) + .onAppear { + DispatchQueue.main.asyncAfter(deadline: .now() + 1) { + displayVideo = true + } } } - } else { - Image(uiImage: contactsManager.textToImage( - firstName: callViewModel.activeSpeakerParticipant!.address.username ?? "Username Error", - lastName: callViewModel.activeSpeakerParticipant!.address.username!.components(separatedBy: " ").count > 1 - ? callViewModel.activeSpeakerParticipant!.address.username!.components(separatedBy: " ")[1] - : "")) + } + } else { + Image("profil-picture-default") .resizable() .frame(width: 200, height: 200) .clipShape(Circle()) - .onAppear { - DispatchQueue.main.asyncAfter(deadline: .now() + 1) { - displayVideo = true - } - } - } - } - } else { - Image("profil-picture-default") - .resizable() - .frame(width: 200, height: 200) - .clipShape(Circle()) } + + 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 - 160 + geometry.safeAreaInsets.bottom + ) 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 + ) if telecomManager.remoteConfVideo && !telecomManager.outgoingCallStarted && callViewModel.activeSpeakerParticipant != nil && displayVideo { - LinphoneVideoViewHolder { view in - DispatchQueue.main.asyncAfter(deadline: .now() + 1) { - coreContext.doOnCoreQueue { core in - core.nativeVideoWindow = view + VStack { + VStack { + LinphoneVideoViewHolder { view in + DispatchQueue.main.asyncAfter(deadline: .now() + 1) { + coreContext.doOnCoreQueue { core in + core.nativeVideoWindow = view + } + } + } + .onTapGesture { + if callViewModel.videoDisplayed { + fullscreenVideo.toggle() + } } } + .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 - 160 + geometry.safeAreaInsets.bottom + ) + .cornerRadius(20) + + Spacer() } .frame( - width: - angleDegree == 0 - ? (fullscreenVideo && !telecomManager.isPausedByRemote ? geometry.size.width : geometry.size.width - 8) - : (fullscreenVideo && !telecomManager.isPausedByRemote ? geometry.size.height + geometry.safeAreaInsets.top + geometry.safeAreaInsets.bottom : geometry.size.height - (minBottomSheetHeight * geometry.size.height > 80 ? minBottomSheetHeight * geometry.size.height : 78) - 40 - 20 + geometry.safeAreaInsets.bottom), - height: - angleDegree == 0 - ? (fullscreenVideo && !telecomManager.isPausedByRemote ? geometry.size.height + geometry.safeAreaInsets.top + geometry.safeAreaInsets.bottom : geometry.size.height - (minBottomSheetHeight * geometry.size.height > 80 ? minBottomSheetHeight * geometry.size.height : 78) - 40 - 20 + geometry.safeAreaInsets.bottom) - : (fullscreenVideo && !telecomManager.isPausedByRemote ? geometry.size.width : geometry.size.width - 8) + width: fullscreenVideo && !telecomManager.isPausedByRemote ? geometry.size.width : geometry.size.width - 8, + height: fullscreenVideo && !telecomManager.isPausedByRemote ? geometry.size.height + geometry.safeAreaInsets.top + 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 callViewModel.videoDisplayed { - fullscreenVideo.toggle() - } - } } + if callViewModel.isConference && !telecomManager.outgoingCallStarted && callViewModel.activeSpeakerParticipant != nil && callViewModel.activeSpeakerParticipant!.isMuted { VStack { HStack { @@ -728,9 +773,11 @@ struct CallView: View { LinphoneVideoViewHolder { view in coreContext.doOnCoreQueue { core in - let participantVideo = core.currentCall?.conference?.participantList.first(where: {$0.address!.equal(address2: callViewModel.participantList[index].address)}) - if participantVideo != nil && participantVideo!.devices.first != nil { - participantVideo!.devices.first!.nativeVideoWindowId = UnsafeMutableRawPointer(Unmanaged.passRetained(view).toOpaque()) + if index < callViewModel.participantList.count { + let participantVideo = core.currentCall?.conference?.participantList.first(where: {$0.address!.equal(address2: callViewModel.participantList[index].address)}) + if participantVideo != nil && participantVideo!.devices.first != nil { + participantVideo!.devices.first!.nativeVideoWindowId = UnsafeMutableRawPointer(Unmanaged.passRetained(view).toOpaque()) + } } } } @@ -810,35 +857,6 @@ struct CallView: View { 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 - ) - } } .frame( maxWidth: fullscreenVideo && !telecomManager.isPausedByRemote ? geometry.size.width : geometry.size.width - 8, @@ -1007,7 +1025,7 @@ struct CallView: View { if orientation != .landscapeLeft && orientation != .landscapeRight { HStack(spacing: 0) { - if !callViewModel.isConference { + if callViewModel.isOneOneCall { VStack { Button { if callViewModel.calls.count < 2 { @@ -1231,7 +1249,7 @@ struct CallView: View { } .frame(width: geo.size.width * 0.25, height: geo.size.width * 0.25) - if !callViewModel.isConference { + if callViewModel.isOneOneCall { VStack { Button { callViewModel.toggleRecording() @@ -1473,7 +1491,7 @@ struct CallView: View { } .frame(width: geo.size.width * 0.125, height: geo.size.width * 0.125) - if !callViewModel.isConference { + if callViewModel.isOneOneCall { VStack { Button { callViewModel.toggleRecording() diff --git a/Linphone/UI/Call/ViewModel/CallViewModel.swift b/Linphone/UI/Call/ViewModel/CallViewModel.swift index 5054339df..143981ae1 100644 --- a/Linphone/UI/Call/ViewModel/CallViewModel.swift +++ b/Linphone/UI/Call/ViewModel/CallViewModel.swift @@ -28,9 +28,9 @@ class CallViewModel: ObservableObject { var coreContext = CoreContext.shared var telecomManager = TelecomManager.shared - @Published var displayName: String = "Example Linphone" + @Published var displayName: String = "" @Published var direction: Call.Dir = .Outgoing - @Published var remoteAddressString: String = "example.linphone@sip.linphone.org" + @Published var remoteAddressString: String = "" @Published var remoteAddress: Address? @Published var avatarModel: ContactAvatarModel? @Published var micMutted: Bool = false @@ -46,6 +46,7 @@ class CallViewModel: ObservableObject { @Published var isRemoteDeviceTrusted: 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] = [] @@ -92,11 +93,41 @@ class CallViewModel: ObservableObject { coreContext.doOnCoreQueue { core in if core.currentCall != nil && core.currentCall!.remoteAddress != nil { self.currentCall = core.currentCall + + var videoDisplayedTmp = false + do { + let params = try core.createCallParams(call: self.currentCall) + videoDisplayedTmp = params.videoDirection == MediaDirection.SendRecv || params.videoDirection == MediaDirection.SendOnly + } catch { + + } + + var isOneOneCallTmp = false + if self.currentCall?.remoteAddress != nil { + let conf = core.findConferenceInformationFromUri(uri: self.currentCall!.remoteAddress!) + + if conf == nil { + isOneOneCallTmp = true + } + } + + var isMediaEncryptedTmp = false + var isZrtpPqTmp = 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 + isZrtpPqTmp = self.currentCall!.currentParams!.mediaEncryption == .ZRTP + } + } + DispatchQueue.main.async { self.direction = self.currentCall!.dir self.remoteAddressString = String(self.currentCall!.remoteAddress!.asStringUriOnly().dropFirst(4)) self.remoteAddress = self.currentCall!.remoteAddress! - + self.displayName = "" if self.currentCall?.conference != nil { self.displayName = self.currentCall?.conference?.subject ?? "" } else if self.currentCall?.remoteAddress != nil { @@ -123,15 +154,33 @@ class CallViewModel: ObservableObject { self.isRemoteDeviceTrusted = self.telecomManager.callInProgress ? isDeviceTrusted : false self.activeSpeakerParticipant = nil - do { - let params = try core.createCallParams(call: self.currentCall) - self.videoDisplayed = params.videoDirection == MediaDirection.SendRecv - } catch { - - } + self.avatarModel = nil + self.isRemoteRecording = false + self.zrtpPopupDisplayed = false + self.upperCaseAuthTokenToRead = "" + self.upperCaseAuthTokenToListen = "" + self.isMediaEncrypted = false + self.isZrtpPq = false + self.isOneOneCall = false + self.isConference = false + self.videoDisplayed = false + self.participantList = [] + self.activeSpeakerParticipant = nil + self.activeSpeakerName = "" + self.myParticipantModel = nil + + self.videoDisplayed = videoDisplayedTmp + self.isOneOneCall = isOneOneCallTmp + self.isMediaEncrypted = isMediaEncryptedTmp + self.isZrtpPq = isZrtpPqTmp self.getCallsList() - self.waitingForCreatedStateConference() + + if self.currentCall?.conference?.state == .Created { + self.getConference() + } else { + self.waitingForCreatedStateConference() + } } self.callSuscriptions.insert(self.currentCall!.publisher?.onEncryptionChanged?.postOnMainQueue {(cbVal: (call: Call, on: Bool, authenticationToken: String?)) in @@ -165,9 +214,23 @@ class CallViewModel: ObservableObject { } if conf.activeSpeakerParticipantDevice?.address != nil { - self.activeSpeakerParticipant = ParticipantModel(address: conf.activeSpeakerParticipantDevice!.address!, isJoining: false, isMuted: conf.activeSpeakerParticipantDevice!.isMuted) - } else if conf.participantList.first?.address != nil { - self.activeSpeakerParticipant = ParticipantModel(address: conf.participantDeviceList.first!.address!, isJoining: false, isMuted: conf.participantDeviceList.first!.isMuted) + self.activeSpeakerParticipant = ParticipantModel( + address: conf.activeSpeakerParticipantDevice!.address!, + isJoining: false, + isMuted: conf.activeSpeakerParticipantDevice!.isMuted + ) + } else if conf.participantList.first?.address != nil && conf.participantList.first!.address!.clone()!.equal(address2: (conf.me?.address)!) { + self.activeSpeakerParticipant = ParticipantModel( + address: conf.participantDeviceList.first!.address!, + isJoining: false, + isMuted: conf.participantDeviceList.first!.isMuted + ) + } else if conf.participantList.last?.address != nil { + self.activeSpeakerParticipant = ParticipantModel( + address: conf.participantDeviceList.last!.address!, + isJoining: false, + isMuted: conf.participantDeviceList.last!.isMuted + ) } if self.activeSpeakerParticipant != nil { diff --git a/Linphone/UI/Main/History/Fragments/HistoryListFragment.swift b/Linphone/UI/Main/History/Fragments/HistoryListFragment.swift index 43fa2f917..38f5d4b5a 100644 --- a/Linphone/UI/Main/History/Fragments/HistoryListFragment.swift +++ b/Linphone/UI/Main/History/Fragments/HistoryListFragment.swift @@ -223,7 +223,7 @@ struct HistoryListFragment: View { ) */ - let reutest = try Factory.Instance.createAddress(addr: "sip:conference-focus@sip.linphone.org;conf-id=7M7oqGrZS;gr=0ee3f37f-6df2-0071-bb9a-a4e24be30135") + let reutest = try Factory.Instance.createAddress(addr: "sip:conference-focus@sip.linphone.org;conf-id=~~zKKyETb;gr=0ee3f37f-6df2-0071-bb9a-a4e24be30135") telecomManager.meetingWaitingRoomDisplayed = true telecomManager.meetingWaitingRoomSelected = reutest @@ -244,7 +244,7 @@ struct HistoryListFragment: View { ) */ - let reutest = try Factory.Instance.createAddress(addr: "sip:conference-focus@sip.linphone.org;conf-id=7M7oqGrZS;gr=0ee3f37f-6df2-0071-bb9a-a4e24be30135") + let reutest = try Factory.Instance.createAddress(addr: "sip:conference-focus@sip.linphone.org;conf-id=~~zKKyETb;gr=0ee3f37f-6df2-0071-bb9a-a4e24be30135") telecomManager.meetingWaitingRoomDisplayed = true telecomManager.meetingWaitingRoomSelected = reutest From a37972abaf5700ee57106591881011d68d7c564b Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Fri, 19 Apr 2024 10:03:13 +0200 Subject: [PATCH 159/486] Disable core restart when switching from foreground to background mode --- Linphone/Core/CoreContext.swift | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/Linphone/Core/CoreContext.swift b/Linphone/Core/CoreContext.swift index a886f426e..30adf40a9 100644 --- a/Linphone/Core/CoreContext.swift +++ b/Linphone/Core/CoreContext.swift @@ -286,7 +286,7 @@ final class CoreContext: ObservableObject { Log.info("App is in foreground, PUBLISHING presence as Online") self.updatePresence(core: self.mCore, presence: ConsolidatedPresence.Online) - try? self.mCore.start() + //try? self.mCore.start() } } @@ -300,7 +300,10 @@ final class CoreContext: ObservableObject { // Flexisip will handle the Busy status depending on other devices self.updatePresence(core: self.mCore, presence: ConsolidatedPresence.Offline) // self.mCore.iterate() - self.mCore.stop() + + if self.mCore.currentCall == nil { + //self.mCore.stop() + } } } From b62a806359f8f57ce80f49bded94c172c52f2207 Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Fri, 19 Apr 2024 11:46:43 +0200 Subject: [PATCH 160/486] Add conf view when the user is alone --- Linphone/Localizable.xcstrings | 6 +++ Linphone/UI/Call/CallView.swift | 83 ++++++++++++++++++++++++++++++--- 2 files changed, 82 insertions(+), 7 deletions(-) diff --git a/Linphone/Localizable.xcstrings b/Linphone/Localizable.xcstrings index 1af0329dd..5a2e07f9e 100644 --- a/Linphone/Localizable.xcstrings +++ b/Linphone/Localizable.xcstrings @@ -328,6 +328,9 @@ }, "Edit picture" : { + }, + "En attente d'autres participants..." : { + }, "En continuant, vous acceptez ces conditions, " : { @@ -500,6 +503,9 @@ }, "Partage d'écran" : { + }, + "Partager le lien" : { + }, "Participants" : { diff --git a/Linphone/UI/Call/CallView.swift b/Linphone/UI/Call/CallView.swift index 4b4b73faa..463f395a3 100644 --- a/Linphone/UI/Call/CallView.swift +++ b/Linphone/UI/Call/CallView.swift @@ -21,6 +21,7 @@ import SwiftUI import CallKit import AVFAudio import linphonesw +import UniformTypeIdentifiers // swiftlint:disable type_body_length // swiftlint:disable line_length @@ -391,6 +392,7 @@ struct CallView: View { } } + // swiftlint:disable function_body_length // swiftlint:disable:next cyclomatic_complexity func simpleCallView(geometry: GeometryProxy) -> some View { ZStack { @@ -688,11 +690,11 @@ struct CallView: View { 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) + .foregroundStyle(Color.white) + .default_text_style_500(styleSize: 20) + .lineLimit(1) + .padding(.horizontal, 10) + .padding(.bottom, 6) ScrollView(.horizontal) { HStack { @@ -718,9 +720,9 @@ struct CallView: View { } .frame(width: angleDegree == 0 ? 120*1.2 : 160*1.2, height: angleDegree == 0 ? 160*1.2 : 120*1.2) .scaledToFill() - .clipped() + .clipped() } - + VStack(alignment: .leading) { Spacer() @@ -834,6 +836,72 @@ struct CallView: View { .padding(.bottom, 10) .padding(.leading, -10) } + } 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_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() + } + + 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 callViewModel.isRecording { @@ -912,6 +980,7 @@ struct CallView: View { callViewModel.orientationUpdate(orientation: orientation) } } + // swiftlint:enable function_body_length // swiftlint:disable function_body_length func bottomSheetContent(geo: GeometryProxy) -> some View { From d96abf151423a5dd87997c962082570e502c147e Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Fri, 19 Apr 2024 13:49:21 +0200 Subject: [PATCH 161/486] Add pause status to conf view --- Linphone/Localizable.xcstrings | 3 + Linphone/UI/Call/CallView.swift | 242 +++++++++++------- Linphone/UI/Call/Model/ParticipantModel.swift | 4 +- .../UI/Call/ViewModel/CallViewModel.swift | 63 ++++- 4 files changed, 203 insertions(+), 109 deletions(-) diff --git a/Linphone/Localizable.xcstrings b/Linphone/Localizable.xcstrings index 5a2e07f9e..402f26965 100644 --- a/Linphone/Localizable.xcstrings +++ b/Linphone/Localizable.xcstrings @@ -334,6 +334,9 @@ }, "En continuant, vous acceptez ces conditions, " : { + }, + "En pause" : { + }, "Error" : { diff --git a/Linphone/UI/Call/CallView.swift b/Linphone/UI/Call/CallView.swift index 463f395a3..cb9a50048 100644 --- a/Linphone/UI/Call/CallView.swift +++ b/Linphone/UI/Call/CallView.swift @@ -551,113 +551,146 @@ struct CallView: View { ) } } else if callViewModel.isConference && !telecomManager.outgoingCallStarted && callViewModel.activeSpeakerParticipant != nil { - VStack { - VStack { - Spacer() - ZStack { - if callViewModel.activeSpeakerParticipant?.address != nil { - let addressFriend = contactsManager.getFriendWithAddress(address: callViewModel.activeSpeakerParticipant!.address) - - let contactAvatarModel = addressFriend != nil - ? ContactsManager.shared.avatarListModel.first(where: { - ($0.friend!.consolidatedPresence == .Online || $0.friend!.consolidatedPresence == .Busy) - && $0.friend!.name == addressFriend!.name - && $0.friend!.address!.asStringUriOnly() == addressFriend!.address!.asStringUriOnly() - }) - : ContactAvatarModel(friend: nil, name: "", withPresence: false) - - if addressFriend != nil && addressFriend!.photo != nil && !addressFriend!.photo!.isEmpty { - if contactAvatarModel != nil { - Avatar(contactAvatarModel: contactAvatarModel!, avatarSize: 200, hidePresence: true) - .onAppear { - DispatchQueue.main.asyncAfter(deadline: .now() + 1) { - displayVideo = true - } - } - } - } else { - if callViewModel.activeSpeakerParticipant!.address.displayName != nil { - Image(uiImage: contactsManager.textToImage( - firstName: callViewModel.activeSpeakerParticipant!.address.displayName!, - lastName: callViewModel.activeSpeakerParticipant!.address.displayName!.components(separatedBy: " ").count > 1 - ? callViewModel.activeSpeakerParticipant!.address.displayName!.components(separatedBy: " ")[1] - : "")) - .resizable() - .frame(width: 200, height: 200) - .clipShape(Circle()) - .onAppear { - DispatchQueue.main.asyncAfter(deadline: .now() + 1) { - displayVideo = true - } - } - - } else { - Image(uiImage: contactsManager.textToImage( - firstName: callViewModel.activeSpeakerParticipant!.address.username ?? "Username Error", - lastName: callViewModel.activeSpeakerParticipant!.address.username!.components(separatedBy: " ").count > 1 - ? callViewModel.activeSpeakerParticipant!.address.username!.components(separatedBy: " ")[1] - : "")) - .resizable() - .frame(width: 200, height: 200) - .clipShape(Circle()) - .onAppear { - DispatchQueue.main.asyncAfter(deadline: .now() + 1) { - displayVideo = true - } - } - } - - } - } else { - Image("profil-picture-default") - .resizable() - .frame(width: 200, height: 200) - .clipShape(Circle()) - } - } - - 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 - 160 + geometry.safeAreaInsets.bottom - ) - - 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 - ) - - if telecomManager.remoteConfVideo && !telecomManager.outgoingCallStarted && callViewModel.activeSpeakerParticipant != nil && displayVideo { + if callViewModel.activeSpeakerParticipant!.onPause { VStack { VStack { - LinphoneVideoViewHolder { view in - DispatchQueue.main.asyncAfter(deadline: .now() + 1) { - coreContext.doOnCoreQueue { core in - core.nativeVideoWindow = view - } - } - } - .onTapGesture { - if callViewModel.videoDisplayed { - fullscreenVideo.toggle() - } - } + 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 : 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 - 160 + geometry.safeAreaInsets.bottom ) - .cornerRadius(20) 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 + 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 { + VStack { + Spacer() + ZStack { + if callViewModel.activeSpeakerParticipant?.address != nil { + let addressFriend = contactsManager.getFriendWithAddress(address: callViewModel.activeSpeakerParticipant!.address) + + let contactAvatarModel = addressFriend != nil + ? ContactsManager.shared.avatarListModel.first(where: { + ($0.friend!.consolidatedPresence == .Online || $0.friend!.consolidatedPresence == .Busy) + && $0.friend!.name == addressFriend!.name + && $0.friend!.address!.asStringUriOnly() == addressFriend!.address!.asStringUriOnly() + }) + : ContactAvatarModel(friend: nil, name: "", withPresence: false) + + if addressFriend != nil && addressFriend!.photo != nil && !addressFriend!.photo!.isEmpty { + if contactAvatarModel != nil { + Avatar(contactAvatarModel: contactAvatarModel!, avatarSize: 200, hidePresence: true) + .onAppear { + DispatchQueue.main.asyncAfter(deadline: .now() + 1) { + displayVideo = true + } + } + } + } else { + if callViewModel.activeSpeakerParticipant!.address.displayName != nil { + Image(uiImage: contactsManager.textToImage( + firstName: callViewModel.activeSpeakerParticipant!.address.displayName!, + lastName: callViewModel.activeSpeakerParticipant!.address.displayName!.components(separatedBy: " ").count > 1 + ? callViewModel.activeSpeakerParticipant!.address.displayName!.components(separatedBy: " ")[1] + : "")) + .resizable() + .frame(width: 200, height: 200) + .clipShape(Circle()) + .onAppear { + DispatchQueue.main.asyncAfter(deadline: .now() + 1) { + displayVideo = true + } + } + + } else { + Image(uiImage: contactsManager.textToImage( + firstName: callViewModel.activeSpeakerParticipant!.address.username ?? "Username Error", + lastName: callViewModel.activeSpeakerParticipant!.address.username!.components(separatedBy: " ").count > 1 + ? callViewModel.activeSpeakerParticipant!.address.username!.components(separatedBy: " ")[1] + : "")) + .resizable() + .frame(width: 200, height: 200) + .clipShape(Circle()) + .onAppear { + DispatchQueue.main.asyncAfter(deadline: .now() + 1) { + displayVideo = true + } + } + } + + } + } else { + Image("profil-picture-default") + .resizable() + .frame(width: 200, height: 200) + .clipShape(Circle()) + } + } + + 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 - 160 + geometry.safeAreaInsets.bottom + ) + + 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 + ) + + if telecomManager.remoteConfVideo && !telecomManager.outgoingCallStarted && callViewModel.activeSpeakerParticipant != nil && displayVideo { + VStack { + VStack { + LinphoneVideoViewHolder { view in + DispatchQueue.main.asyncAfter(deadline: .now() + 1) { + coreContext.doOnCoreQueue { core in + core.nativeVideoWindow = view + } + } + } + .onTapGesture { + if callViewModel.videoDisplayed { + fullscreenVideo.toggle() + } + } + } + .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 - 160 + geometry.safeAreaInsets.bottom + ) + .cornerRadius(20) + + 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 { @@ -762,6 +795,25 @@ struct CallView: View { .lineLimit(1) .padding(.horizontal, 10) + Spacer() + } + } else if callViewModel.participantList[index].onPause { + 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() } } else { diff --git a/Linphone/UI/Call/Model/ParticipantModel.swift b/Linphone/UI/Call/Model/ParticipantModel.swift index 8a69570fe..2f5c9786a 100644 --- a/Linphone/UI/Call/Model/ParticipantModel.swift +++ b/Linphone/UI/Call/Model/ParticipantModel.swift @@ -29,9 +29,10 @@ class ParticipantModel: ObservableObject { @Published var name: String @Published var avatarModel: ContactAvatarModel @Published var isJoining: Bool + @Published var onPause: Bool @Published var isMuted: Bool - init(address: Address, isJoining: Bool, isMuted: Bool) { + init(address: Address, isJoining: Bool, onPause: Bool, isMuted: Bool) { self.address = address self.sipUri = address.asStringUriOnly() @@ -58,6 +59,7 @@ class ParticipantModel: ObservableObject { : ContactAvatarModel(friend: nil, name: nameTmp, withPresence: false) self.isJoining = isJoining + self.onPause = onPause self.isMuted = isMuted } } diff --git a/Linphone/UI/Call/ViewModel/CallViewModel.swift b/Linphone/UI/Call/ViewModel/CallViewModel.swift index 143981ae1..db717f91a 100644 --- a/Linphone/UI/Call/ViewModel/CallViewModel.swift +++ b/Linphone/UI/Call/ViewModel/CallViewModel.swift @@ -208,27 +208,30 @@ class CallViewModel: ObservableObject { self.participantList = [] if conf.me?.address != nil { - self.myParticipantModel = ParticipantModel(address: conf.me!.address!, isJoining: false, isMuted: false) + self.myParticipantModel = ParticipantModel(address: conf.me!.address!, isJoining: false, onPause: false, isMuted: false) } else if self.currentCall?.callLog?.localAddress != nil { - self.myParticipantModel = ParticipantModel(address: self.currentCall!.callLog!.localAddress!, isJoining: false, isMuted: false) + self.myParticipantModel = ParticipantModel(address: self.currentCall!.callLog!.localAddress!, isJoining: false, onPause: false, isMuted: false) } if conf.activeSpeakerParticipantDevice?.address != nil { self.activeSpeakerParticipant = 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)!) { self.activeSpeakerParticipant = 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 { self.activeSpeakerParticipant = ParticipantModel( address: conf.participantDeviceList.last!.address!, isJoining: false, + onPause: conf.participantDeviceList.last!.state == .OnHold, isMuted: conf.participantDeviceList.last!.isMuted ) } @@ -250,7 +253,12 @@ class CallViewModel: ObservableObject { if participantDevice.address != nil && !conf.isMe(uri: participantDevice.address!.clone()!) { if !conf.isMe(uri: participantDevice.address!.clone()!) { self.participantList.append( - ParticipantModel(address: participantDevice.address!, isJoining: participantDevice.state == .Joining || participantDevice.state == .Alerting, isMuted: participantDevice.isMuted) + ParticipantModel( + address: participantDevice.address!, + isJoining: participantDevice.state == .Joining || participantDevice.state == .Alerting, + onPause: participantDevice.state == .OnHold, + isMuted: participantDevice.isMuted + ) ) } } @@ -280,7 +288,12 @@ class CallViewModel: ObservableObject { self.currentCall?.conference?.publisher?.onActiveSpeakerParticipantDevice?.postOnMainQueue {(cbValue: (conference: Conference, participantDevice: ParticipantDevice)) in if cbValue.participantDevice.address != nil { let activeSpeakerParticipantTmp = self.activeSpeakerParticipant - self.activeSpeakerParticipant = ParticipantModel(address: cbValue.participantDevice.address!, isJoining: false, isMuted: cbValue.participantDevice.isMuted) + self.activeSpeakerParticipant = ParticipantModel( + address: cbValue.participantDevice.address!, + isJoining: false, + onPause: cbValue.participantDevice.state == .OnHold, + isMuted: cbValue.participantDevice.isMuted + ) if self.activeSpeakerParticipant != nil { let friend = ContactsManager.shared.getFriendWithAddress(address: self.activeSpeakerParticipant!.address) @@ -304,7 +317,12 @@ class CallViewModel: ObservableObject { if participantDevice.address != nil && !cbValue.conference.isMe(uri: participantDevice.address!.clone()!) { if !cbValue.conference.isMe(uri: participantDevice.address!.clone()!) { self.participantList.append( - ParticipantModel(address: participantDevice.address!, isJoining: participantDevice.state == .Joining || participantDevice.state == .Alerting, isMuted: participantDevice.isMuted) + ParticipantModel( + address: participantDevice.address!, + isJoining: participantDevice.state == .Joining || participantDevice.state == .Alerting, + onPause: participantDevice.state == .OnHold, + isMuted: participantDevice.isMuted + ) ) } } @@ -322,7 +340,12 @@ class CallViewModel: ObservableObject { if participantDevice.address != nil && !cbValue.conference.isMe(uri: participantDevice.address!.clone()!) { if !cbValue.conference.isMe(uri: participantDevice.address!.clone()!) { self.participantList.append( - ParticipantModel(address: participantDevice.address!, isJoining: participantDevice.state == .Joining || participantDevice.state == .Alerting, isMuted: participantDevice.isMuted) + ParticipantModel( + address: participantDevice.address!, + isJoining: participantDevice.state == .Joining || participantDevice.state == .Alerting, + onPause: participantDevice.state == .OnHold, + isMuted: participantDevice.isMuted + ) ) } } @@ -339,11 +362,20 @@ class CallViewModel: ObservableObject { if participantDevice.address != nil && !cbValue.conference.isMe(uri: participantDevice.address!.clone()!) { if !cbValue.conference.isMe(uri: participantDevice.address!.clone()!) { self.participantList.append( - ParticipantModel(address: participantDevice.address!, isJoining: participantDevice.state == .Joining || participantDevice.state == .Alerting, isMuted: participantDevice.isMuted) + ParticipantModel( + address: participantDevice.address!, + isJoining: participantDevice.state == .Joining || participantDevice.state == .Alerting, + onPause: participantDevice.state == .OnHold, + isMuted: participantDevice.isMuted + ) ) } } }) + + if cbValue.conference.participantDeviceList.count == 1 { + self.activeSpeakerParticipant = nil + } } } ) @@ -367,12 +399,17 @@ class CallViewModel: ObservableObject { Log.info( "[CallViewModel] Participant device \(cbValue.device.address!.asStringUriOnly()) state changed \(cbValue.state)" ) - - self.participantList.forEach({ participantDevice in - if participantDevice.address.equal(address2: cbValue.device.address!) { - participantDevice.isJoining = cbValue.state == .Joining || cbValue.state == .Alerting - } - }) + if self.activeSpeakerParticipant != nil && self.activeSpeakerParticipant!.address.equal(address2: cbValue.device.address!) { + self.activeSpeakerParticipant!.onPause = cbValue.state == .OnHold + self.activeSpeakerParticipant!.isJoining = cbValue.state == .Joining || cbValue.state == .Alerting + } else { + self.participantList.forEach({ participantDevice in + if participantDevice.address.equal(address2: cbValue.device.address!) { + participantDevice.onPause = cbValue.state == .OnHold + participantDevice.isJoining = cbValue.state == .Joining || cbValue.state == .Alerting + } + }) + } } ) } From ea18eaa3d63eaaff7285fe7ead50243566bc6c27 Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Fri, 19 Apr 2024 13:55:26 +0200 Subject: [PATCH 162/486] Fix meeting address joined --- .../Fragments/HistoryListFragment.swift | 25 +++---------------- 1 file changed, 4 insertions(+), 21 deletions(-) diff --git a/Linphone/UI/Main/History/Fragments/HistoryListFragment.swift b/Linphone/UI/Main/History/Fragments/HistoryListFragment.swift index 38f5d4b5a..70e2f1171 100644 --- a/Linphone/UI/Main/History/Fragments/HistoryListFragment.swift +++ b/Linphone/UI/Main/History/Fragments/HistoryListFragment.swift @@ -214,19 +214,10 @@ struct HistoryListFragment: View { if historyListViewModel.callLogs[index].dir == .Outgoing && historyListViewModel.callLogs[index].toAddress != nil { if historyListViewModel.callLogs[index].toAddress!.asStringUriOnly().hasPrefix("sip:conference-focus@sip.linphone.org") { do { - //let reudumatin = try Factory.Instance.createAddress(addr: "sip:conference-focus@sip.linphone.org;conf-id=8~YNkpFOv;gr=0ee3f37f-6df2-0071-bb9a-a4e24be30135") - /* - let reutest = try Factory.Instance.createAddress(addr: "sip:conference-focus@sip.linphone.org;conf-id=iVs8XshC~;gr=0ee3f37f-6df2-0071-bb9a-a4e24be30135") - - telecomManager.doCallWithCore( - addr: reutest, isVideo: false, isConference: true - ) - */ - - let reutest = try Factory.Instance.createAddress(addr: "sip:conference-focus@sip.linphone.org;conf-id=~~zKKyETb;gr=0ee3f37f-6df2-0071-bb9a-a4e24be30135") + let meetingAddress = try Factory.Instance.createAddress(addr: historyListViewModel.callLogs[index].toAddress!.asStringUriOnly()) telecomManager.meetingWaitingRoomDisplayed = true - telecomManager.meetingWaitingRoomSelected = reutest + telecomManager.meetingWaitingRoomSelected = meetingAddress } catch {} } else { telecomManager.doCallWithCore( @@ -236,18 +227,10 @@ struct HistoryListFragment: View { } else if historyListViewModel.callLogs[index].fromAddress != nil { if historyListViewModel.callLogs[index].fromAddress!.asStringUriOnly().hasPrefix("sip:conference-focus@sip.linphone.org") { do { - //let reudumatin = try Factory.Instance.createAddress(addr: "sip:conference-focus@sip.linphone.org;conf-id=8~YNkpFOv;gr=0ee3f37f-6df2-0071-bb9a-a4e24be30135") - /* - let reutest = try Factory.Instance.createAddress(addr: "sip:conference-focus@sip.linphone.org;conf-id=iVs8XshC~;gr=0ee3f37f-6df2-0071-bb9a-a4e24be30135") - telecomManager.doCallWithCore( - addr: reutest, isVideo: false, isConference: true - ) - */ - - let reutest = try Factory.Instance.createAddress(addr: "sip:conference-focus@sip.linphone.org;conf-id=~~zKKyETb;gr=0ee3f37f-6df2-0071-bb9a-a4e24be30135") + let meetingAddress = try Factory.Instance.createAddress(addr: historyListViewModel.callLogs[index].fromAddress!.asStringUriOnly()) telecomManager.meetingWaitingRoomDisplayed = true - telecomManager.meetingWaitingRoomSelected = reutest + telecomManager.meetingWaitingRoomSelected = meetingAddress } catch {} } else { telecomManager.doCallWithCore( From 0d210dea6d68d5c3ce2cd74b05d58a1210fc4586 Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Fri, 19 Apr 2024 15:36:12 +0200 Subject: [PATCH 163/486] Fix display of confs in story fragments --- .../users-three-square.imageset/Contents.json | 21 + .../users-three-square.svg | 8 + .../Fragments/HistoryContactFragment.swift | 1039 +++++++++-------- .../Fragments/HistoryListFragment.swift | 241 ++-- .../ViewModel/HistoryListViewModel.swift | 37 +- .../History/ViewModel/HistoryViewModel.swift | 14 + 6 files changed, 761 insertions(+), 599 deletions(-) create mode 100644 Linphone/Assets.xcassets/users-three-square.imageset/Contents.json create mode 100644 Linphone/Assets.xcassets/users-three-square.imageset/users-three-square.svg 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/UI/Main/History/Fragments/HistoryContactFragment.swift b/Linphone/UI/Main/History/Fragments/HistoryContactFragment.swift index 71062797b..ed3a6b0fb 100644 --- a/Linphone/UI/Main/History/Fragments/HistoryContactFragment.swift +++ b/Linphone/UI/Main/History/Fragments/HistoryContactFragment.swift @@ -17,11 +17,12 @@ * along with this program. If not, see . */ -// swiftlint:disable line_length - import SwiftUI import UniformTypeIdentifiers +import linphonesw +// swiftlint:disable line_length +// swiftlint:disable type_body_length struct HistoryContactFragment: View { @State private var orientation = UIDevice.current.orientation @@ -44,518 +45,594 @@ struct HistoryContactFragment: View { var body: some View { NavigationView { - 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 - } - } - } + if historyViewModel.displayedCall != nil { + VStack(spacing: 1) { + Rectangle() + .foregroundColor(Color.orangeMain500) + .edgesIgnoringSafeArea(.top) + .frame(height: 0) - Text("Call history") - .default_text_style_orange_800(styleSize: 20) - - Spacer() - - Menu { - let fromAddressFriend = historyViewModel.displayedCall != nil ? contactsManager.getFriendWithAddress(address: historyViewModel.displayedCall!.fromAddress!) : nil - let toAddressFriend = historyViewModel.displayedCall != nil ? contactsManager.getFriendWithAddress(address: historyViewModel.displayedCall!.toAddress!) : nil - let addressFriend = historyViewModel.displayedCall != nil ? (historyViewModel.displayedCall!.dir == .Incoming ? fromAddressFriend : toAddressFriend) : nil - - Button { - isMenuOpen = false - - if contactsManager.getFriendWithAddress( - address: historyViewModel.displayedCall != nil && historyViewModel.displayedCall!.dir == .Outgoing - ? historyViewModel.displayedCall!.toAddress! - : historyViewModel.displayedCall!.fromAddress! - ) != nil { - let addressCall = historyViewModel.displayedCall != nil && historyViewModel.displayedCall!.dir == .Outgoing - ? historyViewModel.displayedCall!.toAddress! - : historyViewModel.displayedCall!.fromAddress! - - let friendIndex = contactsManager.lastSearch.firstIndex( - where: {$0.friend!.addresses.contains(where: {$0.asStringUriOnly() == addressCall.asStringUriOnly()})}) - if friendIndex != nil { - + 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 - indexPage = 0 - - contactViewModel.indexDisplayedFriend = friendIndex } } - } else { - let addressCall = historyViewModel.displayedCall != nil && historyViewModel.displayedCall!.dir == .Outgoing - ? historyViewModel.displayedCall!.toAddress! - : historyViewModel.displayedCall!.fromAddress! - - withAnimation { - historyViewModel.displayedCall = nil - indexPage = 0 + } + + Text("Call history") + .default_text_style_orange_800(styleSize: 20) + + Spacer() + + Menu { + let fromAddressFriend = historyViewModel.displayedCall != nil ? contactsManager.getFriendWithAddress(address: historyViewModel.displayedCall!.fromAddress!) : nil + let toAddressFriend = historyViewModel.displayedCall != nil ? contactsManager.getFriendWithAddress(address: historyViewModel.displayedCall!.toAddress!) : nil + let addressFriend = historyViewModel.displayedCall != nil ? (historyViewModel.displayedCall!.dir == .Incoming ? fromAddressFriend : toAddressFriend) : nil + + if historyViewModel.displayedCallIsConference.isEmpty { + Button { + isMenuOpen = false - isShowEditContactFragment.toggle() - editContactViewModel.sipAddresses.removeAll() - editContactViewModel.sipAddresses.append(String(addressCall.asStringUriOnly().dropFirst(4))) - editContactViewModel.sipAddresses.append("") + if contactsManager.getFriendWithAddress( + address: historyViewModel.displayedCall != nil && historyViewModel.displayedCall!.dir == .Outgoing + ? historyViewModel.displayedCall!.toAddress! + : historyViewModel.displayedCall!.fromAddress! + ) != nil { + let addressCall = historyViewModel.displayedCall != nil && historyViewModel.displayedCall!.dir == .Outgoing + ? historyViewModel.displayedCall!.toAddress! + : historyViewModel.displayedCall!.fromAddress! + + 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 { + let addressCall = historyViewModel.displayedCall != nil && historyViewModel.displayedCall!.dir == .Outgoing + ? historyViewModel.displayedCall!.toAddress! + : historyViewModel.displayedCall!.fromAddress! + + withAnimation { + historyViewModel.displayedCall = nil + indexPage = 0 + + isShowEditContactFragment.toggle() + editContactViewModel.sipAddresses.removeAll() + editContactViewModel.sipAddresses.append(String(addressCall.asStringUriOnly().dropFirst(4))) + editContactViewModel.sipAddresses.append("") + } + } + + } label: { + HStack { + Text(addressFriend != nil ? "See contact" : "Add to contacts") + Spacer() + Image(addressFriend != nil ? "user-circle" : "plus-circle") + .resizable() + .frame(width: 25, height: 25, alignment: .leading) + .padding(.all, 10) + } } } - - } label: { - HStack { - Text(addressFriend != nil ? "See contact" : "Add to contacts") - Spacer() - Image(addressFriend != nil ? "user-circle" : "plus-circle") + + Button { + isMenuOpen = false + + if historyViewModel.displayedCall != nil && historyViewModel.displayedCall!.dir == .Outgoing { + UIPasteboard.general.setValue( + historyViewModel.displayedCall!.toAddress!.asStringUriOnly().dropFirst(4), + forPasteboardType: UTType.plainText.identifier + ) + } else { + UIPasteboard.general.setValue( + historyViewModel.displayedCall!.fromAddress!.asStringUriOnly().dropFirst(4), + forPasteboardType: UTType.plainText.identifier + ) + } + + ToastViewModel.shared.toastMessage = "Success_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!.dir == .Outgoing { + historyListViewModel.callLogsAddressToDelete = historyViewModel.displayedCall!.toAddress!.asStringUriOnly() + } else { + historyListViewModel.callLogsAddressToDelete = historyViewModel.displayedCall!.fromAddress!.asStringUriOnly() + } + + 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) - Button { - isMenuOpen = false - - if historyViewModel.displayedCall != nil && historyViewModel.displayedCall!.dir == .Outgoing { - UIPasteboard.general.setValue( - historyViewModel.displayedCall!.toAddress!.asStringUriOnly().dropFirst(4), - forPasteboardType: UTType.plainText.identifier - ) - } else { - UIPasteboard.general.setValue( - historyViewModel.displayedCall!.fromAddress!.asStringUriOnly().dropFirst(4), - forPasteboardType: UTType.plainText.identifier - ) - } - - ToastViewModel.shared.toastMessage = "Success_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!.dir == .Outgoing { - historyListViewModel.callLogsAddressToDelete = historyViewModel.displayedCall!.toAddress!.asStringUriOnly() - } else { - historyListViewModel.callLogsAddressToDelete = historyViewModel.displayedCall!.fromAddress!.asStringUriOnly() - } - - 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) - } + ScrollView { VStack(spacing: 0) { - let fromAddressFriend = historyViewModel.displayedCall != nil ? contactsManager.getFriendWithAddress(address: historyViewModel.displayedCall!.fromAddress!) : nil - let toAddressFriend = historyViewModel.displayedCall != nil ? contactsManager.getFriendWithAddress(address: historyViewModel.displayedCall!.toAddress!) : nil - let addressFriend = historyViewModel.displayedCall != nil ? (historyViewModel.displayedCall!.dir == .Incoming ? fromAddressFriend : toAddressFriend) : nil - - if historyViewModel.displayedCall != nil - && addressFriend != nil - && addressFriend!.photo != nil - && !addressFriend!.photo!.isEmpty { - Avatar(contactAvatarModel: contactAvatarModel, avatarSize: 100) - } else if historyViewModel.displayedCall != nil { - if historyViewModel.displayedCall!.dir == .Outgoing && historyViewModel.displayedCall!.toAddress != nil { - if historyViewModel.displayedCall!.toAddress!.displayName != nil { - Image(uiImage: contactsManager.textToImage( - firstName: historyViewModel.displayedCall!.toAddress!.displayName!, - lastName: historyViewModel.displayedCall!.toAddress!.displayName!.components(separatedBy: " ").count > 1 - ? historyViewModel.displayedCall!.toAddress!.displayName!.components(separatedBy: " ")[1] - : "")) - .resizable() - .frame(width: 100, height: 100) - .clipShape(Circle()) - - Text(historyViewModel.displayedCall!.toAddress!.displayName!) - .foregroundStyle(Color.grayMain2c700) - .multilineTextAlignment(.center) - .default_text_style(styleSize: 14) - .frame(maxWidth: .infinity) - .padding(.top, 10) - - Text(historyViewModel.displayedCall!.toAddress!.asStringUriOnly()) - .foregroundStyle(Color.grayMain2c700) - .multilineTextAlignment(.center) - .default_text_style(styleSize: 14) - .frame(maxWidth: .infinity) - .padding(.top, 5) - - Text("") - .multilineTextAlignment(.center) - .default_text_style_300(styleSize: 12) - .frame(maxWidth: .infinity) - .frame(height: 20) - } else { - Image(uiImage: contactsManager.textToImage( - firstName: historyViewModel.displayedCall!.toAddress!.username ?? "Username Error", - lastName: historyViewModel.displayedCall!.toAddress!.username!.components(separatedBy: " ").count > 1 - ? historyViewModel.displayedCall!.toAddress!.username!.components(separatedBy: " ")[1] - : "")) - .resizable() - .frame(width: 100, height: 100) - .clipShape(Circle()) - - Text(historyViewModel.displayedCall!.toAddress!.username ?? "Username Error") - .foregroundStyle(Color.grayMain2c700) - .multilineTextAlignment(.center) - .default_text_style(styleSize: 14) - .frame(maxWidth: .infinity) - .padding(.top, 10) - - Text(historyViewModel.displayedCall!.toAddress!.asStringUriOnly()) - .foregroundStyle(Color.grayMain2c700) - .multilineTextAlignment(.center) - .default_text_style(styleSize: 14) - .frame(maxWidth: .infinity) - .padding(.top, 5) - - Text("") - .multilineTextAlignment(.center) - .default_text_style_300(styleSize: 12) - .frame(maxWidth: .infinity) - .frame(height: 20) - } - - } else if historyViewModel.displayedCall!.fromAddress != nil { - if historyViewModel.displayedCall!.fromAddress!.displayName != nil { - Image(uiImage: contactsManager.textToImage( - firstName: historyViewModel.displayedCall!.fromAddress!.displayName!, - lastName: historyViewModel.displayedCall!.fromAddress!.displayName!.components(separatedBy: " ").count > 1 - ? historyViewModel.displayedCall!.fromAddress!.displayName!.components(separatedBy: " ")[1] - : "")) - .resizable() - .frame(width: 100, height: 100) - .clipShape(Circle()) - - Text(historyViewModel.displayedCall!.fromAddress!.displayName!) - .foregroundStyle(Color.grayMain2c700) - .multilineTextAlignment(.center) - .default_text_style(styleSize: 14) - .frame(maxWidth: .infinity) - .padding(.top, 10) - - Text(historyViewModel.displayedCall!.fromAddress!.asStringUriOnly()) - .foregroundStyle(Color.grayMain2c700) - .multilineTextAlignment(.center) - .default_text_style(styleSize: 14) - .frame(maxWidth: .infinity) - .padding(.top, 5) - - Text("") - .multilineTextAlignment(.center) - .default_text_style_300(styleSize: 12) - .frame(maxWidth: .infinity) - .frame(height: 20) - } else { - Image(uiImage: contactsManager.textToImage( - firstName: historyViewModel.displayedCall!.fromAddress!.username ?? "Username Error", - lastName: historyViewModel.displayedCall!.fromAddress!.username!.components(separatedBy: " ").count > 1 - ? historyViewModel.displayedCall!.fromAddress!.username!.components(separatedBy: " ")[1] - : "")) - .resizable() - .frame(width: 100, height: 100) - .clipShape(Circle()) - - Text(historyViewModel.displayedCall!.fromAddress!.username ?? "Username Error") - .foregroundStyle(Color.grayMain2c700) - .multilineTextAlignment(.center) - .default_text_style(styleSize: 14) - .frame(maxWidth: .infinity) - .padding(.top, 10) - - Text(historyViewModel.displayedCall!.fromAddress!.asStringUriOnly()) - .foregroundStyle(Color.grayMain2c700) - .multilineTextAlignment(.center) - .default_text_style(styleSize: 14) - .frame(maxWidth: .infinity) - .padding(.top, 5) - - Text("") - .multilineTextAlignment(.center) - .default_text_style_300(styleSize: 12) - .frame(maxWidth: .infinity) - .frame(height: 20) - } + VStack(spacing: 0) { + if #unavailable(iOS 16.0) { + Rectangle() + .foregroundColor(Color.gray100) + .frame(height: 7) } - } - if historyViewModel.displayedCall != nil - && addressFriend != nil - && addressFriend!.name != nil { - Text((addressFriend!.name)!) - .foregroundStyle(Color.grayMain2c700) - .multilineTextAlignment(.center) - .default_text_style(styleSize: 14) - .frame(maxWidth: .infinity) - .padding(.top, 10) - - if historyViewModel.displayedCall!.dir == .Outgoing && historyViewModel.displayedCall!.toAddress != nil { - Text(historyViewModel.displayedCall!.toAddress!.asStringUriOnly()) - .foregroundStyle(Color.grayMain2c700) - .multilineTextAlignment(.center) - .default_text_style(styleSize: 14) - .frame(maxWidth: .infinity) - .padding(.top, 5) - } else if historyViewModel.displayedCall!.fromAddress != nil { - Text(historyViewModel.displayedCall!.fromAddress!.asStringUriOnly()) - .foregroundStyle(Color.grayMain2c700) - .multilineTextAlignment(.center) - .default_text_style(styleSize: 14) - .frame(maxWidth: .infinity) - .padding(.top, 5) - } - - 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) - } - } - .frame(minHeight: 150) - .frame(maxWidth: .infinity) - .padding(.top, 10) - .padding(.bottom, 2) - .background(Color.gray100) - - HStack { - Spacer() - - Button(action: { - if historyViewModel.displayedCall!.dir == .Outgoing && historyViewModel.displayedCall!.toAddress != nil { - telecomManager.doCallWithCore( - addr: historyViewModel.displayedCall!.toAddress!, isVideo: false, isConference: false - ) - } else if historyViewModel.displayedCall!.dir == .Incoming && historyViewModel.displayedCall!.fromAddress != nil { - telecomManager.doCallWithCore( - addr: historyViewModel.displayedCall!.fromAddress!, isVideo: false, isConference: false - ) - } - }, 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: { - - }, label: { - VStack { - HStack(alignment: .center) { - Image("chat-teardrop-text") - .renderingMode(.template) - .resizable() - //.foregroundStyle(Color.grayMain2c600) - .foregroundStyle(Color.grayMain2c300) - .frame(width: 25, height: 25) - .onTapGesture { - withAnimation { + VStack(spacing: 0) { + if historyViewModel.displayedCallIsConference.isEmpty { + let fromAddressFriend = historyViewModel.displayedCall != nil ? contactsManager.getFriendWithAddress(address: historyViewModel.displayedCall!.fromAddress!) : nil + let toAddressFriend = historyViewModel.displayedCall != nil ? contactsManager.getFriendWithAddress(address: historyViewModel.displayedCall!.toAddress!) : nil + let addressFriend = historyViewModel.displayedCall != nil ? (historyViewModel.displayedCall!.dir == .Incoming ? fromAddressFriend : toAddressFriend) : nil + + if historyViewModel.displayedCall != nil + && addressFriend != nil + && addressFriend!.photo != nil + && !addressFriend!.photo!.isEmpty { + Avatar(contactAvatarModel: contactAvatarModel, avatarSize: 100) + } else if historyViewModel.displayedCall != nil { + if historyViewModel.displayedCall!.dir == .Outgoing && historyViewModel.displayedCall!.toAddress != nil { + if historyViewModel.displayedCall!.toAddress!.displayName != nil { + Image(uiImage: contactsManager.textToImage( + firstName: historyViewModel.displayedCall!.toAddress!.displayName!, + lastName: historyViewModel.displayedCall!.toAddress!.displayName!.components(separatedBy: " ").count > 1 + ? historyViewModel.displayedCall!.toAddress!.displayName!.components(separatedBy: " ")[1] + : "")) + .resizable() + .frame(width: 100, height: 100) + .clipShape(Circle()) + Text(historyViewModel.displayedCall!.toAddress!.displayName!) + .foregroundStyle(Color.grayMain2c700) + .multilineTextAlignment(.center) + .default_text_style(styleSize: 14) + .frame(maxWidth: .infinity) + .padding(.top, 10) + + Text(historyViewModel.displayedCall!.toAddress!.asStringUriOnly()) + .foregroundStyle(Color.grayMain2c700) + .multilineTextAlignment(.center) + .default_text_style(styleSize: 14) + .frame(maxWidth: .infinity) + .padding(.top, 5) + + Text("") + .multilineTextAlignment(.center) + .default_text_style_300(styleSize: 12) + .frame(maxWidth: .infinity) + .frame(height: 20) + } else { + Image(uiImage: contactsManager.textToImage( + firstName: historyViewModel.displayedCall!.toAddress!.username ?? "Username Error", + lastName: historyViewModel.displayedCall!.toAddress!.username!.components(separatedBy: " ").count > 1 + ? historyViewModel.displayedCall!.toAddress!.username!.components(separatedBy: " ")[1] + : "")) + .resizable() + .frame(width: 100, height: 100) + .clipShape(Circle()) + + Text(historyViewModel.displayedCall!.toAddress!.username ?? "Username Error") + .foregroundStyle(Color.grayMain2c700) + .multilineTextAlignment(.center) + .default_text_style(styleSize: 14) + .frame(maxWidth: .infinity) + .padding(.top, 10) + + Text(historyViewModel.displayedCall!.toAddress!.asStringUriOnly()) + .foregroundStyle(Color.grayMain2c700) + .multilineTextAlignment(.center) + .default_text_style(styleSize: 14) + .frame(maxWidth: .infinity) + .padding(.top, 5) + + Text("") + .multilineTextAlignment(.center) + .default_text_style_300(styleSize: 12) + .frame(maxWidth: .infinity) + .frame(height: 20) + } + + } else if historyViewModel.displayedCall!.fromAddress != nil { + if historyViewModel.displayedCall!.fromAddress!.displayName != nil { + Image(uiImage: contactsManager.textToImage( + firstName: historyViewModel.displayedCall!.fromAddress!.displayName!, + lastName: historyViewModel.displayedCall!.fromAddress!.displayName!.components(separatedBy: " ").count > 1 + ? historyViewModel.displayedCall!.fromAddress!.displayName!.components(separatedBy: " ")[1] + : "")) + .resizable() + .frame(width: 100, height: 100) + .clipShape(Circle()) + + Text(historyViewModel.displayedCall!.fromAddress!.displayName!) + .foregroundStyle(Color.grayMain2c700) + .multilineTextAlignment(.center) + .default_text_style(styleSize: 14) + .frame(maxWidth: .infinity) + .padding(.top, 10) + + Text(historyViewModel.displayedCall!.fromAddress!.asStringUriOnly()) + .foregroundStyle(Color.grayMain2c700) + .multilineTextAlignment(.center) + .default_text_style(styleSize: 14) + .frame(maxWidth: .infinity) + .padding(.top, 5) + + Text("") + .multilineTextAlignment(.center) + .default_text_style_300(styleSize: 12) + .frame(maxWidth: .infinity) + .frame(height: 20) + } else { + Image(uiImage: contactsManager.textToImage( + firstName: historyViewModel.displayedCall!.fromAddress!.username ?? "Username Error", + lastName: historyViewModel.displayedCall!.fromAddress!.username!.components(separatedBy: " ").count > 1 + ? historyViewModel.displayedCall!.fromAddress!.username!.components(separatedBy: " ")[1] + : "")) + .resizable() + .frame(width: 100, height: 100) + .clipShape(Circle()) + + Text(historyViewModel.displayedCall!.fromAddress!.username ?? "Username Error") + .foregroundStyle(Color.grayMain2c700) + .multilineTextAlignment(.center) + .default_text_style(styleSize: 14) + .frame(maxWidth: .infinity) + .padding(.top, 10) + + Text(historyViewModel.displayedCall!.fromAddress!.asStringUriOnly()) + .foregroundStyle(Color.grayMain2c700) + .multilineTextAlignment(.center) + .default_text_style(styleSize: 14) + .frame(maxWidth: .infinity) + .padding(.top, 5) + + Text("") + .multilineTextAlignment(.center) + .default_text_style_300(styleSize: 12) + .frame(maxWidth: .infinity) + .frame(height: 20) } } + } + + if historyViewModel.displayedCall != nil + && addressFriend != nil + && addressFriend!.name != nil { + Text((addressFriend!.name)!) + .foregroundStyle(Color.grayMain2c700) + .multilineTextAlignment(.center) + .default_text_style(styleSize: 14) + .frame(maxWidth: .infinity) + .padding(.top, 10) + + if historyViewModel.displayedCall!.dir == .Outgoing && historyViewModel.displayedCall!.toAddress != nil { + Text(historyViewModel.displayedCall!.toAddress!.asStringUriOnly()) + .foregroundStyle(Color.grayMain2c700) + .multilineTextAlignment(.center) + .default_text_style(styleSize: 14) + .frame(maxWidth: .infinity) + .padding(.top, 5) + } else if historyViewModel.displayedCall!.fromAddress != nil { + Text(historyViewModel.displayedCall!.fromAddress!.asStringUriOnly()) + .foregroundStyle(Color.grayMain2c700) + .multilineTextAlignment(.center) + .default_text_style(styleSize: 14) + .frame(maxWidth: .infinity) + .padding(.top, 5) + } + + 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 { + 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.displayedCallIsConference ?? "") + .foregroundStyle(Color.grayMain2c700) + .multilineTextAlignment(.center) + .default_text_style(styleSize: 14) + .frame(maxWidth: .infinity) + .padding(.top, 10) } - .padding(16) - .background(Color.grayMain2c200) - .cornerRadius(40) - - Text("Message") - .default_text_style(styleSize: 14) - .frame(minWidth: 80) } - }) - - Spacer() - - Button(action: { - if historyViewModel.displayedCall!.dir == .Outgoing && historyViewModel.displayedCall!.toAddress != nil { - telecomManager.doCallWithCore( - addr: historyViewModel.displayedCall!.toAddress!, isVideo: true, isConference: false - ) - } else if historyViewModel.displayedCall!.dir == .Incoming && historyViewModel.displayedCall!.fromAddress != nil { - telecomManager.doCallWithCore( - addr: historyViewModel.displayedCall!.fromAddress!, isVideo: true, isConference: false - ) - } - }, 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) - } - }) - - Spacer() - } - .padding(.top, 20) - .padding(.bottom, 10) - .frame(maxWidth: .infinity) - .background(Color.gray100) - - VStack(spacing: 0) { - - let addressFriend = historyViewModel.displayedCall != nil - ? (historyViewModel.displayedCall!.dir == .Incoming ? historyViewModel.displayedCall!.fromAddress!.asStringUriOnly() - : historyViewModel.displayedCall!.toAddress!.asStringUriOnly()) : nil - - let callLogsFilter = historyListViewModel.callLogs.filter({ $0.dir == .Incoming - ? $0.fromAddress!.asStringUriOnly() == addressFriend - : $0.toAddress!.asStringUriOnly() == addressFriend }) - - ForEach(0.. 1 - ? historyListViewModel.callLogs[index].toAddress!.displayName!.components(separatedBy: " ")[1] - : "")) - .resizable() - .frame(width: 45, height: 45) - .clipShape(Circle()) - - } else { - Image(uiImage: contactsManager.textToImage( - firstName: historyListViewModel.callLogs[index].toAddress!.username ?? "Username Error", - lastName: historyListViewModel.callLogs[index].toAddress!.username!.components(separatedBy: " ").count > 1 - ? historyListViewModel.callLogs[index].toAddress!.username!.components(separatedBy: " ")[1] - : "")) - .resizable() - .frame(width: 45, height: 45) - .clipShape(Circle()) - } - - } else if historyListViewModel.callLogs[index].fromAddress != nil { - if historyListViewModel.callLogs[index].fromAddress!.displayName != nil { - Image(uiImage: contactsManager.textToImage( - firstName: historyListViewModel.callLogs[index].fromAddress!.displayName!, - lastName: historyListViewModel.callLogs[index].fromAddress!.displayName!.components(separatedBy: " ").count > 1 - ? historyListViewModel.callLogs[index].fromAddress!.displayName!.components(separatedBy: " ")[1] - : "")) - .resizable() - .frame(width: 45, height: 45) - .clipShape(Circle()) - } else { - Image(uiImage: contactsManager.textToImage( - firstName: historyListViewModel.callLogs[index].fromAddress!.username ?? "Username Error", - lastName: historyListViewModel.callLogs[index].fromAddress!.username!.components(separatedBy: " ").count > 1 - ? historyListViewModel.callLogs[index].fromAddress!.username!.components(separatedBy: " ")[1] - : "")) - .resizable() - .frame(width: 45, height: 45) - .clipShape(Circle()) - } - } else { - Image("profil-picture-default") - .resizable() - .frame(width: 45, height: 45) - .clipShape(Circle()) - } - } - - VStack(spacing: 0) { - Spacer() - + if historyListViewModel.callLogsIsConference[index].isEmpty { let fromAddressFriend = contactsManager.getFriendWithAddress(address: historyListViewModel.callLogs[index].fromAddress!) let toAddressFriend = contactsManager.getFriendWithAddress(address: historyListViewModel.callLogs[index].toAddress!) let addressFriend = historyListViewModel.callLogs[index].dir == .Incoming ? fromAddressFriend : toAddressFriend - if addressFriend != nil { - Text(addressFriend!.name!) - .default_text_style(styleSize: 14) - .frame(maxWidth: .infinity, alignment: .leading) - .lineLimit(1) + let contactAvatarModel = addressFriend != nil + ? ContactsManager.shared.avatarListModel.first(where: { + ($0.friend!.consolidatedPresence == .Online || $0.friend!.consolidatedPresence == .Busy) + && $0.friend!.name == addressFriend!.name + && $0.friend!.address!.asStringUriOnly() == addressFriend!.address!.asStringUriOnly() + }) + : ContactAvatarModel(friend: nil, name: "", withPresence: false) + + if addressFriend != nil && addressFriend!.photo != nil && !addressFriend!.photo!.isEmpty { + if contactAvatarModel != nil { + Avatar(contactAvatarModel: contactAvatarModel!, avatarSize: 50) + } else { + Image("profil-picture-default") + .resizable() + .frame(width: 45, height: 45) + .clipShape(Circle()) + } } else { if historyListViewModel.callLogs[index].dir == .Outgoing && historyListViewModel.callLogs[index].toAddress != nil { - Text(historyListViewModel.callLogs[index].toAddress!.displayName != nil - ? historyListViewModel.callLogs[index].toAddress!.displayName! - : historyListViewModel.callLogs[index].toAddress!.username!) + if historyListViewModel.callLogs[index].toAddress!.displayName != nil { + Image(uiImage: contactsManager.textToImage( + firstName: historyListViewModel.callLogs[index].toAddress!.displayName!, + lastName: historyListViewModel.callLogs[index].toAddress!.displayName!.components(separatedBy: " ").count > 1 + ? historyListViewModel.callLogs[index].toAddress!.displayName!.components(separatedBy: " ")[1] + : "")) + .resizable() + .frame(width: 45, height: 45) + .clipShape(Circle()) + + } else { + Image(uiImage: contactsManager.textToImage( + firstName: historyListViewModel.callLogs[index].toAddress!.username ?? "Username Error", + lastName: historyListViewModel.callLogs[index].toAddress!.username!.components(separatedBy: " ").count > 1 + ? historyListViewModel.callLogs[index].toAddress!.username!.components(separatedBy: " ")[1] + : "")) + .resizable() + .frame(width: 45, height: 45) + .clipShape(Circle()) + } + + } else if historyListViewModel.callLogs[index].fromAddress != nil { + if historyListViewModel.callLogs[index].fromAddress!.displayName != nil { + Image(uiImage: contactsManager.textToImage( + firstName: historyListViewModel.callLogs[index].fromAddress!.displayName!, + lastName: historyListViewModel.callLogs[index].fromAddress!.displayName!.components(separatedBy: " ").count > 1 + ? historyListViewModel.callLogs[index].fromAddress!.displayName!.components(separatedBy: " ")[1] + : "")) + .resizable() + .frame(width: 45, height: 45) + .clipShape(Circle()) + } else { + Image(uiImage: contactsManager.textToImage( + firstName: historyListViewModel.callLogs[index].fromAddress!.username ?? "Username Error", + lastName: historyListViewModel.callLogs[index].fromAddress!.username!.components(separatedBy: " ").count > 1 + ? historyListViewModel.callLogs[index].fromAddress!.username!.components(separatedBy: " ")[1] + : "")) + .resizable() + .frame(width: 45, height: 45) + .clipShape(Circle()) + } + } else { + Image("profil-picture-default") + .resizable() + .frame(width: 45, height: 45) + .clipShape(Circle()) + } + } + } else { + VStack { + Image("users-three-square") + .renderingMode(.template) + .resizable() + .frame(width: 28, height: 28) + .foregroundStyle(Color.grayMain2c600) + } + .frame(width: 45, height: 45) + .background(Color.grayMain2c200) + .clipShape(Circle()) + } + + VStack(spacing: 0) { + Spacer() + if historyListViewModel.callLogsIsConference[index].isEmpty { + let fromAddressFriend = contactsManager.getFriendWithAddress(address: historyListViewModel.callLogs[index].fromAddress!) + let toAddressFriend = contactsManager.getFriendWithAddress(address: historyListViewModel.callLogs[index].toAddress!) + let addressFriend = historyListViewModel.callLogs[index].dir == .Incoming ? fromAddressFriend : toAddressFriend + + if addressFriend != nil { + Text(addressFriend!.name!) .default_text_style(styleSize: 14) .frame(maxWidth: .infinity, alignment: .leading) .lineLimit(1) - } else if historyListViewModel.callLogs[index].fromAddress != nil { - Text(historyListViewModel.callLogs[index].fromAddress!.displayName != nil - ? historyListViewModel.callLogs[index].fromAddress!.displayName! - : historyListViewModel.callLogs[index].fromAddress!.username!) - .default_text_style(styleSize: 14) - .frame(maxWidth: .infinity, alignment: .leading) - .lineLimit(1) + } else { + if historyListViewModel.callLogs[index].dir == .Outgoing && historyListViewModel.callLogs[index].toAddress != nil { + Text(historyListViewModel.callLogs[index].toAddress!.displayName != nil + ? historyListViewModel.callLogs[index].toAddress!.displayName! + : historyListViewModel.callLogs[index].toAddress!.username!) + .default_text_style(styleSize: 14) + .frame(maxWidth: .infinity, alignment: .leading) + .lineLimit(1) + } else if historyListViewModel.callLogs[index].fromAddress != nil { + Text(historyListViewModel.callLogs[index].fromAddress!.displayName != nil + ? historyListViewModel.callLogs[index].fromAddress!.displayName! + : historyListViewModel.callLogs[index].fromAddress!.username!) + .default_text_style(styleSize: 14) + .frame(maxWidth: .infinity, alignment: .leading) + .lineLimit(1) + } } + } else { + Text(historyListViewModel.callLogsIsConference[index]) + .default_text_style(styleSize: 14) + .frame(maxWidth: .infinity, alignment: .leading) + .lineLimit(1) } + HStack { Image(historyListViewModel.getCallIconResId(callStatus: historyListViewModel.callLogs[index].status, callDir: historyListViewModel.callLogs[index].dir)) - .resizable() - .frame( - width: historyListViewModel.getCallIconResId(callStatus: historyListViewModel.callLogs[index].status, callDir: historyListViewModel.callLogs[index].dir).contains("rejected") ? 12 : 8, - height: historyListViewModel.getCallIconResId(callStatus: historyListViewModel.callLogs[index].status, callDir: historyListViewModel.callLogs[index].dir).contains("rejected") ? 6 : 8) + .resizable() + .frame( + width: historyListViewModel.getCallIconResId(callStatus: historyListViewModel.callLogs[index].status, callDir: historyListViewModel.callLogs[index].dir).contains("rejected") ? 12 : 8, + height: historyListViewModel.getCallIconResId(callStatus: historyListViewModel.callLogs[index].status, callDir: historyListViewModel.callLogs[index].dir).contains("rejected") ? 6 : 8) Text(historyListViewModel.getCallTime(startDate: historyListViewModel.callLogs[index].startDate)) - .default_text_style_300(styleSize: 12) - .frame(maxWidth: .infinity, alignment: .leading) + .default_text_style_300(styleSize: 12) + .frame(maxWidth: .infinity, alignment: .leading) Spacer() } @@ -156,20 +176,22 @@ struct HistoryListFragment: View { Spacer() } - 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 + if historyListViewModel.callLogsIsConference[index].isEmpty { + 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 + } } - } - ) + ) + } } } .buttonStyle(.borderless) @@ -179,6 +201,7 @@ struct HistoryListFragment: View { .onTapGesture { withAnimation { historyViewModel.displayedCall = historyListViewModel.callLogs[index] + historyViewModel.getConferenceSubject() } } .onLongPressGesture(minimumDuration: 0.2) { diff --git a/Linphone/UI/Main/History/ViewModel/HistoryListViewModel.swift b/Linphone/UI/Main/History/ViewModel/HistoryListViewModel.swift index cf9132158..480c3c3f2 100644 --- a/Linphone/UI/Main/History/ViewModel/HistoryListViewModel.swift +++ b/Linphone/UI/Main/History/ViewModel/HistoryListViewModel.swift @@ -25,6 +25,7 @@ class HistoryListViewModel: ObservableObject { private var coreContext = CoreContext.shared @Published var callLogs: [CallLog] = [] + @Published var callLogsIsConference: [String] = [] var callLogsTmp: [CallLog] = [] var callLogsAddressToDelete = "" @@ -42,28 +43,46 @@ class HistoryListViewModel: ObservableObject { let account = core.defaultAccount let logs = account?.callLogs != nil ? account!.callLogs : core.callLogs + var callLogsBis: [CallLog] = [] + var callLogsIsConferenceBis: [String] = [] + var callLogsTmpBis: [CallLog] = [] + + logs.forEach { log in + callLogsBis.append(log) + callLogsIsConferenceBis.append(log.conferenceInfo != nil && log.conferenceInfo!.subject != nil ? log.conferenceInfo!.subject! : "") + callLogsTmpBis.append(log) + } + DispatchQueue.main.async { self.callLogs.removeAll() self.callLogsTmp.removeAll() - logs.forEach { log in - self.callLogs.append(log) - self.callLogsTmp.append(log) - } + self.callLogs = callLogsBis + self.callLogsIsConference = callLogsIsConferenceBis + self.callLogsTmp = callLogsTmpBis } self.callLogSubscription = core.publisher?.onCallLogUpdated?.postOnCoreQueue { (_: (_: Core, _: CallLog)) in let account = core.defaultAccount - let logs = account != nil ? account!.callLogs : core.callLogs + let logs = account?.callLogs != nil ? account!.callLogs : core.callLogs + + var callLogsBis: [CallLog] = [] + var callLogsIsConferenceBis: [String] = [] + var callLogsTmpBis: [CallLog] = [] + + logs.forEach { log in + callLogsBis.append(log) + callLogsIsConferenceBis.append(log.conferenceInfo != nil && log.conferenceInfo!.subject != nil ? log.conferenceInfo!.subject! : "") + callLogsTmpBis.append(log) + } DispatchQueue.main.async { self.callLogs.removeAll() self.callLogsTmp.removeAll() - logs.forEach { log in - self.callLogs.append(log) - self.callLogsTmp.append(log) - } + self.callLogs = callLogsBis + self.callLogsIsConference = callLogsIsConferenceBis + self.callLogsTmp = callLogsTmpBis } self.updateMissedCallsCount() diff --git a/Linphone/UI/Main/History/ViewModel/HistoryViewModel.swift b/Linphone/UI/Main/History/ViewModel/HistoryViewModel.swift index 02425274d..cfbdf6d27 100644 --- a/Linphone/UI/Main/History/ViewModel/HistoryViewModel.swift +++ b/Linphone/UI/Main/History/ViewModel/HistoryViewModel.swift @@ -23,8 +23,22 @@ import linphonesw class HistoryViewModel: ObservableObject { @Published var displayedCall: CallLog? + @Published var displayedCallIsConference: String = "" var selectedCall: CallLog? init() {} + + func getConferenceSubject() { + CoreContext.shared.doOnCoreQueue { core in + var displayedCallIsConferenceTmp = "" + if self.displayedCall?.conferenceInfo != nil { + displayedCallIsConferenceTmp = self.displayedCall?.conferenceInfo?.subject ?? "" + } + + DispatchQueue.main.async { + self.displayedCallIsConference = displayedCallIsConferenceTmp + } + } + } } From dd7661e851ec5124b567d704ba2c4f6452881754 Mon Sep 17 00:00:00 2001 From: QuentinArguillere Date: Fri, 22 Mar 2024 12:53:52 +0100 Subject: [PATCH 164/486] Add Meetings related files for (models, viewmodels, assets...) --- Linphone.xcodeproj/project.pbxproj | 48 +++++++++++++++++++ .../meetings.imageset/Contents.json | 21 ++++++++ .../meetings.imageset/meetings.svg | 8 ++++ .../Main/Meetings/Models/MeetingModel.swift | 8 ++++ .../Models/MeetingsListItemModel.swift | 8 ++++ .../Meetings/ViewModel/MeetingViewModel.swift | 8 ++++ .../ViewModel/MeetingsListViewModel.swift | 8 ++++ 7 files changed, 109 insertions(+) create mode 100644 Linphone/Assets.xcassets/meetings.imageset/Contents.json create mode 100644 Linphone/Assets.xcassets/meetings.imageset/meetings.svg create mode 100644 Linphone/UI/Main/Meetings/Models/MeetingModel.swift create mode 100644 Linphone/UI/Main/Meetings/Models/MeetingsListItemModel.swift create mode 100644 Linphone/UI/Main/Meetings/ViewModel/MeetingViewModel.swift create mode 100644 Linphone/UI/Main/Meetings/ViewModel/MeetingsListViewModel.swift diff --git a/Linphone.xcodeproj/project.pbxproj b/Linphone.xcodeproj/project.pbxproj index 16879d82e..53cd58f77 100644 --- a/Linphone.xcodeproj/project.pbxproj +++ b/Linphone.xcodeproj/project.pbxproj @@ -19,6 +19,10 @@ 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 */; }; + 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 */; }; + 66E56BD22BA9A25B006CE56F /* MeetingViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 66E56BD12BA9A25B006CE56F /* MeetingViewModel.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 */; }; @@ -160,6 +164,10 @@ 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 = ""; }; + 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 = ""; }; + 66E56BD12BA9A25B006CE56F /* MeetingViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeetingViewModel.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 = ""; }; @@ -312,6 +320,41 @@ path = Extensions; sourceTree = ""; }; + 66E56BC52BA45E49006CE56F /* Meetings */ = { + isa = PBXGroup; + children = ( + 66E56BC62BA49938006CE56F /* Fragments */, + 66E56BCA2BA9A1A0006CE56F /* Models */, + 66E56BC72BA4993E006CE56F /* ViewModel */, + ); + path = Meetings; + sourceTree = ""; + }; + 66E56BC62BA49938006CE56F /* Fragments */ = { + isa = PBXGroup; + children = ( + ); + path = Fragments; + sourceTree = ""; + }; + 66E56BC72BA4993E006CE56F /* ViewModel */ = { + isa = PBXGroup; + children = ( + 66E56BC82BA4A6D7006CE56F /* MeetingsListViewModel.swift */, + 66E56BD12BA9A25B006CE56F /* MeetingViewModel.swift */, + ); + path = ViewModel; + sourceTree = ""; + }; + 66E56BCA2BA9A1A0006CE56F /* Models */ = { + isa = PBXGroup; + children = ( + 66E56BCB2BA9A1E0006CE56F /* MeetingsListItemModel.swift */, + 66E56BCD2BA9A1F8006CE56F /* MeetingModel.swift */, + ); + path = Models; + sourceTree = ""; + }; A31AF2AB8C6A3D7B7EA3B424 /* Pods */ = { isa = PBXGroup; children = ( @@ -415,6 +458,7 @@ D7A03FBB2ACC2D850081A588 /* Contacts */, D74C9CFD2ACAEC150021626A /* Fragments */, D7A03FBE2ACC2E010081A588 /* History */, + 66E56BC52BA45E49006CE56F /* Meetings */, D7A2EDD42AC180FE005D90FC /* Viewmodel */, D719ABB82ABC67BF00B41C10 /* ContentView.swift */, ); @@ -909,6 +953,7 @@ D719ABB72ABC67BF00B41C10 /* LinphoneApp.swift in Sources */, D732A91B2B061BD900DB42BA /* HistoryListBottomSheet.swift in Sources */, D72250632ADE9615008FB426 /* HistoryViewModel.swift in Sources */, + 66E56BC92BA4A6D7006CE56F /* MeetingsListViewModel.swift in Sources */, D726E4392B16440C0083C415 /* ContactAvatarModel.swift in Sources */, D76005F62B0798B00054B79A /* IntExtension.swift in Sources */, D7E6D0512AEBDBD500A57AAF /* ContactsListBottomSheet.swift in Sources */, @@ -920,9 +965,11 @@ D748BF2E2ACD82E7004844EB /* ThirdPartySipAccountWarningFragment.swift in Sources */, D7173EBE2B7A5C0A00BCC481 /* LinphoneUtils.swift in Sources */, 66C492012B24DB6900CEA16D /* Log.swift in Sources */, + 66E56BD22BA9A25B006CE56F /* MeetingViewModel.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 */, @@ -935,6 +982,7 @@ 66C491FB2B24D32600CEA16D /* CoreExtension.swift in Sources */, D72A9A052B9750A1000DC093 /* UIList.swift in Sources */, D726E43D2B19E4FE0083C415 /* StartCallFragment.swift in Sources */, + 66E56BCC2BA9A1E0006CE56F /* MeetingsListItemModel.swift in Sources */, D7B99E9B2B29F7C300BE7BF2 /* ActivityIndicator.swift in Sources */, D72343302ACEFEF8009AA24E /* QrCodeScannerFragment.swift in Sources */, D726E43F2B19E56F0083C415 /* StartCallViewModel.swift in Sources */, 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/UI/Main/Meetings/Models/MeetingModel.swift b/Linphone/UI/Main/Meetings/Models/MeetingModel.swift new file mode 100644 index 000000000..f64fbfd6f --- /dev/null +++ b/Linphone/UI/Main/Meetings/Models/MeetingModel.swift @@ -0,0 +1,8 @@ +// +// MeetingModel.swift +// Linphone +// +// Created by QuentinArguillere on 19/03/2024. +// + +import Foundation diff --git a/Linphone/UI/Main/Meetings/Models/MeetingsListItemModel.swift b/Linphone/UI/Main/Meetings/Models/MeetingsListItemModel.swift new file mode 100644 index 000000000..66b491d73 --- /dev/null +++ b/Linphone/UI/Main/Meetings/Models/MeetingsListItemModel.swift @@ -0,0 +1,8 @@ +// +// MeetingsListItemModel.swift +// Linphone +// +// Created by QuentinArguillere on 19/03/2024. +// + +import Foundation diff --git a/Linphone/UI/Main/Meetings/ViewModel/MeetingViewModel.swift b/Linphone/UI/Main/Meetings/ViewModel/MeetingViewModel.swift new file mode 100644 index 000000000..9ae230c9f --- /dev/null +++ b/Linphone/UI/Main/Meetings/ViewModel/MeetingViewModel.swift @@ -0,0 +1,8 @@ +// +// MeetingViewModel.swift +// Linphone +// +// Created by QuentinArguillere on 19/03/2024. +// + +import Foundation diff --git a/Linphone/UI/Main/Meetings/ViewModel/MeetingsListViewModel.swift b/Linphone/UI/Main/Meetings/ViewModel/MeetingsListViewModel.swift new file mode 100644 index 000000000..30ba3e959 --- /dev/null +++ b/Linphone/UI/Main/Meetings/ViewModel/MeetingsListViewModel.swift @@ -0,0 +1,8 @@ +// +// MeetingsListViewModel.swift +// Linphone +// +// Created by QuentinArguillere on 15/03/2024. +// + +import Foundation From ee735ceace023cc76abfe6675ff5d2fb0317a88a Mon Sep 17 00:00:00 2001 From: QuentinArguillere Date: Fri, 22 Mar 2024 12:54:06 +0100 Subject: [PATCH 165/486] Implement meetingmodel --- .../Main/Meetings/Models/MeetingModel.swift | 51 ++++++++++++++++++- 1 file changed, 50 insertions(+), 1 deletion(-) diff --git a/Linphone/UI/Main/Meetings/Models/MeetingModel.swift b/Linphone/UI/Main/Meetings/Models/MeetingModel.swift index f64fbfd6f..d7546fe0a 100644 --- a/Linphone/UI/Main/Meetings/Models/MeetingModel.swift +++ b/Linphone/UI/Main/Meetings/Models/MeetingModel.swift @@ -5,4 +5,53 @@ // Created by QuentinArguillere on 19/03/2024. // -import Foundation +import linphonesw + +class MeetingModel: ObservableObject { + + private var confInfo: ConferenceInfo + var id: String + var meetingDate: 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 + var month: String + + @Published var isBroadcast: Bool + @Published var subject: String + // @Published var firstMeetingOfTheDay: Bool + + 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) + let endDate = meetingDate + Double(confInfo.duration * 60) // confInfo.duration is in minutes + endTime = formatter.string(from: endDate) + time = "\(startTime) - \(endTime)" + + day = meetingDate.formatted(Date.FormatStyle().weekday(.abbreviated)) + dayNumber = meetingDate.formatted(Date.FormatStyle().day(.twoDigits)) + month = meetingDate.formatted(Date.FormatStyle().month(.wide)) // February + + 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 ?? "" + } +} From 5f22d7e4733963f7a8cb24981aa2bf2960fc6b41 Mon Sep 17 00:00:00 2001 From: QuentinArguillere Date: Fri, 22 Mar 2024 18:41:24 +0100 Subject: [PATCH 166/486] Implementing MeetingsListItemModel and MeetingsListViewModel --- .../Main/Meetings/Models/MeetingModel.swift | 2 +- .../Models/MeetingsListItemModel.swift | 15 ++- .../ViewModel/MeetingsListViewModel.swift | 99 +++++++++++++++++-- 3 files changed, 108 insertions(+), 8 deletions(-) diff --git a/Linphone/UI/Main/Meetings/Models/MeetingModel.swift b/Linphone/UI/Main/Meetings/Models/MeetingModel.swift index d7546fe0a..1632709de 100644 --- a/Linphone/UI/Main/Meetings/Models/MeetingModel.swift +++ b/Linphone/UI/Main/Meetings/Models/MeetingModel.swift @@ -24,7 +24,7 @@ class MeetingModel: ObservableObject { @Published var isBroadcast: Bool @Published var subject: String - // @Published var firstMeetingOfTheDay: Bool + @Published var firstMeetingOfTheDay: Bool = false init(conferenceInfo: ConferenceInfo) { confInfo = conferenceInfo diff --git a/Linphone/UI/Main/Meetings/Models/MeetingsListItemModel.swift b/Linphone/UI/Main/Meetings/Models/MeetingsListItemModel.swift index 66b491d73..b56bc3291 100644 --- a/Linphone/UI/Main/Meetings/Models/MeetingsListItemModel.swift +++ b/Linphone/UI/Main/Meetings/Models/MeetingsListItemModel.swift @@ -4,5 +4,18 @@ // // Created by QuentinArguillere on 19/03/2024. // - import Foundation + +class MeetingsListItemModel { + let model: MeetingModel? // if NIL, consider that we are using the fake TodayModel + var month: String = Date.now.formatted(Date.FormatStyle().month(.wide)) + var isToday = true + + init(meetingModel: MeetingModel?) { + model = meetingModel + if let mod = meetingModel { + month = mod.month + isToday = false + } + } +} diff --git a/Linphone/UI/Main/Meetings/ViewModel/MeetingsListViewModel.swift b/Linphone/UI/Main/Meetings/ViewModel/MeetingsListViewModel.swift index 30ba3e959..840571a4e 100644 --- a/Linphone/UI/Main/Meetings/ViewModel/MeetingsListViewModel.swift +++ b/Linphone/UI/Main/Meetings/ViewModel/MeetingsListViewModel.swift @@ -1,8 +1,95 @@ -// -// MeetingsListViewModel.swift -// Linphone -// -// Created by QuentinArguillere on 15/03/2024. -// +/* + * 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 { + + private var coreContext = CoreContext.shared + private var mCoreSuscriptions = Set() + var selectedMeeting: ConversationModel? + + @Published var meetingsList: [MeetingsListItemModel] = [] + + init() { + computeMeetingsList(filter: "") + } + + func computeMeetingsList(filter: String) { + 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 previousModel: MeetingModel? = nil + var meetingForTodayFound = false + + 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) + let firstMeetingOfTheDay = (previousModel != nil) ? previousModel?.day != model.day || previousModel?.dayNumber != model.dayNumber : true + model.firstMeetingOfTheDay = firstMeetingOfTheDay + + // Insert "Today" fake model before the first one of today + if firstMeetingOfTheDay && model.isToday { + meetingsListTmp.append(MeetingsListItemModel(meetingModel: nil)) + meetingForTodayFound = true + } + + // If no meeting was found for today, insert "Today" fake model before the next meeting to come + if !meetingForTodayFound && model.isAfterToday { + meetingsListTmp.append(MeetingsListItemModel(meetingModel: nil)) + meetingForTodayFound = true + } + + meetingsListTmp.append(MeetingsListItemModel(meetingModel: model)) + previousModel = model + } + } + + // If no meeting was found after today, insert "Today" fake model at the end + if !meetingForTodayFound { + meetingsListTmp.append(MeetingsListItemModel(meetingModel: nil)) + } + + self.meetingsList = meetingsListTmp + } + } + + +} From 550859d51dca58591aa6107179cf8b49ee848b1e Mon Sep 17 00:00:00 2001 From: QuentinArguillere Date: Tue, 26 Mar 2024 11:49:26 +0100 Subject: [PATCH 167/486] Start implementing ScheduleMeetingViewModel and fragments, update pbxproj --- Linphone.xcodeproj/project.pbxproj | 20 ++++- .../Meetings/Fragments/MeetingFragment.swift | 35 ++++++++ .../Fragments/MeetingsListFragment.swift | 42 +++++++++ .../Main/Meetings/Models/MeetingModel.swift | 2 +- .../Meetings/ViewModel/MeetingViewModel.swift | 28 ++++-- .../ViewModel/ScheduleMeetingViewModel.swift | 88 +++++++++++++++++++ 6 files changed, 204 insertions(+), 11 deletions(-) create mode 100644 Linphone/UI/Main/Meetings/Fragments/MeetingFragment.swift create mode 100644 Linphone/UI/Main/Meetings/Fragments/MeetingsListFragment.swift create mode 100644 Linphone/UI/Main/Meetings/ViewModel/ScheduleMeetingViewModel.swift diff --git a/Linphone.xcodeproj/project.pbxproj b/Linphone.xcodeproj/project.pbxproj index 53cd58f77..b287e6ada 100644 --- a/Linphone.xcodeproj/project.pbxproj +++ b/Linphone.xcodeproj/project.pbxproj @@ -9,6 +9,10 @@ /* Begin PBXBuildFile section */ 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 */; }; + 6613A0B62BAEBE5C008923A4 /* ScheduleMeetingViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6613A0B52BAEBE5C008923A4 /* ScheduleMeetingViewModel.swift */; }; 662B69D92B25DE18007118BF /* TelecomManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 662B69D82B25DE18007118BF /* TelecomManager.swift */; }; 662B69DB2B25DE25007118BF /* ProviderDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 662B69DA2B25DE25007118BF /* ProviderDelegate.swift */; }; 667E5D7F2B8E430C00EBCFC4 /* linphonerc-factory in Resources */ = {isa = PBXBuildFile; fileRef = D732A90B2B0376F500DB42BA /* linphonerc-factory */; }; @@ -22,7 +26,6 @@ 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 */; }; - 66E56BD22BA9A25B006CE56F /* MeetingViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 66E56BD12BA9A25B006CE56F /* MeetingViewModel.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 */; }; @@ -155,6 +158,10 @@ 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 = ""; }; + 6613A0B52BAEBE5C008923A4 /* ScheduleMeetingViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScheduleMeetingViewModel.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 = ""; }; 667E5D802B8E444D00EBCFC4 /* GoogleService-Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = "GoogleService-Info.plist"; sourceTree = ""; }; @@ -167,7 +174,6 @@ 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 = ""; }; - 66E56BD12BA9A25B006CE56F /* MeetingViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeetingViewModel.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 = ""; }; @@ -333,6 +339,8 @@ 66E56BC62BA49938006CE56F /* Fragments */ = { isa = PBXGroup; children = ( + 6613A0AD2BAEB7DF008923A4 /* MeetingFragment.swift */, + 6613A0AF2BAEB7F4008923A4 /* MeetingsListFragment.swift */, ); path = Fragments; sourceTree = ""; @@ -341,7 +349,8 @@ isa = PBXGroup; children = ( 66E56BC82BA4A6D7006CE56F /* MeetingsListViewModel.swift */, - 66E56BD12BA9A25B006CE56F /* MeetingViewModel.swift */, + 6613A0B32BAEBE3F008923A4 /* MeetingViewModel.swift */, + 6613A0B52BAEBE5C008923A4 /* ScheduleMeetingViewModel.swift */, ); path = ViewModel; sourceTree = ""; @@ -938,6 +947,7 @@ D7F4D9CB2B5FD27200CDCD76 /* CallsListFragment.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 */, D73449992BC6932A00778C56 /* MeetingWaitingRoomFragment.swift in Sources */, @@ -955,6 +965,7 @@ D72250632ADE9615008FB426 /* HistoryViewModel.swift in Sources */, 66E56BC92BA4A6D7006CE56F /* MeetingsListViewModel.swift in Sources */, D726E4392B16440C0083C415 /* ContactAvatarModel.swift in Sources */, + 6613A0B02BAEB7F4008923A4 /* MeetingsListFragment.swift in Sources */, D76005F62B0798B00054B79A /* IntExtension.swift in Sources */, D7E6D0512AEBDBD500A57AAF /* ContactsListBottomSheet.swift in Sources */, D75759322B56D40900E7AC10 /* ZRTPPopup.swift in Sources */, @@ -963,9 +974,10 @@ 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 */, - 66E56BD22BA9A25B006CE56F /* MeetingViewModel.swift in Sources */, + 6613A0B62BAEBE5C008923A4 /* ScheduleMeetingViewModel.swift in Sources */, D748BF2C2ACD82D2004844EB /* ThirdPartySipAccountLoginFragment.swift in Sources */, D7CEE0382B7A214F00FD79B7 /* ConversationsListViewModel.swift in Sources */, D74C9CF82ACACECE0021626A /* WelcomePage1Fragment.swift in Sources */, diff --git a/Linphone/UI/Main/Meetings/Fragments/MeetingFragment.swift b/Linphone/UI/Main/Meetings/Fragments/MeetingFragment.swift new file mode 100644 index 000000000..cce87d668 --- /dev/null +++ b/Linphone/UI/Main/Meetings/Fragments/MeetingFragment.swift @@ -0,0 +1,35 @@ +/* + * 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 MeetingFragment: View { + + @ObservedObject var meetingViewModel: MeetingViewModel + + var body: some View { + ZStack { + Text("TODO") + } + } +} + +#Preview { + MeetingFragment(meetingViewModel: MeetingViewModel()) +} diff --git a/Linphone/UI/Main/Meetings/Fragments/MeetingsListFragment.swift b/Linphone/UI/Main/Meetings/Fragments/MeetingsListFragment.swift new file mode 100644 index 000000000..c9e6a473e --- /dev/null +++ b/Linphone/UI/Main/Meetings/Fragments/MeetingsListFragment.swift @@ -0,0 +1,42 @@ +/* + * 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 . + */ + +// swiftlint:disable line_length + +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)) +} + +// swiftlint:enable line_length diff --git a/Linphone/UI/Main/Meetings/Models/MeetingModel.swift b/Linphone/UI/Main/Meetings/Models/MeetingModel.swift index 1632709de..a6c797916 100644 --- a/Linphone/UI/Main/Meetings/Models/MeetingModel.swift +++ b/Linphone/UI/Main/Meetings/Models/MeetingModel.swift @@ -34,7 +34,7 @@ class MeetingModel: ObservableObject { let formatter = DateFormatter() formatter.dateFormat = Locale.current.identifier == "fr_FR" ? "HH:mm" : "h:mm a" startTime = formatter.string(from: meetingDate) - let endDate = meetingDate + Double(confInfo.duration * 60) // confInfo.duration is in minutes + let endDate = Calendar.current.date(byAdding: .minute, value: Int(confInfo.duration), to: meetingDate) endTime = formatter.string(from: endDate) time = "\(startTime) - \(endTime)" diff --git a/Linphone/UI/Main/Meetings/ViewModel/MeetingViewModel.swift b/Linphone/UI/Main/Meetings/ViewModel/MeetingViewModel.swift index 9ae230c9f..9afe0ac22 100644 --- a/Linphone/UI/Main/Meetings/ViewModel/MeetingViewModel.swift +++ b/Linphone/UI/Main/Meetings/ViewModel/MeetingViewModel.swift @@ -1,8 +1,24 @@ -// -// MeetingViewModel.swift -// Linphone -// -// Created by QuentinArguillere on 19/03/2024. -// +/* + * 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 + +class MeetingViewModel: ObservableObject { + +} diff --git a/Linphone/UI/Main/Meetings/ViewModel/ScheduleMeetingViewModel.swift b/Linphone/UI/Main/Meetings/ViewModel/ScheduleMeetingViewModel.swift new file mode 100644 index 000000000..9c42bc6ef --- /dev/null +++ b/Linphone/UI/Main/Meetings/ViewModel/ScheduleMeetingViewModel.swift @@ -0,0 +1,88 @@ +/* + * 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 ScheduleMeetingViewModel: ObservableObject { + private let TAG = "[ScheduleMeetingViewModel]" + + @Published var isBroadcastSelected: Bool + @Published var showBroadcastHelp: Bool + @Published var subject: String + @Published var description: String + @Published var allDayMeeting: Bool + @Published var fromDateStr: String + @Published var fromTime: String + @Published var toDateStr: String + @Published var toTime: String + @Published var timezone: String + @Published var sendInvitations: Bool + // var participants = MutableLiveData>() + @Published var operationInProgress: Bool + @Published var conferenceCreatedEvent: Bool + + var conferenceScheduler: ConferenceScheduler? + var conferenceInfoToEdit: ConferenceScheduler? + + private var fromDate: Date + private var toDate: Date + + init() { + isBroadcastSelected = false + showBroadcastHelp = false + allDayMeeting = false + sendInvitations = true + + fromDate = Calendar.current.date(byAdding: .hour, value: 1, to: Date.now)! + toDate = Calendar.current.date(byAdding: .hour, value: 1, to: fromDate)! + + computeDateLabels() + computeTimeLabels() + updateTimezone() + } + + private func computeDateLabels() { + var day = fromDate.formatted(Date.FormatStyle().weekday(.wide)) + var dayNumber = fromDate.formatted(Date.FormatStyle().day(.twoDigits)) + var month = fromDate.formatted(Date.FormatStyle().month(.wide)) + fromDateStr = "\(day) \(dayNumber), \(month)" + Log.info("\(TAG) computed start date is \(fromDateStr)") + + day = toDate.formatted(Date.FormatStyle().weekday(.wide)) + dayNumber = toDate.formatted(Date.FormatStyle().day(.twoDigits)) + month = toDate.formatted(Date.FormatStyle().month(.wide)) + toDateStr = "\(day) \(dayNumber), \(month)" + Log.info("\(TAG) computed end date is \(toDateStr)") + } + + private func computeTimeLabels() { + let formatter = DateFormatter() + formatter.dateFormat = Locale.current.identifier == "fr_FR" ? "HH:mm" : "h:mm a" + fromTime = formatter.string(from: fromDate) + toTime = formatter.string(from:toDate) + } + + private func updateTimezone() { + // TODO + } +} + From 0432c9799d8c2ca151014114edaa95527faf4d84 Mon Sep 17 00:00:00 2001 From: QuentinArguillere Date: Fri, 29 Mar 2024 14:08:52 +0100 Subject: [PATCH 168/486] Start schedule meeting fragment and model --- Linphone.xcodeproj/project.pbxproj | 4 + .../earth.imageset/Contents.json | 21 ++ .../Assets.xcassets/earth.imageset/earth.svg | 1 + .../note.imageset/Contents.json | 21 ++ .../Assets.xcassets/note.imageset/note.svg | 1 + .../Fragments/ScheduleMeetingFragment.swift | 223 ++++++++++++++++++ .../Main/Meetings/Models/MeetingModel.swift | 2 +- .../ViewModel/ScheduleMeetingViewModel.swift | 35 ++- 8 files changed, 286 insertions(+), 22 deletions(-) create mode 100644 Linphone/Assets.xcassets/earth.imageset/Contents.json create mode 100644 Linphone/Assets.xcassets/earth.imageset/earth.svg create mode 100644 Linphone/Assets.xcassets/note.imageset/Contents.json create mode 100644 Linphone/Assets.xcassets/note.imageset/note.svg create mode 100644 Linphone/UI/Main/Meetings/Fragments/ScheduleMeetingFragment.swift diff --git a/Linphone.xcodeproj/project.pbxproj b/Linphone.xcodeproj/project.pbxproj index b287e6ada..684110804 100644 --- a/Linphone.xcodeproj/project.pbxproj +++ b/Linphone.xcodeproj/project.pbxproj @@ -15,6 +15,7 @@ 6613A0B62BAEBE5C008923A4 /* ScheduleMeetingViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6613A0B52BAEBE5C008923A4 /* ScheduleMeetingViewModel.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 */; }; @@ -164,6 +165,7 @@ 6613A0B52BAEBE5C008923A4 /* ScheduleMeetingViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScheduleMeetingViewModel.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 = ""; }; @@ -341,6 +343,7 @@ children = ( 6613A0AD2BAEB7DF008923A4 /* MeetingFragment.swift */, 6613A0AF2BAEB7F4008923A4 /* MeetingsListFragment.swift */, + 6646A7A22BB2E224006B842A /* ScheduleMeetingFragment.swift */, ); path = Fragments; sourceTree = ""; @@ -957,6 +960,7 @@ 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 */, 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/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/UI/Main/Meetings/Fragments/ScheduleMeetingFragment.swift b/Linphone/UI/Main/Meetings/Fragments/ScheduleMeetingFragment.swift new file mode 100644 index 000000000..1eea902df --- /dev/null +++ b/Linphone/UI/Main/Meetings/Fragments/ScheduleMeetingFragment.swift @@ -0,0 +1,223 @@ +/* + * 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 . + */ + +// swiftlint:disable line_length + +import SwiftUI + +struct ScheduleMeetingFragment: View { + + @ObservedObject private var sharedMainViewModel = SharedMainViewModel.shared + + @Environment(\.dismiss) var dismiss + + private var idiom: UIUserInterfaceIdiom { UIDevice.current.userInterfaceIdiom } + @State private var orientation = UIDevice.current.orientation + + @ObservedObject var scheduleMeetingViewModel: ScheduleMeetingViewModel + + @State private var delayedColor = Color.white + + var body: some View { + ZStack { + VStack(spacing: 16) { + 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(.top, 2) + .padding(.leading, -10) + .onTapGesture { + } + + Text("New meeting" ) + .multilineTextAlignment(.leading) + .default_text_style_orange_800(styleSize: 16) + + Spacer() + } + .frame(maxWidth: .infinity) + .frame(height: 50) + .padding(.horizontal) + .padding(.bottom, 4) + .background(.white) + + HStack(alignment: .center, spacing: 8) { + Image("users-three") + .renderingMode(.template) + .resizable() + .foregroundStyle(Color.grayMain2c600) + .frame(width: 24, height: 24) + .padding(.leading, 16) + TextField("Subject", text: $scheduleMeetingViewModel.subject) + .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: 8) { + Image("clock") + .renderingMode(.template) + .resizable() + .foregroundStyle(Color.grayMain2c800) + .frame(width: 24, height: 24) + .padding(.leading, 16) + Text(scheduleMeetingViewModel.fromDateStr) + .fontWeight(.bold) + .frame(width: 300, height: 29, alignment: .leading) + .padding(.leading, 8) + .default_text_style_500(styleSize: 16) + .background(Color.gray200) + Spacer() + } + + HStack(alignment: .center, spacing: 8) { + Text(scheduleMeetingViewModel.fromTime) + .fontWeight(.bold) + .frame(height: 29, alignment: .leading) + .default_text_style_500(styleSize: 16) + .opacity(scheduleMeetingViewModel.allDayMeeting ? 0 : 1) + Text(scheduleMeetingViewModel.toTime) + .fontWeight(.bold) + .frame(height: 29, alignment: .leading) + .default_text_style_500(styleSize: 16) + .opacity(scheduleMeetingViewModel.allDayMeeting ? 0 : 1) + Toggle("", isOn: $scheduleMeetingViewModel.allDayMeeting) + .labelsHidden() + .tint(Color.orangeMain300) + Text("All day") + .fontWeight(.bold) + .default_text_style_500(styleSize: 16) + } + + HStack(alignment: .center, spacing: 8) { + Image("earth") + .renderingMode(.template) + .resizable() + .foregroundStyle(Color.grayMain2c800) + .frame(width: 24, height: 24) + .padding(.leading, 16) + Text("TODO : timezone") + .fontWeight(.bold) + .padding(.leading, 8) + .default_text_style_500(styleSize: 16) + Spacer() + } + + HStack(alignment: .center, spacing: 8) { + Image("arrow-clockwise") + .renderingMode(.template) + .resizable() + .foregroundStyle(Color.grayMain2c800) + .frame(width: 24, height: 24) + .padding(.leading, 16) + Text("TODO : repeat") + .fontWeight(.bold) + .padding(.leading, 8) + .default_text_style_500(styleSize: 16) + Spacer() + } + + Rectangle() + .foregroundStyle(.clear) + .frame(height: 1) + .background(Color.gray200) + + HStack(alignment: .center, spacing: 8) { + Image("note") + .renderingMode(.template) + .resizable() + .foregroundStyle(Color.grayMain2c600) + .frame(width: 24, height: 24) + .padding(.leading, 16) + + TextField("Add a description", text: $scheduleMeetingViewModel.subject) + .default_text_style_700(styleSize: 16) + .frame(height: 29, alignment: .leading) + Spacer() + } + + Rectangle() + .foregroundStyle(.clear) + .frame(height: 1) + .background(Color.gray200) + HStack(alignment: .center, spacing: 8) { + 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() + } + ScrollView { + + } + .background(Color.gray100) + } + .background(.white) + .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 + } + } +} + +#Preview { + ScheduleMeetingFragment(scheduleMeetingViewModel: ScheduleMeetingViewModel()) +} + +// swiftlint:enable line_length diff --git a/Linphone/UI/Main/Meetings/Models/MeetingModel.swift b/Linphone/UI/Main/Meetings/Models/MeetingModel.swift index a6c797916..4b3baa492 100644 --- a/Linphone/UI/Main/Meetings/Models/MeetingModel.swift +++ b/Linphone/UI/Main/Meetings/Models/MeetingModel.swift @@ -34,7 +34,7 @@ class MeetingModel: ObservableObject { let formatter = DateFormatter() formatter.dateFormat = Locale.current.identifier == "fr_FR" ? "HH:mm" : "h:mm a" startTime = formatter.string(from: meetingDate) - let endDate = Calendar.current.date(byAdding: .minute, value: Int(confInfo.duration), to: meetingDate) + let endDate = Calendar.current.date(byAdding: .minute, value: Int(confInfo.duration), to: meetingDate)! endTime = formatter.string(from: endDate) time = "\(startTime) - \(endTime)" diff --git a/Linphone/UI/Main/Meetings/ViewModel/ScheduleMeetingViewModel.swift b/Linphone/UI/Main/Meetings/ViewModel/ScheduleMeetingViewModel.swift index 9c42bc6ef..aa04c0ab9 100644 --- a/Linphone/UI/Main/Meetings/ViewModel/ScheduleMeetingViewModel.swift +++ b/Linphone/UI/Main/Meetings/ViewModel/ScheduleMeetingViewModel.swift @@ -21,24 +21,23 @@ import Foundation import linphonesw import Combine - class ScheduleMeetingViewModel: ObservableObject { private let TAG = "[ScheduleMeetingViewModel]" - @Published var isBroadcastSelected: Bool - @Published var showBroadcastHelp: Bool - @Published var subject: String - @Published var description: String - @Published var allDayMeeting: Bool - @Published var fromDateStr: String - @Published var fromTime: String - @Published var toDateStr: String - @Published var toTime: String - @Published var timezone: String - @Published var sendInvitations: Bool + @Published var isBroadcastSelected: Bool = false + @Published var showBroadcastHelp: Bool = false + @Published var subject: String = "" + @Published var description: String = "" + @Published var allDayMeeting: Bool = false + @Published var fromDateStr: String = "" + @Published var fromTime: String = "" + @Published var toDateStr: String = "" + @Published var toTime: String = "" + @Published var timezone: String = "" + @Published var sendInvitations: Bool = true // var participants = MutableLiveData>() - @Published var operationInProgress: Bool - @Published var conferenceCreatedEvent: Bool + @Published var operationInProgress: Bool = false + @Published var conferenceCreatedEvent: Bool = false var conferenceScheduler: ConferenceScheduler? var conferenceInfoToEdit: ConferenceScheduler? @@ -47,11 +46,6 @@ class ScheduleMeetingViewModel: ObservableObject { private var toDate: Date init() { - isBroadcastSelected = false - showBroadcastHelp = false - allDayMeeting = false - sendInvitations = true - fromDate = Calendar.current.date(byAdding: .hour, value: 1, to: Date.now)! toDate = Calendar.current.date(byAdding: .hour, value: 1, to: fromDate)! @@ -78,11 +72,10 @@ class ScheduleMeetingViewModel: ObservableObject { let formatter = DateFormatter() formatter.dateFormat = Locale.current.identifier == "fr_FR" ? "HH:mm" : "h:mm a" fromTime = formatter.string(from: fromDate) - toTime = formatter.string(from:toDate) + toTime = formatter.string(from: toDate) } private func updateTimezone() { // TODO } } - From 1e4d8f55a735e2ba56d494b27933c4ec06fd0e9b Mon Sep 17 00:00:00 2001 From: QuentinArguillere Date: Tue, 9 Apr 2024 12:12:08 +0200 Subject: [PATCH 169/486] Implement MeetingViewModel --- .../Meetings/ViewModel/MeetingViewModel.swift | 179 ++++++++++++++++++ 1 file changed, 179 insertions(+) diff --git a/Linphone/UI/Main/Meetings/ViewModel/MeetingViewModel.swift b/Linphone/UI/Main/Meetings/ViewModel/MeetingViewModel.swift index 9afe0ac22..d65261290 100644 --- a/Linphone/UI/Main/Meetings/ViewModel/MeetingViewModel.swift +++ b/Linphone/UI/Main/Meetings/ViewModel/MeetingViewModel.swift @@ -18,7 +18,186 @@ */ import Foundation +import linphonesw + +// TODO: à merger avec le ParticipantModel de la branche de Benoit +class ParticipantModel: ObservableObject { + + static let TAG = "[Participant Model]" + + let address: Address + @Published var sipUri: String + @Published var name: String + @Published var avatarModel: ContactAvatarModel + + init(address: Address) { + self.address = address + + self.sipUri = address.asStringUriOnly() + + let addressFriend = ContactsManager.shared.getFriendWithAddress(address: self.address) + + var nameTmp = "" + + if addressFriend != nil { + nameTmp = addressFriend!.name! + } else { + nameTmp = address.displayName != nil + ? address.displayName! + : address.username! + } + + self.name = nameTmp + + self.avatarModel = addressFriend != nil + ? ContactsManager.shared.avatarListModel.first(where: { + $0.friend!.name == addressFriend!.name + && $0.friend!.address!.asStringUriOnly() == address.asStringUriOnly() + }) ?? ContactAvatarModel(friend: nil, name: nameTmp, withPresence: false) + : ContactAvatarModel(friend: nil, name: nameTmp, withPresence: false) + } +} class MeetingViewModel: ObservableObject { + static let TAG = "[Meeting ViewModel]" + private var coreContext = CoreContext.shared + + @Published var showBackbutton: Bool = false + @Published var isBroadcast: Bool = false + @Published var isEditable: Bool = false + @Published var subject: String = "" + @Published var sipUri: String = "" + @Published var description: String? + @Published var timezone: String = "" + @Published var startDate: Date? + @Published var endDate: Date? + @Published var dateTime: String = "" + + @Published var speakers: [ParticipantModel] = [] + @Published var participants: [ParticipantModel] = [] + @Published var conferenceInfoFoundEvent: Bool = false + + var conferenceInfo: ConferenceInfo? + + init() { + + } + + func findConferenceInfo(uri: String) { + coreContext.doOnCoreQueue { core in + var confInfoFound = false + if let address = try? Factory.Instance.createAddress(addr: uri) { + let foundConfInfo = core.findConferenceInformationFromUri(uri: address) + if foundConfInfo != nil { + Log.info("\(MeetingViewModel.TAG) Conference info with SIP URI \(uri) was found") + self.conferenceInfo = foundConfInfo + self.configureConferenceInfo(core: core) + confInfoFound = true + } else { + Log.error("\(MeetingViewModel.TAG) Conference info with SIP URI \(uri) couldn't be found!") + confInfoFound = false + } + } else { + Log.error("\(MeetingViewModel.TAG) Failed to parse SIP URI \(uri) as Address!") + confInfoFound = false + } + DispatchQueue.main.sync { + self.conferenceInfoFoundEvent = confInfoFound + } + } + } + + private func configureConferenceInfo(core: Core) { + if let confInfo = self.conferenceInfo { + + /* + timezone.postValue( + AppUtils.getFormattedString( + R.string.meeting_schedule_timezone_title, + TimeZone.getDefault().displayName + ) + .replaceFirstChar { if (it.isLowerCase()) it.titlecase(Locale.getDefault()) else it.toString() } + ) + */ + + var isEditable = false + + if let organizerAddress = confInfo.organizer { + let localAccount = core.accountList.first(where: { + if let address = $0.params?.identityAddress { + return organizerAddress.weakEqual(address2: address) + } else { + return false + } + }) + + isEditable = localAccount != nil + } else { + Log.error("\(MeetingViewModel.TAG) No organizer SIP URI found for: \(confInfo.uri?.asStringUriOnly() ?? "(empty)")") + } + + let startDate = Date(timeIntervalSince1970: TimeInterval(confInfo.dateTime)) + let endDate = Calendar.current.date(byAdding: .minute, value: Int(confInfo.duration), to: startDate)! + + let formatter = DateFormatter() + formatter.dateFormat = Locale.current.identifier == "fr_FR" ? "HH:mm" : "h:mm a" + let startTime = formatter.string(from: startDate) + let endTime = formatter.string(from: endDate) + let dateTime = "\(startTime) - \(endTime)" + + DispatchQueue.main.sync { + self.subject = confInfo.subject ?? "" + self.sipUri = confInfo.uri?.asStringUriOnly() ?? "" + self.description = confInfo.description + self.startDate = startDate + self.endDate = endDate + self.dateTime = dateTime + self.isEditable = isEditable + } + + self.computeParticipantsList(core: core, confInfo: confInfo) + } + } + + private func computeParticipantsList(core: Core, confInfo: ConferenceInfo) { + var speakersList: [ParticipantModel] = [] + var participantsList: [ParticipantModel] = [] + var allSpeaker = true + let organizer = confInfo.organizer + var organizerFound = false + for pInfo in confInfo.participantInfos { + if let participantAddress = pInfo.address { + let isOrganizer = organizer != nil && organizer!.weakEqual(address2: participantAddress) + + Log.info("\(MeetingViewModel.TAG) Conference \(confInfo.subject)[${conferenceInfo.subject}] \(isOrganizer ? "organizer: " : "participant: ") \(participantAddress.asStringUriOnly()) is a \(pInfo.role)") + if isOrganizer { + organizerFound = true + } + + if pInfo.role == Participant.Role.Listener { + allSpeaker = false + participantsList.append(ParticipantModel(address: participantAddress)) + } else { + speakersList.append(ParticipantModel(address: participantAddress)) + } + } + } + + if allSpeaker { + Log.info("$TAG All participants have Speaker role, considering it is a meeting") + participantsList = speakersList + } + + if !organizerFound, let organizerAddress = organizer { + Log.info("$TAG Organizer not found in participants list, adding it to participants list") + participantsList.append(ParticipantModel(address: organizerAddress)) + } + + DispatchQueue.main.sync { + self.isBroadcast = !allSpeaker + speakers = speakersList + participants = participantsList + } + } } From 866bc9dd81f1c4a2cb0dfa71df37e97381e009e2 Mon Sep 17 00:00:00 2001 From: QuentinArguillere Date: Tue, 9 Apr 2024 12:16:42 +0200 Subject: [PATCH 170/486] Add onConferenceInfoReceived subscription --- .../ViewModel/MeetingsListViewModel.swift | 119 +++++++++--------- 1 file changed, 62 insertions(+), 57 deletions(-) diff --git a/Linphone/UI/Main/Meetings/ViewModel/MeetingsListViewModel.swift b/Linphone/UI/Main/Meetings/ViewModel/MeetingsListViewModel.swift index 840571a4e..9d378538a 100644 --- a/Linphone/UI/Main/Meetings/ViewModel/MeetingsListViewModel.swift +++ b/Linphone/UI/Main/Meetings/ViewModel/MeetingsListViewModel.swift @@ -22,74 +22,79 @@ import linphonesw import Combine class MeetingsListViewModel: ObservableObject { + static let TAG = "[Meetings ListViewModel]" private var coreContext = CoreContext.shared private var mCoreSuscriptions = Set() var selectedMeeting: ConversationModel? @Published var meetingsList: [MeetingsListItemModel] = [] + @Published var currentFilter = "" init() { - computeMeetingsList(filter: "") - } - - func computeMeetingsList(filter: String) { coreContext.doOnCoreQueue { core in - var confInfoList: [ConferenceInfo] = [] + self.computeMeetingsList(core: core, filter: self.currentFilter) - if let account = core.defaultAccount { - confInfoList = account.conferenceInformationList - } - if confInfoList.isEmpty { - confInfoList = core.conferenceInformationList - } - - var meetingsListTmp: [MeetingsListItemModel] = [] - var previousModel: MeetingModel? = nil - var meetingForTodayFound = false - - 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) - let firstMeetingOfTheDay = (previousModel != nil) ? previousModel?.day != model.day || previousModel?.dayNumber != model.dayNumber : true - model.firstMeetingOfTheDay = firstMeetingOfTheDay - - // Insert "Today" fake model before the first one of today - if firstMeetingOfTheDay && model.isToday { - meetingsListTmp.append(MeetingsListItemModel(meetingModel: nil)) - meetingForTodayFound = true - } - - // If no meeting was found for today, insert "Today" fake model before the next meeting to come - if !meetingForTodayFound && model.isAfterToday { - meetingsListTmp.append(MeetingsListItemModel(meetingModel: nil)) - meetingForTodayFound = true - } - - meetingsListTmp.append(MeetingsListItemModel(meetingModel: model)) - previousModel = model - } - } - - // If no meeting was found after today, insert "Today" fake model at the end - if !meetingForTodayFound { - meetingsListTmp.append(MeetingsListItemModel(meetingModel: nil)) - } - - self.meetingsList = meetingsListTmp + self.mCoreSuscriptions.insert(core.publisher?.onConferenceInfoReceived?.postOnCoreQueue { (cbVal: (core: Core, conferenceInfo: ConferenceInfo)) in + Log.info("\(MeetingsListViewModel.TAG) Conference info received [\(cbVal.conferenceInfo.uri?.asStringUriOnly())") + self.computeMeetingsList(core: cbVal.core, filter: self.currentFilter) + }) } } - + func computeMeetingsList(core: Core, filter: String) { + var confInfoList: [ConferenceInfo] = [] + + if let account = core.defaultAccount { + confInfoList = account.conferenceInformationList + } + if confInfoList.isEmpty { + confInfoList = core.conferenceInformationList + } + + var meetingsListTmp: [MeetingsListItemModel] = [] + var previousModel: MeetingModel? + var meetingForTodayFound = false + + 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) + let firstMeetingOfTheDay = (previousModel != nil) ? previousModel?.day != model.day || previousModel?.dayNumber != model.dayNumber : true + model.firstMeetingOfTheDay = firstMeetingOfTheDay + + // Insert "Today" fake model before the first one of today + if firstMeetingOfTheDay && model.isToday { + meetingsListTmp.append(MeetingsListItemModel(meetingModel: nil)) + meetingForTodayFound = true + } + + // If no meeting was found for today, insert "Today" fake model before the next meeting to come + if !meetingForTodayFound && model.isAfterToday { + meetingsListTmp.append(MeetingsListItemModel(meetingModel: nil)) + meetingForTodayFound = true + } + + meetingsListTmp.append(MeetingsListItemModel(meetingModel: model)) + previousModel = model + } + } + + // If no meeting was found after today, insert "Today" fake model at the end + if !meetingForTodayFound { + meetingsListTmp.append(MeetingsListItemModel(meetingModel: nil)) + } + + self.meetingsList = meetingsListTmp + } } From b3a602c3303dcffb083844faaa32681faed45036 Mon Sep 17 00:00:00 2001 From: QuentinArguillere Date: Thu, 11 Apr 2024 17:41:27 +0200 Subject: [PATCH 171/486] Fix indentation in ContactAvtarModel --- .../Contacts/Model/ContactAvatarModel.swift | 24 +++++++++---------- 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/Linphone/UI/Main/Contacts/Model/ContactAvatarModel.swift b/Linphone/UI/Main/Contacts/Model/ContactAvatarModel.swift index 950e431c6..7ec577962 100644 --- a/Linphone/UI/Main/Contacts/Model/ContactAvatarModel.swift +++ b/Linphone/UI/Main/Contacts/Model/ContactAvatarModel.swift @@ -28,9 +28,9 @@ class ContactAvatarModel: ObservableObject { let name: String let withPresence: Bool? - + @Published var lastPresenceInfo: String - + @Published var presenceStatus: ConsolidatedPresence private var friendSuscription: AnyCancellable? @@ -39,7 +39,7 @@ class ContactAvatarModel: ObservableObject { self.friend = friend self.name = name self.withPresence = withPresence - if friend != nil && + if friend != nil && withPresence == true { self.lastPresenceInfo = "" @@ -47,7 +47,7 @@ class ContactAvatarModel: ObservableObject { if friend!.consolidatedPresence == .Online || friend!.consolidatedPresence == .Busy { if friend!.consolidatedPresence == .Online || friend!.presenceModel!.latestActivityTimestamp != -1 { - self.lastPresenceInfo = (friend!.consolidatedPresence == .Online) ? + self.lastPresenceInfo = (friend!.consolidatedPresence == .Online) ? "Online" : getCallTime(startDate: friend!.presenceModel!.latestActivityTimestamp) } else { self.lastPresenceInfo = "Away" @@ -55,16 +55,16 @@ class ContactAvatarModel: ObservableObject { } else { self.lastPresenceInfo = "" } - - if self.friendSuscription != nil { - self.friendSuscription = nil - } + + if self.friendSuscription != nil { + self.friendSuscription = nil + } addSubscription() - } else { - self.lastPresenceInfo = "" - self.presenceStatus = .Offline - } + } else { + self.lastPresenceInfo = "" + self.presenceStatus = .Offline + } } func addSubscription() { From f320769b12bfcd74b18c9d6b18e810ffbed0a282 Mon Sep 17 00:00:00 2001 From: QuentinArguillere Date: Thu, 11 Apr 2024 17:41:51 +0200 Subject: [PATCH 172/486] Add static func getAvatarModelFromAddress to ContactAvatarModel --- .../Contacts/Model/ContactAvatarModel.swift | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/Linphone/UI/Main/Contacts/Model/ContactAvatarModel.swift b/Linphone/UI/Main/Contacts/Model/ContactAvatarModel.swift index 7ec577962..6f388a320 100644 --- a/Linphone/UI/Main/Contacts/Model/ContactAvatarModel.swift +++ b/Linphone/UI/Main/Contacts/Model/ContactAvatarModel.swift @@ -113,4 +113,24 @@ class ContactAvatarModel: ObservableObject { return "Online on " + formatter.string(from: myNSDate) } } + + static func getAvatarModelFromAddress(address: Address) -> ContactAvatarModel { + + let addressFriend = ContactsManager.shared.getFriendWithAddress(address: address) + var avatarModel = ContactsManager.shared.avatarListModel.first(where: { + $0.friend!.name == addressFriend!.name + && $0.friend!.address!.asStringUriOnly() == address.asStringUriOnly() + }) + + if avatarModel == nil { + var nameTmp = "" + if addressFriend != nil { + nameTmp = addressFriend!.name! + } else { + nameTmp = address.displayName != nil ? address.displayName! : address.username! + } + avatarModel = ContactAvatarModel(friend: nil, name: nameTmp, withPresence: false) + } + return avatarModel! + } } From 728678a02c903942af15a4ca91c500e3eb00d671 Mon Sep 17 00:00:00 2001 From: QuentinArguillere Date: Thu, 11 Apr 2024 17:46:30 +0200 Subject: [PATCH 173/486] update getAvatarModelFromAddress function to handle case where the address does not match any friend --- .../Contacts/Model/ContactAvatarModel.swift | 27 +++++++++---------- 1 file changed, 12 insertions(+), 15 deletions(-) diff --git a/Linphone/UI/Main/Contacts/Model/ContactAvatarModel.swift b/Linphone/UI/Main/Contacts/Model/ContactAvatarModel.swift index 6f388a320..9f7ba90c0 100644 --- a/Linphone/UI/Main/Contacts/Model/ContactAvatarModel.swift +++ b/Linphone/UI/Main/Contacts/Model/ContactAvatarModel.swift @@ -115,22 +115,19 @@ class ContactAvatarModel: ObservableObject { } static func getAvatarModelFromAddress(address: Address) -> ContactAvatarModel { - - let addressFriend = ContactsManager.shared.getFriendWithAddress(address: address) - var avatarModel = ContactsManager.shared.avatarListModel.first(where: { - $0.friend!.name == addressFriend!.name - && $0.friend!.address!.asStringUriOnly() == address.asStringUriOnly() - }) - - if avatarModel == nil { - var nameTmp = "" - if addressFriend != nil { - nameTmp = addressFriend!.name! - } else { - nameTmp = address.displayName != nil ? address.displayName! : address.username! + if let addressFriend = ContactsManager.shared.getFriendWithAddress(address: address) { + var avatarModel = ContactsManager.shared.avatarListModel.first(where: { + $0.friend!.name == addressFriend.name + && $0.friend!.address!.asStringUriOnly() == address.asStringUriOnly() + }) + + if avatarModel == nil { + avatarModel = ContactAvatarModel(friend: nil, name: addressFriend.name!, withPresence: false) } - avatarModel = ContactAvatarModel(friend: nil, name: nameTmp, withPresence: false) + return avatarModel! + } else { + let name = address.displayName != nil ? address.displayName! : address.username! + return ContactAvatarModel(friend: nil, name: name, withPresence: false) } - return avatarModel! } } From 62a027b3975fa072ee8435e67dc2cc0ee1292356 Mon Sep 17 00:00:00 2001 From: QuentinArguillere Date: Thu, 11 Apr 2024 17:49:27 +0200 Subject: [PATCH 174/486] Continue ScheduleMeetingViewModel impelmentation: ConferenceScheduler listeners, schedule(), update(), addparticipants(), and fillConferenceInfo() --- .../Meetings/ViewModel/MeetingViewModel.swift | 23 +- .../ViewModel/ScheduleMeetingViewModel.swift | 229 +++++++++++++++++- 2 files changed, 220 insertions(+), 32 deletions(-) diff --git a/Linphone/UI/Main/Meetings/ViewModel/MeetingViewModel.swift b/Linphone/UI/Main/Meetings/ViewModel/MeetingViewModel.swift index d65261290..bf612a90d 100644 --- a/Linphone/UI/Main/Meetings/ViewModel/MeetingViewModel.swift +++ b/Linphone/UI/Main/Meetings/ViewModel/MeetingViewModel.swift @@ -34,27 +34,8 @@ class ParticipantModel: ObservableObject { self.address = address self.sipUri = address.asStringUriOnly() - - let addressFriend = ContactsManager.shared.getFriendWithAddress(address: self.address) - - var nameTmp = "" - - if addressFriend != nil { - nameTmp = addressFriend!.name! - } else { - nameTmp = address.displayName != nil - ? address.displayName! - : address.username! - } - - self.name = nameTmp - - self.avatarModel = addressFriend != nil - ? ContactsManager.shared.avatarListModel.first(where: { - $0.friend!.name == addressFriend!.name - && $0.friend!.address!.asStringUriOnly() == address.asStringUriOnly() - }) ?? ContactAvatarModel(friend: nil, name: nameTmp, withPresence: false) - : ContactAvatarModel(friend: nil, name: nameTmp, withPresence: false) + + self.avatarModel = ContactAvatarModel.getAvatarModelFromAddress(address: self.address) } } diff --git a/Linphone/UI/Main/Meetings/ViewModel/ScheduleMeetingViewModel.swift b/Linphone/UI/Main/Meetings/ViewModel/ScheduleMeetingViewModel.swift index aa04c0ab9..9015389fb 100644 --- a/Linphone/UI/Main/Meetings/ViewModel/ScheduleMeetingViewModel.swift +++ b/Linphone/UI/Main/Meetings/ViewModel/ScheduleMeetingViewModel.swift @@ -21,26 +21,37 @@ import Foundation import linphonesw import Combine +class SelectedAddressModel { + var address: Address + var avatarModel: ContactAvatarModel? + + init (addr: Address, avModel: ContactAvatarModel?) { + address = addr + avatarModel = avModel + } +} + class ScheduleMeetingViewModel: ObservableObject { - private let TAG = "[ScheduleMeetingViewModel]" + static let TAG = "[ScheduleMeetingViewModel]" @Published var isBroadcastSelected: Bool = false @Published var showBroadcastHelp: Bool = false @Published var subject: String = "" - @Published var description: String = "" + @Published var description: String = "aaaaaa aaaaaa" @Published var allDayMeeting: Bool = false @Published var fromDateStr: String = "" @Published var fromTime: String = "" @Published var toDateStr: String = "" - @Published var toTime: String = "" - @Published var timezone: String = "" - @Published var sendInvitations: Bool = true - // var participants = MutableLiveData>() - @Published var operationInProgress: Bool = false - @Published var conferenceCreatedEvent: Bool = false + @Published var toTime: String = "" + @Published var timezone: String = "" + @Published var sendInvitations: Bool = true + @Published var participants: [SelectedAddressModel] = [] + @Published var operationInProgress: Bool = false + @Published var conferenceCreatedEvent: Bool = false var conferenceScheduler: ConferenceScheduler? - var conferenceInfoToEdit: ConferenceScheduler? + private var mSchedulerSubscriptions = Set() + var conferenceInfoToEdit: ConferenceInfo? private var fromDate: Date private var toDate: Date @@ -59,13 +70,13 @@ class ScheduleMeetingViewModel: ObservableObject { var dayNumber = fromDate.formatted(Date.FormatStyle().day(.twoDigits)) var month = fromDate.formatted(Date.FormatStyle().month(.wide)) fromDateStr = "\(day) \(dayNumber), \(month)" - Log.info("\(TAG) computed start date is \(fromDateStr)") + Log.info("\(ScheduleMeetingViewModel.TAG) computed start date is \(fromDateStr)") day = toDate.formatted(Date.FormatStyle().weekday(.wide)) dayNumber = toDate.formatted(Date.FormatStyle().day(.twoDigits)) month = toDate.formatted(Date.FormatStyle().month(.wide)) toDateStr = "\(day) \(dayNumber), \(month)" - Log.info("\(TAG) computed end date is \(toDateStr)") + Log.info("\(ScheduleMeetingViewModel.TAG)) computed end date is \(toDateStr)") } private func computeTimeLabels() { @@ -78,4 +89,200 @@ class ScheduleMeetingViewModel: ObservableObject { private func updateTimezone() { // TODO } + + func addParticipants(toAdd: [String]) { + CoreContext.shared.doOnCoreQueue { _ in + var list = self.participants + for participant in toAdd { + if let address = try? Factory.Instance.createAddress(addr: participant) { + if let found = list.first(where: { $0.address.weakEqual(address2: address) }) { + Log.info("\(ScheduleMeetingViewModel.TAG) Participant \(found.address.asStringUriOnly()) already in list, skipping") + continue + } + + let avatarModel = ContactAvatarModel.getAvatarModelFromAddress(address: address) + list.append(SelectedAddressModel(addr: address, avModel: avatarModel)) + Log.info("\(ScheduleMeetingViewModel.TAG) Added participant \(address.asStringUriOnly())") + } else { + Log.error("\(ScheduleMeetingViewModel.TAG) Failed to parse \(participant) as address!") + } + } + + Log.info("\(ScheduleMeetingViewModel.TAG) [\(toAdd.count) participants added, now there are \(list.count) participants in list") + DispatchQueue.main.async { + self.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("\(ScheduleMeetingViewModel.TAG) Failed to create Participant Info from address \(participant.address.asStringUriOnly())") + } + } + confInfo.participantInfos = participantsInfoList + } + + private func initConferenceSchedulerAndListeners(core: Core) { + self.conferenceScheduler = try? core.createConferenceScheduler() + + self.mSchedulerSubscriptions.insert(self.conferenceScheduler?.publisher?.onStateChanged?.postOnCoreQueue { (cbVal: (conferenceScheduler: ConferenceScheduler, state: ConferenceScheduler.State)) in + + Log.info("\(ScheduleMeetingViewModel.TAG) Conference state changed \(cbVal.state)") + if cbVal.state == ConferenceScheduler.State.Error { + DispatchQueue.main.async { + self.operationInProgress = false + // TODO: show error toast + } + } else if cbVal.state == ConferenceScheduler.State.Ready { + let conferenceAddress = self.conferenceScheduler?.info?.uri + if let confInfoToEdit = self.conferenceInfoToEdit { + Log.info("\(ScheduleMeetingViewModel.TAG) Conference info \(confInfoToEdit.uri?.asStringUriOnly() ?? "'nil'") has been updated") + } else { + Log.info("\(ScheduleMeetingViewModel.TAG) Conference info created, address will be \(conferenceAddress?.asStringUriOnly() ?? "'nil'")") + } + + if self.sendInvitations { + Log.info("\(ScheduleMeetingViewModel.TAG) User asked for invitations to be sent, let's do it") + if let chatRoomParams = try? core.createDefaultChatRoomParams() { + chatRoomParams.groupEnabled = false + chatRoomParams.backend = ChatRoom.Backend.FlexisipChat + chatRoomParams.encryptionEnabled = true + chatRoomParams.subject = "Meeting invitation" // Won't be used + self.conferenceScheduler?.sendInvitations(chatRoomParams: chatRoomParams) + } else { + Log.error("\(ScheduleMeetingViewModel.TAG) Failed to create default chatroom parameters. This should not happen") + } + } else { + Log.info("\(ScheduleMeetingViewModel.TAG) User didn't asked for invitations to be sent") + DispatchQueue.main.async { + self.operationInProgress = false + self.conferenceCreatedEvent = false + } + } + } + }) + + self.mSchedulerSubscriptions.insert(self.conferenceScheduler?.publisher?.onInvitationsSent?.postOnCoreQueue { (cbVal: (conferenceScheduler: ConferenceScheduler, failedInvitations: [Address])) in + + if cbVal.failedInvitations.isEmpty { + Log.info("\(ScheduleMeetingViewModel.TAG) All invitations have been sent") + } else if cbVal.failedInvitations.count == self.participants.count { + Log.error("\(ScheduleMeetingViewModel.TAG) No invitation sent!") + // TODO: show error toast + } else { + Log.warn("\(ScheduleMeetingViewModel.TAG) \(cbVal.failedInvitations.count) invitations couldn't have been sent for:") + for failInv in cbVal.failedInvitations { + Log.warn(failInv.asStringUriOnly()) + } + // TODO: show error toast + } + + DispatchQueue.main.async { + self.operationInProgress = false + self.conferenceCreatedEvent = true + } + }) + } + + func schedule() { + if subject.isEmpty || participants.isEmpty { + Log.error("\(ScheduleMeetingViewModel.TAG) Either no subject was set or no participant was selected, can't schedule meeting.") + // TODO: show red toast + return + } + operationInProgress = true + + CoreContext.shared.doOnCoreQueue { core in + Log.info("\(ScheduleMeetingViewModel.TAG) Scheduling \(self.isBroadcastSelected ? "broadcast" : "meeting")") + + let localAccount = core.defaultAccount + let localAddress = localAccount?.params?.identityAddress + + if let conferenceInfo = try? Factory.Instance.createConferenceInfo() { + conferenceInfo.organizer = localAddress + self.fillConferenceInfo(confInfo: conferenceInfo) + if self.conferenceScheduler == nil { + self.initConferenceSchedulerAndListeners(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("\(ScheduleMeetingViewModel.TAG) Updating \(self.isBroadcastSelected ? "broadcast" : "meeting")") + + if let conferenceInfo = self.conferenceInfoToEdit { + self.fillConferenceInfo(confInfo: conferenceInfo) + if self.conferenceScheduler == nil { + self.initConferenceSchedulerAndListeners(core: core) + } + + // Will trigger the conference update automatically + self.conferenceScheduler?.info = conferenceInfo + } else { + Log.error("No conference info to edit found!") + return + } + } + } + + func loadExistingConferenceInfoFromUri(conferenceUri: String) { + CoreContext.shared.doOnCoreQueue { core in + if let conferenceAddress = core.interpretUrl(url: conferenceUri, applyInternationalPrefix: false) { + if let conferenceInfo = core.findConferenceInformationFromUri(uri: conferenceAddress) { + + self.conferenceInfoToEdit = conferenceInfo + Log.info("\(ScheduleMeetingViewModel.TAG) Found conference info matching URI \(conferenceInfo.uri?.asString()) with subject \(conferenceInfo.subject)") + + self.fromDate = Date(timeIntervalSince1970: TimeInterval(conferenceInfo.dateTime)) + self.toDate = Calendar.current.date(byAdding: .minute, value: Int(conferenceInfo.duration), to: self.fromDate)! + + var list: [SelectedAddressModel] = [] + for partInfo in conferenceInfo.participantInfos { + if let addr = partInfo.address { + let avatarModel = ContactAvatarModel.getAvatarModelFromAddress(address: addr) + list.append(SelectedAddressModel(addr: addr, avModel: avatarModel)) + Log.info("\(ScheduleMeetingViewModel.TAG) Loaded participant \(addr.asStringUriOnly())") + } + } + Log.info("\(ScheduleMeetingViewModel.TAG) \(list.count) participants loaded from found conference info") + + DispatchQueue.main.async { + self.subject = conferenceInfo.subject ?? "" + self.description = conferenceInfo.description ?? "" + self.isBroadcastSelected = false // TODO FIXME + self.computeDateLabels() + self.computeTimeLabels() + self.updateTimezone() + self.participants = list + } + + } else { + Log.error("\(ScheduleMeetingViewModel.TAG) Failed to find a conference info matching URI [${conferenceAddress.asString()}], abort") + } + } else { + Log.error("\(ScheduleMeetingViewModel.TAG) Failed to parse conference URI [$conferenceUri], abort") + } + + } + } + } From da75af008e430240619f04c423d1338dac4b2364 Mon Sep 17 00:00:00 2001 From: QuentinArguillere Date: Fri, 12 Apr 2024 10:59:17 +0200 Subject: [PATCH 175/486] Fix build : did not initalize ParticipantModel name --- Linphone/UI/Main/Meetings/ViewModel/MeetingViewModel.swift | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/Linphone/UI/Main/Meetings/ViewModel/MeetingViewModel.swift b/Linphone/UI/Main/Meetings/ViewModel/MeetingViewModel.swift index bf612a90d..6910d011f 100644 --- a/Linphone/UI/Main/Meetings/ViewModel/MeetingViewModel.swift +++ b/Linphone/UI/Main/Meetings/ViewModel/MeetingViewModel.swift @@ -35,6 +35,12 @@ class ParticipantModel: ObservableObject { self.sipUri = address.asStringUriOnly() + if let addressFriend = ContactsManager.shared.getFriendWithAddress(address: self.address) { + self.name = addressFriend.name! + } else { + self.name = address.displayName != nil ? address.displayName! : address.username! + } + self.avatarModel = ContactAvatarModel.getAvatarModelFromAddress(address: self.address) } } From d21c026bc9f9475f974a5c753cb45f6222d183f5 Mon Sep 17 00:00:00 2001 From: QuentinArguillere Date: Thu, 18 Apr 2024 11:31:46 +0200 Subject: [PATCH 176/486] Implement AddParticipantsFragment --- .../Fragments/AddParticipantsFragment.swift | 233 ++++++++++++++++++ 1 file changed, 233 insertions(+) create mode 100644 Linphone/UI/Main/Meetings/Fragments/AddParticipantsFragment.swift diff --git a/Linphone/UI/Main/Meetings/Fragments/AddParticipantsFragment.swift b/Linphone/UI/Main/Meetings/Fragments/AddParticipantsFragment.swift new file mode 100644 index 000000000..8775ce5d8 --- /dev/null +++ b/Linphone/UI/Main/Meetings/Fragments/AddParticipantsFragment.swift @@ -0,0 +1,233 @@ +// +// ParticipantsListFragment.swift +// Linphone +// +// Created by QuentinArguillere on 16/04/2024. +// + +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 scheduleMeetingViewModel: ScheduleMeetingViewModel + + @State private var delayedColor = Color.white + + @Binding var isShowAddParticipantFragment: Bool + @FocusState var isSearchFieldFocused: Bool + + var body: some View { + ZStack(alignment: .bottomTrailing) { + VStack(spacing: 16) { + 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(.top, 2) + .padding(.leading, -10) + .onTapGesture { + isShowAddParticipantFragment = false + scheduleMeetingViewModel.participantsToAdd = [] + } + + VStack(alignment: .leading, spacing: 3) { + Text("Add participants") + .multilineTextAlignment(.leading) + .default_text_style_orange_800(styleSize: 16) + .padding(.top, 20) + Text("\($scheduleMeetingViewModel.participants.count) selected participants") + .default_text_style_300(styleSize: 12) + } + Spacer() + } + .frame(maxWidth: .infinity) + .frame(height: 50) + .padding(.horizontal) + .padding(.bottom, 4) + .background(.white) + + ForEach(0.. Date: Fri, 19 Apr 2024 15:34:05 +0200 Subject: [PATCH 177/486] Add skeleton for MeetingsView MeetingsFragment --- .../Meetings/Fragments/MeetingsFragment.swift | 18 ++++++++++++++++++ Linphone/UI/Main/Meetings/MeetingsView.swift | 18 ++++++++++++++++++ 2 files changed, 36 insertions(+) create mode 100644 Linphone/UI/Main/Meetings/Fragments/MeetingsFragment.swift create mode 100644 Linphone/UI/Main/Meetings/MeetingsView.swift diff --git a/Linphone/UI/Main/Meetings/Fragments/MeetingsFragment.swift b/Linphone/UI/Main/Meetings/Fragments/MeetingsFragment.swift new file mode 100644 index 000000000..fd8237503 --- /dev/null +++ b/Linphone/UI/Main/Meetings/Fragments/MeetingsFragment.swift @@ -0,0 +1,18 @@ +// +// MeetingsFragment.swift +// Linphone +// +// Created by QuentinArguillere on 18/04/2024. +// + +import SwiftUI + +struct MeetingsFragment: View { + var body: some View { + Text(/*@START_MENU_TOKEN@*/"Hello, World!"/*@END_MENU_TOKEN@*/) + } +} + +#Preview { + MeetingsFragment() +} diff --git a/Linphone/UI/Main/Meetings/MeetingsView.swift b/Linphone/UI/Main/Meetings/MeetingsView.swift new file mode 100644 index 000000000..adf0088d7 --- /dev/null +++ b/Linphone/UI/Main/Meetings/MeetingsView.swift @@ -0,0 +1,18 @@ +// +// MeetingsView.swift +// Linphone +// +// Created by QuentinArguillere on 18/04/2024. +// + +import SwiftUI + +struct MeetingsView: View { + var body: some View { + Text(/*@START_MENU_TOKEN@*/"Hello, World!"/*@END_MENU_TOKEN@*/) + } +} + +#Preview { + MeetingsView() +} From 0730e9b73892280ea87c2c693f5c24d914319ed7 Mon Sep 17 00:00:00 2001 From: QuentinArguillere Date: Fri, 19 Apr 2024 15:34:22 +0200 Subject: [PATCH 178/486] Restore groupchat and lime specs --- Linphone/Core/CoreContext.swift | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/Linphone/Core/CoreContext.swift b/Linphone/Core/CoreContext.swift index 30adf40a9..ccf40720a 100644 --- a/Linphone/Core/CoreContext.swift +++ b/Linphone/Core/CoreContext.swift @@ -135,17 +135,6 @@ final class CoreContext: ObservableObject { account.params = newParams } - // Remove specs for 6.0 first version - Log.info("Removing spec 'conference' from core for this version") - self.mCore.removeLinphoneSpec(spec: "conference") - Log.info("Removing spec 'ephemeral' from core for this version") - self.mCore.removeLinphoneSpec(spec: "ephemeral") - /* - Log.info("Removing spec 'groupchat' from core for this version") - self.mCore.removeLinphoneSpec(spec: "groupchat") - Log.info("Removing spec 'lime' from core for this version") - self.mCore.removeLinphoneSpec(spec: "lime") - */ } }) From 36bfadcfde7e075b4d799ef038501629f2ae3836 Mon Sep 17 00:00:00 2001 From: QuentinArguillere Date: Fri, 19 Apr 2024 15:40:24 +0200 Subject: [PATCH 179/486] Update pbxproj with new files --- Linphone.xcodeproj/project.pbxproj | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/Linphone.xcodeproj/project.pbxproj b/Linphone.xcodeproj/project.pbxproj index 684110804..321f92296 100644 --- a/Linphone.xcodeproj/project.pbxproj +++ b/Linphone.xcodeproj/project.pbxproj @@ -24,9 +24,12 @@ 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 */; }; + 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 */; }; @@ -173,9 +176,12 @@ 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 = ""; }; + 66F626B12BCEBB86003E2DEC /* AddParticipantsFragment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddParticipantsFragment.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 = ""; }; @@ -334,6 +340,7 @@ 66E56BC62BA49938006CE56F /* Fragments */, 66E56BCA2BA9A1A0006CE56F /* Models */, 66E56BC72BA4993E006CE56F /* ViewModel */, + 66E50A482BD12B2300AD61CA /* MeetingsView.swift */, ); path = Meetings; sourceTree = ""; @@ -341,9 +348,11 @@ 66E56BC62BA49938006CE56F /* Fragments */ = { isa = PBXGroup; children = ( + 66E50A4A2BD12B7800AD61CA /* MeetingsFragment.swift */, 6613A0AD2BAEB7DF008923A4 /* MeetingFragment.swift */, 6613A0AF2BAEB7F4008923A4 /* MeetingsListFragment.swift */, 6646A7A22BB2E224006B842A /* ScheduleMeetingFragment.swift */, + 66F626B12BCEBB86003E2DEC /* AddParticipantsFragment.swift */, ); path = Fragments; sourceTree = ""; @@ -943,6 +952,7 @@ 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 */, D732A90F2B04C3B400DB42BA /* HistoryFragment.swift in Sources */, D79622342B1DFE600037EACD /* DialerBottomSheet.swift in Sources */, @@ -965,6 +975,7 @@ D71FCA812AE14CFC00D2E43E /* ContactsListFragment.swift in Sources */, D734499B2BC694C900778C56 /* MeetingWaitingRoomViewModel.swift in Sources */, D719ABB72ABC67BF00B41C10 /* LinphoneApp.swift in Sources */, + 66E50A4B2BD12B7800AD61CA /* MeetingsFragment.swift in Sources */, D732A91B2B061BD900DB42BA /* HistoryListBottomSheet.swift in Sources */, D72250632ADE9615008FB426 /* HistoryViewModel.swift in Sources */, 66E56BC92BA4A6D7006CE56F /* MeetingsListViewModel.swift in Sources */, @@ -1000,6 +1011,7 @@ 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 */, D726E43F2B19E56F0083C415 /* StartCallViewModel.swift in Sources */, D70A26EE2B7CF60B006CC8FC /* ConversationsListBottomSheet.swift in Sources */, From 924a7413fa40b935909708a5bdeaeb12d68300d2 Mon Sep 17 00:00:00 2001 From: QuentinArguillere Date: Fri, 19 Apr 2024 15:41:53 +0200 Subject: [PATCH 180/486] Integrate meetingsview in the main view, and implement date and participant selection in meeting scheduling --- Linphone/LinphoneApp.swift | 5 +- Linphone/UI/Main/ContentView.swift | 62 +- .../Fragments/AddParticipantsFragment.swift | 193 ++++-- .../Fragments/ScheduleMeetingFragment.swift | 581 +++++++++++++----- .../ViewModel/ScheduleMeetingViewModel.swift | 62 +- 5 files changed, 641 insertions(+), 262 deletions(-) diff --git a/Linphone/LinphoneApp.swift b/Linphone/LinphoneApp.swift index 2bc7b715f..da5d206c1 100644 --- a/Linphone/LinphoneApp.swift +++ b/Linphone/LinphoneApp.swift @@ -79,6 +79,7 @@ struct LinphoneApp: App { @State private var meetingWaitingRoomViewModel: MeetingWaitingRoomViewModel? @State private var conversationsListViewModel: ConversationsListViewModel? @State private var conversationViewModel: ConversationViewModel? + @State private var scheduleMeetingViewModel: ScheduleMeetingViewModel? var body: some Scene { WindowGroup { @@ -112,7 +113,8 @@ struct LinphoneApp: App { callViewModel: callViewModel!, meetingWaitingRoomViewModel: meetingWaitingRoomViewModel!, conversationsListViewModel: conversationsListViewModel!, - conversationViewModel: conversationViewModel! + conversationViewModel: conversationViewModel!, + scheduleMeetingViewModel: scheduleMeetingViewModel! ) } else { SplashScreen() @@ -129,6 +131,7 @@ struct LinphoneApp: App { meetingWaitingRoomViewModel = MeetingWaitingRoomViewModel() conversationsListViewModel = ConversationsListViewModel() conversationViewModel = ConversationViewModel() + scheduleMeetingViewModel = ScheduleMeetingViewModel() } } }.onChange(of: scenePhase) { newPhase in diff --git a/Linphone/UI/Main/ContentView.swift b/Linphone/UI/Main/ContentView.swift index 7f7fcae8e..f22199412 100644 --- a/Linphone/UI/Main/ContentView.swift +++ b/Linphone/UI/Main/ContentView.swift @@ -43,6 +43,7 @@ struct ContentView: View { @ObservedObject var meetingWaitingRoomViewModel: MeetingWaitingRoomViewModel @ObservedObject var conversationsListViewModel: ConversationsListViewModel @ObservedObject var conversationViewModel: ConversationViewModel + @ObservedObject var scheduleMeetingViewModel: ScheduleMeetingViewModel @State var index = 0 @State private var orientation = UIDevice.current.orientation @@ -62,6 +63,8 @@ struct ContentView: View { @State var fullscreenVideo = false @State var isShowCallsListFragment = false + @State var isShowScheduleMeetingFragment = false + var body: some View { let pub = NotificationCenter.default .publisher(for: NSNotification.Name("ContactLoaded")) @@ -229,7 +232,7 @@ struct ContentView: View { openMenu() } - Text(index == 0 ? "Contacts" : (index == 1 ? "Calls" : "Conversations")) + Text(index == 0 ? "Contacts" : (index == 1 ? "Calls" : (index == 2 ? "Conversations" : "Meetings"))) .default_text_style_white_800(styleSize: 20) .padding(.leading, 10) @@ -456,6 +459,11 @@ struct ContentView: View { ) } else if self.index == 2 { ConversationsView(conversationViewModel: conversationViewModel, conversationsListViewModel: conversationsListViewModel) + } else if self.index == 3 { + MeetingsView( + scheduleMeetingViewModel: scheduleMeetingViewModel, + isShowScheduleMeetingFragment: $isShowScheduleMeetingFragment + ) } } .frame(maxWidth: @@ -505,7 +513,7 @@ struct ContentView: View { } }) .padding(.top) - .frame(width: 100) + .frame(width: 66) Spacer() @@ -546,15 +554,15 @@ struct ContentView: View { .frame(width: 25, height: 25) if self.index == 1 { Text("Calls") - .default_text_style_700(styleSize: 10) + .default_text_style_700(styleSize: 9) } else { Text("Calls") - .default_text_style(styleSize: 10) + .default_text_style(styleSize: 9) } } }) .padding(.top) - .frame(width: 100) + .frame(width: 66) } Spacer() @@ -594,17 +602,42 @@ struct ContentView: View { if self.index == 2 { Text("Conversations") - .default_text_style_700(styleSize: 10) + .default_text_style_700(styleSize: 9) } else { Text("Conversations") - .default_text_style(styleSize: 10) + .default_text_style(styleSize: 9) } } }) .padding(.top) - .frame(width: 100) + .frame(width: 66) } + Spacer() + Button(action: { + self.index = 3 + contactViewModel.indexDisplayedFriend = nil + historyViewModel.displayedCall = nil + conversationViewModel.displayedConversation = nil + }, label: { + VStack { + Image("meetings") + .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() } } @@ -857,6 +890,16 @@ struct ContentView: View { } } + if isShowScheduleMeetingFragment { + ScheduleMeetingFragment( + scheduleMeetingViewModel: scheduleMeetingViewModel, + isShowScheduleMeetingFragment: $isShowScheduleMeetingFragment + ) + .zIndex(3) + .transition(.move(edge: .bottom)) + .onAppear { + } + } if telecomManager.meetingWaitingRoomDisplayed { MeetingWaitingRoomFragment(meetingWaitingRoomViewModel: meetingWaitingRoomViewModel) .zIndex(3) @@ -923,7 +966,8 @@ struct ContentView: View { callViewModel: CallViewModel(), meetingWaitingRoomViewModel: MeetingWaitingRoomViewModel(), conversationsListViewModel: ConversationsListViewModel(), - conversationViewModel: ConversationViewModel() + conversationViewModel: ConversationViewModel(), + scheduleMeetingViewModel: ScheduleMeetingViewModel() ) } // swiftlint:enable type_body_length diff --git a/Linphone/UI/Main/Meetings/Fragments/AddParticipantsFragment.swift b/Linphone/UI/Main/Meetings/Fragments/AddParticipantsFragment.swift index 8775ce5d8..96e2ecebd 100644 --- a/Linphone/UI/Main/Meetings/Fragments/AddParticipantsFragment.swift +++ b/Linphone/UI/Main/Meetings/Fragments/AddParticipantsFragment.swift @@ -21,8 +21,6 @@ struct AddParticipantsFragment: View { @ObservedObject var scheduleMeetingViewModel: ScheduleMeetingViewModel @State private var delayedColor = Color.white - - @Binding var isShowAddParticipantFragment: Bool @FocusState var isSearchFieldFocused: Bool var body: some View { @@ -52,8 +50,8 @@ struct AddParticipantsFragment: View { .padding(.top, 2) .padding(.leading, -10) .onTapGesture { - isShowAddParticipantFragment = false scheduleMeetingViewModel.participantsToAdd = [] + dismiss() } VStack(alignment: .leading, spacing: 3) { @@ -72,26 +70,31 @@ struct AddParticipantsFragment: View { .padding(.bottom, 4) .background(.white) - ForEach(0.. 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(.top, 2) + .padding(.leading, -10) + .onTapGesture { + withAnimation { + isShowScheduleMeetingFragment.toggle() + } + } + + Text("New meeting" ) + .multilineTextAlignment(.leading) + .default_text_style_orange_800(styleSize: 16) + + Spacer() + } + .frame(maxWidth: .infinity) + .frame(height: 50) + .padding(.horizontal) + .padding(.bottom, 4) + .background(.white) + /* + HStack { + Spacer() + HStack(alignment: .center) { + Button(action: { + scheduleMeetingViewModel.isBroadcastSelected.toggle() + }, label: { + Image("users-three") + .renderingMode(.template) + .resizable() + .foregroundStyle(scheduleMeetingViewModel.isBroadcastSelected ? .white : Color.orangeMain500) + .frame(width: 25, height: 25) + }) + Text("Meeting") + .default_text_style_orange_500( styleSize: 15) + .foregroundStyle(scheduleMeetingViewModel.isBroadcastSelected ? .white : Color.orangeMain500) + } + .padding(.horizontal, 40) + .padding(.vertical, 10) + .cornerRadius(60) + .overlay( + RoundedRectangle(cornerRadius: 60) + .inset(by: 0.5) + .stroke(Color.orangeMain500, lineWidth: 1) + .background(scheduleMeetingViewModel.isBroadcastSelected ? Color.orangeMain500 : Color.white) + ) + Spacer() + + HStack(alignment: .center) { + Button(action: { + scheduleMeetingViewModel.isBroadcastSelected.toggle() + }, label: { + Image("slideshow") + .renderingMode(.template) + .resizable() + .foregroundStyle(Color.orangeMain500) + .frame(width: 25, height: 25) + }) + + Text("Broadcast") + .default_text_style_orange_500( styleSize: 15) + } + .padding(.horizontal, 40) + .padding(.vertical, 10) + .cornerRadius(60) + .overlay( + RoundedRectangle(cornerRadius: 60) + .inset(by: 0.5) + .stroke(Color.orangeMain500, lineWidth: 1) + ) + Spacer() + } + */ + HStack(alignment: .center, spacing: 8) { + Image("users-three") + .renderingMode(.template) + .resizable() + .foregroundStyle(Color.grayMain2c600) + .frame(width: 24, height: 24) + .padding(.leading, 16) + TextField("Subject", text: $scheduleMeetingViewModel.subject) + .default_text_style_700(styleSize: 20) + .frame(height: 29, alignment: .leading) + Spacer() + } + 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) + .foregroundStyle(.clear) .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(.top, 2) - .padding(.leading, -10) - .onTapGesture { + .background(Color.gray200) + + HStack(alignment: .center, spacing: 8) { + Image("clock") + .renderingMode(.template) + .resizable() + .foregroundStyle(Color.grayMain2c800) + .frame(width: 24, height: 24) + .padding(.leading, 16) + Text(scheduleMeetingViewModel.fromDateStr) + .fontWeight(.bold) + .default_text_style_500(styleSize: 16) + .onTapGesture { + setFromDate = true + selectedDate = scheduleMeetingViewModel.fromDate + showDatePicker.toggle() + } + Spacer() + } + + if !scheduleMeetingViewModel.allDayMeeting { + HStack(spacing: 8) { + Text(scheduleMeetingViewModel.fromTime) + .fontWeight(.bold) + .padding(.leading, 48) + .frame(height: 29, alignment: .leading) + .default_text_style_500(styleSize: 16) + .opacity(scheduleMeetingViewModel.allDayMeeting ? 0 : 1) + .onTapGesture { + setFromDate = true + selectedDate = scheduleMeetingViewModel.fromDate + showTimePicker.toggle() + } + Text(scheduleMeetingViewModel.toTime) + .fontWeight(.bold) + .padding(.leading, 8) + .frame(height: 29, alignment: .leading) + .default_text_style_500(styleSize: 16) + .opacity(scheduleMeetingViewModel.allDayMeeting ? 0 : 1) + .onTapGesture { + setFromDate = false + selectedDate = scheduleMeetingViewModel.toDate + showTimePicker.toggle() + } + Spacer() + Toggle("", isOn: $scheduleMeetingViewModel.allDayMeeting) + .labelsHidden() + .tint(Color.orangeMain300) + Text("All day") + .default_text_style_500(styleSize: 16) + .padding(.trailing, 16) } + } else { + HStack(alignment: .center, spacing: 8) { + Image("clock") + .renderingMode(.template) + .resizable() + .foregroundStyle(Color.grayMain2c800) + .frame(width: 24, height: 24) + .padding(.leading, 16) + Text(scheduleMeetingViewModel.toDateStr) + .fontWeight(.bold) + .default_text_style_500(styleSize: 16) + .onTapGesture { + setFromDate = false + selectedDate = scheduleMeetingViewModel.toDate + showDatePicker.toggle() + } + Spacer() + Toggle("", isOn: $scheduleMeetingViewModel.allDayMeeting) + .labelsHidden() + .tint(Color.orangeMain300) + Text("All day") + .default_text_style_500(styleSize: 16) + .padding(.trailing, 16) } + } + HStack(alignment: .center, spacing: 8) { + Image("earth") + .renderingMode(.template) + .resizable() + .foregroundStyle(Color.grayMain2c800) + .frame(width: 24, height: 24) + .padding(.leading, 16) + Text("TODO : timezone") + .fontWeight(.bold) + .padding(.leading, 8) + .default_text_style_500(styleSize: 16) + Spacer() + } - Text("New meeting" ) - .multilineTextAlignment(.leading) - .default_text_style_orange_800(styleSize: 16) + HStack(alignment: .center, spacing: 8) { + Image("arrow-clockwise") + .renderingMode(.template) + .resizable() + .foregroundStyle(Color.grayMain2c800) + .frame(width: 24, height: 24) + .padding(.leading, 16) + Text("TODO : repeat") + .fontWeight(.bold) + .padding(.leading, 8) + .default_text_style_500(styleSize: 16) + Spacer() + } + Rectangle() + .foregroundStyle(.clear) + .frame(height: 1) + .background(Color.gray200) + + HStack(alignment: .top, spacing: 8) { + Image("note") + .renderingMode(.template) + .resizable() + .foregroundStyle(Color.grayMain2c600) + .frame(width: 24, height: 24) + .padding(.leading, 16) + + TextField("Add a description", text: $scheduleMeetingViewModel.description) + .default_text_style_700(styleSize: 16) + } + + Rectangle() + .foregroundStyle(.clear) + .frame(height: 1) + .background(Color.gray200) + + VStack { + NavigationLink(destination: { + AddParticipantsFragment(scheduleMeetingViewModel: scheduleMeetingViewModel) + }, label: { + HStack(alignment: .center, spacing: 8) { + 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 !scheduleMeetingViewModel.participants.isEmpty { + ScrollView { + ForEach(0.. some View { + return 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 = scheduleMeetingViewModel.fromDate.distance(to: scheduleMeetingViewModel.toDate) + if setFromDate { + scheduleMeetingViewModel.fromDate = selectedDate + // If new startdate is after previous end date, bump up the end date + if selectedDate > scheduleMeetingViewModel.toDate { + scheduleMeetingViewModel.toDate = Calendar.current.date(byAdding: .second, value: Int(duration), to: selectedDate)! + } + } else { + scheduleMeetingViewModel.toDate = selectedDate + if selectedDate < scheduleMeetingViewModel.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) { + scheduleMeetingViewModel.fromDate = Date.now + } else { + scheduleMeetingViewModel.fromDate = Calendar.current.date(byAdding: .second, value: (-1)*Int(duration), to: selectedDate)! + } + } + } + scheduleMeetingViewModel.computeDateLabels() + scheduleMeetingViewModel.computeTimeLabels() } @Sendable private func delayColor() async { @@ -217,7 +487,8 @@ struct ScheduleMeetingFragment: View { } #Preview { - ScheduleMeetingFragment(scheduleMeetingViewModel: ScheduleMeetingViewModel()) + ScheduleMeetingFragment(scheduleMeetingViewModel: ScheduleMeetingViewModel() + , isShowScheduleMeetingFragment: .constant(true)) } // swiftlint:enable line_length diff --git a/Linphone/UI/Main/Meetings/ViewModel/ScheduleMeetingViewModel.swift b/Linphone/UI/Main/Meetings/ViewModel/ScheduleMeetingViewModel.swift index 9015389fb..71260c046 100644 --- a/Linphone/UI/Main/Meetings/ViewModel/ScheduleMeetingViewModel.swift +++ b/Linphone/UI/Main/Meetings/ViewModel/ScheduleMeetingViewModel.swift @@ -21,11 +21,11 @@ import Foundation import linphonesw import Combine -class SelectedAddressModel { +class SelectedAddressModel: ObservableObject { var address: Address - var avatarModel: ContactAvatarModel? + var avatarModel: ContactAvatarModel - init (addr: Address, avModel: ContactAvatarModel?) { + init (addr: Address, avModel: ContactAvatarModel) { address = addr avatarModel = avModel } @@ -37,7 +37,7 @@ class ScheduleMeetingViewModel: ObservableObject { @Published var isBroadcastSelected: Bool = false @Published var showBroadcastHelp: Bool = false @Published var subject: String = "" - @Published var description: String = "aaaaaa aaaaaa" + @Published var description: String = "" @Published var allDayMeeting: Bool = false @Published var fromDateStr: String = "" @Published var fromTime: String = "" @@ -45,27 +45,30 @@ class ScheduleMeetingViewModel: ObservableObject { @Published var toTime: String = "" @Published var timezone: String = "" @Published var sendInvitations: Bool = true + @Published var participantsToAdd: [SelectedAddressModel] = [] @Published var participants: [SelectedAddressModel] = [] @Published var operationInProgress: Bool = false @Published var conferenceCreatedEvent: Bool = false + @Published var searchField: String = "" + var conferenceScheduler: ConferenceScheduler? private var mSchedulerSubscriptions = Set() var conferenceInfoToEdit: ConferenceInfo? - private var fromDate: Date - private var toDate: Date + @Published var fromDate: Date + @Published var toDate: Date init() { fromDate = Calendar.current.date(byAdding: .hour, value: 1, to: Date.now)! - toDate = Calendar.current.date(byAdding: .hour, value: 1, to: fromDate)! + toDate = Calendar.current.date(byAdding: .hour, value: 2, to: Date.now)! computeDateLabels() computeTimeLabels() updateTimezone() } - private func computeDateLabels() { + func computeDateLabels() { var day = fromDate.formatted(Date.FormatStyle().weekday(.wide)) var dayNumber = fromDate.formatted(Date.FormatStyle().day(.twoDigits)) var month = fromDate.formatted(Date.FormatStyle().month(.wide)) @@ -79,7 +82,7 @@ class ScheduleMeetingViewModel: ObservableObject { Log.info("\(ScheduleMeetingViewModel.TAG)) computed end date is \(toDateStr)") } - private func computeTimeLabels() { + func computeTimeLabels() { let formatter = DateFormatter() formatter.dateFormat = Locale.current.identifier == "fr_FR" ? "HH:mm" : "h:mm a" fromTime = formatter.string(from: fromDate) @@ -90,29 +93,28 @@ class ScheduleMeetingViewModel: ObservableObject { // TODO } - func addParticipants(toAdd: [String]) { - CoreContext.shared.doOnCoreQueue { _ in - var list = self.participants - for participant in toAdd { - if let address = try? Factory.Instance.createAddress(addr: participant) { - if let found = list.first(where: { $0.address.weakEqual(address2: address) }) { - Log.info("\(ScheduleMeetingViewModel.TAG) Participant \(found.address.asStringUriOnly()) already in list, skipping") - continue - } - - let avatarModel = ContactAvatarModel.getAvatarModelFromAddress(address: address) - list.append(SelectedAddressModel(addr: address, avModel: avatarModel)) - Log.info("\(ScheduleMeetingViewModel.TAG) Added participant \(address.asStringUriOnly())") - } else { - Log.error("\(ScheduleMeetingViewModel.TAG) Failed to parse \(participant) as address!") - } + func selectParticipant(addr: Address) { + if let idx = participantsToAdd.firstIndex(where: {$0.address.weakEqual(address2: addr)}) { + participantsToAdd.remove(at: idx) + } else { + participantsToAdd.append(SelectedAddressModel(addr: addr, avModel: ContactAvatarModel.getAvatarModelFromAddress(address: addr))) + } + } + func addParticipants() { + var list = participants + for selectedAddr in participantsToAdd { + if let found = list.first(where: { $0.address.weakEqual(address2: selectedAddr.address) }) { + Log.info("\(ScheduleMeetingViewModel.TAG) Participant \(found.address.asStringUriOnly()) already in list, skipping") + continue } - Log.info("\(ScheduleMeetingViewModel.TAG) [\(toAdd.count) participants added, now there are \(list.count) participants in list") - DispatchQueue.main.async { - self.participants = list - } + list.append(selectedAddr) + Log.info("\(ScheduleMeetingViewModel.TAG) Added participant \(selectedAddr.address.asStringUriOnly())") } + Log.info("\(ScheduleMeetingViewModel.TAG) [\(list.count - participants.count) participants added, now there are \(list.count) participants in list") + + participants = list + participantsToAdd = [] } private func fillConferenceInfo(confInfo: ConferenceInfo) { @@ -169,7 +171,7 @@ class ScheduleMeetingViewModel: ObservableObject { Log.info("\(ScheduleMeetingViewModel.TAG) User didn't asked for invitations to be sent") DispatchQueue.main.async { self.operationInProgress = false - self.conferenceCreatedEvent = false + self.conferenceCreatedEvent = true } } } From 08f164fc88a1db8b68b21e669d595271d23cec71 Mon Sep 17 00:00:00 2001 From: QuentinArguillere Date: Fri, 19 Apr 2024 16:42:45 +0200 Subject: [PATCH 181/486] Remove temporary participantmodel from meetingviewmodel --- Linphone/UI/Call/Model/ParticipantModel.swift | 23 ++++------------- .../Meetings/ViewModel/MeetingViewModel.swift | 25 ------------------- 2 files changed, 5 insertions(+), 43 deletions(-) diff --git a/Linphone/UI/Call/Model/ParticipantModel.swift b/Linphone/UI/Call/Model/ParticipantModel.swift index 2f5c9786a..6f66617ef 100644 --- a/Linphone/UI/Call/Model/ParticipantModel.swift +++ b/Linphone/UI/Call/Model/ParticipantModel.swift @@ -32,31 +32,18 @@ class ParticipantModel: ObservableObject { @Published var onPause: Bool @Published var isMuted: Bool - init(address: Address, isJoining: Bool, onPause: Bool, isMuted: Bool) { + init(address: Address, isJoining: Bool = false, onPause: Bool = false, isMuted: Bool = false) { self.address = address self.sipUri = address.asStringUriOnly() - let addressFriend = ContactsManager.shared.getFriendWithAddress(address: self.address) - - var nameTmp = "" - - if addressFriend != nil { - nameTmp = addressFriend!.name! + if let addressFriend = ContactsManager.shared.getFriendWithAddress(address: self.address) { + self.name = addressFriend.name! } else { - nameTmp = address.displayName != nil - ? address.displayName! - : address.username! + self.name = address.displayName != nil ? address.displayName! : address.username! } - self.name = nameTmp - - self.avatarModel = addressFriend != nil - ? ContactsManager.shared.avatarListModel.first(where: { - $0.friend!.name == addressFriend!.name - && $0.friend!.address!.asStringUriOnly() == address.asStringUriOnly() - }) ?? ContactAvatarModel(friend: nil, name: nameTmp, withPresence: false) - : ContactAvatarModel(friend: nil, name: nameTmp, withPresence: false) + self.avatarModel = ContactAvatarModel.getAvatarModelFromAddress(address: self.address) self.isJoining = isJoining self.onPause = onPause diff --git a/Linphone/UI/Main/Meetings/ViewModel/MeetingViewModel.swift b/Linphone/UI/Main/Meetings/ViewModel/MeetingViewModel.swift index 6910d011f..12e16a223 100644 --- a/Linphone/UI/Main/Meetings/ViewModel/MeetingViewModel.swift +++ b/Linphone/UI/Main/Meetings/ViewModel/MeetingViewModel.swift @@ -20,31 +20,6 @@ import Foundation import linphonesw -// TODO: à merger avec le ParticipantModel de la branche de Benoit -class ParticipantModel: ObservableObject { - - static let TAG = "[Participant Model]" - - let address: Address - @Published var sipUri: String - @Published var name: String - @Published var avatarModel: ContactAvatarModel - - init(address: Address) { - self.address = address - - self.sipUri = address.asStringUriOnly() - - if let addressFriend = ContactsManager.shared.getFriendWithAddress(address: self.address) { - self.name = addressFriend.name! - } else { - self.name = address.displayName != nil ? address.displayName! : address.username! - } - - self.avatarModel = ContactAvatarModel.getAvatarModelFromAddress(address: self.address) - } -} - class MeetingViewModel: ObservableObject { static let TAG = "[Meeting ViewModel]" From 269eeba4803e4f044859c64123689853c7550b74 Mon Sep 17 00:00:00 2001 From: QuentinArguillere Date: Fri, 19 Apr 2024 16:49:03 +0200 Subject: [PATCH 182/486] Implement MeetingsFragment and MeetingsView --- .../Meetings/Fragments/MeetingsFragment.swift | 38 +++++++++++++++++-- Linphone/UI/Main/Meetings/MeetingsView.swift | 37 ++++++++++++++++-- 2 files changed, 67 insertions(+), 8 deletions(-) diff --git a/Linphone/UI/Main/Meetings/Fragments/MeetingsFragment.swift b/Linphone/UI/Main/Meetings/Fragments/MeetingsFragment.swift index fd8237503..63e4d7f57 100644 --- a/Linphone/UI/Main/Meetings/Fragments/MeetingsFragment.swift +++ b/Linphone/UI/Main/Meetings/Fragments/MeetingsFragment.swift @@ -8,11 +8,41 @@ import SwiftUI struct MeetingsFragment: View { - var body: some View { - Text(/*@START_MENU_TOKEN@*/"Hello, World!"/*@END_MENU_TOKEN@*/) - } + + @ObservedObject var scheduleMeetingViewModel: ScheduleMeetingViewModel + + private var idiom: UIUserInterfaceIdiom { UIDevice.current.userInterfaceIdiom } + + @State var showingSheet: Bool = false + + var body: some View { + VStack { + Spacer() + Text("Hello meetings list") + Spacer() + /* + if #available(iOS 16.0, *), idiom != .pad { + MeetingsListFragment(conversationViewModel: conversationViewModel, conversationsListViewModel: conversationsListViewModel, showingSheet: $showingSheet) + .sheet(isPresented: $showingSheet) { + ConversationsListBottomSheet( + conversationsListViewModel: conversationsListViewModel, + showingSheet: $showingSheet + ) + .presentationDetents([.fraction(0.4)]) + } + } else { + ConversationsListFragment(conversationViewModel: conversationViewModel, conversationsListViewModel: conversationsListViewModel, showingSheet: $showingSheet) + .halfSheet(showSheet: $showingSheet) { + ConversationsListBottomSheet( + conversationsListViewModel: conversationsListViewModel, + showingSheet: $showingSheet + ) + } onDismiss: {} + } */ + } + } } #Preview { - MeetingsFragment() + MeetingsFragment(scheduleMeetingViewModel: ScheduleMeetingViewModel()) } diff --git a/Linphone/UI/Main/Meetings/MeetingsView.swift b/Linphone/UI/Main/Meetings/MeetingsView.swift index adf0088d7..4af7b09f0 100644 --- a/Linphone/UI/Main/Meetings/MeetingsView.swift +++ b/Linphone/UI/Main/Meetings/MeetingsView.swift @@ -8,11 +8,40 @@ import SwiftUI struct MeetingsView: View { - var body: some View { - Text(/*@START_MENU_TOKEN@*/"Hello, World!"/*@END_MENU_TOKEN@*/) - } + + @ObservedObject var scheduleMeetingViewModel: ScheduleMeetingViewModel + + @Binding var isShowScheduleMeetingFragment: Bool + + var body: some View { + NavigationView { + ZStack(alignment: .bottomTrailing) { + MeetingsFragment(scheduleMeetingViewModel: scheduleMeetingViewModel) + + Button { + withAnimation { + isShowScheduleMeetingFragment.toggle() + } + } label: { + Image("plus-circle") + .renderingMode(.template) + .foregroundStyle(.white) + .padding() + .background(Color.orangeMain500) + .clipShape(Circle()) + .shadow(color: .black.opacity(0.2), radius: 4) + + } + .padding() + } + } + .navigationViewStyle(.stack) + } } #Preview { - MeetingsView() + MeetingsView( + scheduleMeetingViewModel: ScheduleMeetingViewModel(), + isShowScheduleMeetingFragment: .constant(false) + ) } From 352cfae6f535c1b8d956ca6bfbbbbd4962662ebd Mon Sep 17 00:00:00 2001 From: QuentinArguillere Date: Fri, 19 Apr 2024 17:43:34 +0200 Subject: [PATCH 183/486] Fix date adjustments when scheduling meeting --- .../UI/Main/Meetings/Fragments/ScheduleMeetingFragment.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Linphone/UI/Main/Meetings/Fragments/ScheduleMeetingFragment.swift b/Linphone/UI/Main/Meetings/Fragments/ScheduleMeetingFragment.swift index 354d4d7a5..8beb2f117 100644 --- a/Linphone/UI/Main/Meetings/Fragments/ScheduleMeetingFragment.swift +++ b/Linphone/UI/Main/Meetings/Fragments/ScheduleMeetingFragment.swift @@ -451,7 +451,7 @@ struct ScheduleMeetingFragment: View { } func pickDate() { - let duration = scheduleMeetingViewModel.fromDate.distance(to: scheduleMeetingViewModel.toDate) + let duration = min(scheduleMeetingViewModel.fromDate.distance(to: scheduleMeetingViewModel.toDate), 86400) // Limit auto correction of dates to 24h if setFromDate { scheduleMeetingViewModel.fromDate = selectedDate // If new startdate is after previous end date, bump up the end date From 0f57545a2a31d8dbaf93e952f64356b5036e834d Mon Sep 17 00:00:00 2001 From: QuentinArguillere Date: Mon, 22 Apr 2024 11:31:21 +0200 Subject: [PATCH 184/486] Update build version to 10 --- Linphone.xcodeproj/project.pbxproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Linphone.xcodeproj/project.pbxproj b/Linphone.xcodeproj/project.pbxproj index 321f92296..4cc29b46e 100644 --- a/Linphone.xcodeproj/project.pbxproj +++ b/Linphone.xcodeproj/project.pbxproj @@ -1304,7 +1304,7 @@ ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = NO; CODE_SIGN_ENTITLEMENTS = Linphone/Linphone.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 4; + CURRENT_PROJECT_VERSION = 10; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_ASSET_PATHS = "\"Linphone/Preview Content\""; DEVELOPMENT_TEAM = Z2V957B3D6; From 4647b00b9e27093a85722978c3d517771ecf3500 Mon Sep 17 00:00:00 2001 From: QuentinArguillere Date: Mon, 22 Apr 2024 11:32:10 +0200 Subject: [PATCH 185/486] Delete code that disabled conference features for previous versions --- Linphone/UI/Assistant/Viewmodel/AccountLoginViewModel.swift | 6 ------ 1 file changed, 6 deletions(-) diff --git a/Linphone/UI/Assistant/Viewmodel/AccountLoginViewModel.swift b/Linphone/UI/Assistant/Viewmodel/AccountLoginViewModel.swift index fbae5ce2d..57dd6688d 100644 --- a/Linphone/UI/Assistant/Viewmodel/AccountLoginViewModel.swift +++ b/Linphone/UI/Assistant/Viewmodel/AccountLoginViewModel.swift @@ -98,12 +98,6 @@ class AccountLoginViewModel: ObservableObject { #endif accountParams.pushNotificationConfig?.provider = "apns" + pushEnvironment - // Temporary disable these features are they are not used for 6.0 first version - //accountParams.conferenceFactoryUri = nil - //accountParams.conferenceFactoryAddress = nil - accountParams.audioVideoConferenceFactoryAddress = nil - //accountParams.limeServerUrl = nil - // Now that our AccountParams is configured, we can create the Account object let account = try core.createAccount(params: accountParams) From 9bdd3c088ea51e89fb97d2984ff95c8fbac16e88 Mon Sep 17 00:00:00 2001 From: QuentinArguillere Date: Mon, 22 Apr 2024 11:32:55 +0200 Subject: [PATCH 186/486] Reset scheduler data when opening a new conf scheduling view --- Linphone/UI/Main/Meetings/MeetingsView.swift | 1 + .../ViewModel/ScheduleMeetingViewModel.swift | 20 +++++++++++++++++++ 2 files changed, 21 insertions(+) diff --git a/Linphone/UI/Main/Meetings/MeetingsView.swift b/Linphone/UI/Main/Meetings/MeetingsView.swift index 4af7b09f0..1591267c2 100644 --- a/Linphone/UI/Main/Meetings/MeetingsView.swift +++ b/Linphone/UI/Main/Meetings/MeetingsView.swift @@ -20,6 +20,7 @@ struct MeetingsView: View { Button { withAnimation { + scheduleMeetingViewModel.resetViewModelData() isShowScheduleMeetingFragment.toggle() } } label: { diff --git a/Linphone/UI/Main/Meetings/ViewModel/ScheduleMeetingViewModel.swift b/Linphone/UI/Main/Meetings/ViewModel/ScheduleMeetingViewModel.swift index 71260c046..d24333783 100644 --- a/Linphone/UI/Main/Meetings/ViewModel/ScheduleMeetingViewModel.swift +++ b/Linphone/UI/Main/Meetings/ViewModel/ScheduleMeetingViewModel.swift @@ -62,7 +62,27 @@ class ScheduleMeetingViewModel: ObservableObject { init() { fromDate = Calendar.current.date(byAdding: .hour, value: 1, to: Date.now)! toDate = Calendar.current.date(byAdding: .hour, value: 2, to: Date.now)! + computeDateLabels() + computeTimeLabels() + updateTimezone() + } + + func resetViewModelData() { + isBroadcastSelected = false + showBroadcastHelp = false + subject = "" + description = "" + allDayMeeting = false + timezone = "" + sendInvitations = true + participantsToAdd = [] + participants = [] + operationInProgress = false + conferenceCreatedEvent = false + searchField = "" + fromDate = Calendar.current.date(byAdding: .hour, value: 1, to: Date.now)! + toDate = Calendar.current.date(byAdding: .hour, value: 2, to: Date.now)! computeDateLabels() computeTimeLabels() updateTimezone() From 2b574cd896208c29700a3e0c4b3bb8b49836c8bb Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Mon, 22 Apr 2024 10:52:28 +0200 Subject: [PATCH 187/486] Fix call view --- Linphone/Localizable.xcstrings | 49 +++ Linphone/UI/Call/CallView.swift | 146 +++++--- .../UI/Call/ViewModel/CallViewModel.swift | 353 +++++++++++------- .../MeetingWaitingRoomViewModel.swift | 7 +- 4 files changed, 361 insertions(+), 194 deletions(-) diff --git a/Linphone/Localizable.xcstrings b/Linphone/Localizable.xcstrings index 402f26965..0fe4e9fd1 100644 --- a/Linphone/Localizable.xcstrings +++ b/Linphone/Localizable.xcstrings @@ -104,6 +104,9 @@ } } } + }, + "%lld selected participants" : { + }, "+" : { @@ -146,9 +149,15 @@ }, "Active" : { + }, + "Add a description" : { + }, "Add a picture" : { + }, + "Add participants" : { + }, "Add the contact" : { @@ -164,6 +173,9 @@ }, "All contacts" : { + }, + "All day" : { + }, "All modifications will be canceled." : { @@ -375,6 +387,9 @@ }, "Headphones" : { + }, + "Hello meetings list" : { + }, "History has been deleted" : { @@ -455,6 +470,9 @@ }, "Marquer comme non lu" : { + }, + "Meetings" : { + }, "Mercredi 10 Avril 2024 | 2:41 PM - 2:42 PM" : { @@ -476,6 +494,9 @@ }, "New contact" : { + }, + "New meeting" : { + }, "Next" : { @@ -595,6 +616,9 @@ }, "Scan QR code" : { + }, + "Search contact" : { + }, "Search contact or history call" : { @@ -610,6 +634,19 @@ }, "See Linphone contact" : { + }, + "Select %@ %@" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Select %1$@ %2$@" + } + } + } + }, + "Send invitations to participants" : { + }, "Send logs" : { @@ -637,6 +674,9 @@ }, "Start" : { + }, + "Subject" : { + }, "Suggestions" : { @@ -664,6 +704,15 @@ }, "to Linphone" : { + }, + "TODO" : { + + }, + "TODO : repeat" : { + + }, + "TODO : timezone" : { + }, "Transfer" : { diff --git a/Linphone/UI/Call/CallView.swift b/Linphone/UI/Call/CallView.swift index cb9a50048..a372776ef 100644 --- a/Linphone/UI/Call/CallView.swift +++ b/Linphone/UI/Call/CallView.swift @@ -666,10 +666,8 @@ struct CallView: View { VStack { VStack { LinphoneVideoViewHolder { view in - DispatchQueue.main.asyncAfter(deadline: .now() + 1) { - coreContext.doOnCoreQueue { core in - core.nativeVideoWindow = view - } + coreContext.doOnCoreQueue { core in + core.nativeVideoWindow = view } } .onTapGesture { @@ -745,10 +743,8 @@ struct CallView: View { if callViewModel.videoDisplayed { LinphoneVideoViewHolder { view in - DispatchQueue.main.asyncAfter(deadline: .now() + 1) { - coreContext.doOnCoreQueue { core in - core.nativePreviewWindow = view - } + 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) @@ -1023,12 +1019,6 @@ struct CallView: View { } } - telecomManager.callStarted = false - - DispatchQueue.global().asyncAfter(deadline: .now() + 0.5) { - telecomManager.callStarted = true - } - callViewModel.orientationUpdate(orientation: orientation) } } @@ -1297,28 +1287,52 @@ struct CallView: View { } .frame(width: geo.size.width * 0.25, height: geo.size.width * 0.25) - VStack { - Button { - showingDialer.toggle() - } label: { - HStack { - Image("dialer") - .renderingMode(.template) - .resizable() - .foregroundStyle(.white) - .frame(width: 32, height: 32) + if callViewModel.isOneOneCall { + VStack { + Button { + showingDialer.toggle() + } label: { + HStack { + Image("dialer") + .renderingMode(.template) + .resizable() + .foregroundStyle(.white) + .frame(width: 32, height: 32) + } } + .buttonStyle(PressedButtonStyle()) + .frame(width: 60, height: 60) + .background(Color.gray500) + .cornerRadius(40) + + Text("Dialer") + .foregroundStyle(.white) + .default_text_style(styleSize: 15) } - .buttonStyle(PressedButtonStyle()) - .frame(width: 60, height: 60) - .background(Color.gray500) - .cornerRadius(40) - - Text("Dialer") - .foregroundStyle(.white) - .default_text_style(styleSize: 15) + .frame(width: geo.size.width * 0.25, height: geo.size.width * 0.25) + } else { + VStack { + Button { + } label: { + HStack { + Image("notebook") + .renderingMode(.template) + .resizable() + .foregroundStyle(.white) + .frame(width: 32, height: 32) + } + } + .buttonStyle(PressedButtonStyle()) + .frame(width: 60, height: 60) + .background(Color.gray500) + .cornerRadius(40) + + Text("Disposition") + .foregroundStyle(.white) + .default_text_style(styleSize: 15) + } + .frame(width: geo.size.width * 0.25, height: geo.size.width * 0.25) } - .frame(width: geo.size.width * 0.25, height: geo.size.width * 0.25) } .frame(height: geo.size.height * 0.15) @@ -1501,6 +1515,10 @@ struct CallView: View { VStack { ZStack { Button { + callViewModel.getCallsList() + withAnimation { + isShowCallsListFragment.toggle() + } } label: { HStack { Image("phone-list") @@ -1542,28 +1560,52 @@ struct CallView: View { } .frame(width: geo.size.width * 0.125, height: geo.size.width * 0.125) - VStack { - Button { - showingDialer.toggle() - } label: { - HStack { - Image("dialer") - .renderingMode(.template) - .resizable() - .foregroundStyle(.white) - .frame(width: 32, height: 32) + if callViewModel.isOneOneCall { + VStack { + Button { + showingDialer.toggle() + } label: { + HStack { + Image("dialer") + .renderingMode(.template) + .resizable() + .foregroundStyle(.white) + .frame(width: 32, height: 32) + } } + .buttonStyle(PressedButtonStyle()) + .frame(width: 60, height: 60) + .background(Color.gray500) + .cornerRadius(40) + + Text("Dialer") + .foregroundStyle(.white) + .default_text_style(styleSize: 15) } - .buttonStyle(PressedButtonStyle()) - .frame(width: 60, height: 60) - .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 { + } label: { + HStack { + Image("notebook") + .renderingMode(.template) + .resizable() + .foregroundStyle(.white) + .frame(width: 32, height: 32) + } + } + .buttonStyle(PressedButtonStyle()) + .frame(width: 60, height: 60) + .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) } - .frame(width: geo.size.width * 0.125, height: geo.size.width * 0.125) VStack { Button { diff --git a/Linphone/UI/Call/ViewModel/CallViewModel.swift b/Linphone/UI/Call/ViewModel/CallViewModel.swift index db717f91a..d0a4ee766 100644 --- a/Linphone/UI/Call/ViewModel/CallViewModel.swift +++ b/Linphone/UI/Call/ViewModel/CallViewModel.swift @@ -50,9 +50,9 @@ class CallViewModel: ObservableObject { @Published var isConference: Bool = false @Published var videoDisplayed: Bool = false @Published var participantList: [ParticipantModel] = [] - @Published var activeSpeakerParticipant: ParticipantModel? = nil + @Published var activeSpeakerParticipant: ParticipantModel? @Published var activeSpeakerName: String = "" - @Published var myParticipantModel: ParticipantModel? = nil + @Published var myParticipantModel: ParticipantModel? private var mConferenceSuscriptions = Set() @@ -70,7 +70,6 @@ class CallViewModel: ObservableObject { } catch _ { } - resetCallView() } func enableAVAudioSession() { @@ -104,9 +103,9 @@ class CallViewModel: ObservableObject { var isOneOneCallTmp = false if self.currentCall?.remoteAddress != nil { - let conf = core.findConferenceInformationFromUri(uri: self.currentCall!.remoteAddress!) - - if conf == nil { + let conf = self.currentCall!.conference + let confInfo = core.findConferenceInformationFromUri(uri: self.currentCall!.remoteAddress!) + if conf == nil && confInfo == nil { isOneOneCallTmp = true } } @@ -123,35 +122,47 @@ class CallViewModel: ObservableObject { } } - DispatchQueue.main.async { - self.direction = self.currentCall!.dir - self.remoteAddressString = String(self.currentCall!.remoteAddress!.asStringUriOnly().dropFirst(4)) - self.remoteAddress = self.currentCall!.remoteAddress! - self.displayName = "" - if self.currentCall?.conference != nil { - self.displayName = 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 { - self.displayName = friend!.address!.displayName! - } else { - if self.currentCall!.remoteAddress!.displayName != nil { - self.displayName = self.currentCall!.remoteAddress!.displayName! - } else if self.currentCall!.remoteAddress!.username != nil { - self.displayName = self.currentCall!.remoteAddress!.username! - } + let directionTmp = self.currentCall!.dir + let remoteAddressStringTmp = String(self.currentCall!.remoteAddress!.asStringUriOnly().dropFirst(4)) + let remoteAddressTmp = self.currentCall!.remoteAddress! + var displayNameTmp = "" + 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 = self.currentCall!.remoteAddress!.username! } } + } + + 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!.authenticationToken + let isDeviceTrusted = self.currentCall!.authenticationTokenVerified && authToken != nil + let isRemoteDeviceTrustedTmp = self.telecomManager.callInProgress ? isDeviceTrusted : false + + DispatchQueue.main.async { + self.direction = directionTmp + self.remoteAddressString = remoteAddressStringTmp + self.remoteAddress = remoteAddressTmp + self.displayName = displayNameTmp //self.avatarModel = ??? - self.micMutted = self.currentCall!.microphoneMuted || !core.micEnabled - self.isRecording = self.currentCall!.params!.isRecording - self.isPaused = self.isCallPaused() - self.timeElapsed = self.currentCall?.duration ?? 0 + self.micMutted = micMuttedTmp + self.isRecording = isRecordingTmp + self.isPaused = isPausedTmp + self.timeElapsed = timeElapsedTmp - let authToken = self.currentCall!.authenticationToken - let isDeviceTrusted = self.currentCall!.authenticationTokenVerified && authToken != nil - self.isRemoteDeviceTrusted = self.telecomManager.callInProgress ? isDeviceTrusted : false + self.isRemoteDeviceTrusted = isRemoteDeviceTrustedTmp self.activeSpeakerParticipant = nil self.avatarModel = nil @@ -203,66 +214,81 @@ class CallViewModel: ObservableObject { if self.currentCall?.conference != nil { let conf = self.currentCall!.conference! self.isConference = true + + let displayNameTmp = conf.subject ?? "" + + var myParticipantModelTmp: ParticipantModel? = nil + if conf.me?.address != nil { + myParticipantModelTmp = ParticipantModel(address: conf.me!.address!, isJoining: false, onPause: false, isMuted: false) + } else if self.currentCall?.callLog?.localAddress != nil { + myParticipantModelTmp = ParticipantModel(address: self.currentCall!.callLog!.localAddress!, isJoining: false, onPause: false, isMuted: false) + } + + var activeSpeakerParticipantTmp: ParticipantModel? = nil + 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()!) { + participantListTmp.append( + ParticipantModel( + address: participantDevice.address!, + isJoining: participantDevice.state == .Joining || participantDevice.state == .Alerting, + onPause: participantDevice.state == .OnHold, + isMuted: participantDevice.isMuted + ) + ) + } + } + }) + DispatchQueue.main.async { - self.displayName = conf.subject ?? "" + self.displayName = displayNameTmp self.participantList = [] - if conf.me?.address != nil { - self.myParticipantModel = ParticipantModel(address: conf.me!.address!, isJoining: false, onPause: false, isMuted: false) - } else if self.currentCall?.callLog?.localAddress != nil { - self.myParticipantModel = ParticipantModel(address: self.currentCall!.callLog!.localAddress!, isJoining: false, onPause: false, isMuted: false) - } + self.myParticipantModel = myParticipantModelTmp - if conf.activeSpeakerParticipantDevice?.address != nil { - self.activeSpeakerParticipant = 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)!) { - self.activeSpeakerParticipant = 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 { - self.activeSpeakerParticipant = ParticipantModel( - address: conf.participantDeviceList.last!.address!, - isJoining: false, - onPause: conf.participantDeviceList.last!.state == .OnHold, - isMuted: conf.participantDeviceList.last!.isMuted - ) - } + self.activeSpeakerParticipant = activeSpeakerParticipantTmp - if self.activeSpeakerParticipant != nil { - let friend = ContactsManager.shared.getFriendWithAddress(address: self.activeSpeakerParticipant!.address) - if friend != nil && friend!.address != nil && friend!.address!.displayName != nil { - self.activeSpeakerName = friend!.address!.displayName! - } else { - if self.activeSpeakerParticipant!.address.displayName != nil { - self.activeSpeakerName = self.activeSpeakerParticipant!.address.displayName! - } else if self.activeSpeakerParticipant!.address.username != nil { - self.activeSpeakerName = self.activeSpeakerParticipant!.address.username! - } - } - } + self.activeSpeakerName = activeSpeakerNameTmp - conf.participantDeviceList.forEach({ participantDevice in - if participantDevice.address != nil && !conf.isMe(uri: participantDevice.address!.clone()!) { - if !conf.isMe(uri: participantDevice.address!.clone()!) { - self.participantList.append( - ParticipantModel( - address: participantDevice.address!, - isJoining: participantDevice.state == .Joining || participantDevice.state == .Alerting, - onPause: participantDevice.state == .OnHold, - isMuted: participantDevice.isMuted - ) - ) - } - } - }) + self.participantList = participantListTmp self.addConferenceCallBacks() } @@ -287,36 +313,35 @@ class CallViewModel: ObservableObject { self.mConferenceSuscriptions.insert( self.currentCall?.conference?.publisher?.onActiveSpeakerParticipantDevice?.postOnMainQueue {(cbValue: (conference: Conference, participantDevice: ParticipantDevice)) in if cbValue.participantDevice.address != nil { - let activeSpeakerParticipantTmp = self.activeSpeakerParticipant - self.activeSpeakerParticipant = ParticipantModel( + let activeSpeakerParticipantBis = self.activeSpeakerParticipant + + let activeSpeakerParticipantTmp = ParticipantModel( address: cbValue.participantDevice.address!, isJoining: false, onPause: cbValue.participantDevice.state == .OnHold, isMuted: cbValue.participantDevice.isMuted ) - if self.activeSpeakerParticipant != nil { - let friend = ContactsManager.shared.getFriendWithAddress(address: self.activeSpeakerParticipant!.address) - if friend != nil && friend!.address != nil && friend!.address!.displayName != nil { - self.activeSpeakerName = friend!.address!.displayName! - } else { - if self.activeSpeakerParticipant!.address.displayName != nil { - self.activeSpeakerName = self.activeSpeakerParticipant!.address.displayName! - } else if self.activeSpeakerParticipant!.address.username != nil { - self.activeSpeakerName = self.activeSpeakerParticipant!.address.username! - } + 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! } } - if self.activeSpeakerParticipant != nil - && ((activeSpeakerParticipantTmp != nil && !activeSpeakerParticipantTmp!.address.equal(address2: self.activeSpeakerParticipant!.address)) - || ( activeSpeakerParticipantTmp == nil)) { + var participantListTmp: [ParticipantModel] = [] + if (activeSpeakerParticipantBis != nil && !activeSpeakerParticipantBis!.address.equal(address2: activeSpeakerParticipantTmp.address)) + || ( activeSpeakerParticipantBis == nil) { - self.participantList = [] cbValue.conference.participantDeviceList.forEach({ participantDevice in if participantDevice.address != nil && !cbValue.conference.isMe(uri: participantDevice.address!.clone()!) { if !cbValue.conference.isMe(uri: participantDevice.address!.clone()!) { - self.participantList.append( + participantListTmp.append( ParticipantModel( address: participantDevice.address!, isJoining: participantDevice.state == .Joining || participantDevice.state == .Alerting, @@ -328,6 +353,12 @@ class CallViewModel: ObservableObject { } }) } + + DispatchQueue.main.async { + self.activeSpeakerParticipant = activeSpeakerParticipantTmp + self.activeSpeakerName = activeSpeakerNameTmp + self.participantList = participantListTmp + } } } ) @@ -335,33 +366,11 @@ class CallViewModel: ObservableObject { self.mConferenceSuscriptions.insert( self.currentCall?.conference?.publisher?.onParticipantDeviceAdded?.postOnMainQueue {(cbValue: (conference: Conference, participantDevice: ParticipantDevice)) in if cbValue.participantDevice.address != nil { - self.participantList = [] + var participantListTmp: [ParticipantModel] = [] cbValue.conference.participantDeviceList.forEach({ participantDevice in if participantDevice.address != nil && !cbValue.conference.isMe(uri: participantDevice.address!.clone()!) { if !cbValue.conference.isMe(uri: participantDevice.address!.clone()!) { - self.participantList.append( - ParticipantModel( - address: participantDevice.address!, - isJoining: participantDevice.state == .Joining || participantDevice.state == .Alerting, - onPause: participantDevice.state == .OnHold, - isMuted: participantDevice.isMuted - ) - ) - } - } - }) - } - } - ) - - self.mConferenceSuscriptions.insert( - self.currentCall?.conference?.publisher?.onParticipantDeviceRemoved?.postOnMainQueue {(cbValue: (conference: Conference, participantDevice: ParticipantDevice)) in - if cbValue.participantDevice.address != nil { - self.participantList = [] - cbValue.conference.participantDeviceList.forEach({ participantDevice in - if participantDevice.address != nil && !cbValue.conference.isMe(uri: participantDevice.address!.clone()!) { - if !cbValue.conference.isMe(uri: participantDevice.address!.clone()!) { - self.participantList.append( + participantListTmp.append( ParticipantModel( address: participantDevice.address!, isJoining: participantDevice.state == .Joining || participantDevice.state == .Alerting, @@ -373,8 +382,40 @@ class CallViewModel: ObservableObject { } }) - if cbValue.conference.participantDeviceList.count == 1 { - self.activeSpeakerParticipant = nil + DispatchQueue.main.async { + self.participantList = participantListTmp + } + } + } + ) + + self.mConferenceSuscriptions.insert( + self.currentCall?.conference?.publisher?.onParticipantDeviceRemoved?.postOnMainQueue {(cbValue: (conference: Conference, participantDevice: ParticipantDevice)) in + if cbValue.participantDevice.address != nil { + var participantListTmp: [ParticipantModel] = [] + cbValue.conference.participantDeviceList.forEach({ participantDevice in + if participantDevice.address != nil && !cbValue.conference.isMe(uri: participantDevice.address!.clone()!) { + if !cbValue.conference.isMe(uri: participantDevice.address!.clone()!) { + participantListTmp.append( + ParticipantModel( + address: participantDevice.address!, + isJoining: participantDevice.state == .Joining || participantDevice.state == .Alerting, + onPause: participantDevice.state == .OnHold, + isMuted: participantDevice.isMuted + ) + ) + } + } + }) + + let participantDeviceListCount = cbValue.conference.participantDeviceList.count + + DispatchQueue.main.async { + self.participantList = participantListTmp + + if participantDeviceListCount == 1 { + self.activeSpeakerParticipant = nil + } } } } @@ -383,11 +424,19 @@ class CallViewModel: ObservableObject { self.mConferenceSuscriptions.insert( self.currentCall?.conference?.publisher?.onParticipantDeviceIsMuted?.postOnMainQueue {(cbValue: (conference: Conference, participantDevice: ParticipantDevice, isMuted: Bool)) in if self.activeSpeakerParticipant != nil && self.activeSpeakerParticipant!.address.equal(address2: cbValue.participantDevice.address!) { - self.activeSpeakerParticipant!.isMuted = cbValue.isMuted + let isMutedTmp = cbValue.isMuted + + DispatchQueue.main.async { + self.activeSpeakerParticipant!.isMuted = isMutedTmp + } } else { self.participantList.forEach({ participantDevice in if participantDevice.address.equal(address2: cbValue.participantDevice.address!) { - participantDevice.isMuted = cbValue.isMuted + let isMutedTmp = cbValue.isMuted + + DispatchQueue.main.async { + participantDevice.isMuted = isMutedTmp + } } }) } @@ -400,14 +449,22 @@ class CallViewModel: ObservableObject { "[CallViewModel] Participant device \(cbValue.device.address!.asStringUriOnly()) state changed \(cbValue.state)" ) if self.activeSpeakerParticipant != nil && self.activeSpeakerParticipant!.address.equal(address2: cbValue.device.address!) { - self.activeSpeakerParticipant!.onPause = cbValue.state == .OnHold - self.activeSpeakerParticipant!.isJoining = cbValue.state == .Joining || cbValue.state == .Alerting + let activeSpeakerParticipantOnPauseTmp = cbValue.state == .OnHold + let activeSpeakerParticipantIsJoiningTmp = cbValue.state == .Joining || cbValue.state == .Alerting + DispatchQueue.main.async { + self.activeSpeakerParticipant!.onPause = activeSpeakerParticipantOnPauseTmp + self.activeSpeakerParticipant!.isJoining = activeSpeakerParticipantIsJoiningTmp + } } else { self.participantList.forEach({ participantDevice in - if participantDevice.address.equal(address2: cbValue.device.address!) { - participantDevice.onPause = cbValue.state == .OnHold - participantDevice.isJoining = cbValue.state == .Joining || cbValue.state == .Alerting - } + if participantDevice.address.equal(address2: cbValue.device.address!) { + let participantDeviceOnPauseTmp = cbValue.state == .OnHold + let participantDeviceIsJoiningTmp = cbValue.state == .Joining || cbValue.state == .Alerting + DispatchQueue.main.async { + participantDevice.onPause = participantDeviceOnPauseTmp + participantDevice.isJoining = participantDeviceIsJoiningTmp + } + } }) } } @@ -422,7 +479,9 @@ class CallViewModel: ObservableObject { } if core.callsNb == 0 { - self.timer.upstream.connect().cancel() + DispatchQueue.main.async { + self.timer.upstream.connect().cancel() + } } } } @@ -453,7 +512,10 @@ class CallViewModel: ObservableObject { self.currentCall!.microphoneMuted = !self.currentCall!.microphoneMuted } - self.micMutted = self.currentCall!.microphoneMuted || !core.micEnabled + let micMuttedTmp = self.currentCall!.microphoneMuted || !core.micEnabled + DispatchQueue.main.async { + self.micMutted = micMuttedTmp + } Log.info( "[CallViewModel] Microphone mute switch \(self.micMutted)" @@ -486,7 +548,7 @@ class CallViewModel: ObservableObject { let video = params.videoDirection == .SendRecv || params.videoDirection == .SendOnly - DispatchQueue.main.asyncAfter(deadline: .now() + (video ? 1 : 0)) { + DispatchQueue.main.async { if video { self.videoDisplayed = false } @@ -528,7 +590,10 @@ class CallViewModel: ObservableObject { self.currentCall!.startRecording() } - self.isRecording = self.currentCall!.params!.isRecording + let isRecordingTmp = self.currentCall!.params!.isRecording + DispatchQueue.main.async { + self.isRecording = isRecordingTmp + } } } } @@ -540,11 +605,17 @@ class CallViewModel: ObservableObject { if self.isCallPaused() { Log.info("[CallViewModel] Resuming call \(self.currentCall!.remoteAddress!.asStringUriOnly())") try self.currentCall!.resume() - self.isPaused = false + + DispatchQueue.main.async { + self.isPaused = false + } } else { Log.info("[CallViewModel] Pausing call \(self.currentCall!.remoteAddress!.asStringUriOnly())") try self.currentCall!.pause() - self.isPaused = true + + DispatchQueue.main.async { + self.isPaused = true + } } } catch _ { diff --git a/Linphone/UI/Call/ViewModel/MeetingWaitingRoomViewModel.swift b/Linphone/UI/Call/ViewModel/MeetingWaitingRoomViewModel.swift index 150233219..5c67af20f 100644 --- a/Linphone/UI/Call/ViewModel/MeetingWaitingRoomViewModel.swift +++ b/Linphone/UI/Call/ViewModel/MeetingWaitingRoomViewModel.swift @@ -107,6 +107,9 @@ class MeetingWaitingRoomViewModel: ObservableObject { func enableVideoPreview() { self.coreContext.doOnCoreQueue { core in if core.videoEnabled { + DispatchQueue.main.async { + self.videoDisplayed = true + } self.videoDisplayed = true core.videoPreviewEnabled = true } @@ -116,7 +119,9 @@ class MeetingWaitingRoomViewModel: ObservableObject { func disableVideoPreview() { coreContext.doOnCoreQueue { core in if core.videoEnabled { - self.videoDisplayed = false + DispatchQueue.main.async { + self.videoDisplayed = false + } core.videoPreviewEnabled = false } } From f106f54021d03f0b5bce4015b0986cf3d17c0cf0 Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Mon, 22 Apr 2024 13:48:42 +0200 Subject: [PATCH 188/486] Add a temporary meeting list --- Linphone/LinphoneApp.swift | 7 +- Linphone/Localizable.xcstrings | 6 +- Linphone/UI/Main/ContentView.swift | 3 + .../Fragments/HistoryListFragment.swift | 35 ++++++-- .../Meetings/Fragments/MeetingsFragment.swift | 86 ++++++++++++++----- Linphone/UI/Main/Meetings/MeetingsView.swift | 4 +- .../Main/Meetings/Models/MeetingModel.swift | 3 + .../ViewModel/MeetingsListViewModel.swift | 8 +- 8 files changed, 117 insertions(+), 35 deletions(-) diff --git a/Linphone/LinphoneApp.swift b/Linphone/LinphoneApp.swift index da5d206c1..fff6667d7 100644 --- a/Linphone/LinphoneApp.swift +++ b/Linphone/LinphoneApp.swift @@ -79,6 +79,7 @@ struct LinphoneApp: App { @State private var meetingWaitingRoomViewModel: MeetingWaitingRoomViewModel? @State private var conversationsListViewModel: ConversationsListViewModel? @State private var conversationViewModel: ConversationViewModel? + @State private var meetingsListViewModel: MeetingsListViewModel? @State private var scheduleMeetingViewModel: ScheduleMeetingViewModel? var body: some Scene { @@ -103,7 +104,9 @@ struct LinphoneApp: App { && callViewModel != nil && meetingWaitingRoomViewModel != nil && conversationsListViewModel != nil - && conversationViewModel != nil { + && conversationViewModel != nil + && meetingsListViewModel != nil + && scheduleMeetingViewModel != nil { ContentView( contactViewModel: contactViewModel!, editContactViewModel: editContactViewModel!, @@ -114,6 +117,7 @@ struct LinphoneApp: App { meetingWaitingRoomViewModel: meetingWaitingRoomViewModel!, conversationsListViewModel: conversationsListViewModel!, conversationViewModel: conversationViewModel!, + meetingsListViewModel: meetingsListViewModel!, scheduleMeetingViewModel: scheduleMeetingViewModel! ) } else { @@ -131,6 +135,7 @@ struct LinphoneApp: App { meetingWaitingRoomViewModel = MeetingWaitingRoomViewModel() conversationsListViewModel = ConversationsListViewModel() conversationViewModel = ConversationViewModel() + meetingsListViewModel = MeetingsListViewModel() scheduleMeetingViewModel = ScheduleMeetingViewModel() } } diff --git a/Linphone/Localizable.xcstrings b/Linphone/Localizable.xcstrings index 0fe4e9fd1..c9993f7f4 100644 --- a/Linphone/Localizable.xcstrings +++ b/Linphone/Localizable.xcstrings @@ -387,9 +387,6 @@ }, "Headphones" : { - }, - "Hello meetings list" : { - }, "History has been deleted" : { @@ -509,6 +506,9 @@ }, "No conversation for the moment..." : { + }, + "No meeting for the moment..." : { + }, "Not account yet?" : { diff --git a/Linphone/UI/Main/ContentView.swift b/Linphone/UI/Main/ContentView.swift index f22199412..1d6ae4e8e 100644 --- a/Linphone/UI/Main/ContentView.swift +++ b/Linphone/UI/Main/ContentView.swift @@ -43,6 +43,7 @@ struct ContentView: View { @ObservedObject var meetingWaitingRoomViewModel: MeetingWaitingRoomViewModel @ObservedObject var conversationsListViewModel: ConversationsListViewModel @ObservedObject var conversationViewModel: ConversationViewModel + @ObservedObject var meetingsListViewModel: MeetingsListViewModel @ObservedObject var scheduleMeetingViewModel: ScheduleMeetingViewModel @State var index = 0 @@ -461,6 +462,7 @@ struct ContentView: View { ConversationsView(conversationViewModel: conversationViewModel, conversationsListViewModel: conversationsListViewModel) } else if self.index == 3 { MeetingsView( + meetingsListViewModel: meetingsListViewModel, scheduleMeetingViewModel: scheduleMeetingViewModel, isShowScheduleMeetingFragment: $isShowScheduleMeetingFragment ) @@ -967,6 +969,7 @@ struct ContentView: View { meetingWaitingRoomViewModel: MeetingWaitingRoomViewModel(), conversationsListViewModel: ConversationsListViewModel(), conversationViewModel: ConversationViewModel(), + meetingsListViewModel: MeetingsListViewModel(), scheduleMeetingViewModel: ScheduleMeetingViewModel() ) } diff --git a/Linphone/UI/Main/History/Fragments/HistoryListFragment.swift b/Linphone/UI/Main/History/Fragments/HistoryListFragment.swift index e8cc0b8c9..6a7f96220 100644 --- a/Linphone/UI/Main/History/Fragments/HistoryListFragment.swift +++ b/Linphone/UI/Main/History/Fragments/HistoryListFragment.swift @@ -72,17 +72,27 @@ struct HistoryListFragment: View { .frame(width: 45, height: 45) .clipShape(Circle()) - } else { + } else if historyListViewModel.callLogs[index].toAddress!.username != nil { Image(uiImage: contactsManager.textToImage( - firstName: historyListViewModel.callLogs[index].toAddress!.username ?? "Username Error", + firstName: historyListViewModel.callLogs[index].toAddress!.username!, lastName: historyListViewModel.callLogs[index].toAddress!.username!.components(separatedBy: " ").count > 1 ? historyListViewModel.callLogs[index].toAddress!.username!.components(separatedBy: " ")[1] : "")) .resizable() .frame(width: 45, height: 45) .clipShape(Circle()) + } else { + VStack { + Image("users-three-square") + .renderingMode(.template) + .resizable() + .frame(width: 28, height: 28) + .foregroundStyle(Color.grayMain2c600) + } + .frame(width: 45, height: 45) + .background(Color.grayMain2c200) + .clipShape(Circle()) } - } else if historyListViewModel.callLogs[index].fromAddress != nil { if historyListViewModel.callLogs[index].fromAddress!.displayName != nil { Image(uiImage: contactsManager.textToImage( @@ -93,15 +103,26 @@ struct HistoryListFragment: View { .resizable() .frame(width: 45, height: 45) .clipShape(Circle()) - } else { + } else if historyListViewModel.callLogs[index].fromAddress!.username != nil { Image(uiImage: contactsManager.textToImage( - firstName: historyListViewModel.callLogs[index].fromAddress!.username ?? "Username Error", + firstName: historyListViewModel.callLogs[index].fromAddress!.username!, lastName: historyListViewModel.callLogs[index].fromAddress!.username!.components(separatedBy: " ").count > 1 ? historyListViewModel.callLogs[index].fromAddress!.username!.components(separatedBy: " ")[1] : "")) .resizable() .frame(width: 45, height: 45) .clipShape(Circle()) + } else { + VStack { + Image("users-three-square") + .renderingMode(.template) + .resizable() + .frame(width: 28, height: 28) + .foregroundStyle(Color.grayMain2c600) + } + .frame(width: 45, height: 45) + .background(Color.grayMain2c200) + .clipShape(Circle()) } } else { Image("profil-picture-default") @@ -139,14 +160,14 @@ struct HistoryListFragment: View { if historyListViewModel.callLogs[index].dir == .Outgoing && historyListViewModel.callLogs[index].toAddress != nil { Text(historyListViewModel.callLogs[index].toAddress!.displayName != nil ? historyListViewModel.callLogs[index].toAddress!.displayName! - : historyListViewModel.callLogs[index].toAddress!.username!) + : historyListViewModel.callLogs[index].toAddress!.username ?? "") .default_text_style(styleSize: 14) .frame(maxWidth: .infinity, alignment: .leading) .lineLimit(1) } else if historyListViewModel.callLogs[index].fromAddress != nil { Text(historyListViewModel.callLogs[index].fromAddress!.displayName != nil ? historyListViewModel.callLogs[index].fromAddress!.displayName! - : historyListViewModel.callLogs[index].fromAddress!.username!) + : historyListViewModel.callLogs[index].fromAddress!.username ?? "") .default_text_style(styleSize: 14) .frame(maxWidth: .infinity, alignment: .leading) .lineLimit(1) diff --git a/Linphone/UI/Main/Meetings/Fragments/MeetingsFragment.swift b/Linphone/UI/Main/Meetings/Fragments/MeetingsFragment.swift index 63e4d7f57..b6cf7193c 100644 --- a/Linphone/UI/Main/Meetings/Fragments/MeetingsFragment.swift +++ b/Linphone/UI/Main/Meetings/Fragments/MeetingsFragment.swift @@ -6,9 +6,11 @@ // import SwiftUI +import linphonesw struct MeetingsFragment: View { + @ObservedObject var meetingsListViewModel: MeetingsListViewModel @ObservedObject var scheduleMeetingViewModel: ScheduleMeetingViewModel private var idiom: UIUserInterfaceIdiom { UIDevice.current.userInterfaceIdiom } @@ -17,32 +19,72 @@ struct MeetingsFragment: View { var body: some View { VStack { - Spacer() - Text("Hello meetings list") - Spacer() - /* - if #available(iOS 16.0, *), idiom != .pad { - MeetingsListFragment(conversationViewModel: conversationViewModel, conversationsListViewModel: conversationsListViewModel, showingSheet: $showingSheet) - .sheet(isPresented: $showingSheet) { - ConversationsListBottomSheet( - conversationsListViewModel: conversationsListViewModel, - showingSheet: $showingSheet - ) - .presentationDetents([.fraction(0.4)]) + List { + ForEach(0.. Date: Mon, 22 Apr 2024 14:40:26 +0200 Subject: [PATCH 189/486] Fix participant list in conf --- Linphone/UI/Call/ViewModel/CallViewModel.swift | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/Linphone/UI/Call/ViewModel/CallViewModel.swift b/Linphone/UI/Call/ViewModel/CallViewModel.swift index d0a4ee766..fa8639d48 100644 --- a/Linphone/UI/Call/ViewModel/CallViewModel.swift +++ b/Linphone/UI/Call/ViewModel/CallViewModel.swift @@ -280,7 +280,6 @@ class CallViewModel: ObservableObject { DispatchQueue.main.async { self.displayName = displayNameTmp - self.participantList = [] self.myParticipantModel = myParticipantModelTmp @@ -357,7 +356,10 @@ class CallViewModel: ObservableObject { DispatchQueue.main.async { self.activeSpeakerParticipant = activeSpeakerParticipantTmp self.activeSpeakerName = activeSpeakerNameTmp - self.participantList = participantListTmp + if (activeSpeakerParticipantBis != nil && !activeSpeakerParticipantBis!.address.equal(address2: activeSpeakerParticipantTmp.address)) + || ( activeSpeakerParticipantBis == nil) { + self.participantList = participantListTmp + } } } } From 0ab7450b46941afa71120d09a76cd9f9b02a73f0 Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Mon, 22 Apr 2024 15:53:14 +0200 Subject: [PATCH 190/486] Fix native preview video in conf --- Linphone/UI/Call/ViewModel/CallViewModel.swift | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/Linphone/UI/Call/ViewModel/CallViewModel.swift b/Linphone/UI/Call/ViewModel/CallViewModel.swift index fa8639d48..eac7b488d 100644 --- a/Linphone/UI/Call/ViewModel/CallViewModel.swift +++ b/Linphone/UI/Call/ViewModel/CallViewModel.swift @@ -170,11 +170,7 @@ class CallViewModel: ObservableObject { self.zrtpPopupDisplayed = false self.upperCaseAuthTokenToRead = "" self.upperCaseAuthTokenToListen = "" - self.isMediaEncrypted = false - self.isZrtpPq = false - self.isOneOneCall = false self.isConference = false - self.videoDisplayed = false self.participantList = [] self.activeSpeakerParticipant = nil self.activeSpeakerName = "" @@ -550,7 +546,7 @@ class CallViewModel: ObservableObject { let video = params.videoDirection == .SendRecv || params.videoDirection == .SendOnly - DispatchQueue.main.async { + DispatchQueue.main.asyncAfter(deadline: .now() + (video ? 1 : 0)) { if video { self.videoDisplayed = false } From 6dfc87062421db5ad147753435fd66ea3b0b3c00 Mon Sep 17 00:00:00 2001 From: QuentinArguillere Date: Mon, 22 Apr 2024 15:58:17 +0200 Subject: [PATCH 191/486] fix some padding, update meeting list to add the newly created conference when exiting scheduler menu --- Linphone/UI/Main/ContentView.swift | 1 + .../Fragments/AddParticipantsFragment.swift | 8 +- .../Fragments/ScheduleMeetingFragment.swift | 5 +- .../ViewModel/MeetingsListViewModel.swift | 115 +++++++++--------- 4 files changed, 69 insertions(+), 60 deletions(-) diff --git a/Linphone/UI/Main/ContentView.swift b/Linphone/UI/Main/ContentView.swift index 1d6ae4e8e..d82114e56 100644 --- a/Linphone/UI/Main/ContentView.swift +++ b/Linphone/UI/Main/ContentView.swift @@ -895,6 +895,7 @@ struct ContentView: View { if isShowScheduleMeetingFragment { ScheduleMeetingFragment( scheduleMeetingViewModel: scheduleMeetingViewModel, + meetingsListViewModel: meetingsListViewModel, isShowScheduleMeetingFragment: $isShowScheduleMeetingFragment ) .zIndex(3) diff --git a/Linphone/UI/Main/Meetings/Fragments/AddParticipantsFragment.swift b/Linphone/UI/Main/Meetings/Fragments/AddParticipantsFragment.swift index 96e2ecebd..53ed85a86 100644 --- a/Linphone/UI/Main/Meetings/Fragments/AddParticipantsFragment.swift +++ b/Linphone/UI/Main/Meetings/Fragments/AddParticipantsFragment.swift @@ -168,14 +168,14 @@ struct AddParticipantsFragment: View { ).first)!)) .contact_text_style_500(styleSize: 20) .frame(width: 18) - .padding(.leading, -5) - .padding(.trailing, 10) + .padding(.leading, 5) + .padding(.trailing, 5) } else { Text("") .contact_text_style_500(styleSize: 20) .frame(width: 18) - .padding(.leading, -5) - .padding(.trailing, 10) + .padding(.leading, 5) + .padding(.trailing, 5) } if index < contactsManager.avatarListModel.count diff --git a/Linphone/UI/Main/Meetings/Fragments/ScheduleMeetingFragment.swift b/Linphone/UI/Main/Meetings/Fragments/ScheduleMeetingFragment.swift index 8beb2f117..833fed15c 100644 --- a/Linphone/UI/Main/Meetings/Fragments/ScheduleMeetingFragment.swift +++ b/Linphone/UI/Main/Meetings/Fragments/ScheduleMeetingFragment.swift @@ -29,6 +29,7 @@ struct ScheduleMeetingFragment: View { @State private var orientation = UIDevice.current.orientation @ObservedObject var scheduleMeetingViewModel: ScheduleMeetingViewModel + @ObservedObject var meetingsListViewModel: MeetingsListViewModel @State private var delayedColor = Color.white @State private var showDatePicker = false @@ -305,7 +306,7 @@ struct ScheduleMeetingFragment: View { VStack { HStack { Avatar(contactAvatarModel: scheduleMeetingViewModel.participants[index].avatarModel, avatarSize: 50) - .padding(.leading, 66) + .padding(.leading, 20) Text(scheduleMeetingViewModel.participants[index].avatarModel.name) .default_text_style(styleSize: 16) @@ -374,6 +375,7 @@ struct ScheduleMeetingFragment: View { }.onDisappear { withAnimation { if scheduleMeetingViewModel.conferenceCreatedEvent { + meetingsListViewModel.computeMeetingsList() isShowScheduleMeetingFragment.toggle() } } @@ -488,6 +490,7 @@ struct ScheduleMeetingFragment: View { #Preview { ScheduleMeetingFragment(scheduleMeetingViewModel: ScheduleMeetingViewModel() + , meetingsListViewModel: MeetingsListViewModel() , isShowScheduleMeetingFragment: .constant(true)) } diff --git a/Linphone/UI/Main/Meetings/ViewModel/MeetingsListViewModel.swift b/Linphone/UI/Main/Meetings/ViewModel/MeetingsListViewModel.swift index abd42d565..9e85634a6 100644 --- a/Linphone/UI/Main/Meetings/ViewModel/MeetingsListViewModel.swift +++ b/Linphone/UI/Main/Meetings/ViewModel/MeetingsListViewModel.swift @@ -33,74 +33,79 @@ class MeetingsListViewModel: ObservableObject { init() { coreContext.doOnCoreQueue { core in - self.computeMeetingsList(core: core, filter: self.currentFilter) - self.mCoreSuscriptions.insert(core.publisher?.onConferenceInfoReceived?.postOnCoreQueue { (cbVal: (core: Core, conferenceInfo: ConferenceInfo)) in Log.info("\(MeetingsListViewModel.TAG) Conference info received [\(cbVal.conferenceInfo.uri?.asStringUriOnly())") - self.computeMeetingsList(core: cbVal.core, filter: self.currentFilter) + self.computeMeetingsList() }) } + computeMeetingsList() } - func computeMeetingsList(core: Core, filter: String) { - var confInfoList: [ConferenceInfo] = [] + func computeMeetingsList() { + let filter = self.currentFilter - if let account = core.defaultAccount { - confInfoList = account.conferenceInformationList - } - if confInfoList.isEmpty { - confInfoList = core.conferenceInformationList - } - - var meetingsListTmp: [MeetingsListItemModel] = [] - var previousModel: MeetingModel? - var meetingForTodayFound = false - - 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 + coreContext.doOnCoreQueue { core in + var confInfoList: [ConferenceInfo] = [] + + if let account = core.defaultAccount { + confInfoList = account.conferenceInformationList + } + if confInfoList.isEmpty { + confInfoList = core.conferenceInformationList } - if add { - let model = MeetingModel(conferenceInfo: confInfo) - let firstMeetingOfTheDay = (previousModel != nil) ? previousModel?.day != model.day || previousModel?.dayNumber != model.dayNumber : true - model.firstMeetingOfTheDay = firstMeetingOfTheDay - - // Insert "Today" fake model before the first one of today - /* - if firstMeetingOfTheDay && model.isToday { - meetingsListTmp.append(MeetingsListItemModel(meetingModel: nil)) - meetingForTodayFound = true + var meetingsListTmp: [MeetingsListItemModel] = [] + var previousModel: MeetingModel? + // var meetingForTodayFound = false + + 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 no meeting was found for today, insert "Today" fake model before the next meeting to come - /* - if !meetingForTodayFound && model.isAfterToday { - meetingsListTmp.append(MeetingsListItemModel(meetingModel: nil)) - meetingForTodayFound = true + if add { + let model = MeetingModel(conferenceInfo: confInfo) + let firstMeetingOfTheDay = (previousModel != nil) ? previousModel?.day != model.day || previousModel?.dayNumber != model.dayNumber : true + model.firstMeetingOfTheDay = firstMeetingOfTheDay + + // Insert "Today" fake model before the first one of today + /* + if firstMeetingOfTheDay && model.isToday { + meetingsListTmp.append(MeetingsListItemModel(meetingModel: nil)) + meetingForTodayFound = true + } + */ + + // If no meeting was found for today, insert "Today" fake model before the next meeting to come + /* + if !meetingForTodayFound && model.isAfterToday { + meetingsListTmp.append(MeetingsListItemModel(meetingModel: nil)) + meetingForTodayFound = true + } + */ + + meetingsListTmp.append(MeetingsListItemModel(meetingModel: model)) + previousModel = model } - */ - - meetingsListTmp.append(MeetingsListItemModel(meetingModel: model)) - previousModel = model + } + + // If no meeting was found after today, insert "Today" fake model at the end + /* + if !meetingForTodayFound { + meetingsListTmp.append(MeetingsListItemModel(meetingModel: nil)) + } + */ + + DispatchQueue.main.sync { + self.meetingsList = meetingsListTmp } } - - // If no meeting was found after today, insert "Today" fake model at the end - /* - if !meetingForTodayFound { - meetingsListTmp.append(MeetingsListItemModel(meetingModel: nil)) - } - */ - - self.meetingsList = meetingsListTmp } } From e6e1087d85bee25cf052573b6a1bf97a8d681117 Mon Sep 17 00:00:00 2001 From: QuentinArguillere Date: Mon, 22 Apr 2024 16:53:45 +0200 Subject: [PATCH 192/486] Disable remote push --- Linphone/LinphoneApp.swift | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/Linphone/LinphoneApp.swift b/Linphone/LinphoneApp.swift index fff6667d7..e634a7722 100644 --- a/Linphone/LinphoneApp.swift +++ b/Linphone/LinphoneApp.swift @@ -27,9 +27,10 @@ class AppDelegate: NSObject, UIApplicationDelegate { 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.info("Forwarding remote push token to core") - core.didRegisterForRemotePushWithStringifiedToken(deviceTokenStr: tokenStr + ":remote") + 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") } } From a34a4268271cb3dfbea5d0892fabc7cf6826fc1e Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Mon, 22 Apr 2024 17:01:56 +0200 Subject: [PATCH 193/486] Fix presence of phone numbers and updated subscription once for friends lists --- Linphone/Contacts/ContactsManager.swift | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/Linphone/Contacts/ContactsManager.swift b/Linphone/Contacts/ContactsManager.swift index 5033d25b4..0cbc6e925 100644 --- a/Linphone/Contacts/ContactsManager.swift +++ b/Linphone/Contacts/ContactsManager.swift @@ -21,6 +21,7 @@ import linphonesw import Contacts import SwiftUI import ContactsUI +import Combine final class ContactsManager: ObservableObject { @@ -38,6 +39,8 @@ final class ContactsManager: ObservableObject { @Published var lastSearchSuggestions: [SearchResult] = [] @Published var avatarListModel: [ContactAvatarModel] = [] + private var friendListSuscription: AnyCancellable? + private init() { fetchContacts() } @@ -137,6 +140,15 @@ final class ContactsManager: ObservableObject { } } + self.linphoneFriendList?.updateSubscriptions() + self.friendList?.updateSubscriptions() + + self.friendListSuscription = self.friendList?.publisher?.onPresenceReceived?.postOnMainQueue { (cbValue: (friendList: FriendList, friends: [Friend])) in + MagicSearchSingleton.shared.searchForContacts(sourceFlags: MagicSearch.Source.Friends.rawValue | MagicSearch.Source.LdapServers.rawValue) + self.friendListSuscription = nil + } + + MagicSearchSingleton.shared.searchForContacts(sourceFlags: MagicSearch.Source.Friends.rawValue | MagicSearch.Source.LdapServers.rawValue) } } @@ -179,10 +191,8 @@ final class ContactsManager: ObservableObject { if resultFriend != nil { if linphoneFriend && existingFriend == nil { _ = self.linphoneFriendList?.addFriend(linphoneFriend: resultFriend!) - self.linphoneFriendList?.updateSubscriptions() } else if existingFriend == nil { _ = self.friendList?.addLocalFriend(linphoneFriend: resultFriend!) - self.friendList?.updateSubscriptions() } } } From f99f1c6d3271a853fd6e4399a8ddfb9441e319c2 Mon Sep 17 00:00:00 2001 From: QuentinArguillere Date: Mon, 22 Apr 2024 17:05:09 +0200 Subject: [PATCH 194/486] fix build --- Linphone/LinphoneApp.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Linphone/LinphoneApp.swift b/Linphone/LinphoneApp.swift index e634a7722..cb1d481ce 100644 --- a/Linphone/LinphoneApp.swift +++ b/Linphone/LinphoneApp.swift @@ -27,7 +27,7 @@ class AppDelegate: NSObject, UIApplicationDelegate { 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, + 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") From 16c386dcc8680d8edcbb632b53c2759ee31448a9 Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Tue, 23 Apr 2024 11:49:31 +0200 Subject: [PATCH 195/486] Check internet connection before deleting accounts --- Linphone/Core/CoreContext.swift | 19 ++++++++++++------- Linphone/UI/Call/CallView.swift | 1 - 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/Linphone/Core/CoreContext.swift b/Linphone/Core/CoreContext.swift index ccf40720a..35cddb155 100644 --- a/Linphone/Core/CoreContext.swift +++ b/Linphone/Core/CoreContext.swift @@ -193,14 +193,19 @@ final class CoreContext: ObservableObject { self.updatePresence(core: self.mCore, presence: ConsolidatedPresence.Online) } } else if cbVal.state != .Ok && cbVal.state != .Progress { // If registration failed, remove account from core - let params = cbVal.account.params - let clonedParams = params?.clone() - clonedParams?.registerEnabled = false - cbVal.account.params = clonedParams - cbVal.core.removeAccount(account: cbVal.account) - cbVal.core.clearAccounts() - cbVal.core.clearAllAuthInfo() + self.monitor.pathUpdateHandler = { path in + if path.status == .satisfied { + let params = cbVal.account.params + let clonedParams = params?.clone() + clonedParams?.registerEnabled = false + cbVal.account.params = clonedParams + + cbVal.core.removeAccount(account: cbVal.account) + cbVal.core.clearAccounts() + cbVal.core.clearAllAuthInfo() + } + } } TelecomManager.shared.onAccountRegistrationStateChanged(core: cbVal.core, account: cbVal.account, state: cbVal.state, message: cbVal.message) }) diff --git a/Linphone/UI/Call/CallView.swift b/Linphone/UI/Call/CallView.swift index a372776ef..250f4cdb1 100644 --- a/Linphone/UI/Call/CallView.swift +++ b/Linphone/UI/Call/CallView.swift @@ -752,7 +752,6 @@ struct CallView: View { .clipped() } - VStack(alignment: .leading) { Spacer() From 582c1b1d6688c32a226561bf9be322503695f3b2 Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Tue, 23 Apr 2024 16:07:05 +0200 Subject: [PATCH 196/486] Remove core.removeAccount from the main queue --- Linphone/Core/CoreContext.swift | 15 --------------- .../Fragments/ParticipantsListFragment.swift | 18 ++++++++++++++++++ 2 files changed, 18 insertions(+), 15 deletions(-) create mode 100644 Linphone/UI/Call/Fragments/ParticipantsListFragment.swift diff --git a/Linphone/Core/CoreContext.swift b/Linphone/Core/CoreContext.swift index 35cddb155..0cd8593b1 100644 --- a/Linphone/Core/CoreContext.swift +++ b/Linphone/Core/CoreContext.swift @@ -169,21 +169,6 @@ final class CoreContext: ObservableObject { self.loggedIn = false ToastViewModel.shared.toastMessage = "Registration failed" ToastViewModel.shared.displayToast = true - - self.monitor.pathUpdateHandler = { path in - if path.status == .satisfied { - if cbVal.state != .Ok && cbVal.state != .Progress { - let params = cbVal.account.params - let clonedParams = params?.clone() - clonedParams?.registerEnabled = false - cbVal.account.params = clonedParams - - cbVal.core.removeAccount(account: cbVal.account) - cbVal.core.clearAccounts() - cbVal.core.clearAllAuthInfo() - } - } - } } }) diff --git a/Linphone/UI/Call/Fragments/ParticipantsListFragment.swift b/Linphone/UI/Call/Fragments/ParticipantsListFragment.swift new file mode 100644 index 000000000..100b7d882 --- /dev/null +++ b/Linphone/UI/Call/Fragments/ParticipantsListFragment.swift @@ -0,0 +1,18 @@ +// +// ParticipantsListFragment.swift +// Linphone +// +// Created by Benoît Martins on 23/04/2024. +// + +import SwiftUI + +struct ParticipantsListFragment: View { + var body: some View { + Text(/*@START_MENU_TOKEN@*/"Hello, World!"/*@END_MENU_TOKEN@*/) + } +} + +#Preview { + ParticipantsListFragment() +} From 0a2d4a1682b07a40343fdf988e78962c309bcb56 Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Tue, 23 Apr 2024 16:11:48 +0200 Subject: [PATCH 197/486] Add participant list fragment to the call view --- Linphone.xcodeproj/project.pbxproj | 4 + Linphone/Localizable.xcstrings | 28 ++ Linphone/UI/Call/CallView.swift | 158 ++++++++---- .../Fragments/ParticipantsListFragment.swift | 241 +++++++++++++++++- Linphone/UI/Call/Model/ParticipantModel.swift | 4 +- .../UI/Call/ViewModel/CallViewModel.swift | 63 ++++- .../Fragments/ContactsListFragment.swift | 6 +- Linphone/UI/Main/ContentView.swift | 5 +- .../Fragments/ConversationsListFragment.swift | 1 + .../Fragments/HistoryListFragment.swift | 21 +- .../Meetings/Fragments/MeetingsFragment.swift | 18 +- 11 files changed, 463 insertions(+), 86 deletions(-) diff --git a/Linphone.xcodeproj/project.pbxproj b/Linphone.xcodeproj/project.pbxproj index 4cc29b46e..a472253e4 100644 --- a/Linphone.xcodeproj/project.pbxproj +++ b/Linphone.xcodeproj/project.pbxproj @@ -43,6 +43,7 @@ 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 */; }; @@ -191,6 +192,7 @@ 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 = ""; }; @@ -582,6 +584,7 @@ children = ( D75759312B56D40900E7AC10 /* ZRTPPopup.swift */, D7F4D9CA2B5FD27200CDCD76 /* CallsListFragment.swift */, + D717630C2BD7BD0E00464097 /* ParticipantsListFragment.swift */, ); path = Fragments; sourceTree = ""; @@ -954,6 +957,7 @@ D7A03FBD2ACC2DB60081A588 /* ContactsView.swift in Sources */, 66E50A492BD12B2300AD61CA /* MeetingsView.swift in Sources */, D719ABCF2ABC779A00B41C10 /* AccountLoginViewModel.swift in Sources */, + D717630D2BD7BD0E00464097 /* ParticipantsListFragment.swift in Sources */, D732A90F2B04C3B400DB42BA /* HistoryFragment.swift in Sources */, D79622342B1DFE600037EACD /* DialerBottomSheet.swift in Sources */, D78290BB2ADD40B2004AA85C /* ContactViewModel.swift in Sources */, diff --git a/Linphone/Localizable.xcstrings b/Linphone/Localizable.xcstrings index c9993f7f4..d26d8e3f5 100644 --- a/Linphone/Localizable.xcstrings +++ b/Linphone/Localizable.xcstrings @@ -51,6 +51,16 @@ }, "%lld" : { + }, + "%lld %@" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "%1$lld %2$@" + } + } + } }, "%lld Book (Example)" : { "extractionState" : "manual", @@ -167,6 +177,9 @@ }, "Add to favourites" : { + }, + "Administrateur" : { + }, "All calls will be removed from the history." : { @@ -355,6 +368,9 @@ }, "Error Name" : { + }, + "Etes-vous sûr de vouloir supprimer %@ ?" : { + }, "Favourites" : { @@ -509,6 +525,12 @@ }, "No meeting for the moment..." : { + }, + "No participant for the moment..." : { + + }, + "Non" : { + }, "Not account yet?" : { @@ -524,6 +546,9 @@ }, "Other actions" : { + }, + "Oui" : { + }, "Partage d'écran" : { @@ -683,6 +708,9 @@ }, "Supprimer la conversation" : { + }, + "Supprimer un participant" : { + }, "TCP" : { diff --git a/Linphone/UI/Call/CallView.swift b/Linphone/UI/Call/CallView.swift index 250f4cdb1..a874bf3c7 100644 --- a/Linphone/UI/Call/CallView.swift +++ b/Linphone/UI/Call/CallView.swift @@ -50,7 +50,8 @@ struct CallView: View { @State var displayVideo = false @Binding var fullscreenVideo: Bool - @Binding var isShowCallsListFragment: Bool + @State var isShowCallsListFragment: Bool = false + @State var isShowParticipantsListFragment: Bool = false @Binding var isShowStartCallFragment: Bool var body: some View { @@ -107,8 +108,14 @@ struct CallView: View { if isShowCallsListFragment { CallsListFragment(callViewModel: callViewModel, isShowCallsListFragment: $isShowCallsListFragment) - .zIndex(4) - .transition(.move(edge: .bottom)) + .zIndex(4) + .transition(.move(edge: .bottom)) + } + + if isShowParticipantsListFragment { + ParticipantsListFragment(callViewModel: callViewModel, isShowParticipantsListFragment: $isShowParticipantsListFragment) + .zIndex(4) + .transition(.move(edge: .bottom)) } if callViewModel.zrtpPopupDisplayed == true { @@ -1218,6 +1225,9 @@ struct CallView: View { VStack { Button { + withAnimation { + isShowParticipantsListFragment.toggle() + } } label: { HStack { Image("users") @@ -1458,58 +1468,110 @@ struct CallView: View { .frame(height: geo.size.height * 0.15) } else { HStack { - VStack { - Button { - withAnimation { - callViewModel.isTransferInsteadCall = true - MagicSearchSingleton.shared.searchForSuggestions() - isShowStartCallFragment.toggle() + if callViewModel.isOneOneCall { + VStack { + Button { + withAnimation { + callViewModel.isTransferInsteadCall = true + MagicSearchSingleton.shared.searchForSuggestions() + isShowStartCallFragment.toggle() + } + } label: { + HStack { + Image("phone-transfer") + .renderingMode(.template) + .resizable() + .foregroundStyle(.white) + .frame(width: 32, height: 32) + } } - } label: { - HStack { - Image("phone-transfer") - .renderingMode(.template) - .resizable() + .buttonStyle(PressedButtonStyle()) + .frame(width: 60, height: 60) + .background(Color.gray500) + .cornerRadius(40) + + Text(callViewModel.calls.count < 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() + } + } label: { + HStack { + Image("phone-plus") + .renderingMode(.template) + .resizable() + .foregroundStyle(.white) + .frame(width: 32, height: 32) + } + } + .buttonStyle(PressedButtonStyle()) + .frame(width: 60, height: 60) + .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()) + .frame(width: 60, height: 60) + .background(.white) + .cornerRadius(40) + .disabled(true) + + Text("Partage d'écran") .foregroundStyle(.white) - .frame(width: 32, height: 32) + .default_text_style(styleSize: 15) } } - .buttonStyle(PressedButtonStyle()) - .frame(width: 60, height: 60) - .background(Color.gray500) - .cornerRadius(40) + .frame(width: geo.size.width * 0.125, height: geo.size.width * 0.125) - Text(callViewModel.calls.count < 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() - } - } label: { - HStack { - Image("phone-plus") - .renderingMode(.template) - .resizable() - .foregroundStyle(.white) - .frame(width: 32, height: 32) + VStack { + Button { + withAnimation { + isShowParticipantsListFragment.toggle() + } + } label: { + HStack { + Image("users") + .renderingMode(.template) + .resizable() + .foregroundStyle(.white) + .frame(width: 32, height: 32) + } } + .buttonStyle(PressedButtonStyle()) + .frame(width: 60, height: 60) + .background(Color.gray500) + .cornerRadius(40) + + Text("Participants") + .foregroundStyle(.white) + .default_text_style(styleSize: 15) } - .buttonStyle(PressedButtonStyle()) - .frame(width: 60, height: 60) - .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) } - .frame(width: geo.size.width * 0.125, height: geo.size.width * 0.125) VStack { ZStack { @@ -1823,7 +1885,7 @@ struct PressedButtonStyle: ButtonStyle { } #Preview { - CallView(callViewModel: CallViewModel(), fullscreenVideo: .constant(false), isShowCallsListFragment: .constant(false), isShowStartCallFragment: .constant(false)) + CallView(callViewModel: CallViewModel(), fullscreenVideo: .constant(false), isShowStartCallFragment: .constant(false)) } // swiftlint:enable type_body_length // swiftlint:enable line_length diff --git a/Linphone/UI/Call/Fragments/ParticipantsListFragment.swift b/Linphone/UI/Call/Fragments/ParticipantsListFragment.swift index 100b7d882..301d1de99 100644 --- a/Linphone/UI/Call/Fragments/ParticipantsListFragment.swift +++ b/Linphone/UI/Call/Fragments/ParticipantsListFragment.swift @@ -1,18 +1,239 @@ -// -// ParticipantsListFragment.swift -// Linphone -// -// Created by Benoît Martins on 23/04/2024. -// +/* + * 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 { - var body: some View { - Text(/*@START_MENU_TOKEN@*/"Hello, World!"/*@END_MENU_TOKEN@*/) - } + + @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 + + @Binding var isShowParticipantsListFragment: Bool + + @State private var isShowPopup = false + @State private var indexToRemove = -1 + + 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 { + 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 + } + .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.. Date: Wed, 24 Apr 2024 17:03:52 +0200 Subject: [PATCH 198/486] Fix call view on iPhone SE --- Linphone/UI/Call/CallView.swift | 145 +++++++++--------- .../UI/Call/MeetingWaitingRoomFragment.swift | 6 +- 2 files changed, 78 insertions(+), 73 deletions(-) diff --git a/Linphone/UI/Call/CallView.swift b/Linphone/UI/Call/CallView.swift index a874bf3c7..1574c9dbe 100644 --- a/Linphone/UI/Call/CallView.swift +++ b/Linphone/UI/Call/CallView.swift @@ -54,6 +54,8 @@ struct CallView: View { @State var isShowParticipantsListFragment: Bool = false @Binding var isShowStartCallFragment: Bool + @State var buttonSize = 60.0 + var body: some View { GeometryReader { geo in ZStack { @@ -136,6 +138,9 @@ struct CallView: View { } .onAppear { callViewModel.enableAVAudioSession() + if geo.size.width < 350 || geo.size.height < 350 { + buttonSize = 45.0 + } } .onDisappear { callViewModel.disableAVAudioSession() @@ -789,7 +794,6 @@ struct CallView: View { .frame(width: 40, height: 40) .padding(.bottom, 5) - Text("Joining...") .frame(maxWidth: .infinity, alignment: .center) .foregroundStyle(Color.white) @@ -1064,7 +1068,7 @@ struct CallView: View { .frame(width: 32, height: 32) } - .frame(width: 90, height: 60) + .frame(width: buttonSize == 60 ? 90 : 70, height: buttonSize) .background(Color.redDanger500) .cornerRadius(40) @@ -1081,8 +1085,8 @@ struct CallView: View { .frame(width: 32, height: 32) } } - .buttonStyle(PressedButtonStyle()) - .frame(width: 60, height: 60) + .buttonStyle(PressedButtonStyle(buttonSize: buttonSize)) + .frame(width: buttonSize, height: buttonSize) .background((callViewModel.isPaused || telecomManager.isPausedByRemote) ? .white : Color.gray500) .cornerRadius(40) .disabled(callViewModel.isPaused || telecomManager.isPausedByRemote) @@ -1098,8 +1102,8 @@ struct CallView: View { .frame(width: 32, height: 32) } } - .buttonStyle(PressedButtonStyle()) - .frame(width: 60, height: 60) + .buttonStyle(PressedButtonStyle(buttonSize: buttonSize)) + .frame(width: buttonSize, height: buttonSize) .background(callViewModel.micMutted ? Color.redDanger500 : Color.gray500) .cornerRadius(40) @@ -1131,14 +1135,14 @@ struct CallView: View { } } } - .buttonStyle(PressedButtonStyle()) - .frame(width: 60, height: 60) + .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, -12) + .padding(.top, -5) if orientation != .landscapeLeft && orientation != .landscapeRight { HStack(spacing: 0) { @@ -1163,8 +1167,8 @@ struct CallView: View { .frame(width: 32, height: 32) } } - .buttonStyle(PressedButtonStyle()) - .frame(width: 60, height: 60) + .buttonStyle(PressedButtonStyle(buttonSize: buttonSize)) + .frame(width: buttonSize, height: buttonSize) .background(Color.gray500) .cornerRadius(40) @@ -1172,7 +1176,7 @@ struct CallView: View { .foregroundStyle(.white) .default_text_style(styleSize: 15) } - .frame(width: geo.size.width * 0.25, height: geo.size.width * 0.25) + .frame(width: geo.size.width * 0.24, height: geo.size.width * 0.24) VStack { Button { @@ -1189,8 +1193,8 @@ struct CallView: View { .frame(width: 32, height: 32) } } - .buttonStyle(PressedButtonStyle()) - .frame(width: 60, height: 60) + .buttonStyle(PressedButtonStyle(buttonSize: buttonSize)) + .frame(width: buttonSize, height: buttonSize) .background(Color.gray500) .cornerRadius(40) @@ -1198,7 +1202,7 @@ struct CallView: View { .foregroundStyle(.white) .default_text_style(styleSize: 15) } - .frame(width: geo.size.width * 0.25, height: geo.size.width * 0.25) + .frame(width: geo.size.width * 0.24, height: geo.size.width * 0.24) } else { VStack { Button { @@ -1211,8 +1215,8 @@ struct CallView: View { .frame(width: 32, height: 32) } } - .buttonStyle(PressedButtonStyle()) - .frame(width: 60, height: 60) + .buttonStyle(PressedButtonStyle(buttonSize: buttonSize)) + .frame(width: buttonSize, height: buttonSize) .background(.white) .cornerRadius(40) .disabled(true) @@ -1221,7 +1225,7 @@ struct CallView: View { .foregroundStyle(.white) .default_text_style(styleSize: 15) } - .frame(width: geo.size.width * 0.25, height: geo.size.width * 0.25) + .frame(width: geo.size.width * 0.24, height: geo.size.width * 0.24) VStack { Button { @@ -1237,8 +1241,8 @@ struct CallView: View { .frame(width: 32, height: 32) } } - .buttonStyle(PressedButtonStyle()) - .frame(width: 60, height: 60) + .buttonStyle(PressedButtonStyle(buttonSize: buttonSize)) + .frame(width: buttonSize, height: buttonSize) .background(Color.gray500) .cornerRadius(40) @@ -1246,7 +1250,7 @@ struct CallView: View { .foregroundStyle(.white) .default_text_style(styleSize: 15) } - .frame(width: geo.size.width * 0.25, height: geo.size.width * 0.25) + .frame(width: geo.size.width * 0.24, height: geo.size.width * 0.24) } VStack { ZStack { @@ -1264,8 +1268,8 @@ struct CallView: View { .frame(width: 32, height: 32) } } - .buttonStyle(PressedButtonStyle()) - .frame(width: 60, height: 60) + .buttonStyle(PressedButtonStyle(buttonSize: buttonSize)) + .frame(width: buttonSize, height: buttonSize) .background(Color.gray500) .cornerRadius(40) @@ -1286,7 +1290,7 @@ struct CallView: View { Spacer() } - .frame(width: 60, height: 60) + .frame(width: buttonSize, height: buttonSize) } } @@ -1294,7 +1298,7 @@ struct CallView: View { .foregroundStyle(.white) .default_text_style(styleSize: 15) } - .frame(width: geo.size.width * 0.25, height: geo.size.width * 0.25) + .frame(width: geo.size.width * 0.24, height: geo.size.width * 0.24) if callViewModel.isOneOneCall { VStack { @@ -1309,8 +1313,8 @@ struct CallView: View { .frame(width: 32, height: 32) } } - .buttonStyle(PressedButtonStyle()) - .frame(width: 60, height: 60) + .buttonStyle(PressedButtonStyle(buttonSize: buttonSize)) + .frame(width: buttonSize, height: buttonSize) .background(Color.gray500) .cornerRadius(40) @@ -1318,7 +1322,7 @@ struct CallView: View { .foregroundStyle(.white) .default_text_style(styleSize: 15) } - .frame(width: geo.size.width * 0.25, height: geo.size.width * 0.25) + .frame(width: geo.size.width * 0.24, height: geo.size.width * 0.24) } else { VStack { Button { @@ -1331,8 +1335,8 @@ struct CallView: View { .frame(width: 32, height: 32) } } - .buttonStyle(PressedButtonStyle()) - .frame(width: 60, height: 60) + .buttonStyle(PressedButtonStyle(buttonSize: buttonSize)) + .frame(width: buttonSize, height: buttonSize) .background(Color.gray500) .cornerRadius(40) @@ -1340,7 +1344,7 @@ struct CallView: View { .foregroundStyle(.white) .default_text_style(styleSize: 15) } - .frame(width: geo.size.width * 0.25, height: geo.size.width * 0.25) + .frame(width: geo.size.width * 0.24, height: geo.size.width * 0.24) } } .frame(height: geo.size.height * 0.15) @@ -1357,8 +1361,8 @@ struct CallView: View { .frame(width: 32, height: 32) } } - .buttonStyle(PressedButtonStyle()) - .frame(width: 60, height: 60) + .buttonStyle(PressedButtonStyle(buttonSize: buttonSize)) + .frame(width: buttonSize, height: buttonSize) .background(.white) .cornerRadius(40) .disabled(true) @@ -1367,7 +1371,7 @@ struct CallView: View { .foregroundStyle(.white) .default_text_style(styleSize: 15) } - .frame(width: geo.size.width * 0.25, height: geo.size.width * 0.25) + .frame(width: geo.size.width * 0.24, height: geo.size.width * 0.24) VStack { Button { @@ -1381,8 +1385,8 @@ struct CallView: View { .frame(width: 32, height: 32) } } - .buttonStyle(PressedButtonStyle()) - .frame(width: 60, height: 60) + .buttonStyle(PressedButtonStyle(buttonSize: buttonSize)) + .frame(width: buttonSize, height: buttonSize) .background(telecomManager.isPausedByRemote ? .white : (callViewModel.isPaused ? Color.greenSuccess500 : Color.gray500)) .cornerRadius(40) .disabled(telecomManager.isPausedByRemote) @@ -1391,7 +1395,7 @@ struct CallView: View { .foregroundStyle(.white) .default_text_style(styleSize: 15) } - .frame(width: geo.size.width * 0.25, height: geo.size.width * 0.25) + .frame(width: geo.size.width * 0.24, height: geo.size.width * 0.24) if callViewModel.isOneOneCall { VStack { @@ -1406,8 +1410,8 @@ struct CallView: View { .frame(width: 32, height: 32) } } - .buttonStyle(PressedButtonStyle()) - .frame(width: 60, height: 60) + .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) @@ -1416,7 +1420,7 @@ struct CallView: View { .foregroundStyle(.white) .default_text_style(styleSize: 15) } - .frame(width: geo.size.width * 0.25, height: geo.size.width * 0.25) + .frame(width: geo.size.width * 0.24, height: geo.size.width * 0.24) } else { VStack { Button { @@ -1429,8 +1433,8 @@ struct CallView: View { .frame(width: 32, height: 32) } } - .buttonStyle(PressedButtonStyle()) - .frame(width: 60, height: 60) + .buttonStyle(PressedButtonStyle(buttonSize: buttonSize)) + .frame(width: buttonSize, height: buttonSize) .background(.white) .cornerRadius(40) .disabled(true) @@ -1439,7 +1443,7 @@ struct CallView: View { .foregroundStyle(.white) .default_text_style(styleSize: 15) } - .frame(width: geo.size.width * 0.25, height: geo.size.width * 0.25) + .frame(width: geo.size.width * 0.24, height: geo.size.width * 0.24) } VStack { @@ -1453,8 +1457,8 @@ struct CallView: View { .frame(width: 32, height: 32) } } - .buttonStyle(PressedButtonStyle()) - .frame(width: 60, height: 60) + .buttonStyle(PressedButtonStyle(buttonSize: buttonSize)) + .frame(width: buttonSize, height: buttonSize) .background(Color.gray500) .cornerRadius(40) @@ -1462,7 +1466,7 @@ struct CallView: View { .foregroundStyle(.white) .default_text_style(styleSize: 15) } - .frame(width: geo.size.width * 0.25, height: geo.size.width * 0.25) + .frame(width: geo.size.width * 0.24, height: geo.size.width * 0.24) .hidden() } .frame(height: geo.size.height * 0.15) @@ -1485,8 +1489,8 @@ struct CallView: View { .frame(width: 32, height: 32) } } - .buttonStyle(PressedButtonStyle()) - .frame(width: 60, height: 60) + .buttonStyle(PressedButtonStyle(buttonSize: buttonSize)) + .frame(width: buttonSize, height: buttonSize) .background(Color.gray500) .cornerRadius(40) @@ -1511,8 +1515,8 @@ struct CallView: View { .frame(width: 32, height: 32) } } - .buttonStyle(PressedButtonStyle()) - .frame(width: 60, height: 60) + .buttonStyle(PressedButtonStyle(buttonSize: buttonSize)) + .frame(width: buttonSize, height: buttonSize) .background(Color.gray500) .cornerRadius(40) @@ -1534,8 +1538,8 @@ struct CallView: View { .frame(width: 32, height: 32) } } - .buttonStyle(PressedButtonStyle()) - .frame(width: 60, height: 60) + .buttonStyle(PressedButtonStyle(buttonSize: buttonSize)) + .frame(width: buttonSize, height: buttonSize) .background(.white) .cornerRadius(40) .disabled(true) @@ -1561,8 +1565,8 @@ struct CallView: View { .frame(width: 32, height: 32) } } - .buttonStyle(PressedButtonStyle()) - .frame(width: 60, height: 60) + .buttonStyle(PressedButtonStyle(buttonSize: buttonSize)) + .frame(width: buttonSize, height: buttonSize) .background(Color.gray500) .cornerRadius(40) @@ -1589,8 +1593,8 @@ struct CallView: View { .frame(width: 32, height: 32) } } - .buttonStyle(PressedButtonStyle()) - .frame(width: 60, height: 60) + .buttonStyle(PressedButtonStyle(buttonSize: buttonSize)) + .frame(width: buttonSize, height: buttonSize) .background(Color.gray500) .cornerRadius(40) @@ -1611,7 +1615,7 @@ struct CallView: View { Spacer() } - .frame(width: 60, height: 60) + .frame(width: buttonSize, height: buttonSize) } } @@ -1634,8 +1638,8 @@ struct CallView: View { .frame(width: 32, height: 32) } } - .buttonStyle(PressedButtonStyle()) - .frame(width: 60, height: 60) + .buttonStyle(PressedButtonStyle(buttonSize: buttonSize)) + .frame(width: buttonSize, height: buttonSize) .background(Color.gray500) .cornerRadius(40) @@ -1656,8 +1660,8 @@ struct CallView: View { .frame(width: 32, height: 32) } } - .buttonStyle(PressedButtonStyle()) - .frame(width: 60, height: 60) + .buttonStyle(PressedButtonStyle(buttonSize: buttonSize)) + .frame(width: buttonSize, height: buttonSize) .background(Color.gray500) .cornerRadius(40) @@ -1679,8 +1683,8 @@ struct CallView: View { .frame(width: 32, height: 32) } } - .buttonStyle(PressedButtonStyle()) - .frame(width: 60, height: 60) + .buttonStyle(PressedButtonStyle(buttonSize: buttonSize)) + .frame(width: buttonSize, height: buttonSize) .background(.white) .cornerRadius(40) .disabled(true) @@ -1703,8 +1707,8 @@ struct CallView: View { .frame(width: 32, height: 32) } } - .buttonStyle(PressedButtonStyle()) - .frame(width: 60, height: 60) + .buttonStyle(PressedButtonStyle(buttonSize: buttonSize)) + .frame(width: buttonSize, height: buttonSize) .background(telecomManager.isPausedByRemote ? .white : (callViewModel.isPaused ? Color.greenSuccess500 : Color.gray500)) .cornerRadius(40) .disabled(telecomManager.isPausedByRemote) @@ -1728,8 +1732,8 @@ struct CallView: View { .frame(width: 32, height: 32) } } - .buttonStyle(PressedButtonStyle()) - .frame(width: 60, height: 60) + .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) @@ -1751,8 +1755,8 @@ struct CallView: View { .frame(width: 32, height: 32) } } - .buttonStyle(PressedButtonStyle()) - .frame(width: 60, height: 60) + .buttonStyle(PressedButtonStyle(buttonSize: buttonSize)) + .frame(width: buttonSize, height: buttonSize) .background(.white) .cornerRadius(40) .disabled(true) @@ -1876,9 +1880,10 @@ struct ChevronShape: Shape { } struct PressedButtonStyle: ButtonStyle { + var buttonSize: CGFloat func makeBody(configuration: Self.Configuration) -> some View { configuration.label - .frame(width: 60, height: 60) + .frame(width: buttonSize, height: buttonSize) .background(configuration.isPressed ? .white : .clear) .cornerRadius(40) } diff --git a/Linphone/UI/Call/MeetingWaitingRoomFragment.swift b/Linphone/UI/Call/MeetingWaitingRoomFragment.swift index dcb9195c1..003b56cd2 100644 --- a/Linphone/UI/Call/MeetingWaitingRoomFragment.swift +++ b/Linphone/UI/Call/MeetingWaitingRoomFragment.swift @@ -268,7 +268,7 @@ struct MeetingWaitingRoomFragment: View { .frame(width: 32, height: 32) } } - .buttonStyle(PressedButtonStyle()) + .buttonStyle(PressedButtonStyle(buttonSize: 60)) .frame(width: 60, height: 60) .background(Color.gray500) .cornerRadius(40) @@ -285,7 +285,7 @@ struct MeetingWaitingRoomFragment: View { .frame(width: 32, height: 32) } } - .buttonStyle(PressedButtonStyle()) + .buttonStyle(PressedButtonStyle(buttonSize: 60)) .frame(width: 60, height: 60) .background(Color.gray500) .cornerRadius(40) @@ -320,7 +320,7 @@ struct MeetingWaitingRoomFragment: View { } } } - .buttonStyle(PressedButtonStyle()) + .buttonStyle(PressedButtonStyle(buttonSize: 60)) .frame(width: 60, height: 60) .background(Color.gray500) .cornerRadius(40) From 0e818ee772571db586c3543b90890331d0d608cc Mon Sep 17 00:00:00 2001 From: QuentinArguillere Date: Mon, 29 Apr 2024 12:05:08 +0200 Subject: [PATCH 199/486] Fix build with dispatch queue renaming --- Linphone/Core/CoreContext.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Linphone/Core/CoreContext.swift b/Linphone/Core/CoreContext.swift index 0cd8593b1..110ef6bfc 100644 --- a/Linphone/Core/CoreContext.swift +++ b/Linphone/Core/CoreContext.swift @@ -21,7 +21,7 @@ // swiftlint:disable line_length import linphonesw -import linphone // needed for unwrapped function linphone_core_set_push_registry_dispatch_queue +import linphone // needed for unwrapped function linphone_core_set_push_and_app_delegate_dispatch_queue import Combine import UniformTypeIdentifiers import Network @@ -100,7 +100,7 @@ final class CoreContext: ObservableObject { Log.info("Initialising core") self.mCore = try? Factory.Instance.createSharedCoreWithConfig(config: Config.get(), systemContext: nil, appGroupId: Config.appGroupName, mainCore: true) - linphone_core_set_push_registry_dispatch_queue(self.mCore.getCobject, Unmanaged.passUnretained(coreQueue).toOpaque()) + 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 From b16372c42013dda3aac6d8b859f48461ab8ef21a Mon Sep 17 00:00:00 2001 From: QuentinArguillere Date: Mon, 29 Apr 2024 12:05:58 +0200 Subject: [PATCH 200/486] Replace defaultAccount by boolean hasDefaultAccount in CoreContext --- Linphone/Core/CoreContext.swift | 8 ++++---- Linphone/LinphoneApp.swift | 4 ++-- .../UI/Assistant/Viewmodel/AccountLoginViewModel.swift | 4 ++-- Linphone/UI/Main/Fragments/SideMenu.swift | 2 +- 4 files changed, 9 insertions(+), 9 deletions(-) diff --git a/Linphone/Core/CoreContext.swift b/Linphone/Core/CoreContext.swift index 110ef6bfc..4609bbba3 100644 --- a/Linphone/Core/CoreContext.swift +++ b/Linphone/Core/CoreContext.swift @@ -38,7 +38,7 @@ final class CoreContext: ObservableObject { var coreVersion: String = Core.getVersion @Published var loggedIn: Bool = false @Published var loggingInProgress: Bool = false - @Published var defaultAccount: Account? + @Published var hasDefaultAccount: Bool = false @Published var coreIsStarted: Bool = false private var mCore: Core! @@ -113,11 +113,11 @@ final class CoreContext: ObservableObject { self.mCoreSuscriptions.insert(self.mCore.publisher?.onGlobalStateChanged?.postOnMainQueue { (cbVal: (core: Core, state: GlobalState, message: String)) in if cbVal.state == GlobalState.On { - self.defaultAccount = self.mCore.defaultAccount + self.hasDefaultAccount = true self.coreIsStarted = true } else if cbVal.state == GlobalState.Off { - self.defaultAccount = nil - self.coreIsStarted = true + self.hasDefaultAccount = false + self.coreIsStarted = false } }) diff --git a/Linphone/LinphoneApp.swift b/Linphone/LinphoneApp.swift index cb1d481ce..e0baf7f60 100644 --- a/Linphone/LinphoneApp.swift +++ b/Linphone/LinphoneApp.swift @@ -88,14 +88,14 @@ struct LinphoneApp: App { if coreContext.coreIsStarted { if !sharedMainViewModel.welcomeViewDisplayed { WelcomeView() - } else if coreContext.defaultAccount == nil || sharedMainViewModel.displayProfileMode { + } else if !coreContext.hasDefaultAccount || sharedMainViewModel.displayProfileMode { ZStack { AssistantView() ToastView() .zIndex(3) } - } else if coreContext.defaultAccount != nil + } else if coreContext.hasDefaultAccount && coreContext.loggedIn && contactViewModel != nil && editContactViewModel != nil diff --git a/Linphone/UI/Assistant/Viewmodel/AccountLoginViewModel.swift b/Linphone/UI/Assistant/Viewmodel/AccountLoginViewModel.swift index 57dd6688d..b4d8229e5 100644 --- a/Linphone/UI/Assistant/Viewmodel/AccountLoginViewModel.swift +++ b/Linphone/UI/Assistant/Viewmodel/AccountLoginViewModel.swift @@ -108,7 +108,7 @@ class AccountLoginViewModel: ObservableObject { // Also set the newly added account as default core.defaultAccount = account DispatchQueue.main.async { - self.coreContext.defaultAccount = account + self.coreContext.hasDefaultAccount = true } self.domain = "sip.linphone.org" @@ -142,7 +142,7 @@ class AccountLoginViewModel: ObservableObject { if let account = core.defaultAccount { core.removeAccount(account: account) DispatchQueue.main.async { - self.coreContext.defaultAccount = nil + self.coreContext.hasDefaultAccount = false } // To remove all accounts use diff --git a/Linphone/UI/Main/Fragments/SideMenu.swift b/Linphone/UI/Main/Fragments/SideMenu.swift index 8e86d4b42..200619300 100644 --- a/Linphone/UI/Main/Fragments/SideMenu.swift +++ b/Linphone/UI/Main/Fragments/SideMenu.swift @@ -127,7 +127,7 @@ struct SideMenu: View { Log.info("$TAG Account has been removed") DispatchQueue.main.async { - coreContext.defaultAccount = nil + coreContext.hasDefaultAccount = false coreContext.loggedIn = false } } From 1f0c3fa5f72cce2b1ee61ba57f0803ba3064fd7a Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Mon, 29 Apr 2024 16:03:40 +0200 Subject: [PATCH 201/486] Add mosaic mode to conference call view --- .../picture-in-picture.imageset/Contents.json | 21 + .../picture-in-picture.svg | 1 + .../plus.imageset/Contents.json | 21 + .../Assets.xcassets/plus.imageset/plus.svg | 1 + .../squares-four.imageset/Contents.json | 21 + .../squares-four.imageset/squares-four.svg | 1 + .../waveform.imageset/Contents.json | 21 + .../waveform.imageset/waveform.svg | 1 + Linphone/Localizable.xcstrings | 9 + Linphone/UI/Call/CallView.swift | 1294 ++++++++++++----- .../Fragments/ParticipantsListFragment.swift | 21 + Linphone/UI/Call/Model/ParticipantModel.swift | 4 +- .../UI/Call/ViewModel/CallViewModel.swift | 119 +- 13 files changed, 1165 insertions(+), 370 deletions(-) create mode 100644 Linphone/Assets.xcassets/picture-in-picture.imageset/Contents.json create mode 100644 Linphone/Assets.xcassets/picture-in-picture.imageset/picture-in-picture.svg create mode 100644 Linphone/Assets.xcassets/plus.imageset/Contents.json create mode 100644 Linphone/Assets.xcassets/plus.imageset/plus.svg create mode 100644 Linphone/Assets.xcassets/squares-four.imageset/Contents.json create mode 100644 Linphone/Assets.xcassets/squares-four.imageset/squares-four.svg create mode 100644 Linphone/Assets.xcassets/waveform.imageset/Contents.json create mode 100644 Linphone/Assets.xcassets/waveform.imageset/waveform.svg 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/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/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/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/Localizable.xcstrings b/Linphone/Localizable.xcstrings index d26d8e3f5..2652bf8e4 100644 --- a/Linphone/Localizable.xcstrings +++ b/Linphone/Localizable.xcstrings @@ -218,6 +218,9 @@ }, "Attended transfer" : { + }, + "Audio seulement" : { + }, "Block the address" : { @@ -501,6 +504,9 @@ }, "Missed call" : { + }, + "Mosaïque" : { + }, "New call" : { @@ -555,6 +561,9 @@ }, "Partager le lien" : { + }, + "Participant actif" : { + }, "Participants" : { diff --git a/Linphone/UI/Call/CallView.swift b/Linphone/UI/Call/CallView.swift index 1574c9dbe..2e52f23f4 100644 --- a/Linphone/UI/Call/CallView.swift +++ b/Linphone/UI/Call/CallView.swift @@ -25,6 +25,7 @@ import UniformTypeIdentifiers // swiftlint:disable type_body_length // swiftlint:disable line_length +// swiftlint:disable file_length struct CallView: View { @ObservedObject private var coreContext = CoreContext.shared @@ -39,7 +40,9 @@ struct CallView: View { let pub = NotificationCenter.default.publisher(for: AVAudioSession.routeChangeNotification) @State var audioRouteSheet: Bool = false - @State var options: Int = 1 + @State var changeLayoutSheet: 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 @@ -64,7 +67,13 @@ struct CallView: View { .sheet(isPresented: $audioRouteSheet, onDismiss: { audioRouteSheet = false }) { - innerBottomSheet() + audioRouteBottomSheet() + .presentationDetents([.fraction(0.3)]) + } + .sheet(isPresented: $changeLayoutSheet, onDismiss: { + changeLayoutSheet = false + }) { + changeLayoutBottomSheet() .presentationDetents([.fraction(0.3)]) } .sheet(isPresented: $showingDialer) { @@ -81,7 +90,13 @@ struct CallView: View { .sheet(isPresented: $audioRouteSheet, onDismiss: { audioRouteSheet = false }) { - innerBottomSheet() + audioRouteBottomSheet() + .presentationDetents([.fraction(0.3)]) + } + .sheet(isPresented: $changeLayoutSheet, onDismiss: { + changeLayoutSheet = false + }) { + changeLayoutBottomSheet() .presentationDetents([.fraction(0.3)]) } .sheet(isPresented: $showingDialer) { @@ -95,10 +110,15 @@ struct CallView: View { } else { innerView(geometry: geo) .halfSheet(showSheet: $audioRouteSheet) { - innerBottomSheet() + audioRouteBottomSheet() } onDismiss: { audioRouteSheet = false } + .halfSheet(showSheet: $changeLayoutSheet) { + changeLayoutBottomSheet() + } onDismiss: { + changeLayoutSheet = false + } .halfSheet(showSheet: $showingDialer) { DialerBottomSheet( startCallViewModel: StartCallViewModel(), @@ -149,10 +169,10 @@ struct CallView: View { } @ViewBuilder - func innerBottomSheet() -> some View { + func audioRouteBottomSheet() -> some View { VStack(spacing: 0) { Button(action: { - options = 1 + optionsAudioRoute = 1 do { try AVAudioSession.sharedInstance().overrideOutputAudioPort(.none) @@ -166,7 +186,7 @@ struct CallView: View { } }, label: { HStack { - Image(options == 1 ? "radio-button-fill" : "radio-button") + Image(optionsAudioRoute == 1 ? "radio-button-fill" : "radio-button") .renderingMode(.template) .resizable() .foregroundStyle(.white) @@ -189,7 +209,7 @@ struct CallView: View { .frame(maxHeight: .infinity) Button(action: { - options = 2 + optionsAudioRoute = 2 do { try AVAudioSession.sharedInstance().overrideOutputAudioPort(.speaker) @@ -198,7 +218,7 @@ struct CallView: View { } }, label: { HStack { - Image(options == 2 ? "radio-button-fill" : "radio-button") + Image(optionsAudioRoute == 2 ? "radio-button-fill" : "radio-button") .renderingMode(.template) .resizable() .foregroundStyle(.white) @@ -221,7 +241,7 @@ struct CallView: View { .frame(maxHeight: .infinity) Button(action: { - options = 3 + optionsAudioRoute = 3 do { try AVAudioSession.sharedInstance().overrideOutputAudioPort(.none) @@ -231,7 +251,7 @@ struct CallView: View { } }, label: { HStack { - Image(options == 3 ? "radio-button-fill" : "radio-button") + Image(optionsAudioRoute == 3 ? "radio-button-fill" : "radio-button") .renderingMode(.template) .resizable() .foregroundStyle(.white) @@ -258,6 +278,97 @@ struct CallView: View { .frame(maxHeight: .infinity) } + @ViewBuilder + func changeLayoutBottomSheet() -> some View { + VStack(spacing: 0) { + Button(action: { + optionsChangeLayout = 1 + changeLayoutSheet = false + }, 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 + changeLayoutSheet = false + }, 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 + changeLayoutSheet = false + }, 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) + } + @ViewBuilder // swiftlint:disable:next cyclomatic_complexity func innerView(geometry: GeometryProxy) -> some View { @@ -563,336 +674,10 @@ struct CallView: View { ) } } else if callViewModel.isConference && !telecomManager.outgoingCallStarted && callViewModel.activeSpeakerParticipant != nil { - 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 : 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 - 160 + geometry.safeAreaInsets.bottom - ) - - 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 - ) + if optionsChangeLayout == 1 && callViewModel.participantList.count <= 5 { + mosaicMode(geometry: geometry, 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)) } else { - VStack { - VStack { - Spacer() - ZStack { - if callViewModel.activeSpeakerParticipant?.address != nil { - let addressFriend = contactsManager.getFriendWithAddress(address: callViewModel.activeSpeakerParticipant!.address) - - let contactAvatarModel = addressFriend != nil - ? ContactsManager.shared.avatarListModel.first(where: { - ($0.friend!.consolidatedPresence == .Online || $0.friend!.consolidatedPresence == .Busy) - && $0.friend!.name == addressFriend!.name - && $0.friend!.address!.asStringUriOnly() == addressFriend!.address!.asStringUriOnly() - }) - : ContactAvatarModel(friend: nil, name: "", withPresence: false) - - if addressFriend != nil && addressFriend!.photo != nil && !addressFriend!.photo!.isEmpty { - if contactAvatarModel != nil { - Avatar(contactAvatarModel: contactAvatarModel!, avatarSize: 200, hidePresence: true) - .onAppear { - DispatchQueue.main.asyncAfter(deadline: .now() + 1) { - displayVideo = true - } - } - } - } else { - if callViewModel.activeSpeakerParticipant!.address.displayName != nil { - Image(uiImage: contactsManager.textToImage( - firstName: callViewModel.activeSpeakerParticipant!.address.displayName!, - lastName: callViewModel.activeSpeakerParticipant!.address.displayName!.components(separatedBy: " ").count > 1 - ? callViewModel.activeSpeakerParticipant!.address.displayName!.components(separatedBy: " ")[1] - : "")) - .resizable() - .frame(width: 200, height: 200) - .clipShape(Circle()) - .onAppear { - DispatchQueue.main.asyncAfter(deadline: .now() + 1) { - displayVideo = true - } - } - - } else { - Image(uiImage: contactsManager.textToImage( - firstName: callViewModel.activeSpeakerParticipant!.address.username ?? "Username Error", - lastName: callViewModel.activeSpeakerParticipant!.address.username!.components(separatedBy: " ").count > 1 - ? callViewModel.activeSpeakerParticipant!.address.username!.components(separatedBy: " ")[1] - : "")) - .resizable() - .frame(width: 200, height: 200) - .clipShape(Circle()) - .onAppear { - DispatchQueue.main.asyncAfter(deadline: .now() + 1) { - displayVideo = true - } - } - } - - } - } else { - Image("profil-picture-default") - .resizable() - .frame(width: 200, height: 200) - .clipShape(Circle()) - } - } - - 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 - 160 + geometry.safeAreaInsets.bottom - ) - - 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 - ) - - if telecomManager.remoteConfVideo && !telecomManager.outgoingCallStarted && callViewModel.activeSpeakerParticipant != nil && displayVideo { - VStack { - VStack { - LinphoneVideoViewHolder { view in - coreContext.doOnCoreQueue { core in - core.nativeVideoWindow = view - } - } - .onTapGesture { - if callViewModel.videoDisplayed { - fullscreenVideo.toggle() - } - } - } - .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 - 160 + geometry.safeAreaInsets.bottom - ) - .cornerRadius(20) - - 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) - } - 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) - - 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) - .cornerRadius(20) - - ForEach(0.. 80 ? minBottomSheetHeight * geometry.size.height : 78) - 40 - 20 + geometry.safeAreaInsets.bottom - ) - .padding(.bottom, 10) - .padding(.leading, -10) + activeSpeakerMode(geometry: geometry) } } else if callViewModel.isConference && !telecomManager.outgoingCallStarted && callViewModel.participantList.isEmpty { VStack { @@ -1034,6 +819,832 @@ struct CallView: View { } // swiftlint:enable function_body_length + func activeSpeakerMode(geometry: GeometryProxy) -> some View { + ZStack { + 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 : 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 - 160 + geometry.safeAreaInsets.bottom + ) + + 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 { + VStack { + Spacer() + ZStack { + if callViewModel.activeSpeakerParticipant?.address != nil { + let addressFriend = contactsManager.getFriendWithAddress(address: callViewModel.activeSpeakerParticipant!.address) + + let contactAvatarModel = addressFriend != nil + ? ContactsManager.shared.avatarListModel.first(where: { + ($0.friend!.consolidatedPresence == .Online || $0.friend!.consolidatedPresence == .Busy) + && $0.friend!.name == addressFriend!.name + && $0.friend!.address!.asStringUriOnly() == addressFriend!.address!.asStringUriOnly() + }) + : ContactAvatarModel(friend: nil, name: "", withPresence: false) + + if addressFriend != nil && addressFriend!.photo != nil && !addressFriend!.photo!.isEmpty { + if contactAvatarModel != nil { + Avatar(contactAvatarModel: contactAvatarModel!, avatarSize: 200, hidePresence: true) + .onAppear { + DispatchQueue.main.asyncAfter(deadline: .now() + 1) { + displayVideo = true + } + } + } + } else { + if callViewModel.activeSpeakerParticipant!.address.displayName != nil { + Image(uiImage: contactsManager.textToImage( + firstName: callViewModel.activeSpeakerParticipant!.address.displayName!, + lastName: callViewModel.activeSpeakerParticipant!.address.displayName!.components(separatedBy: " ").count > 1 + ? callViewModel.activeSpeakerParticipant!.address.displayName!.components(separatedBy: " ")[1] + : "")) + .resizable() + .frame(width: 200, height: 200) + .clipShape(Circle()) + .onAppear { + DispatchQueue.main.asyncAfter(deadline: .now() + 1) { + displayVideo = true + } + } + + } else { + Image(uiImage: contactsManager.textToImage( + firstName: callViewModel.activeSpeakerParticipant!.address.username ?? "Username Error", + lastName: callViewModel.activeSpeakerParticipant!.address.username!.components(separatedBy: " ").count > 1 + ? callViewModel.activeSpeakerParticipant!.address.username!.components(separatedBy: " ")[1] + : "")) + .resizable() + .frame(width: 200, height: 200) + .clipShape(Circle()) + .onAppear { + DispatchQueue.main.asyncAfter(deadline: .now() + 1) { + displayVideo = true + } + } + } + + } + } else { + Image("profil-picture-default") + .resizable() + .frame(width: 200, height: 200) + .clipShape(Circle()) + } + } + + 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 - 160 + geometry.safeAreaInsets.bottom + ) + + 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 + ) + + if telecomManager.remoteConfVideo && !telecomManager.outgoingCallStarted && callViewModel.activeSpeakerParticipant != nil && displayVideo { + VStack { + VStack { + LinphoneVideoViewHolder { view in + coreContext.doOnCoreQueue { core in + core.nativeVideoWindow = view + } + } + .onTapGesture { + if callViewModel.videoDisplayed { + fullscreenVideo.toggle() + } + } + } + .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 - 160 + geometry.safeAreaInsets.bottom + ) + .cornerRadius(20) + + 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) + } + 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) + + 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 : Color.gray600, lineWidth: 4) + ) + .cornerRadius(20) + + ForEach(0.. 80 ? minBottomSheetHeight * geometry.size.height : 78) - 40 - 20 + geometry.safeAreaInsets.bottom + ) + .padding(.bottom, 10) + .padding(.leading, -10) + } + } + .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 : Color.gray600, 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 : Color.gray600, lineWidth: 4) + ) + .cornerRadius(20) + } + + ForEach(0.. some View { GeometryReader { _ in @@ -1326,6 +1937,7 @@ struct CallView: View { } else { VStack { Button { + changeLayoutSheet = true } label: { HStack { Image("notebook") @@ -1651,6 +2263,7 @@ struct CallView: View { } else { VStack { Button { + changeLayoutSheet = true } label: { HStack { Image("notebook") @@ -1894,3 +2507,4 @@ struct PressedButtonStyle: ButtonStyle { } // swiftlint:enable type_body_length // swiftlint:enable line_length +// swiftlint:enable file_length diff --git a/Linphone/UI/Call/Fragments/ParticipantsListFragment.swift b/Linphone/UI/Call/Fragments/ParticipantsListFragment.swift index 301d1de99..31152edb7 100644 --- a/Linphone/UI/Call/Fragments/ParticipantsListFragment.swift +++ b/Linphone/UI/Call/Fragments/ParticipantsListFragment.swift @@ -74,6 +74,27 @@ struct ParticipantsListFragment: View { .background(.white) participantsList + + HStack { + Spacer() + + NavigationLink(destination: { + //AddParticipantsFragment() + }, 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) diff --git a/Linphone/UI/Call/Model/ParticipantModel.swift b/Linphone/UI/Call/Model/ParticipantModel.swift index e68da99cf..0a1ec61d4 100644 --- a/Linphone/UI/Call/Model/ParticipantModel.swift +++ b/Linphone/UI/Call/Model/ParticipantModel.swift @@ -32,8 +32,9 @@ class ParticipantModel: ObservableObject { @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) { + 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() @@ -50,5 +51,6 @@ class ParticipantModel: ObservableObject { self.onPause = onPause self.isMuted = isMuted self.isAdmin = isAdmin + self.isSpeaking = isSpeaking } } diff --git a/Linphone/UI/Call/ViewModel/CallViewModel.swift b/Linphone/UI/Call/ViewModel/CallViewModel.swift index 24299823f..72a08548a 100644 --- a/Linphone/UI/Call/ViewModel/CallViewModel.swift +++ b/Linphone/UI/Call/ViewModel/CallViewModel.swift @@ -305,6 +305,7 @@ class CallViewModel: ObservableObject { ) } + // swiftlint:disable:next cyclomatic_complexity func addConferenceCallBacks() { coreContext.doOnCoreQueue { core in self.mConferenceSuscriptions.insert( @@ -386,7 +387,52 @@ class CallViewModel: ObservableObject { } }) + var activeSpeakerParticipantTmp: ParticipantModel? = nil + var activeSpeakerNameTmp = "" + + if self.activeSpeakerParticipant == nil { + if cbValue.conference.activeSpeakerParticipantDevice?.address != nil { + activeSpeakerParticipantTmp = ParticipantModel( + address: cbValue.conference.activeSpeakerParticipantDevice!.address!, + isJoining: false, + onPause: cbValue.conference.activeSpeakerParticipantDevice!.state == .OnHold, + isMuted: cbValue.conference.activeSpeakerParticipantDevice!.isMuted + ) + } else if cbValue.conference.participantList.first?.address != nil && cbValue.conference.participantList.first!.address!.clone()!.equal(address2: (cbValue.conference.me?.address)!) { + activeSpeakerParticipantTmp = ParticipantModel( + address: cbValue.conference.participantDeviceList.first!.address!, + isJoining: false, + onPause: cbValue.conference.participantDeviceList.first!.state == .OnHold, + isMuted: cbValue.conference.participantDeviceList.first!.isMuted + ) + } else if cbValue.conference.participantList.last?.address != nil { + activeSpeakerParticipantTmp = ParticipantModel( + address: cbValue.conference.participantDeviceList.last!.address!, + isJoining: false, + onPause: cbValue.conference.participantDeviceList.last!.state == .OnHold, + isMuted: cbValue.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.activeSpeakerParticipant = activeSpeakerParticipantTmp + self.activeSpeakerName = activeSpeakerNameTmp + } self.participantList = participantListTmp } } @@ -435,17 +481,16 @@ class CallViewModel: ObservableObject { DispatchQueue.main.async { self.activeSpeakerParticipant!.isMuted = isMutedTmp } - } else { - self.participantList.forEach({ participantDevice in - if participantDevice.address.equal(address2: cbValue.participantDevice.address!) { - let isMutedTmp = cbValue.isMuted - - DispatchQueue.main.async { - participantDevice.isMuted = isMutedTmp - } - } - }) } + self.participantList.forEach({ participantDevice in + if participantDevice.address.equal(address2: cbValue.participantDevice.address!) { + let isMutedTmp = cbValue.isMuted + + DispatchQueue.main.async { + participantDevice.isMuted = isMutedTmp + } + } + }) } ) @@ -461,18 +506,17 @@ class CallViewModel: ObservableObject { self.activeSpeakerParticipant!.onPause = activeSpeakerParticipantOnPauseTmp self.activeSpeakerParticipant!.isJoining = activeSpeakerParticipantIsJoiningTmp } - } else { - self.participantList.forEach({ participantDevice in - if participantDevice.address.equal(address2: cbValue.device.address!) { - let participantDeviceOnPauseTmp = cbValue.state == .OnHold - let participantDeviceIsJoiningTmp = cbValue.state == .Joining || cbValue.state == .Alerting - DispatchQueue.main.async { - participantDevice.onPause = participantDeviceOnPauseTmp - participantDevice.isJoining = participantDeviceIsJoiningTmp - } - } - }) } + self.participantList.forEach({ participantDevice in + if participantDevice.address.equal(address2: cbValue.device.address!) { + let participantDeviceOnPauseTmp = cbValue.state == .OnHold + let participantDeviceIsJoiningTmp = cbValue.state == .Joining || cbValue.state == .Alerting + DispatchQueue.main.async { + participantDevice.onPause = participantDeviceOnPauseTmp + participantDevice.isJoining = participantDeviceIsJoiningTmp + } + } + }) } ) @@ -483,15 +527,32 @@ class CallViewModel: ObservableObject { DispatchQueue.main.async { self.myParticipantModel!.isAdmin = isAdmin } - } else { - self.participantList.forEach({ participantDevice in - if participantDevice.address.clone()!.equal(address2: cbValue.participant.address!) { - DispatchQueue.main.async { - participantDevice.isAdmin = isAdmin - } - } - }) } + self.participantList.forEach({ participantDevice in + if participantDevice.address.clone()!.equal(address2: cbValue.participant.address!) { + DispatchQueue.main.async { + participantDevice.isAdmin = isAdmin + } + } + }) + } + ) + + self.mConferenceSuscriptions.insert( + self.currentCall?.conference?.publisher?.onParticipantDeviceIsSpeakingChanged?.postOnMainQueue {(cbValue: (conference: Conference, participantDevice: ParticipantDevice, isSpeaking: Bool)) in + let isSpeaking = cbValue.participantDevice.isSpeaking + if self.myParticipantModel != nil && self.myParticipantModel!.address.clone()!.equal(address2: cbValue.participantDevice.address!) { + DispatchQueue.main.async { + self.myParticipantModel!.isSpeaking = isSpeaking + } + } + self.participantList.forEach({ participantDeviceList in + if participantDeviceList.address.clone()!.equal(address2: cbValue.participantDevice.address!) { + DispatchQueue.main.async { + participantDeviceList.isSpeaking = isSpeaking + } + } + }) } ) } From 19da4e0d64bb2b135ed2d203d717dcbd37ef1460 Mon Sep 17 00:00:00 2001 From: QuentinArguillere Date: Mon, 29 Apr 2024 17:34:53 +0200 Subject: [PATCH 202/486] Extract AddParticipantsViewModel from the ScheduleMeetingViewModel to be used for other future views --- Linphone.xcodeproj/project.pbxproj | 6 ++- .../Fragments/AddParticipantsFragment.swift | 31 +++++++------- .../Fragments/ScheduleMeetingFragment.swift | 8 +++- .../ViewModel/ScheduleMeetingViewModel.swift | 25 +---------- .../Viewmodel/AddParticipantsViewModel.swift | 42 +++++++++++++++++++ 5 files changed, 71 insertions(+), 41 deletions(-) create mode 100644 Linphone/UI/Main/Viewmodel/AddParticipantsViewModel.swift diff --git a/Linphone.xcodeproj/project.pbxproj b/Linphone.xcodeproj/project.pbxproj index a472253e4..1d119567a 100644 --- a/Linphone.xcodeproj/project.pbxproj +++ b/Linphone.xcodeproj/project.pbxproj @@ -13,6 +13,7 @@ 6613A0B02BAEB7F4008923A4 /* MeetingsListFragment.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6613A0AF2BAEB7F4008923A4 /* MeetingsListFragment.swift */; }; 6613A0B42BAEBE3F008923A4 /* MeetingViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6613A0B32BAEBE3F008923A4 /* MeetingViewModel.swift */; }; 6613A0B62BAEBE5C008923A4 /* ScheduleMeetingViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6613A0B52BAEBE5C008923A4 /* ScheduleMeetingViewModel.swift */; }; + 66162A202BDFC2F900DCE913 /* AddParticipantsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 66162A1F2BDFC2F900DCE913 /* AddParticipantsViewModel.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 */; }; @@ -167,6 +168,7 @@ 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 = ""; }; 6613A0B52BAEBE5C008923A4 /* ScheduleMeetingViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScheduleMeetingViewModel.swift; sourceTree = ""; }; + 66162A1F2BDFC2F900DCE913 /* AddParticipantsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddParticipantsViewModel.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 = ""; }; @@ -658,6 +660,7 @@ D7A2EDD42AC180FE005D90FC /* Viewmodel */ = { isa = PBXGroup; children = ( + 66162A1F2BDFC2F900DCE913 /* AddParticipantsViewModel.swift */, D7A2EDD52AC18115005D90FC /* SharedMainViewModel.swift */, D796F1FF2B0BB61A0041115F /* ToastViewModel.swift */, ); @@ -980,6 +983,7 @@ 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 */, 66E56BC92BA4A6D7006CE56F /* MeetingsListViewModel.swift in Sources */, @@ -1252,7 +1256,7 @@ ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = NO; CODE_SIGN_ENTITLEMENTS = Linphone/Linphone.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 4; + CURRENT_PROJECT_VERSION = 10; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_ASSET_PATHS = "\"Linphone/Preview Content\""; diff --git a/Linphone/UI/Main/Meetings/Fragments/AddParticipantsFragment.swift b/Linphone/UI/Main/Meetings/Fragments/AddParticipantsFragment.swift index 53ed85a86..557f39be4 100644 --- a/Linphone/UI/Main/Meetings/Fragments/AddParticipantsFragment.swift +++ b/Linphone/UI/Main/Meetings/Fragments/AddParticipantsFragment.swift @@ -18,7 +18,8 @@ struct AddParticipantsFragment: View { @ObservedObject var contactsManager = ContactsManager.shared @ObservedObject var magicSearch = MagicSearchSingleton.shared - @ObservedObject var scheduleMeetingViewModel: ScheduleMeetingViewModel + @ObservedObject var addParticipantsViewModel: AddParticipantsViewModel + var confirmAddParticipantsFunc: ([SelectedAddressModel]) -> Void @State private var delayedColor = Color.white @FocusState var isSearchFieldFocused: Bool @@ -50,7 +51,7 @@ struct AddParticipantsFragment: View { .padding(.top, 2) .padding(.leading, -10) .onTapGesture { - scheduleMeetingViewModel.participantsToAdd = [] + addParticipantsViewModel.reset() dismiss() } @@ -59,7 +60,7 @@ struct AddParticipantsFragment: View { .multilineTextAlignment(.leading) .default_text_style_orange_800(styleSize: 16) .padding(.top, 20) - Text("\($scheduleMeetingViewModel.participants.count) selected participants") + Text("\($addParticipantsViewModel.participantsToAdd.count) selected participants") .default_text_style_300(styleSize: 12) } Spacer() @@ -72,12 +73,12 @@ struct AddParticipantsFragment: View { ScrollView(.horizontal) { HStack { - ForEach(0..() var conferenceInfoToEdit: ConferenceInfo? @@ -75,11 +62,9 @@ class ScheduleMeetingViewModel: ObservableObject { allDayMeeting = false timezone = "" sendInvitations = true - participantsToAdd = [] participants = [] operationInProgress = false conferenceCreatedEvent = false - searchField = "" fromDate = Calendar.current.date(byAdding: .hour, value: 1, to: Date.now)! toDate = Calendar.current.date(byAdding: .hour, value: 2, to: Date.now)! @@ -113,14 +98,7 @@ class ScheduleMeetingViewModel: ObservableObject { // TODO } - func selectParticipant(addr: Address) { - if let idx = participantsToAdd.firstIndex(where: {$0.address.weakEqual(address2: addr)}) { - participantsToAdd.remove(at: idx) - } else { - participantsToAdd.append(SelectedAddressModel(addr: addr, avModel: ContactAvatarModel.getAvatarModelFromAddress(address: addr))) - } - } - func addParticipants() { + func addParticipants(participantsToAdd: [SelectedAddressModel]) { var list = participants for selectedAddr in participantsToAdd { if let found = list.first(where: { $0.address.weakEqual(address2: selectedAddr.address) }) { @@ -134,7 +112,6 @@ class ScheduleMeetingViewModel: ObservableObject { Log.info("\(ScheduleMeetingViewModel.TAG) [\(list.count - participants.count) participants added, now there are \(list.count) participants in list") participants = list - participantsToAdd = [] } private func fillConferenceInfo(confInfo: ConferenceInfo) { diff --git a/Linphone/UI/Main/Viewmodel/AddParticipantsViewModel.swift b/Linphone/UI/Main/Viewmodel/AddParticipantsViewModel.swift new file mode 100644 index 000000000..d8140d3a7 --- /dev/null +++ b/Linphone/UI/Main/Viewmodel/AddParticipantsViewModel.swift @@ -0,0 +1,42 @@ +// +// AddParticipantsViewModel.swift +// Linphone +// +// Created by QuentinArguillere on 29/04/2024. +// + +import Foundation +import linphonesw +import Combine + +class SelectedAddressModel: ObservableObject { + var address: Address + var avatarModel: ContactAvatarModel + + init (addr: Address, avModel: ContactAvatarModel) { + address = addr + avatarModel = avModel + } +} + +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") + participantsToAdd.append(SelectedAddressModel(addr: addr, avModel: ContactAvatarModel.getAvatarModelFromAddress(address: addr))) + } + } + + func reset() { + participantsToAdd = [] + searchField = "" + } +} From 5c5fd2ad8d284ce5354d2d3dfd83fe6a9d4065b9 Mon Sep 17 00:00:00 2001 From: QuentinArguillere Date: Tue, 30 Apr 2024 10:42:11 +0200 Subject: [PATCH 203/486] Stop/start core on background/foreground --- Linphone/Core/CoreContext.swift | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/Linphone/Core/CoreContext.swift b/Linphone/Core/CoreContext.swift index 4609bbba3..479a71ec3 100644 --- a/Linphone/Core/CoreContext.swift +++ b/Linphone/Core/CoreContext.swift @@ -252,9 +252,7 @@ final class CoreContext: ObservableObject { func updatePresence(core: Core, presence: ConsolidatedPresence) { if core.config!.getBool(section: "app", key: "publish_presence", defaultValue: true) { - DispatchQueue.main.async { - core.consolidatedPresence = presence - } + core.consolidatedPresence = presence } } @@ -263,9 +261,9 @@ final class CoreContext: ObservableObject { // 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) - //try? self.mCore.start() } } @@ -278,10 +276,10 @@ final class CoreContext: ObservableObject { // 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() + self.mCore.iterate() if self.mCore.currentCall == nil { - //self.mCore.stop() + self.mCore.stop() } } } From f6e935c65f9f053bcecb3c3d11660adf4c54b2f8 Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Tue, 30 Apr 2024 11:28:36 +0200 Subject: [PATCH 204/486] Add audio only mode to conference call view Enable FEC Fix active speaker video --- Linphone/Core/CoreContext.swift | 2 + Linphone/Ressources/linphonerc-default | 3 + Linphone/TelecomManager/TelecomManager.swift | 16 ++- Linphone/UI/Call/CallView.swift | 119 +++++++++++++++++- .../UI/Call/ViewModel/CallViewModel.swift | 16 +++ 5 files changed, 151 insertions(+), 5 deletions(-) diff --git a/Linphone/Core/CoreContext.swift b/Linphone/Core/CoreContext.swift index 479a71ec3..61504cbc5 100644 --- a/Linphone/Core/CoreContext.swift +++ b/Linphone/Core/CoreContext.swift @@ -111,6 +111,8 @@ final class CoreContext: ObservableObject { self.mCore.videoDisplayEnabled = true self.mCore.videoPreviewEnabled = false + self.mCore.fecEnabled = true + self.mCoreSuscriptions.insert(self.mCore.publisher?.onGlobalStateChanged?.postOnMainQueue { (cbVal: (core: Core, state: GlobalState, message: String)) in if cbVal.state == GlobalState.On { self.hasDefaultAccount = true diff --git a/Linphone/Ressources/linphonerc-default b/Linphone/Ressources/linphonerc-default index ce7ae7161..8126cf1cc 100644 --- a/Linphone/Ressources/linphonerc-default +++ b/Linphone/Ressources/linphonerc-default @@ -40,4 +40,7 @@ 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/TelecomManager/TelecomManager.swift b/Linphone/TelecomManager/TelecomManager.swift index 92d32421a..ed3994313 100644 --- a/Linphone/TelecomManager/TelecomManager.swift +++ b/Linphone/TelecomManager/TelecomManager.swift @@ -385,15 +385,25 @@ class TelecomManager: ObservableObject { if call.conference != nil { if call.conference!.activeSpeakerParticipantDevice != nil { let direction = call.conference?.activeSpeakerParticipantDevice!.getStreamCapability(streamType: StreamType.Video) - self.remoteConfVideo = direction == MediaDirection.SendRecv || direction == MediaDirection.SendOnly + self.remoteConfVideo = direction == .SendRecv || direction == .SendOnly + } else if call.conference!.participantList.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) + self.remoteConfVideo = direction == .SendRecv || direction == .SendOnly + } else if call.conference!.participantList.last != nil + && call.conference!.participantList.last?.address != nil { + let direction = call.conference!.participantDeviceList.last!.getStreamCapability(streamType: StreamType.Video) + self.remoteConfVideo = direction == .SendRecv || direction == .SendOnly } else { - self.remoteConfVideo = true + self.remoteConfVideo = false } + } else { self.remoteConfVideo = false DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { - self.remoteConfVideo = call.currentParams!.videoEnabled && call.currentParams!.videoDirection == MediaDirection.SendRecv || call.currentParams!.videoDirection == MediaDirection.SendOnly + self.remoteConfVideo = call.currentParams!.videoEnabled && call.currentParams!.videoDirection == .SendRecv || call.currentParams!.videoDirection == .SendOnly } } diff --git a/Linphone/UI/Call/CallView.swift b/Linphone/UI/Call/CallView.swift index 2e52f23f4..90d1e08b0 100644 --- a/Linphone/UI/Call/CallView.swift +++ b/Linphone/UI/Call/CallView.swift @@ -283,6 +283,7 @@ struct CallView: View { VStack(spacing: 0) { Button(action: { optionsChangeLayout = 1 + callViewModel.toggleVideoMode(isAudioOnlyMode: false) changeLayoutSheet = false }, label: { HStack { @@ -312,6 +313,7 @@ struct CallView: View { Button(action: { optionsChangeLayout = 2 + callViewModel.toggleVideoMode(isAudioOnlyMode: false) changeLayoutSheet = false }, label: { HStack { @@ -339,6 +341,10 @@ struct CallView: View { Button(action: { optionsChangeLayout = 3 + if callViewModel.videoDisplayed { + callViewModel.displayMyVideo() + } + callViewModel.toggleVideoMode(isAudioOnlyMode: true) changeLayoutSheet = false }, label: { HStack { @@ -509,6 +515,10 @@ struct CallView: View { 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) { optionsChangeLayoutValue 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) } } @@ -674,8 +684,11 @@ struct CallView: View { ) } } else if callViewModel.isConference && !telecomManager.outgoingCallStarted && callViewModel.activeSpeakerParticipant != nil { + let heightValue = (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 optionsChangeLayout == 1 && callViewModel.participantList.count <= 5 { - mosaicMode(geometry: geometry, 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)) + mosaicMode(geometry: geometry, height: heightValue) + } else if optionsChangeLayout == 3 { + audioOnlyMode(geometry: geometry, height: heightValue) } else { activeSpeakerMode(geometry: geometry) } @@ -1645,6 +1658,103 @@ struct CallView: View { } } + 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 : Color.gray600, lineWidth: 4) + ) + .cornerRadius(20) + } + + ForEach(0.. some View { GeometryReader { _ in @@ -1686,7 +1796,12 @@ struct CallView: View { Spacer() Button { - callViewModel.displayMyVideo() + if optionsChangeLayout == 3 { + optionsChangeLayout = 2 + callViewModel.toggleVideoMode(isAudioOnlyMode: false) + } else { + callViewModel.displayMyVideo() + } } label: { HStack { Image(callViewModel.videoDisplayed ? "video-camera" : "video-camera-slash") diff --git a/Linphone/UI/Call/ViewModel/CallViewModel.swift b/Linphone/UI/Call/ViewModel/CallViewModel.swift index 72a08548a..16629dafb 100644 --- a/Linphone/UI/Call/ViewModel/CallViewModel.swift +++ b/Linphone/UI/Call/ViewModel/CallViewModel.swift @@ -647,6 +647,22 @@ class CallViewModel: ObservableObject { } } + 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 From 46c41c1218082ca9935c9a5d65d27a2c6e3499d2 Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Tue, 30 Apr 2024 11:48:16 +0200 Subject: [PATCH 205/486] Fix when participant device is nil --- Linphone/TelecomManager/TelecomManager.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Linphone/TelecomManager/TelecomManager.swift b/Linphone/TelecomManager/TelecomManager.swift index ed3994313..72ff6e84e 100644 --- a/Linphone/TelecomManager/TelecomManager.swift +++ b/Linphone/TelecomManager/TelecomManager.swift @@ -386,12 +386,12 @@ class TelecomManager: ObservableObject { if call.conference!.activeSpeakerParticipantDevice != nil { let direction = call.conference?.activeSpeakerParticipantDevice!.getStreamCapability(streamType: StreamType.Video) self.remoteConfVideo = direction == .SendRecv || direction == .SendOnly - } else if call.conference!.participantList.first != nil + } 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) self.remoteConfVideo = direction == .SendRecv || direction == .SendOnly - } else if call.conference!.participantList.last != nil + } 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) self.remoteConfVideo = direction == .SendRecv || direction == .SendOnly From 4949ca329abd12234248eb3cec07018378847af1 Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Thu, 2 May 2024 09:46:23 +0200 Subject: [PATCH 206/486] Call encryption statistics --- Linphone.xcodeproj/project.pbxproj | 4 + Linphone/Localizable.xcstrings | 9 ++ Linphone/UI/Call/CallView.swift | 97 ++++++++++++++++++- .../UI/Call/MeetingWaitingRoomFragment.swift | 15 +++ .../Call/Model/CallMediaEncryptionModel.swift | 97 +++++++++++++++++++ .../UI/Call/ViewModel/CallViewModel.swift | 8 ++ .../MeetingWaitingRoomViewModel.swift | 8 ++ 7 files changed, 237 insertions(+), 1 deletion(-) create mode 100644 Linphone/UI/Call/Model/CallMediaEncryptionModel.swift diff --git a/Linphone.xcodeproj/project.pbxproj b/Linphone.xcodeproj/project.pbxproj index 1d119567a..92fbb98c5 100644 --- a/Linphone.xcodeproj/project.pbxproj +++ b/Linphone.xcodeproj/project.pbxproj @@ -41,6 +41,7 @@ D70A26F02B7D02E6006CC8FC /* ConversationViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D70A26EF2B7D02E6006CC8FC /* ConversationViewModel.swift */; }; D70A26F22B7F5D95006CC8FC /* ConversationFragment.swift in Sources */ = {isa = PBXBuildFile; fileRef = D70A26F12B7F5D95006CC8FC /* ConversationFragment.swift */; }; D70C93DE2AC2D0F60063CA3B /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = D70C93DD2AC2D0F60063CA3B /* Localizable.xcstrings */; }; + D714035B2BE11E00004BD8CA /* CallMediaEncryptionModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D714035A2BE11E00004BD8CA /* CallMediaEncryptionModel.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 */; }; @@ -191,6 +192,7 @@ D70A26EF2B7D02E6006CC8FC /* ConversationViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConversationViewModel.swift; sourceTree = ""; }; D70A26F12B7F5D95006CC8FC /* ConversationFragment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConversationFragment.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 = ""; }; 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 = ""; }; @@ -523,6 +525,7 @@ isa = PBXGroup; children = ( D720E6AC2BAD822000DDFD87 /* ParticipantModel.swift */, + D714035A2BE11E00004BD8CA /* CallMediaEncryptionModel.swift */, ); path = Model; sourceTree = ""; @@ -1000,6 +1003,7 @@ 6613A0B42BAEBE3F008923A4 /* MeetingViewModel.swift in Sources */, D7173EBE2B7A5C0A00BCC481 /* LinphoneUtils.swift in Sources */, 66C492012B24DB6900CEA16D /* Log.swift in Sources */, + D714035B2BE11E00004BD8CA /* CallMediaEncryptionModel.swift in Sources */, 6613A0B62BAEBE5C008923A4 /* ScheduleMeetingViewModel.swift in Sources */, D748BF2C2ACD82D2004844EB /* ThirdPartySipAccountLoginFragment.swift in Sources */, D7CEE0382B7A214F00FD79B7 /* ConversationsListViewModel.swift in Sources */, diff --git a/Linphone/Localizable.xcstrings b/Linphone/Localizable.xcstrings index 2652bf8e4..4a74bed5c 100644 --- a/Linphone/Localizable.xcstrings +++ b/Linphone/Localizable.xcstrings @@ -192,6 +192,9 @@ }, "All modifications will be canceled." : { + }, + "Annuler" : { + }, "Appel" : { @@ -257,6 +260,9 @@ }, "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" : { @@ -374,6 +380,9 @@ }, "Etes-vous sûr de vouloir supprimer %@ ?" : { + }, + "Faire la validation à nouveau" : { + }, "Favourites" : { diff --git a/Linphone/UI/Call/CallView.swift b/Linphone/UI/Call/CallView.swift index 90d1e08b0..a25d1dc49 100644 --- a/Linphone/UI/Call/CallView.swift +++ b/Linphone/UI/Call/CallView.swift @@ -41,6 +41,7 @@ struct CallView: View { @State var audioRouteSheet: Bool = false @State var changeLayoutSheet: Bool = false + @State var mediaEncryptedSheet: Bool = false @State var optionsAudioRoute: Int = 1 @State var optionsChangeLayout: Int = 2 @State var imageAudioRoute: String = "" @@ -64,6 +65,12 @@ struct CallView: View { ZStack { if #available(iOS 16.4, *), idiom != .pad { innerView(geometry: geo) + .sheet(isPresented: $mediaEncryptedSheet, onDismiss: { + mediaEncryptedSheet = false + }) { + mediaEncryptedSheetBottomSheet() + .presentationDetents([.medium]) + } .sheet(isPresented: $audioRouteSheet, onDismiss: { audioRouteSheet = false }) { @@ -87,6 +94,12 @@ struct CallView: View { } } else if #available(iOS 16.0, *), idiom != .pad { innerView(geometry: geo) + .sheet(isPresented: $mediaEncryptedSheet, onDismiss: { + mediaEncryptedSheet = false + }) { + mediaEncryptedSheetBottomSheet() + .presentationDetents([.medium]) + } .sheet(isPresented: $audioRouteSheet, onDismiss: { audioRouteSheet = false }) { @@ -109,6 +122,11 @@ struct CallView: View { } } else { innerView(geometry: geo) + .halfSheet(showSheet: $mediaEncryptedSheet) { + mediaEncryptedSheetBottomSheet() + } onDismiss: { + mediaEncryptedSheet = false + } .halfSheet(showSheet: $audioRouteSheet) { audioRouteBottomSheet() } onDismiss: { @@ -168,6 +186,83 @@ struct CallView: View { } } + @ViewBuilder + func mediaEncryptedSheetBottomSheet() -> 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 + } + } + .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 + }, 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) + } + @ViewBuilder func audioRouteBottomSheet() -> some View { VStack(spacing: 0) { @@ -479,7 +574,7 @@ struct CallView: View { Spacer() } .onTapGesture { - callViewModel.showZrtpSasDialogIfPossible() + mediaEncryptedSheet = true } .frame(height: 40) .zIndex(1) diff --git a/Linphone/UI/Call/MeetingWaitingRoomFragment.swift b/Linphone/UI/Call/MeetingWaitingRoomFragment.swift index 003b56cd2..a6aec3d93 100644 --- a/Linphone/UI/Call/MeetingWaitingRoomFragment.swift +++ b/Linphone/UI/Call/MeetingWaitingRoomFragment.swift @@ -386,6 +386,21 @@ struct MeetingWaitingRoomFragment: View { .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) } } diff --git a/Linphone/UI/Call/Model/CallMediaEncryptionModel.swift b/Linphone/UI/Call/Model/CallMediaEncryptionModel.swift new file mode 100644 index 000000000..a4eaf15c0 --- /dev/null +++ b/Linphone/UI/Call/Model/CallMediaEncryptionModel.swift @@ -0,0 +1,97 @@ +/* + * 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 { core in + var 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" + default: + mediaEncryptionTmp = "Media encryption: " + "None" + } + } + + 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/ViewModel/CallViewModel.swift b/Linphone/UI/Call/ViewModel/CallViewModel.swift index 16629dafb..a8c33de8d 100644 --- a/Linphone/UI/Call/ViewModel/CallViewModel.swift +++ b/Linphone/UI/Call/ViewModel/CallViewModel.swift @@ -53,6 +53,7 @@ class CallViewModel: ObservableObject { @Published var activeSpeakerParticipant: ParticipantModel? @Published var activeSpeakerName: String = "" @Published var myParticipantModel: ParticipantModel? + @Published var callMediaEncryptionModel = CallMediaEncryptionModel() private var mConferenceSuscriptions = Set() @@ -150,6 +151,10 @@ class CallViewModel: ObservableObject { let isDeviceTrusted = self.currentCall!.authenticationTokenVerified && authToken != nil let isRemoteDeviceTrustedTmp = self.telecomManager.callInProgress ? isDeviceTrusted : false + if self.currentCall != nil { + self.callMediaEncryptionModel.update(call: self.currentCall!) + } + DispatchQueue.main.async { self.direction = directionTmp self.remoteAddressString = remoteAddressStringTmp @@ -192,6 +197,9 @@ class CallViewModel: ObservableObject { self.callSuscriptions.insert(self.currentCall!.publisher?.onEncryptionChanged?.postOnMainQueue {(cbVal: (call: Call, on: Bool, authenticationToken: String?)) in _ = self.updateEncryption() + if self.currentCall != nil { + self.callMediaEncryptionModel.update(call: self.currentCall!) + } }) } } diff --git a/Linphone/UI/Call/ViewModel/MeetingWaitingRoomViewModel.swift b/Linphone/UI/Call/ViewModel/MeetingWaitingRoomViewModel.swift index 5c67af20f..6cba61b85 100644 --- a/Linphone/UI/Call/ViewModel/MeetingWaitingRoomViewModel.swift +++ b/Linphone/UI/Call/ViewModel/MeetingWaitingRoomViewModel.swift @@ -263,4 +263,12 @@ class MeetingWaitingRoomViewModel: ObservableObject { } } } + + func cancelMeeting() { + coreContext.doOnCoreQueue { core in + if core.currentCall != nil { + self.telecomManager.terminateCall(call: core.currentCall!) + } + } + } } From 32dc6ea3452bcf52383877233ca7eb4fc83c5f63 Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Thu, 2 May 2024 14:16:02 +0200 Subject: [PATCH 207/486] Audio/video call statistics --- Linphone.xcodeproj/project.pbxproj | 4 + .../cell-signal-full.svg | 2 +- .../cell-signal-high.imageset/Contents.json | 21 +++++ .../cell-signal-high.svg | 1 + .../cell-signal-low.svg | 2 +- .../cell-signal-medium.imageset/Contents.json | 21 +++++ .../cell-signal-medium.svg | 1 + .../cell-signal-none.imageset/Contents.json | 21 +++++ .../cell-signal-none.svg | 1 + Linphone/Localizable.xcstrings | 6 ++ Linphone/TelecomManager/TelecomManager.swift | 1 - Linphone/UI/Call/CallView.swift | 89 ++++++++++++++++++- .../Call/Model/CallMediaEncryptionModel.swift | 2 - Linphone/UI/Call/Model/CallStatsModel.swift | 85 ++++++++++++++++++ .../UI/Call/ViewModel/CallViewModel.swift | 43 +++++++++ 15 files changed, 294 insertions(+), 6 deletions(-) create mode 100644 Linphone/Assets.xcassets/cell-signal-high.imageset/Contents.json create mode 100644 Linphone/Assets.xcassets/cell-signal-high.imageset/cell-signal-high.svg create mode 100644 Linphone/Assets.xcassets/cell-signal-medium.imageset/Contents.json create mode 100644 Linphone/Assets.xcassets/cell-signal-medium.imageset/cell-signal-medium.svg create mode 100644 Linphone/Assets.xcassets/cell-signal-none.imageset/Contents.json create mode 100644 Linphone/Assets.xcassets/cell-signal-none.imageset/cell-signal-none.svg create mode 100644 Linphone/UI/Call/Model/CallStatsModel.swift diff --git a/Linphone.xcodeproj/project.pbxproj b/Linphone.xcodeproj/project.pbxproj index 92fbb98c5..2dae51c9e 100644 --- a/Linphone.xcodeproj/project.pbxproj +++ b/Linphone.xcodeproj/project.pbxproj @@ -95,6 +95,7 @@ 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 */; }; D79622342B1DFE600037EACD /* DialerBottomSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = D79622332B1DFE600037EACD /* DialerBottomSheet.swift */; }; D796F2002B0BB61A0041115F /* ToastViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D796F1FF2B0BB61A0041115F /* ToastViewModel.swift */; }; D7A03FBD2ACC2DB60081A588 /* ContactsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7A03FBC2ACC2DB60081A588 /* ContactsView.swift */; }; @@ -248,6 +249,7 @@ 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 = ""; }; D79622332B1DFE600037EACD /* DialerBottomSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DialerBottomSheet.swift; sourceTree = ""; }; D796F1FF2B0BB61A0041115F /* ToastViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToastViewModel.swift; sourceTree = ""; }; D7A03FBC2ACC2DB60081A588 /* ContactsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactsView.swift; sourceTree = ""; }; @@ -526,6 +528,7 @@ children = ( D720E6AC2BAD822000DDFD87 /* ParticipantModel.swift */, D714035A2BE11E00004BD8CA /* CallMediaEncryptionModel.swift */, + D78E06272BE3811D00CE3783 /* CallStatsModel.swift */, ); path = Model; sourceTree = ""; @@ -993,6 +996,7 @@ D726E4392B16440C0083C415 /* ContactAvatarModel.swift in Sources */, 6613A0B02BAEB7F4008923A4 /* MeetingsListFragment.swift in Sources */, D76005F62B0798B00054B79A /* IntExtension.swift in Sources */, + D78E06282BE3811D00CE3783 /* CallStatsModel.swift in Sources */, D7E6D0512AEBDBD500A57AAF /* ContactsListBottomSheet.swift in Sources */, D75759322B56D40900E7AC10 /* ZRTPPopup.swift in Sources */, D7A2EDD62AC18115005D90FC /* SharedMainViewModel.swift in Sources */, 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 index 8a04f8fed..2149b9e0d 100644 --- a/Linphone/Assets.xcassets/cell-signal-full.imageset/cell-signal-full.svg +++ b/Linphone/Assets.xcassets/cell-signal-full.imageset/cell-signal-full.svg @@ -1 +1 @@ - \ No newline at end of file + \ 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/cell-signal-low.svg b/Linphone/Assets.xcassets/cell-signal-low.imageset/cell-signal-low.svg index fac7f934c..dd093bcc8 100644 --- a/Linphone/Assets.xcassets/cell-signal-low.imageset/cell-signal-low.svg +++ b/Linphone/Assets.xcassets/cell-signal-low.imageset/cell-signal-low.svg @@ -1 +1 @@ - \ No newline at end of file + \ 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/Localizable.xcstrings b/Linphone/Localizable.xcstrings index 4a74bed5c..b94e6b17d 100644 --- a/Linphone/Localizable.xcstrings +++ b/Linphone/Localizable.xcstrings @@ -221,6 +221,9 @@ }, "Attended transfer" : { + }, + "Audio" : { + }, "Audio seulement" : { @@ -806,6 +809,9 @@ }, "Validate the device" : { + }, + "Vidéo" : { + }, "Video Call" : { diff --git a/Linphone/TelecomManager/TelecomManager.swift b/Linphone/TelecomManager/TelecomManager.swift index 72ff6e84e..e6b01dcf9 100644 --- a/Linphone/TelecomManager/TelecomManager.swift +++ b/Linphone/TelecomManager/TelecomManager.swift @@ -398,7 +398,6 @@ class TelecomManager: ObservableObject { } else { self.remoteConfVideo = false } - } else { self.remoteConfVideo = false diff --git a/Linphone/UI/Call/CallView.swift b/Linphone/UI/Call/CallView.swift index a25d1dc49..59d0c54bf 100644 --- a/Linphone/UI/Call/CallView.swift +++ b/Linphone/UI/Call/CallView.swift @@ -42,6 +42,7 @@ struct CallView: View { @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 = "" @@ -71,6 +72,12 @@ struct CallView: View { mediaEncryptedSheetBottomSheet() .presentationDetents([.medium]) } + .sheet(isPresented: $callStatisticsSheet, onDismiss: { + callStatisticsSheet = false + }) { + callStatisticsSheetBottomSheet() + .presentationDetents(!callViewModel.callStatsModel.isVideoEnabled ? [.fraction(0.3)] : [.medium]) + } .sheet(isPresented: $audioRouteSheet, onDismiss: { audioRouteSheet = false }) { @@ -100,6 +107,12 @@ struct CallView: View { mediaEncryptedSheetBottomSheet() .presentationDetents([.medium]) } + .sheet(isPresented: $callStatisticsSheet, onDismiss: { + callStatisticsSheet = false + }) { + callStatisticsSheetBottomSheet() + .presentationDetents(!callViewModel.callStatsModel.isVideoEnabled ? [.fraction(0.3)] : [.medium]) + } .sheet(isPresented: $audioRouteSheet, onDismiss: { audioRouteSheet = false }) { @@ -127,6 +140,11 @@ struct CallView: View { } onDismiss: { mediaEncryptedSheet = false } + .halfSheet(showSheet: $callStatisticsSheet) { + callStatisticsSheetBottomSheet() + } onDismiss: { + callStatisticsSheet = false + } .halfSheet(showSheet: $audioRouteSheet) { audioRouteBottomSheet() } onDismiss: { @@ -263,6 +281,74 @@ struct CallView: View { .background(Color.gray600) } + @ViewBuilder + func callStatisticsSheetBottomSheet() -> 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 + } + } + .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() + + 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.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) + } @ViewBuilder func audioRouteBottomSheet() -> some View { VStack(spacing: 0) { @@ -533,8 +619,9 @@ struct CallView: View { Spacer() Button { + callStatisticsSheet = true } label: { - Image("cell-signal-full") + Image(callViewModel.qualityIcon) .renderingMode(.template) .resizable() .foregroundStyle(.white) diff --git a/Linphone/UI/Call/Model/CallMediaEncryptionModel.swift b/Linphone/UI/Call/Model/CallMediaEncryptionModel.swift index a4eaf15c0..86174516b 100644 --- a/Linphone/UI/Call/Model/CallMediaEncryptionModel.swift +++ b/Linphone/UI/Call/Model/CallMediaEncryptionModel.swift @@ -53,8 +53,6 @@ class CallMediaEncryptionModel: ObservableObject { mediaEncryptionTmp = "Media encryption: " + "ZRTP" case .DTLS: mediaEncryptionTmp = "Media encryption: " + "DTLS" - default: - mediaEncryptionTmp = "Media encryption: " + "None" } } diff --git a/Linphone/UI/Call/Model/CallStatsModel.swift b/Linphone/UI/Call/Model/CallStatsModel.swift new file mode 100644 index 000000000..3afc78bf7 --- /dev/null +++ b/Linphone/UI/Call/Model/CallStatsModel.swift @@ -0,0 +1,85 @@ +/* + * 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 isVideoEnabled = false + @Published var videoCodec = "" + @Published var videoBandwidth = "" + @Published var videoResolution = "" + @Published var videoFps = "" + + func update(call: Call, stats: CallStats) { + coreContext.doOnCoreQueue { core in + if call.params != nil { + self.isVideoEnabled = call.params!.videoEnabled && 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" + + let uploadBandwidth = Int(stats.uploadBandwidth.rounded()) + let downloadBandwidth = Int(stats.downloadBandwidth.rounded()) + let bandwidthLabel = "Bandwidth: " + "↑ \(uploadBandwidth) kbits/s ↓ \(downloadBandwidth) kbits/s" + + DispatchQueue.main.async { + self.audioCodec = codecLabel + self.audioBandwidth = bandwidthLabel + } + } + case .Video: + if call.currentParams != nil { + let payloadType = call.currentParams!.usedVideoPayloadType + let clockRate = (payloadType?.clockRate != nil ? payloadType!.clockRate : 0) / 1000 + let codecLabel = "Codec: " + "\(payloadType!.mimeType)/\(clockRate) kHz" + + let uploadBandwidth = Int(stats.uploadBandwidth.rounded()) + let downloadBandwidth = Int(stats.downloadBandwidth.rounded()) + let bandwidthLabel = "Bandwidth: " + "↑ \(uploadBandwidth) kbits/s ↓ \(downloadBandwidth) kbits/s" + + 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.videoResolution = resolutionLabel + self.videoFps = fpsLabel + } + } + default: break + } + } + } + } +} diff --git a/Linphone/UI/Call/ViewModel/CallViewModel.swift b/Linphone/UI/Call/ViewModel/CallViewModel.swift index a8c33de8d..a1848c03e 100644 --- a/Linphone/UI/Call/ViewModel/CallViewModel.swift +++ b/Linphone/UI/Call/ViewModel/CallViewModel.swift @@ -54,6 +54,10 @@ class CallViewModel: ObservableObject { @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 mConferenceSuscriptions = Set() @@ -153,6 +157,9 @@ class CallViewModel: ObservableObject { 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 { @@ -201,6 +208,14 @@ class CallViewModel: ObservableObject { self.callMediaEncryptionModel.update(call: self.currentCall!) } }) + + self.callSuscriptions.insert(self.currentCall!.publisher?.onStatsUpdated?.postOnMainQueue {(cbVal: (call: Call, stats: CallStats)) in + if self.currentCall != nil { + self.callStatsModel.update(call: self.currentCall!, stats: cbVal.stats) + } + }) + + self.updateCallQualityIcon() } } } @@ -575,6 +590,7 @@ class CallViewModel: ObservableObject { if core.callsNb == 0 { DispatchQueue.main.async { self.timer.upstream.connect().cancel() + self.currentCall = nil } } } @@ -966,5 +982,32 @@ class CallViewModel: ObservableObject { }) } } + + 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" + } + + print("iconiconicon \(icon) \(self.currentCall!.currentQuality)") + DispatchQueue.main.async { + self.qualityValue = quality + self.qualityIcon = icon + } + + if core.callsNb > 0 { + self.updateCallQualityIcon() + } + } + } + } + } } // swiftlint:enable type_body_length From cfe3bef32d1287a4b36370ab5f3389f25a605921 Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Thu, 2 May 2024 15:38:55 +0200 Subject: [PATCH 208/486] Add green call banner --- Linphone/Localizable.xcstrings | 6 ++++++ Linphone/TelecomManager/TelecomManager.swift | 3 +++ .../UI/Call/ViewModel/CallViewModel.swift | 3 +-- Linphone/UI/Main/ContentView.swift | 21 +++++++++++++++++++ 4 files changed, 31 insertions(+), 2 deletions(-) diff --git a/Linphone/Localizable.xcstrings b/Linphone/Localizable.xcstrings index b94e6b17d..1a8f779e2 100644 --- a/Linphone/Localizable.xcstrings +++ b/Linphone/Localizable.xcstrings @@ -48,6 +48,9 @@ }, "#" : { + }, + "%@" : { + }, "%lld" : { @@ -61,6 +64,9 @@ } } } + }, + "%lld appels" : { + }, "%lld Book (Example)" : { "extractionState" : "manual", diff --git a/Linphone/TelecomManager/TelecomManager.swift b/Linphone/TelecomManager/TelecomManager.swift index e6b01dcf9..b51064db2 100644 --- a/Linphone/TelecomManager/TelecomManager.swift +++ b/Linphone/TelecomManager/TelecomManager.swift @@ -113,6 +113,9 @@ class TelecomManager: ObservableObject { setHeldOtherCalls(core: core, exceptCallid: "") requestTransaction(transaction, action: "startCall") + withAnimation { + self.callDisplayed = true + } } else { try doCall(core: core, addr: addr!, isSas: isSas, isVideo: isVideo, isConference: isConference) } diff --git a/Linphone/UI/Call/ViewModel/CallViewModel.swift b/Linphone/UI/Call/ViewModel/CallViewModel.swift index a1848c03e..cb959a89d 100644 --- a/Linphone/UI/Call/ViewModel/CallViewModel.swift +++ b/Linphone/UI/Call/ViewModel/CallViewModel.swift @@ -61,7 +61,7 @@ class CallViewModel: ObservableObject { private var mConferenceSuscriptions = Set() - var calls: [Call] = [] + @Published var calls: [Call] = [] let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect() @@ -996,7 +996,6 @@ class CallViewModel: ObservableObject { default: "cell-signal-none" } - print("iconiconicon \(icon) \(self.currentCall!.currentQuality)") DispatchQueue.main.async { self.qualityValue = quality self.qualityIcon = icon diff --git a/Linphone/UI/Main/ContentView.swift b/Linphone/UI/Main/ContentView.swift index c67e8f111..a86ce15f5 100644 --- a/Linphone/UI/Main/ContentView.swift +++ b/Linphone/UI/Main/ContentView.swift @@ -73,7 +73,28 @@ struct ContentView: View { VStack(spacing: 0) { if telecomManager.callInProgress && !fullscreenVideo && ((!telecomManager.callDisplayed && callViewModel.calls.count == 1) || callViewModel.calls.count > 1) { HStack { + Image("phone") + .renderingMode(.template) + .resizable() + .foregroundStyle(.white) + .frame(width: 26, height: 26) + .padding(.leading, 10) + if callViewModel.calls.count > 1 { + Text("\(callViewModel.calls.count) appels") + .default_text_style_white(styleSize: 16) + } else { + Text("\(callViewModel.displayName)") + .default_text_style_white(styleSize: 16) + } + + Spacer() + + if callViewModel.calls.count == 1 { + Text("\(callViewModel.isPaused || telecomManager.isPausedByRemote ? "En pause" : "Actif")") + .default_text_style_white(styleSize: 16) + .padding(.trailing, 10) + } } .frame(maxWidth: .infinity) .frame(height: 30) From 517ff079048efac3880cb7f90e15bfa5fe3a5a85 Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Fri, 3 May 2024 10:43:51 +0200 Subject: [PATCH 209/486] Call view fixes --- .../UI/Call/ViewModel/CallViewModel.swift | 3 ++ Linphone/UI/Main/ContentView.swift | 11 +++-- .../History/Fragments/DialerBottomSheet.swift | 47 +++++++++++++------ 3 files changed, 43 insertions(+), 18 deletions(-) diff --git a/Linphone/UI/Call/ViewModel/CallViewModel.swift b/Linphone/UI/Call/ViewModel/CallViewModel.swift index cb959a89d..472da15fe 100644 --- a/Linphone/UI/Call/ViewModel/CallViewModel.swift +++ b/Linphone/UI/Call/ViewModel/CallViewModel.swift @@ -98,6 +98,9 @@ class CallViewModel: ObservableObject { if core.currentCall != nil && core.currentCall!.remoteAddress != nil { self.currentCall = core.currentCall + self.callSuscriptions.removeAll() + self.mConferenceSuscriptions.removeAll() + var videoDisplayedTmp = false do { let params = try core.createCallParams(call: self.currentCall) diff --git a/Linphone/UI/Main/ContentView.swift b/Linphone/UI/Main/ContentView.swift index a86ce15f5..1741e307b 100644 --- a/Linphone/UI/Main/ContentView.swift +++ b/Linphone/UI/Main/ContentView.swift @@ -784,7 +784,7 @@ struct ContentView: View { showingDialer: $showingDialer, resetCallView: {callViewModel.resetCallView()} ) - .zIndex(4) + .zIndex(6) .transition(.opacity.combined(with: .move(edge: .bottom))) .sheet(isPresented: $showingDialer) { DialerBottomSheet( @@ -803,7 +803,7 @@ struct ContentView: View { showingDialer: $showingDialer, resetCallView: {callViewModel.resetCallView()} ) - .zIndex(4) + .zIndex(6) .transition(.opacity.combined(with: .move(edge: .bottom))) .halfSheet(showSheet: $showingDialer) { DialerBottomSheet( @@ -938,11 +938,16 @@ struct ContentView: View { .transition(.scale.combined(with: .move(edge: .top))) .onAppear { callViewModel.resetCallView() + if callViewModel.calls.count >= 1 { + DispatchQueue.main.asyncAfter(deadline: .now() + 1) { + callViewModel.resetCallView() + } + } } } ToastView() - .zIndex(3) + .zIndex(6) } } .onAppear { diff --git a/Linphone/UI/Main/History/Fragments/DialerBottomSheet.swift b/Linphone/UI/Main/History/Fragments/DialerBottomSheet.swift index 267c8aded..c07b8a3a1 100644 --- a/Linphone/UI/Main/History/Fragments/DialerBottomSheet.swift +++ b/Linphone/UI/Main/History/Fragments/DialerBottomSheet.swift @@ -58,7 +58,7 @@ struct DialerBottomSheet: View { .padding(.trailing) } else { Capsule() - .fill(Color.grayMain2c300) + .fill(currentCall != nil ? .white : Color.grayMain2c300) .frame(width: 75, height: 5) .padding(15) } @@ -66,6 +66,7 @@ struct DialerBottomSheet: View { if currentCall != nil { HStack { Text(dialerField) + .foregroundStyle(currentCall != nil ? .white : Color.grayMain2c600) .default_text_style(styleSize: 25) .frame(maxWidth: .infinity) .padding(.horizontal, 10) @@ -76,7 +77,9 @@ struct DialerBottomSheet: View { dialerField = String(dialerField.dropLast()) } label: { Image("backspace-fill") + .renderingMode(.template) .resizable() + .foregroundStyle(currentCall != nil ? .white : Color.grayMain2c500) .frame(width: 32, height: 32) } @@ -106,10 +109,11 @@ struct DialerBottomSheet: View { } } label: { Text("1") + .foregroundStyle(currentCall != nil ? .white : Color.grayMain2c600) .default_text_style(styleSize: 32) .multilineTextAlignment(.center) .frame(width: 60, height: 60) - .background(.white) + .background(currentCall != nil ? Color.gray500 : .white) .clipShape(Circle()) .shadow(color: .black.opacity(0.2), radius: 4) } @@ -130,10 +134,11 @@ struct DialerBottomSheet: View { } } label: { Text("2") + .foregroundStyle(currentCall != nil ? .white : Color.grayMain2c600) .default_text_style(styleSize: 32) .multilineTextAlignment(.center) .frame(width: 60, height: 60) - .background(.white) + .background(currentCall != nil ? Color.gray500 : .white) .clipShape(Circle()) .shadow(color: .black.opacity(0.2), radius: 4) } @@ -154,10 +159,11 @@ struct DialerBottomSheet: View { } } label: { Text("3") + .foregroundStyle(currentCall != nil ? .white : Color.grayMain2c600) .default_text_style(styleSize: 32) .multilineTextAlignment(.center) .frame(width: 60, height: 60) - .background(.white) + .background(currentCall != nil ? Color.gray500 : .white) .clipShape(Circle()) .shadow(color: .black.opacity(0.2), radius: 4) } @@ -180,10 +186,11 @@ struct DialerBottomSheet: View { } } label: { Text("4") + .foregroundStyle(currentCall != nil ? .white : Color.grayMain2c600) .default_text_style(styleSize: 32) .multilineTextAlignment(.center) .frame(width: 60, height: 60) - .background(.white) + .background(currentCall != nil ? Color.gray500 : .white) .clipShape(Circle()) .shadow(color: .black.opacity(0.2), radius: 4) } @@ -204,10 +211,11 @@ struct DialerBottomSheet: View { } } label: { Text("5") + .foregroundStyle(currentCall != nil ? .white : Color.grayMain2c600) .default_text_style(styleSize: 32) .multilineTextAlignment(.center) .frame(width: 60, height: 60) - .background(.white) + .background(currentCall != nil ? Color.gray500 : .white) .clipShape(Circle()) .shadow(color: .black.opacity(0.2), radius: 4) } @@ -228,10 +236,11 @@ struct DialerBottomSheet: View { } } label: { Text("6") + .foregroundStyle(currentCall != nil ? .white : Color.grayMain2c600) .default_text_style(styleSize: 32) .multilineTextAlignment(.center) .frame(width: 60, height: 60) - .background(.white) + .background(currentCall != nil ? Color.gray500 : .white) .clipShape(Circle()) .shadow(color: .black.opacity(0.2), radius: 4) } @@ -255,10 +264,11 @@ struct DialerBottomSheet: View { } } label: { Text("7") + .foregroundStyle(currentCall != nil ? .white : Color.grayMain2c600) .default_text_style(styleSize: 32) .multilineTextAlignment(.center) .frame(width: 60, height: 60) - .background(.white) + .background(currentCall != nil ? Color.gray500 : .white) .clipShape(Circle()) .shadow(color: .black.opacity(0.2), radius: 4) } @@ -279,10 +289,11 @@ struct DialerBottomSheet: View { } } label: { Text("8") + .foregroundStyle(currentCall != nil ? .white : Color.grayMain2c600) .default_text_style(styleSize: 32) .multilineTextAlignment(.center) .frame(width: 60, height: 60) - .background(.white) + .background(currentCall != nil ? Color.gray500 : .white) .clipShape(Circle()) .shadow(color: .black.opacity(0.2), radius: 4) } @@ -303,10 +314,11 @@ struct DialerBottomSheet: View { } } label: { Text("9") + .foregroundStyle(currentCall != nil ? .white : Color.grayMain2c600) .default_text_style(styleSize: 32) .multilineTextAlignment(.center) .frame(width: 60, height: 60) - .background(.white) + .background(currentCall != nil ? Color.gray500 : .white) .clipShape(Circle()) .shadow(color: .black.opacity(0.2), radius: 4) } @@ -330,10 +342,11 @@ struct DialerBottomSheet: View { } } label: { Text("*") + .foregroundStyle(currentCall != nil ? .white : Color.grayMain2c600) .default_text_style(styleSize: 32) .multilineTextAlignment(.center) .frame(width: 60, height: 60) - .background(.white) + .background(currentCall != nil ? Color.gray500 : .white) .clipShape(Circle()) .shadow(color: .black.opacity(0.2), radius: 4) } @@ -345,14 +358,16 @@ struct DialerBottomSheet: View { } 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(.white) + .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) @@ -384,10 +399,11 @@ struct DialerBottomSheet: View { } } label: { Text("0") + .foregroundStyle(currentCall != nil ? .white : Color.grayMain2c600) .default_text_style(styleSize: 32) .multilineTextAlignment(.center) .frame(width: 60, height: 60) - .background(.white) + .background(currentCall != nil ? Color.gray500 : .white) .clipShape(Circle()) .shadow(color: .black.opacity(0.2), radius: 4) } @@ -409,10 +425,11 @@ struct DialerBottomSheet: View { } } label: { Text("#") + .foregroundStyle(currentCall != nil ? .white : Color.grayMain2c600) .default_text_style(styleSize: 32) .multilineTextAlignment(.center) .frame(width: 60, height: 60) - .background(.white) + .background(currentCall != nil ? Color.gray500 : .white) .clipShape(Circle()) .shadow(color: .black.opacity(0.2), radius: 4) } @@ -474,7 +491,7 @@ struct DialerBottomSheet: View { .frame(maxWidth: .infinity) .frame(maxHeight: .infinity) } - .background(Color.gray100) + .background(currentCall != nil ? Color.gray600 : Color.gray100) .frame(maxWidth: .infinity) .frame(maxHeight: .infinity) .onRotate { newOrientation in From 2306d338a8ade6aad42aab8f08f101e187c0d081 Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Fri, 3 May 2024 14:50:33 +0200 Subject: [PATCH 210/486] Display conference date in meeting waiting room --- Linphone/Localizable.xcstrings | 3 --- .../UI/Call/MeetingWaitingRoomFragment.swift | 2 +- .../MeetingWaitingRoomViewModel.swift | 19 +++++++++++++++++++ 3 files changed, 20 insertions(+), 4 deletions(-) diff --git a/Linphone/Localizable.xcstrings b/Linphone/Localizable.xcstrings index 1a8f779e2..129e33904 100644 --- a/Linphone/Localizable.xcstrings +++ b/Linphone/Localizable.xcstrings @@ -507,9 +507,6 @@ }, "Meetings" : { - }, - "Mercredi 10 Avril 2024 | 2:41 PM - 2:42 PM" : { - }, "Message" : { diff --git a/Linphone/UI/Call/MeetingWaitingRoomFragment.swift b/Linphone/UI/Call/MeetingWaitingRoomFragment.swift index a6aec3d93..521bc5434 100644 --- a/Linphone/UI/Call/MeetingWaitingRoomFragment.swift +++ b/Linphone/UI/Call/MeetingWaitingRoomFragment.swift @@ -135,7 +135,7 @@ struct MeetingWaitingRoomFragment: View { } .hidden() - Text("Mercredi 10 Avril 2024 | 2:41 PM - 2:42 PM") + Text(meetingWaitingRoomViewModel.meetingDate) .foregroundStyle(.white) .default_text_style_white(styleSize: 12) diff --git a/Linphone/UI/Call/ViewModel/MeetingWaitingRoomViewModel.swift b/Linphone/UI/Call/ViewModel/MeetingWaitingRoomViewModel.swift index 6cba61b85..dd1beeaf8 100644 --- a/Linphone/UI/Call/ViewModel/MeetingWaitingRoomViewModel.swift +++ b/Linphone/UI/Call/ViewModel/MeetingWaitingRoomViewModel.swift @@ -35,6 +35,7 @@ class MeetingWaitingRoomViewModel: ObservableObject { @Published var videoDisplayed: Bool = false @Published var avatarDisplayed: Bool = true @Published var imageAudioRoute: String = "" + @Published var meetingDate: String = "" init() { do { @@ -89,6 +90,23 @@ class MeetingWaitingRoomViewModel: ObservableObject { 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 @@ -97,6 +115,7 @@ class MeetingWaitingRoomViewModel: ObservableObject { self.userName = userNameTmp self.avatarModel = avatarModelTmp self.micMutted = micMuttedTmp + self.meetingDate = meetingDateTmp } } From 45f24756349cd5f8254b3cdbc5bc4c3176f938e7 Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Fri, 3 May 2024 15:35:23 +0200 Subject: [PATCH 211/486] Fix meeting button in navigation bottom bar --- Linphone/UI/Main/ContentView.swift | 37 +++++++++++++++++++++++++----- 1 file changed, 31 insertions(+), 6 deletions(-) diff --git a/Linphone/UI/Main/ContentView.swift b/Linphone/UI/Main/ContentView.swift index 1741e307b..ad6d718ec 100644 --- a/Linphone/UI/Main/ContentView.swift +++ b/Linphone/UI/Main/ContentView.swift @@ -112,9 +112,10 @@ struct ContentView: View { if orientation == .landscapeLeft || orientation == .landscapeRight || UIScreen.main.bounds.size.width > UIScreen.main.bounds.size.height { - VStack { + VStack(spacing: 0) { Group { Spacer() + Button(action: { self.index = 0 historyViewModel.displayedCall = nil @@ -135,8 +136,8 @@ struct ContentView: View { } } }) - - Spacer() + .padding(.top) + .frame(height: geometry.size.height/4) ZStack { if historyListViewModel.missedCallsCount > 0 { @@ -184,8 +185,7 @@ struct ContentView: View { }) .padding(.top) } - - Spacer() + .frame(height: geometry.size.height/4) ZStack { if conversationsListViewModel.unreadMessages > 0 { @@ -231,11 +231,36 @@ struct ContentView: View { }) .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("meetings") + .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) + .frame(width: 75, height: geometry.size.height) .padding(.leading, orientation == .landscapeRight && geometry.safeAreaInsets.bottom > 0 ? -geometry.safeAreaInsets.leading From 7da5b9567f545453ca32ec24c089b93a347dcf4a Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Fri, 3 May 2024 16:43:24 +0200 Subject: [PATCH 212/486] Disable upper case for sip address and check if payloadType is null --- Linphone/UI/Call/Model/CallStatsModel.swift | 2 +- Linphone/UI/Main/Contacts/Fragments/EditContactFragment.swift | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/Linphone/UI/Call/Model/CallStatsModel.swift b/Linphone/UI/Call/Model/CallStatsModel.swift index 3afc78bf7..98f788a7c 100644 --- a/Linphone/UI/Call/Model/CallStatsModel.swift +++ b/Linphone/UI/Call/Model/CallStatsModel.swift @@ -56,7 +56,7 @@ class CallStatsModel: ObservableObject { if call.currentParams != nil { let payloadType = call.currentParams!.usedVideoPayloadType let clockRate = (payloadType?.clockRate != nil ? payloadType!.clockRate : 0) / 1000 - let codecLabel = "Codec: " + "\(payloadType!.mimeType)/\(clockRate) kHz" + let codecLabel = "Codec: " + "\(payloadType != nil ? payloadType!.mimeType : "null")/\(clockRate) kHz" let uploadBandwidth = Int(stats.uploadBandwidth.rounded()) let downloadBandwidth = Int(stats.downloadBandwidth.rounded()) diff --git a/Linphone/UI/Main/Contacts/Fragments/EditContactFragment.swift b/Linphone/UI/Main/Contacts/Fragments/EditContactFragment.swift index 38cc9e87c..fdb38f566 100644 --- a/Linphone/UI/Main/Contacts/Fragments/EditContactFragment.swift +++ b/Linphone/UI/Main/Contacts/Fragments/EditContactFragment.swift @@ -321,6 +321,8 @@ struct EditContactFragment: View { HStack(alignment: .center) { TextField("SIP address", text: $editContactViewModel.sipAddresses[index]) .default_text_style(styleSize: 15) + .disableAutocorrection(true) + .autocapitalization(.none) .frame(height: 25) .padding(.horizontal, 20) .padding(.vertical, 15) From 4eafaa1dc6891e95b498223f28f0e837fc10deee Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Mon, 6 May 2024 10:15:36 +0200 Subject: [PATCH 213/486] Check whether the default account is null when the core state is ON to set the hasDefaultAccount value --- Linphone/Core/CoreContext.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Linphone/Core/CoreContext.swift b/Linphone/Core/CoreContext.swift index 61504cbc5..899f974d3 100644 --- a/Linphone/Core/CoreContext.swift +++ b/Linphone/Core/CoreContext.swift @@ -115,7 +115,7 @@ final class CoreContext: ObservableObject { self.mCoreSuscriptions.insert(self.mCore.publisher?.onGlobalStateChanged?.postOnMainQueue { (cbVal: (core: Core, state: GlobalState, message: String)) in if cbVal.state == GlobalState.On { - self.hasDefaultAccount = true + self.hasDefaultAccount = self.mCore.defaultAccount != nil ? true : false self.coreIsStarted = true } else if cbVal.state == GlobalState.Off { self.hasDefaultAccount = false From ec842f28302e8f433c7c74b15389ed29d47b83bc Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Mon, 6 May 2024 10:38:49 +0200 Subject: [PATCH 214/486] Set default front camera --- Linphone/TelecomManager/TelecomManager.swift | 5 +++++ .../UI/Call/ViewModel/MeetingWaitingRoomViewModel.swift | 7 ++++++- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/Linphone/TelecomManager/TelecomManager.swift b/Linphone/TelecomManager/TelecomManager.swift index b51064db2..7f788753a 100644 --- a/Linphone/TelecomManager/TelecomManager.swift +++ b/Linphone/TelecomManager/TelecomManager.swift @@ -620,6 +620,11 @@ class TelecomManager: ObservableObject { //if core.callsNb == 0 { 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 diff --git a/Linphone/UI/Call/ViewModel/MeetingWaitingRoomViewModel.swift b/Linphone/UI/Call/ViewModel/MeetingWaitingRoomViewModel.swift index dd1beeaf8..080d9897e 100644 --- a/Linphone/UI/Call/ViewModel/MeetingWaitingRoomViewModel.swift +++ b/Linphone/UI/Call/ViewModel/MeetingWaitingRoomViewModel.swift @@ -54,6 +54,12 @@ class MeetingWaitingRoomViewModel: ObservableObject { 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 = "" @@ -129,7 +135,6 @@ class MeetingWaitingRoomViewModel: ObservableObject { DispatchQueue.main.async { self.videoDisplayed = true } - self.videoDisplayed = true core.videoPreviewEnabled = true } } From 6d8dfbf1a1e5dedb7d1fcedd1d47992c9873af46 Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Mon, 6 May 2024 15:33:29 +0200 Subject: [PATCH 215/486] Fix fullscreen mode in call view --- Linphone/TelecomManager/TelecomManager.swift | 6 +- Linphone/UI/Call/CallView.swift | 125 +++++++++++------- .../UI/Call/ViewModel/CallViewModel.swift | 2 +- 3 files changed, 79 insertions(+), 54 deletions(-) diff --git a/Linphone/TelecomManager/TelecomManager.swift b/Linphone/TelecomManager/TelecomManager.swift index 7f788753a..9579262ba 100644 --- a/Linphone/TelecomManager/TelecomManager.swift +++ b/Linphone/TelecomManager/TelecomManager.swift @@ -402,11 +402,7 @@ class TelecomManager: ObservableObject { self.remoteConfVideo = false } } else { - self.remoteConfVideo = false - - DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { - self.remoteConfVideo = call.currentParams!.videoEnabled && call.currentParams!.videoDirection == .SendRecv || call.currentParams!.videoDirection == .SendOnly - } + self.remoteConfVideo = call.currentParams!.videoEnabled && call.currentParams!.videoDirection == .SendRecv || call.currentParams!.videoDirection == .RecvOnly } /* diff --git a/Linphone/UI/Call/CallView.swift b/Linphone/UI/Call/CallView.swift index 59d0c54bf..6191d845c 100644 --- a/Linphone/UI/Call/CallView.swift +++ b/Linphone/UI/Call/CallView.swift @@ -676,9 +676,7 @@ struct CallView: View { .frame(height: geometry.size.height) .frame(maxWidth: .infinity) .background(Color.gray900) - .if(fullscreenVideo && !telecomManager.isPausedByRemote) { view in - view.ignoresSafeArea(.all) - } + if !fullscreenVideo || (fullscreenVideo && telecomManager.isPausedByRemote) { if telecomManager.callStarted { @@ -792,30 +790,48 @@ struct CallView: View { Spacer() } - LinphoneVideoViewHolder { view in - coreContext.doOnCoreQueue { core in - core.nativeVideoWindow = view + if telecomManager.remoteConfVideo { + LinphoneVideoViewHolder { view in + coreContext.doOnCoreQueue { core in + core.nativeVideoWindow = view + } } - } - .frame( - width: - angleDegree == 0 - ? (fullscreenVideo && !telecomManager.isPausedByRemote ? geometry.size.width : geometry.size.width - 8) - : (fullscreenVideo && !telecomManager.isPausedByRemote ? geometry.size.height + geometry.safeAreaInsets.top + geometry.safeAreaInsets.bottom : geometry.size.height - (minBottomSheetHeight * geometry.size.height > 80 ? minBottomSheetHeight * geometry.size.height : 78) - 40 - 20 + geometry.safeAreaInsets.bottom), - height: - angleDegree == 0 - ? (fullscreenVideo && !telecomManager.isPausedByRemote ? geometry.size.height + geometry.safeAreaInsets.top + geometry.safeAreaInsets.bottom : geometry.size.height - (minBottomSheetHeight * geometry.size.height > 80 ? minBottomSheetHeight * geometry.size.height : 78) - 40 - 20 + geometry.safeAreaInsets.bottom) - : (fullscreenVideo && !telecomManager.isPausedByRemote ? geometry.size.width : geometry.size.width - 8) - ) - .scaledToFill() - .clipped() - .onTapGesture { - if callViewModel.videoDisplayed { - fullscreenVideo.toggle() + .frame( + width: + angleDegree == 0 + ? (fullscreenVideo && !telecomManager.isPausedByRemote ? geometry.size.width : geometry.size.width - 8) + : (fullscreenVideo && !telecomManager.isPausedByRemote ? geometry.size.height + geometry.safeAreaInsets.top + geometry.safeAreaInsets.bottom + geometry.safeAreaInsets.bottom : geometry.size.height - (minBottomSheetHeight * geometry.size.height > 80 ? minBottomSheetHeight * geometry.size.height : 78) - 40 - 20 + geometry.safeAreaInsets.bottom), + height: + angleDegree == 0 + ? (fullscreenVideo && !telecomManager.isPausedByRemote ? geometry.size.height + geometry.safeAreaInsets.top + geometry.safeAreaInsets.bottom + geometry.safeAreaInsets.bottom : geometry.size.height - (minBottomSheetHeight * geometry.size.height > 80 ? minBottomSheetHeight * geometry.size.height : 78) - 40 - 20 + geometry.safeAreaInsets.bottom) + : (fullscreenVideo && !telecomManager.isPausedByRemote ? geometry.size.width : geometry.size.width - 8) + ) + .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 + } + } } } - if callViewModel.videoDisplayed && telecomManager.remoteConfVideo { + if callViewModel.videoDisplayed { HStack { Spacer() VStack { @@ -866,7 +882,7 @@ struct CallView: View { ) } } else if callViewModel.isConference && !telecomManager.outgoingCallStarted && callViewModel.activeSpeakerParticipant != nil { - let heightValue = (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) + 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 { @@ -920,6 +936,9 @@ struct CallView: View { Spacer() } + .onAppear { + fullscreenVideo = false + } HStack { Spacer() @@ -1038,7 +1057,7 @@ struct CallView: View { } .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 - 160 + geometry.safeAreaInsets.bottom + height: fullscreenVideo && !telecomManager.isPausedByRemote ? geometry.size.height + geometry.safeAreaInsets.top + geometry.safeAreaInsets.bottom - 160 : geometry.size.height - (minBottomSheetHeight * geometry.size.height > 80 ? minBottomSheetHeight * geometry.size.height : 78) - 40 - 20 - 160 + geometry.safeAreaInsets.bottom ) Spacer() @@ -1117,7 +1136,7 @@ struct CallView: View { } .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 - 160 + geometry.safeAreaInsets.bottom + height: fullscreenVideo && !telecomManager.isPausedByRemote ? geometry.size.height + geometry.safeAreaInsets.top + geometry.safeAreaInsets.bottom - 160 : geometry.size.height - (minBottomSheetHeight * geometry.size.height > 80 ? minBottomSheetHeight * geometry.size.height : 78) - 40 - 20 - 160 + geometry.safeAreaInsets.bottom ) Spacer() @@ -1127,33 +1146,27 @@ struct CallView: View { 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.remoteConfVideo && !telecomManager.outgoingCallStarted && callViewModel.activeSpeakerParticipant != nil && displayVideo { - VStack { + VStack { + if telecomManager.remoteConfVideo && !telecomManager.outgoingCallStarted && callViewModel.activeSpeakerParticipant != nil && displayVideo { VStack { LinphoneVideoViewHolder { view in coreContext.doOnCoreQueue { core in core.nativeVideoWindow = view } } - .onTapGesture { - if callViewModel.videoDisplayed { - fullscreenVideo.toggle() - } - } } .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 - 160 + geometry.safeAreaInsets.bottom + height: fullscreenVideo && !telecomManager.isPausedByRemote ? geometry.size.height + geometry.safeAreaInsets.top + geometry.safeAreaInsets.bottom - 160 : geometry.size.height - (minBottomSheetHeight * geometry.size.height > 80 ? minBottomSheetHeight * geometry.size.height : 78) - 40 - 20 - 160 + geometry.safeAreaInsets.bottom ) .cornerRadius(20) - - 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 - ) + 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 { @@ -1236,7 +1249,7 @@ struct CallView: View { .background(Color.gray600) .overlay( RoundedRectangle(cornerRadius: 20) - .stroke(callViewModel.myParticipantModel != nil && callViewModel.myParticipantModel!.isSpeaking ? .white : Color.gray600, lineWidth: 4) + .stroke(callViewModel.myParticipantModel != nil && callViewModel.myParticipantModel!.isSpeaking ? .white : .clear, lineWidth: 4) ) .cornerRadius(20) @@ -1339,7 +1352,7 @@ struct CallView: View { .background(Color.gray600) .overlay( RoundedRectangle(cornerRadius: 20) - .stroke(callViewModel.participantList[index].isSpeaking ? .white : Color.gray600, lineWidth: 4) + .stroke(callViewModel.participantList[index].isSpeaking ? .white : .clear, lineWidth: 4) ) .cornerRadius(20) } @@ -1356,6 +1369,14 @@ struct CallView: View { .padding(.leading, -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 : geometry.size.height - (minBottomSheetHeight * geometry.size.height > 80 ? minBottomSheetHeight * geometry.size.height : 78) - 40 - 20 + geometry.safeAreaInsets.bottom + ) + .contentShape(Rectangle()) + .onTapGesture { + fullscreenVideo.toggle() + } .onAppear { optionsChangeLayout = 2 } @@ -1485,7 +1506,7 @@ struct CallView: View { .background(Color.gray600) .overlay( RoundedRectangle(cornerRadius: 20) - .stroke(callViewModel.myParticipantModel!.isSpeaking ? .white : Color.gray600, lineWidth: 4) + .stroke(callViewModel.myParticipantModel!.isSpeaking ? .white : .clear, lineWidth: 4) ) .cornerRadius(20) } @@ -1594,7 +1615,7 @@ struct CallView: View { .background(Color.gray600) .overlay( RoundedRectangle(cornerRadius: 20) - .stroke(callViewModel.participantList[index].isSpeaking ? .white : Color.gray600, lineWidth: 4) + .stroke(callViewModel.participantList[index].isSpeaking ? .white : .clear, lineWidth: 4) ) .cornerRadius(20) } @@ -1721,7 +1742,7 @@ struct CallView: View { .background(Color.gray600) .overlay( RoundedRectangle(cornerRadius: 20) - .stroke(callViewModel.myParticipantModel!.isSpeaking ? .white : Color.gray600, lineWidth: 4) + .stroke(callViewModel.myParticipantModel!.isSpeaking ? .white : .clear, lineWidth: 4) ) .cornerRadius(20) } @@ -1830,7 +1851,7 @@ struct CallView: View { .background(Color.gray600) .overlay( RoundedRectangle(cornerRadius: 20) - .stroke(callViewModel.participantList[index].isSpeaking ? .white : Color.gray600, lineWidth: 4) + .stroke(callViewModel.participantList[index].isSpeaking ? .white : .clear, lineWidth: 4) ) .cornerRadius(20) } @@ -1838,6 +1859,14 @@ struct CallView: View { } } } + .frame( + maxWidth: fullscreenVideo && !telecomManager.isPausedByRemote ? geometry.size.width : geometry.size.width - 8, + maxHeight: fullscreenVideo && !telecomManager.isPausedByRemote ? geometry.size.height : geometry.size.height - (minBottomSheetHeight * geometry.size.height > 80 ? minBottomSheetHeight * geometry.size.height : 78) - 40 - 20 + geometry.safeAreaInsets.bottom + ) + .contentShape(Rectangle()) + .onTapGesture { + fullscreenVideo.toggle() + } } func audioOnlyMode(geometry: GeometryProxy, height: Double) -> some View { @@ -1885,7 +1914,7 @@ struct CallView: View { .background(Color.gray600) .overlay( RoundedRectangle(cornerRadius: 20) - .stroke(callViewModel.myParticipantModel!.isSpeaking ? .white : Color.gray600, lineWidth: 4) + .stroke(callViewModel.myParticipantModel!.isSpeaking ? .white : .clear, lineWidth: 4) ) .cornerRadius(20) } @@ -1927,7 +1956,7 @@ struct CallView: View { .background(Color.gray600) .overlay( RoundedRectangle(cornerRadius: 20) - .stroke(callViewModel.participantList[index].isSpeaking ? .white : Color.gray600, lineWidth: 4) + .stroke(callViewModel.participantList[index].isSpeaking ? .white : .clear, lineWidth: 4) ) .cornerRadius(20) } diff --git a/Linphone/UI/Call/ViewModel/CallViewModel.swift b/Linphone/UI/Call/ViewModel/CallViewModel.swift index 472da15fe..605868fdc 100644 --- a/Linphone/UI/Call/ViewModel/CallViewModel.swift +++ b/Linphone/UI/Call/ViewModel/CallViewModel.swift @@ -104,7 +104,7 @@ class CallViewModel: ObservableObject { var videoDisplayedTmp = false do { let params = try core.createCallParams(call: self.currentCall) - videoDisplayedTmp = params.videoDirection == MediaDirection.SendRecv || params.videoDirection == MediaDirection.SendOnly + videoDisplayedTmp = params.videoEnabled && params.videoDirection == .SendRecv || params.videoDirection == .SendOnly } catch { } From 69165aa3ed8642dd821ede2d3efd378f9385b4cd Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Mon, 6 May 2024 16:27:17 +0200 Subject: [PATCH 216/486] Prevent screen lock during a call --- Linphone/UI/Main/ContentView.swift | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Linphone/UI/Main/ContentView.swift b/Linphone/UI/Main/ContentView.swift index ad6d718ec..1bac741d9 100644 --- a/Linphone/UI/Main/ContentView.swift +++ b/Linphone/UI/Main/ContentView.swift @@ -962,6 +962,7 @@ struct ContentView: View { .zIndex(5) .transition(.scale.combined(with: .move(edge: .top))) .onAppear { + UIApplication.shared.isIdleTimerDisabled = true callViewModel.resetCallView() if callViewModel.calls.count >= 1 { DispatchQueue.main.asyncAfter(deadline: .now() + 1) { @@ -969,6 +970,9 @@ struct ContentView: View { } } } + .onDisappear { + UIApplication.shared.isIdleTimerDisabled = false + } } ToastView() From 2ba2e4095897782fbd0df48a733bd2b75c217600 Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Tue, 7 May 2024 14:32:59 +0200 Subject: [PATCH 217/486] Change call view in landscape mode --- Linphone/TelecomManager/TelecomManager.swift | 23 +- Linphone/UI/Call/CallView.swift | 351 ++++++++++++++----- Linphone/UI/Call/Model/CallStatsModel.swift | 6 + 3 files changed, 295 insertions(+), 85 deletions(-) diff --git a/Linphone/TelecomManager/TelecomManager.swift b/Linphone/TelecomManager/TelecomManager.swift index 9579262ba..7a353d160 100644 --- a/Linphone/TelecomManager/TelecomManager.swift +++ b/Linphone/TelecomManager/TelecomManager.swift @@ -33,6 +33,7 @@ class CallAppData: NSObject { } +// swiftlint:disable type_body_length class TelecomManager: ObservableObject { static let shared = TelecomManager() static var uuidReplacedCall: String? @@ -388,21 +389,33 @@ class TelecomManager: ObservableObject { if call.conference != nil { if call.conference!.activeSpeakerParticipantDevice != nil { let direction = call.conference?.activeSpeakerParticipantDevice!.getStreamCapability(streamType: StreamType.Video) - self.remoteConfVideo = direction == .SendRecv || direction == .SendOnly + self.remoteConfVideo = false + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + self.remoteConfVideo = 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) - self.remoteConfVideo = direction == .SendRecv || direction == .SendOnly + self.remoteConfVideo = false + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + self.remoteConfVideo = 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) - self.remoteConfVideo = direction == .SendRecv || direction == .SendOnly + self.remoteConfVideo = false + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + self.remoteConfVideo = direction == .SendRecv || direction == .SendOnly + } } else { self.remoteConfVideo = false } } else { - self.remoteConfVideo = call.currentParams!.videoEnabled && call.currentParams!.videoDirection == .SendRecv || call.currentParams!.videoDirection == .RecvOnly + self.remoteConfVideo = false + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + self.remoteConfVideo = call.currentParams!.videoEnabled && call.currentParams!.videoDirection == .SendRecv || call.currentParams!.videoDirection == .RecvOnly + } } /* @@ -715,5 +728,5 @@ class TelecomManager: ObservableObject { ]) } } - +// swiftlint:enable type_body_length // swiftlint:enable cyclomatic_complexity diff --git a/Linphone/UI/Call/CallView.swift b/Linphone/UI/Call/CallView.swift index 6191d845c..cac96dcae 100644 --- a/Linphone/UI/Call/CallView.swift +++ b/Linphone/UI/Call/CallView.swift @@ -1033,8 +1033,10 @@ struct CallView: View { } // swiftlint:enable function_body_length + // 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 { @@ -1056,8 +1058,8 @@ struct CallView: View { 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 - 160 : geometry.size.height - (minBottomSheetHeight * geometry.size.height > 80 ? minBottomSheetHeight * geometry.size.height : 78) - 40 - 20 - 160 + geometry.safeAreaInsets.bottom + 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() @@ -1068,76 +1070,84 @@ struct CallView: View { ) } else { VStack { - VStack { - Spacer() - ZStack { - if callViewModel.activeSpeakerParticipant?.address != nil { - let addressFriend = contactsManager.getFriendWithAddress(address: callViewModel.activeSpeakerParticipant!.address) - - let contactAvatarModel = addressFriend != nil - ? ContactsManager.shared.avatarListModel.first(where: { - ($0.friend!.consolidatedPresence == .Online || $0.friend!.consolidatedPresence == .Busy) - && $0.friend!.name == addressFriend!.name - && $0.friend!.address!.asStringUriOnly() == addressFriend!.address!.asStringUriOnly() - }) - : ContactAvatarModel(friend: nil, name: "", withPresence: false) - - if addressFriend != nil && addressFriend!.photo != nil && !addressFriend!.photo!.isEmpty { - if contactAvatarModel != nil { - Avatar(contactAvatarModel: contactAvatarModel!, avatarSize: 200, hidePresence: true) - .onAppear { - DispatchQueue.main.asyncAfter(deadline: .now() + 1) { - displayVideo = true + HStack { + VStack { + Spacer() + HStack { + ZStack { + if callViewModel.activeSpeakerParticipant?.address != nil { + let addressFriend = contactsManager.getFriendWithAddress(address: callViewModel.activeSpeakerParticipant!.address) + + let contactAvatarModel = addressFriend != nil + ? ContactsManager.shared.avatarListModel.first(where: { + ($0.friend!.consolidatedPresence == .Online || $0.friend!.consolidatedPresence == .Busy) + && $0.friend!.name == addressFriend!.name + && $0.friend!.address!.asStringUriOnly() == addressFriend!.address!.asStringUriOnly() + }) + : ContactAvatarModel(friend: nil, name: "", withPresence: false) + + if addressFriend != nil && addressFriend!.photo != nil && !addressFriend!.photo!.isEmpty { + if contactAvatarModel != nil { + Avatar(contactAvatarModel: contactAvatarModel!, avatarSize: 200, hidePresence: true) + .onAppear { + DispatchQueue.main.asyncAfter(deadline: .now() + 1) { + displayVideo = true + } + } + } + } else { + if callViewModel.activeSpeakerParticipant!.address.displayName != nil { + Image(uiImage: contactsManager.textToImage( + firstName: callViewModel.activeSpeakerParticipant!.address.displayName!, + lastName: callViewModel.activeSpeakerParticipant!.address.displayName!.components(separatedBy: " ").count > 1 + ? callViewModel.activeSpeakerParticipant!.address.displayName!.components(separatedBy: " ")[1] + : "")) + .resizable() + .frame(width: 200, height: 200) + .clipShape(Circle()) + .onAppear { + DispatchQueue.main.asyncAfter(deadline: .now() + 1) { + displayVideo = true + } + } + + } else { + Image(uiImage: contactsManager.textToImage( + firstName: callViewModel.activeSpeakerParticipant!.address.username ?? "Username Error", + lastName: callViewModel.activeSpeakerParticipant!.address.username!.components(separatedBy: " ").count > 1 + ? callViewModel.activeSpeakerParticipant!.address.username!.components(separatedBy: " ")[1] + : "")) + .resizable() + .frame(width: 200, height: 200) + .clipShape(Circle()) + .onAppear { + DispatchQueue.main.asyncAfter(deadline: .now() + 1) { + displayVideo = true + } } } - } - } else { - if callViewModel.activeSpeakerParticipant!.address.displayName != nil { - Image(uiImage: contactsManager.textToImage( - firstName: callViewModel.activeSpeakerParticipant!.address.displayName!, - lastName: callViewModel.activeSpeakerParticipant!.address.displayName!.components(separatedBy: " ").count > 1 - ? callViewModel.activeSpeakerParticipant!.address.displayName!.components(separatedBy: " ")[1] - : "")) - .resizable() - .frame(width: 200, height: 200) - .clipShape(Circle()) - .onAppear { - DispatchQueue.main.asyncAfter(deadline: .now() + 1) { - displayVideo = true - } + } - } else { - Image(uiImage: contactsManager.textToImage( - firstName: callViewModel.activeSpeakerParticipant!.address.username ?? "Username Error", - lastName: callViewModel.activeSpeakerParticipant!.address.username!.components(separatedBy: " ").count > 1 - ? callViewModel.activeSpeakerParticipant!.address.username!.components(separatedBy: " ")[1] - : "")) - .resizable() - .frame(width: 200, height: 200) - .clipShape(Circle()) - .onAppear { - DispatchQueue.main.asyncAfter(deadline: .now() + 1) { - displayVideo = true - } - } + Image("profil-picture-default") + .resizable() + .frame(width: 200, height: 200) + .clipShape(Circle()) } - } - } else { - Image("profil-picture-default") - .resizable() - .frame(width: 200, height: 200) - .clipShape(Circle()) } + + 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 + ) - Spacer() + if isLandscapeMode { + 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 - 160 : geometry.size.height - (minBottomSheetHeight * geometry.size.height > 80 ? minBottomSheetHeight * geometry.size.height : 78) - 40 - 20 - 160 + geometry.safeAreaInsets.bottom - ) Spacer() } @@ -1148,18 +1158,24 @@ struct CallView: View { VStack { if telecomManager.remoteConfVideo && !telecomManager.outgoingCallStarted && callViewModel.activeSpeakerParticipant != nil && displayVideo { - VStack { - LinphoneVideoViewHolder { view in - coreContext.doOnCoreQueue { core in - core.nativeVideoWindow = view + 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() + } } - .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 - 160 : geometry.size.height - (minBottomSheetHeight * geometry.size.height > 80 ? minBottomSheetHeight * geometry.size.height : 78) - 40 - 20 - 160 + geometry.safeAreaInsets.bottom - ) - .cornerRadius(20) } Spacer() } @@ -1184,6 +1200,11 @@ struct CallView: View { .padding(5) .background(.white) .cornerRadius(40) + + if isLandscapeMode { + Spacer() + .frame(width: 160) + } } Spacer() } @@ -1204,9 +1225,178 @@ struct CallView: View { .lineLimit(1) .padding(.horizontal, 10) .padding(.bottom, 6) + .padding(.top, isLandscapeMode && fullscreenVideo && !telecomManager.isPausedByRemote ? -70 : 0) - ScrollView(.horizontal) { - HStack { + 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() @@ -1360,18 +1550,19 @@ struct CallView: View { } } } + .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 + ) + .padding(.bottom, 10) + .padding(.leading, -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 : geometry.size.height - (minBottomSheetHeight * geometry.size.height > 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 : geometry.size.height - (minBottomSheetHeight * geometry.size.height > 80 ? minBottomSheetHeight * geometry.size.height : 78) - 40 - 20 + geometry.safeAreaInsets.bottom + 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 { diff --git a/Linphone/UI/Call/Model/CallStatsModel.swift b/Linphone/UI/Call/Model/CallStatsModel.swift index 98f788a7c..d48e09f96 100644 --- a/Linphone/UI/Call/Model/CallStatsModel.swift +++ b/Linphone/UI/Call/Model/CallStatsModel.swift @@ -43,6 +43,9 @@ class CallStatsModel: ObservableObject { let clockRate = (payloadType?.clockRate != nil ? payloadType!.clockRate : 0) / 1000 let codecLabel = "Codec: " + "\(payloadType != nil ? payloadType!.mimeType : "")/\(clockRate) kHz" + guard !(stats.uploadBandwidth.rounded().isNaN || stats.uploadBandwidth.rounded().isInfinite || stats.downloadBandwidth.rounded().isNaN || stats.downloadBandwidth.rounded().isInfinite) else { + return + } let uploadBandwidth = Int(stats.uploadBandwidth.rounded()) let downloadBandwidth = Int(stats.downloadBandwidth.rounded()) let bandwidthLabel = "Bandwidth: " + "↑ \(uploadBandwidth) kbits/s ↓ \(downloadBandwidth) kbits/s" @@ -58,6 +61,9 @@ class CallStatsModel: ObservableObject { let clockRate = (payloadType?.clockRate != nil ? payloadType!.clockRate : 0) / 1000 let codecLabel = "Codec: " + "\(payloadType != nil ? payloadType!.mimeType : "null")/\(clockRate) kHz" + guard !(stats.uploadBandwidth.rounded().isNaN || stats.uploadBandwidth.rounded().isInfinite || stats.downloadBandwidth.rounded().isNaN || stats.downloadBandwidth.rounded().isInfinite) else { + return + } let uploadBandwidth = Int(stats.uploadBandwidth.rounded()) let downloadBandwidth = Int(stats.downloadBandwidth.rounded()) let bandwidthLabel = "Bandwidth: " + "↑ \(uploadBandwidth) kbits/s ↓ \(downloadBandwidth) kbits/s" From 18c4b46d6356a9448b6fc6434000542e5c747197 Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Tue, 7 May 2024 16:17:19 +0200 Subject: [PATCH 218/486] Dismiss bottom sheet in call view --- Linphone.xcodeproj/project.pbxproj | 108 +++++ Linphone/UI/Call/CallView.swift | 376 +----------------- .../Fragments/AudioRouteBottomSheet.swift | 141 +++++++ .../CallStatisticsSheetBottomSheet.swift | 100 +++++ .../Fragments/ChangeLayoutBottomSheet.swift | 131 ++++++ .../MediaEncryptedSheetBottomSheet.swift | 109 +++++ Linphone/UI/Call/Model/CallStatsModel.swift | 6 +- 7 files changed, 605 insertions(+), 366 deletions(-) create mode 100644 Linphone/UI/Call/Fragments/AudioRouteBottomSheet.swift create mode 100644 Linphone/UI/Call/Fragments/CallStatisticsSheetBottomSheet.swift create mode 100644 Linphone/UI/Call/Fragments/ChangeLayoutBottomSheet.swift create mode 100644 Linphone/UI/Call/Fragments/MediaEncryptedSheetBottomSheet.swift diff --git a/Linphone.xcodeproj/project.pbxproj b/Linphone.xcodeproj/project.pbxproj index 2dae51c9e..4f16d3ea5 100644 --- a/Linphone.xcodeproj/project.pbxproj +++ b/Linphone.xcodeproj/project.pbxproj @@ -35,6 +35,7 @@ 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 */; }; + B7B8D61A96BD84CFE971D91A /* Pods_Linphone.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 58C93C0613C0C9EE9F683101 /* Pods_Linphone.framework */; }; 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 */; }; @@ -96,6 +97,10 @@ 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 */; }; D7A03FBD2ACC2DB60081A588 /* ContactsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7A03FBC2ACC2DB60081A588 /* ContactsView.swift */; }; @@ -136,6 +141,7 @@ D7EAACCF2AD6ED8000AA6A8A /* PermissionsFragment.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7EAACCE2AD6ED8000AA6A8A /* PermissionsFragment.swift */; }; D7F4D9CB2B5FD27200CDCD76 /* CallsListFragment.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7F4D9CA2B5FD27200CDCD76 /* CallsListFragment.swift */; }; D7FB55112AD447FD00A5AB15 /* RegisterFragment.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7FB55102AD447FD00A5AB15 /* RegisterFragment.swift */; }; + F9B4F742AD49194891FD3229 /* Pods_msgNotificationService.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 7F354247FC9212E91C1A125F /* Pods_msgNotificationService.framework */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -163,6 +169,9 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + 4354755A6D707621E1E163BA /* Pods-msgNotificationService.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-msgNotificationService.debug.xcconfig"; path = "Target Support Files/Pods-msgNotificationService/Pods-msgNotificationService.debug.xcconfig"; sourceTree = ""; }; + 4A44483E8A6A48F06F982C27 /* Pods-Linphone.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Linphone.release.xcconfig"; path = "Target Support Files/Pods-Linphone/Pods-Linphone.release.xcconfig"; sourceTree = ""; }; + 58C93C0613C0C9EE9F683101 /* Pods_Linphone.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Linphone.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 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 = ""; }; @@ -187,6 +196,9 @@ 66E56BCB2BA9A1E0006CE56F /* MeetingsListItemModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeetingsListItemModel.swift; sourceTree = ""; }; 66E56BCD2BA9A1F8006CE56F /* MeetingModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeetingModel.swift; sourceTree = ""; }; 66F626B12BCEBB86003E2DEC /* AddParticipantsFragment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddParticipantsFragment.swift; sourceTree = ""; }; + 7B05388834A68F3139984AF9 /* Pods-Linphone.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Linphone.debug.xcconfig"; path = "Target Support Files/Pods-Linphone/Pods-Linphone.debug.xcconfig"; sourceTree = ""; }; + 7F354247FC9212E91C1A125F /* Pods_msgNotificationService.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_msgNotificationService.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 8ED7664E120A7A0B96AF2301 /* Pods-msgNotificationService.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-msgNotificationService.release.xcconfig"; path = "Target Support Files/Pods-msgNotificationService/Pods-msgNotificationService.release.xcconfig"; 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 = ""; }; @@ -250,6 +262,10 @@ 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 = ""; }; D7A03FBC2ACC2DB60081A588 /* ContactsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactsView.swift; sourceTree = ""; }; @@ -298,6 +314,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + F9B4F742AD49194891FD3229 /* Pods_msgNotificationService.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -305,12 +322,22 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + B7B8D61A96BD84CFE971D91A /* Pods_Linphone.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + 09575EFA659D4B5A36D3567A /* Frameworks */ = { + isa = PBXGroup; + children = ( + 58C93C0613C0C9EE9F683101 /* Pods_Linphone.framework */, + 7F354247FC9212E91C1A125F /* Pods_msgNotificationService.framework */, + ); + name = Frameworks; + sourceTree = ""; + }; 660AAF7C2B839272004C0FA6 /* msgNotificationService */ = { isa = PBXGroup; children = ( @@ -387,6 +414,10 @@ A31AF2AB8C6A3D7B7EA3B424 /* Pods */ = { isa = PBXGroup; children = ( + 7B05388834A68F3139984AF9 /* Pods-Linphone.debug.xcconfig */, + 4A44483E8A6A48F06F982C27 /* Pods-Linphone.release.xcconfig */, + 4354755A6D707621E1E163BA /* Pods-msgNotificationService.debug.xcconfig */, + 8ED7664E120A7A0B96AF2301 /* Pods-msgNotificationService.release.xcconfig */, ); path = Pods; sourceTree = ""; @@ -428,6 +459,7 @@ 660AAF7C2B839272004C0FA6 /* msgNotificationService */, D719ABB42ABC67BF00B41C10 /* Products */, A31AF2AB8C6A3D7B7EA3B424 /* Pods */, + 09575EFA659D4B5A36D3567A /* Frameworks */, ); sourceTree = ""; }; @@ -593,6 +625,10 @@ D75759312B56D40900E7AC10 /* ZRTPPopup.swift */, D7F4D9CA2B5FD27200CDCD76 /* CallsListFragment.swift */, D717630C2BD7BD0E00464097 /* ParticipantsListFragment.swift */, + D78E06292BEA698E00CE3783 /* MediaEncryptedSheetBottomSheet.swift */, + D78E062B2BEA69BC00CE3783 /* CallStatisticsSheetBottomSheet.swift */, + D78E062D2BEA69F400CE3783 /* AudioRouteBottomSheet.swift */, + D78E062F2BEA6A4A00CE3783 /* ChangeLayoutBottomSheet.swift */, ); path = Fragments; sourceTree = ""; @@ -772,6 +808,7 @@ isa = PBXNativeTarget; buildConfigurationList = 660AAF832B839272004C0FA6 /* Build configuration list for PBXNativeTarget "msgNotificationService" */; buildPhases = ( + 24598C3B27268E421F6C44CE /* [CP] Check Pods Manifest.lock */, 660AAF772B839271004C0FA6 /* Sources */, 660AAF782B839271004C0FA6 /* Frameworks */, 660AAF792B839271004C0FA6 /* Resources */, @@ -789,12 +826,14 @@ isa = PBXNativeTarget; buildConfigurationList = D719ABC22ABC67BF00B41C10 /* Build configuration list for PBXNativeTarget "Linphone" */; buildPhases = ( + 4CE154B79984202D6309AD08 /* [CP] Check Pods Manifest.lock */, D719ABAF2ABC67BF00B41C10 /* Sources */, D719ABB02ABC67BF00B41C10 /* Frameworks */, 660AAF802B839272004C0FA6 /* Embed Foundation Extensions */, D719ABB12ABC67BF00B41C10 /* Resources */, D7FB55122AD53FE200A5AB15 /* Run Script */, 66BF2D4B2B558A3100A5F2E3 /* Crashlytics */, + F29799A2294ED13B2953DBDE /* [CP] Embed Pods Frameworks */, ); buildRules = ( ); @@ -878,6 +917,50 @@ /* End PBXResourcesBuildPhase section */ /* Begin PBXShellScriptBuildPhase section */ + 24598C3B27268E421F6C44CE /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-msgNotificationService-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; + 4CE154B79984202D6309AD08 /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-Linphone-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; 66BF2D4B2B558A3100A5F2E3 /* Crashlytics */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; @@ -920,6 +1003,23 @@ 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"; }; + F29799A2294ED13B2953DBDE /* [CP] Embed Pods Frameworks */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Linphone/Pods-Linphone-frameworks-${CONFIGURATION}-input-files.xcfilelist", + ); + name = "[CP] Embed Pods Frameworks"; + outputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Linphone/Pods-Linphone-frameworks-${CONFIGURATION}-output-files.xcfilelist", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Linphone/Pods-Linphone-frameworks.sh\"\n"; + showEnvVarsInLog = 0; + }; /* End PBXShellScriptBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ @@ -947,6 +1047,7 @@ 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 */, D750D3392AD3E6EE00EC99C5 /* PopupLoadingView.swift in Sources */, @@ -992,6 +1093,7 @@ 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 */, 6613A0B02BAEB7F4008923A4 /* MeetingsListFragment.swift in Sources */, @@ -999,6 +1101,7 @@ D78E06282BE3811D00CE3783 /* CallStatsModel.swift in Sources */, D7E6D0512AEBDBD500A57AAF /* ContactsListBottomSheet.swift in Sources */, D75759322B56D40900E7AC10 /* ZRTPPopup.swift in Sources */, + D78E062E2BEA69F400CE3783 /* AudioRouteBottomSheet.swift in Sources */, D7A2EDD62AC18115005D90FC /* SharedMainViewModel.swift in Sources */, D7A03FC62ACC458A0081A588 /* SplashScreen.swift in Sources */, D7E6ADF52B9876ED0009A2BC /* Attachment.swift in Sources */, @@ -1041,6 +1144,7 @@ D7CEE03D2B7A23B200FD79B7 /* ConversationsListFragment.swift in Sources */, D74C9CFC2ACACF370021626A /* WelcomePage3Fragment.swift in Sources */, D719ABCC2ABC769C00B41C10 /* AssistantView.swift in Sources */, + D78E062C2BEA69BC00CE3783 /* CallStatisticsSheetBottomSheet.swift in Sources */, D7C365082AEFAB7F00FE6142 /* ContactListBottomSheet.swift in Sources */, D7E6D04B2AE9347D00A57AAF /* FavoriteContactsListViewModel.swift in Sources */, D74C9CFA2ACACF2D0021626A /* WelcomePage2Fragment.swift in Sources */, @@ -1062,6 +1166,7 @@ /* Begin XCBuildConfiguration section */ 660AAF812B839272004C0FA6 /* Debug */ = { isa = XCBuildConfiguration; + baseConfigurationReference = 4354755A6D707621E1E163BA /* Pods-msgNotificationService.debug.xcconfig */; buildSettings = { ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; CLANG_ENABLE_MODULES = YES; @@ -1104,6 +1209,7 @@ }; 660AAF822B839272004C0FA6 /* Release */ = { isa = XCBuildConfiguration; + baseConfigurationReference = 8ED7664E120A7A0B96AF2301 /* Pods-msgNotificationService.release.xcconfig */; buildSettings = { ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; CLANG_ENABLE_MODULES = YES; @@ -1257,6 +1363,7 @@ }; D719ABC32ABC67BF00B41C10 /* Debug */ = { isa = XCBuildConfiguration; + baseConfigurationReference = 7B05388834A68F3139984AF9 /* Pods-Linphone.debug.xcconfig */; buildSettings = { ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; @@ -1313,6 +1420,7 @@ }; D719ABC42ABC67BF00B41C10 /* Release */ = { isa = XCBuildConfiguration; + baseConfigurationReference = 4A44483E8A6A48F06F982C27 /* Pods-Linphone.release.xcconfig */; buildSettings = { ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; diff --git a/Linphone/UI/Call/CallView.swift b/Linphone/UI/Call/CallView.swift index cac96dcae..47614097b 100644 --- a/Linphone/UI/Call/CallView.swift +++ b/Linphone/UI/Call/CallView.swift @@ -69,25 +69,25 @@ struct CallView: View { .sheet(isPresented: $mediaEncryptedSheet, onDismiss: { mediaEncryptedSheet = false }) { - mediaEncryptedSheetBottomSheet() + MediaEncryptedSheetBottomSheet(callViewModel: callViewModel, mediaEncryptedSheet: $mediaEncryptedSheet) .presentationDetents([.medium]) } .sheet(isPresented: $callStatisticsSheet, onDismiss: { callStatisticsSheet = false }) { - callStatisticsSheetBottomSheet() + CallStatisticsSheetBottomSheet(callViewModel: callViewModel, callStatisticsSheet: $callStatisticsSheet) .presentationDetents(!callViewModel.callStatsModel.isVideoEnabled ? [.fraction(0.3)] : [.medium]) } .sheet(isPresented: $audioRouteSheet, onDismiss: { audioRouteSheet = false }) { - audioRouteBottomSheet() + AudioRouteBottomSheet(callViewModel: callViewModel, optionsAudioRoute: $optionsAudioRoute) .presentationDetents([.fraction(0.3)]) } .sheet(isPresented: $changeLayoutSheet, onDismiss: { changeLayoutSheet = false }) { - changeLayoutBottomSheet() + ChangeLayoutBottomSheet(callViewModel: callViewModel, changeLayoutSheet: $changeLayoutSheet, optionsChangeLayout: $optionsChangeLayout) .presentationDetents([.fraction(0.3)]) } .sheet(isPresented: $showingDialer) { @@ -104,25 +104,25 @@ struct CallView: View { .sheet(isPresented: $mediaEncryptedSheet, onDismiss: { mediaEncryptedSheet = false }) { - mediaEncryptedSheetBottomSheet() + MediaEncryptedSheetBottomSheet(callViewModel: callViewModel, mediaEncryptedSheet: $mediaEncryptedSheet) .presentationDetents([.medium]) } .sheet(isPresented: $callStatisticsSheet, onDismiss: { callStatisticsSheet = false }) { - callStatisticsSheetBottomSheet() + CallStatisticsSheetBottomSheet(callViewModel: callViewModel, callStatisticsSheet: $callStatisticsSheet) .presentationDetents(!callViewModel.callStatsModel.isVideoEnabled ? [.fraction(0.3)] : [.medium]) } .sheet(isPresented: $audioRouteSheet, onDismiss: { audioRouteSheet = false }) { - audioRouteBottomSheet() + AudioRouteBottomSheet(callViewModel: callViewModel, optionsAudioRoute: $optionsAudioRoute) .presentationDetents([.fraction(0.3)]) } .sheet(isPresented: $changeLayoutSheet, onDismiss: { changeLayoutSheet = false }) { - changeLayoutBottomSheet() + ChangeLayoutBottomSheet(callViewModel: callViewModel, changeLayoutSheet: $changeLayoutSheet, optionsChangeLayout: $optionsChangeLayout) .presentationDetents([.fraction(0.3)]) } .sheet(isPresented: $showingDialer) { @@ -136,22 +136,22 @@ struct CallView: View { } else { innerView(geometry: geo) .halfSheet(showSheet: $mediaEncryptedSheet) { - mediaEncryptedSheetBottomSheet() + MediaEncryptedSheetBottomSheet(callViewModel: callViewModel, mediaEncryptedSheet: $mediaEncryptedSheet) } onDismiss: { mediaEncryptedSheet = false } .halfSheet(showSheet: $callStatisticsSheet) { - callStatisticsSheetBottomSheet() + CallStatisticsSheetBottomSheet(callViewModel: callViewModel, callStatisticsSheet: $callStatisticsSheet) } onDismiss: { callStatisticsSheet = false } .halfSheet(showSheet: $audioRouteSheet) { - audioRouteBottomSheet() + AudioRouteBottomSheet(callViewModel: callViewModel, optionsAudioRoute: $optionsAudioRoute) } onDismiss: { audioRouteSheet = false } .halfSheet(showSheet: $changeLayoutSheet) { - changeLayoutBottomSheet() + ChangeLayoutBottomSheet(callViewModel: callViewModel, changeLayoutSheet: $changeLayoutSheet, optionsChangeLayout: $optionsChangeLayout) } onDismiss: { changeLayoutSheet = false } @@ -204,358 +204,6 @@ struct CallView: View { } } - @ViewBuilder - func mediaEncryptedSheetBottomSheet() -> 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 - } - } - .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 - }, 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) - } - - @ViewBuilder - func callStatisticsSheetBottomSheet() -> 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 - } - } - .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() - - 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.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) - } - @ViewBuilder - func audioRouteBottomSheet() -> 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) - } - - @ViewBuilder - func changeLayoutBottomSheet() -> some View { - VStack(spacing: 0) { - Button(action: { - optionsChangeLayout = 1 - callViewModel.toggleVideoMode(isAudioOnlyMode: false) - changeLayoutSheet = false - }, 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 - }, 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 - }, 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) - } - @ViewBuilder // swiftlint:disable:next cyclomatic_complexity func innerView(geometry: GeometryProxy) -> some View { diff --git a/Linphone/UI/Call/Fragments/AudioRouteBottomSheet.swift b/Linphone/UI/Call/Fragments/AudioRouteBottomSheet.swift new file mode 100644 index 000000000..4b85ee333 --- /dev/null +++ b/Linphone/UI/Call/Fragments/AudioRouteBottomSheet.swift @@ -0,0 +1,141 @@ +/* + * 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..3b94f53b5 --- /dev/null +++ b/Linphone/UI/Call/Fragments/CallStatisticsSheetBottomSheet.swift @@ -0,0 +1,100 @@ +/* + * 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() + + 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.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/ChangeLayoutBottomSheet.swift b/Linphone/UI/Call/Fragments/ChangeLayoutBottomSheet.swift new file mode 100644 index 000000000..7a7bbc7d6 --- /dev/null +++ b/Linphone/UI/Call/Fragments/ChangeLayoutBottomSheet.swift @@ -0,0 +1,131 @@ +/* + * 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 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/Model/CallStatsModel.swift b/Linphone/UI/Call/Model/CallStatsModel.swift index d48e09f96..32b8b36e8 100644 --- a/Linphone/UI/Call/Model/CallStatsModel.swift +++ b/Linphone/UI/Call/Model/CallStatsModel.swift @@ -43,9 +43,10 @@ class CallStatsModel: ObservableObject { let clockRate = (payloadType?.clockRate != nil ? payloadType!.clockRate : 0) / 1000 let codecLabel = "Codec: " + "\(payloadType != nil ? payloadType!.mimeType : "")/\(clockRate) kHz" - guard !(stats.uploadBandwidth.rounded().isNaN || stats.uploadBandwidth.rounded().isInfinite || stats.downloadBandwidth.rounded().isNaN || stats.downloadBandwidth.rounded().isInfinite) else { + 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" @@ -61,9 +62,10 @@ class CallStatsModel: ObservableObject { let clockRate = (payloadType?.clockRate != nil ? payloadType!.clockRate : 0) / 1000 let codecLabel = "Codec: " + "\(payloadType != nil ? payloadType!.mimeType : "null")/\(clockRate) kHz" - guard !(stats.uploadBandwidth.rounded().isNaN || stats.uploadBandwidth.rounded().isInfinite || stats.downloadBandwidth.rounded().isNaN || stats.downloadBandwidth.rounded().isInfinite) else { + 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" From d2d8c9cd8d412c333ae44b614d9b97057fa0dfdd Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Tue, 7 May 2024 16:30:48 +0200 Subject: [PATCH 219/486] Fix statistics --- Linphone.xcodeproj/project.pbxproj | 92 --------------------- Linphone/UI/Call/Model/CallStatsModel.swift | 4 +- 2 files changed, 2 insertions(+), 94 deletions(-) diff --git a/Linphone.xcodeproj/project.pbxproj b/Linphone.xcodeproj/project.pbxproj index 4f16d3ea5..2ec02b416 100644 --- a/Linphone.xcodeproj/project.pbxproj +++ b/Linphone.xcodeproj/project.pbxproj @@ -35,7 +35,6 @@ 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 */; }; - B7B8D61A96BD84CFE971D91A /* Pods_Linphone.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 58C93C0613C0C9EE9F683101 /* Pods_Linphone.framework */; }; 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 */; }; @@ -141,7 +140,6 @@ D7EAACCF2AD6ED8000AA6A8A /* PermissionsFragment.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7EAACCE2AD6ED8000AA6A8A /* PermissionsFragment.swift */; }; D7F4D9CB2B5FD27200CDCD76 /* CallsListFragment.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7F4D9CA2B5FD27200CDCD76 /* CallsListFragment.swift */; }; D7FB55112AD447FD00A5AB15 /* RegisterFragment.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7FB55102AD447FD00A5AB15 /* RegisterFragment.swift */; }; - F9B4F742AD49194891FD3229 /* Pods_msgNotificationService.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 7F354247FC9212E91C1A125F /* Pods_msgNotificationService.framework */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -169,9 +167,6 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ - 4354755A6D707621E1E163BA /* Pods-msgNotificationService.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-msgNotificationService.debug.xcconfig"; path = "Target Support Files/Pods-msgNotificationService/Pods-msgNotificationService.debug.xcconfig"; sourceTree = ""; }; - 4A44483E8A6A48F06F982C27 /* Pods-Linphone.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Linphone.release.xcconfig"; path = "Target Support Files/Pods-Linphone/Pods-Linphone.release.xcconfig"; sourceTree = ""; }; - 58C93C0613C0C9EE9F683101 /* Pods_Linphone.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Linphone.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 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 = ""; }; @@ -196,9 +191,6 @@ 66E56BCB2BA9A1E0006CE56F /* MeetingsListItemModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeetingsListItemModel.swift; sourceTree = ""; }; 66E56BCD2BA9A1F8006CE56F /* MeetingModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeetingModel.swift; sourceTree = ""; }; 66F626B12BCEBB86003E2DEC /* AddParticipantsFragment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddParticipantsFragment.swift; sourceTree = ""; }; - 7B05388834A68F3139984AF9 /* Pods-Linphone.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Linphone.debug.xcconfig"; path = "Target Support Files/Pods-Linphone/Pods-Linphone.debug.xcconfig"; sourceTree = ""; }; - 7F354247FC9212E91C1A125F /* Pods_msgNotificationService.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_msgNotificationService.framework; sourceTree = BUILT_PRODUCTS_DIR; }; - 8ED7664E120A7A0B96AF2301 /* Pods-msgNotificationService.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-msgNotificationService.release.xcconfig"; path = "Target Support Files/Pods-msgNotificationService/Pods-msgNotificationService.release.xcconfig"; 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 = ""; }; @@ -314,7 +306,6 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - F9B4F742AD49194891FD3229 /* Pods_msgNotificationService.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -322,22 +313,12 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - B7B8D61A96BD84CFE971D91A /* Pods_Linphone.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ - 09575EFA659D4B5A36D3567A /* Frameworks */ = { - isa = PBXGroup; - children = ( - 58C93C0613C0C9EE9F683101 /* Pods_Linphone.framework */, - 7F354247FC9212E91C1A125F /* Pods_msgNotificationService.framework */, - ); - name = Frameworks; - sourceTree = ""; - }; 660AAF7C2B839272004C0FA6 /* msgNotificationService */ = { isa = PBXGroup; children = ( @@ -414,10 +395,6 @@ A31AF2AB8C6A3D7B7EA3B424 /* Pods */ = { isa = PBXGroup; children = ( - 7B05388834A68F3139984AF9 /* Pods-Linphone.debug.xcconfig */, - 4A44483E8A6A48F06F982C27 /* Pods-Linphone.release.xcconfig */, - 4354755A6D707621E1E163BA /* Pods-msgNotificationService.debug.xcconfig */, - 8ED7664E120A7A0B96AF2301 /* Pods-msgNotificationService.release.xcconfig */, ); path = Pods; sourceTree = ""; @@ -459,7 +436,6 @@ 660AAF7C2B839272004C0FA6 /* msgNotificationService */, D719ABB42ABC67BF00B41C10 /* Products */, A31AF2AB8C6A3D7B7EA3B424 /* Pods */, - 09575EFA659D4B5A36D3567A /* Frameworks */, ); sourceTree = ""; }; @@ -808,7 +784,6 @@ isa = PBXNativeTarget; buildConfigurationList = 660AAF832B839272004C0FA6 /* Build configuration list for PBXNativeTarget "msgNotificationService" */; buildPhases = ( - 24598C3B27268E421F6C44CE /* [CP] Check Pods Manifest.lock */, 660AAF772B839271004C0FA6 /* Sources */, 660AAF782B839271004C0FA6 /* Frameworks */, 660AAF792B839271004C0FA6 /* Resources */, @@ -826,14 +801,12 @@ isa = PBXNativeTarget; buildConfigurationList = D719ABC22ABC67BF00B41C10 /* Build configuration list for PBXNativeTarget "Linphone" */; buildPhases = ( - 4CE154B79984202D6309AD08 /* [CP] Check Pods Manifest.lock */, D719ABAF2ABC67BF00B41C10 /* Sources */, D719ABB02ABC67BF00B41C10 /* Frameworks */, 660AAF802B839272004C0FA6 /* Embed Foundation Extensions */, D719ABB12ABC67BF00B41C10 /* Resources */, D7FB55122AD53FE200A5AB15 /* Run Script */, 66BF2D4B2B558A3100A5F2E3 /* Crashlytics */, - F29799A2294ED13B2953DBDE /* [CP] Embed Pods Frameworks */, ); buildRules = ( ); @@ -917,50 +890,6 @@ /* End PBXResourcesBuildPhase section */ /* Begin PBXShellScriptBuildPhase section */ - 24598C3B27268E421F6C44CE /* [CP] Check Pods Manifest.lock */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - inputFileListPaths = ( - ); - inputPaths = ( - "${PODS_PODFILE_DIR_PATH}/Podfile.lock", - "${PODS_ROOT}/Manifest.lock", - ); - name = "[CP] Check Pods Manifest.lock"; - outputFileListPaths = ( - ); - outputPaths = ( - "$(DERIVED_FILE_DIR)/Pods-msgNotificationService-checkManifestLockResult.txt", - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; - showEnvVarsInLog = 0; - }; - 4CE154B79984202D6309AD08 /* [CP] Check Pods Manifest.lock */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - inputFileListPaths = ( - ); - inputPaths = ( - "${PODS_PODFILE_DIR_PATH}/Podfile.lock", - "${PODS_ROOT}/Manifest.lock", - ); - name = "[CP] Check Pods Manifest.lock"; - outputFileListPaths = ( - ); - outputPaths = ( - "$(DERIVED_FILE_DIR)/Pods-Linphone-checkManifestLockResult.txt", - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; - showEnvVarsInLog = 0; - }; 66BF2D4B2B558A3100A5F2E3 /* Crashlytics */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; @@ -1003,23 +932,6 @@ 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"; }; - F29799A2294ED13B2953DBDE /* [CP] Embed Pods Frameworks */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - inputFileListPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-Linphone/Pods-Linphone-frameworks-${CONFIGURATION}-input-files.xcfilelist", - ); - name = "[CP] Embed Pods Frameworks"; - outputFileListPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-Linphone/Pods-Linphone-frameworks-${CONFIGURATION}-output-files.xcfilelist", - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Linphone/Pods-Linphone-frameworks.sh\"\n"; - showEnvVarsInLog = 0; - }; /* End PBXShellScriptBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ @@ -1166,7 +1078,6 @@ /* Begin XCBuildConfiguration section */ 660AAF812B839272004C0FA6 /* Debug */ = { isa = XCBuildConfiguration; - baseConfigurationReference = 4354755A6D707621E1E163BA /* Pods-msgNotificationService.debug.xcconfig */; buildSettings = { ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; CLANG_ENABLE_MODULES = YES; @@ -1209,7 +1120,6 @@ }; 660AAF822B839272004C0FA6 /* Release */ = { isa = XCBuildConfiguration; - baseConfigurationReference = 8ED7664E120A7A0B96AF2301 /* Pods-msgNotificationService.release.xcconfig */; buildSettings = { ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; CLANG_ENABLE_MODULES = YES; @@ -1363,7 +1273,6 @@ }; D719ABC32ABC67BF00B41C10 /* Debug */ = { isa = XCBuildConfiguration; - baseConfigurationReference = 7B05388834A68F3139984AF9 /* Pods-Linphone.debug.xcconfig */; buildSettings = { ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; @@ -1420,7 +1329,6 @@ }; D719ABC42ABC67BF00B41C10 /* Release */ = { isa = XCBuildConfiguration; - baseConfigurationReference = 4A44483E8A6A48F06F982C27 /* Pods-Linphone.release.xcconfig */; buildSettings = { ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; diff --git a/Linphone/UI/Call/Model/CallStatsModel.swift b/Linphone/UI/Call/Model/CallStatsModel.swift index 32b8b36e8..bfc7ef746 100644 --- a/Linphone/UI/Call/Model/CallStatsModel.swift +++ b/Linphone/UI/Call/Model/CallStatsModel.swift @@ -43,7 +43,7 @@ class CallStatsModel: ObservableObject { 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) { + if stats.uploadBandwidth.rounded().isNaN || stats.uploadBandwidth.rounded().isInfinite || stats.downloadBandwidth.rounded().isNaN || stats.downloadBandwidth.rounded().isInfinite { return } @@ -62,7 +62,7 @@ class CallStatsModel: ObservableObject { 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) { + if stats.uploadBandwidth.rounded().isNaN || stats.uploadBandwidth.rounded().isInfinite || stats.downloadBandwidth.rounded().isNaN || stats.downloadBandwidth.rounded().isInfinite { return } From ad58d80939501e98a08fe0633f60c24a88b62a0e Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Tue, 7 May 2024 17:47:32 +0200 Subject: [PATCH 220/486] Fix when conversationsListTmp.first is nil --- .../Conversations/ViewModel/ConversationsListViewModel.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Linphone/UI/Main/Conversations/ViewModel/ConversationsListViewModel.swift b/Linphone/UI/Main/Conversations/ViewModel/ConversationsListViewModel.swift index 3f7cab4ad..c21a2d749 100644 --- a/Linphone/UI/Main/Conversations/ViewModel/ConversationsListViewModel.swift +++ b/Linphone/UI/Main/Conversations/ViewModel/ConversationsListViewModel.swift @@ -65,7 +65,9 @@ class ConversationsListViewModel: ObservableObject { } DispatchQueue.main.async { - self.conversationsList[0] = conversationsListTmp.first! + if conversationsListTmp.first != nil { + self.conversationsList[0] = conversationsListTmp.first! + } } } else { DispatchQueue.main.async { From eeb8c94c699953f0ec81528ece59f57a04779c4f Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Fri, 10 May 2024 09:54:55 +0200 Subject: [PATCH 221/486] Start and stop core in sync queue --- Linphone/Contacts/ContactsManager.swift | 4 +--- Linphone/Core/CoreContext.swift | 6 ++---- Linphone/LinphoneApp.swift | 1 + 3 files changed, 4 insertions(+), 7 deletions(-) diff --git a/Linphone/Contacts/ContactsManager.swift b/Linphone/Contacts/ContactsManager.swift index 0cbc6e925..a228dbb5b 100644 --- a/Linphone/Contacts/ContactsManager.swift +++ b/Linphone/Contacts/ContactsManager.swift @@ -41,9 +41,7 @@ final class ContactsManager: ObservableObject { private var friendListSuscription: AnyCancellable? - private init() { - fetchContacts() - } + private init() {} func fetchContacts() { coreContext.doOnCoreQueue { core in diff --git a/Linphone/Core/CoreContext.swift b/Linphone/Core/CoreContext.swift index 899f974d3..3a345aee7 100644 --- a/Linphone/Core/CoreContext.swift +++ b/Linphone/Core/CoreContext.swift @@ -247,8 +247,6 @@ final class CoreContext: ObservableObject { .sink { _ in self.mCore.iterate() } - - try? self.mCore.start() } } @@ -259,7 +257,7 @@ final class CoreContext: ObservableObject { } func onEnterForeground() { - coreQueue.async { + coreQueue.sync { // We can't rely on defaultAccount?.params?.isPublishEnabled // as it will be modified by the SDK when changing the presence status @@ -270,7 +268,7 @@ final class CoreContext: ObservableObject { } func onEnterBackground() { - coreQueue.async { + 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") diff --git a/Linphone/LinphoneApp.swift b/Linphone/LinphoneApp.swift index e0baf7f60..0b018c617 100644 --- a/Linphone/LinphoneApp.swift +++ b/Linphone/LinphoneApp.swift @@ -144,6 +144,7 @@ struct LinphoneApp: App { if newPhase == .active { Log.info("Entering foreground") coreContext.onEnterForeground() + ContactsManager.shared.fetchContacts() } else if newPhase == .inactive { } else if newPhase == .background { Log.info("Entering background") From cb8af8deeabf54a60b90d61be520b34f14de059c Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Mon, 13 May 2024 11:23:43 +0200 Subject: [PATCH 222/486] Fix image and gif size --- .../Fragments/ChatBubbleView.swift | 106 +++++++++--------- .../Fragments/ConversationsListFragment.swift | 20 ++-- 2 files changed, 60 insertions(+), 66 deletions(-) diff --git a/Linphone/UI/Main/Conversations/Fragments/ChatBubbleView.swift b/Linphone/UI/Main/Conversations/Fragments/ChatBubbleView.swift index 71d7d59bf..72efc4905 100644 --- a/Linphone/UI/Main/Conversations/Fragments/ChatBubbleView.swift +++ b/Linphone/UI/Main/Conversations/Fragments/ChatBubbleView.swift @@ -37,61 +37,7 @@ struct ChatBubbleView: View { VStack(alignment: message.isOutgoing ? .trailing : .leading) { if !message.attachments.isEmpty { - if message.attachments.count == 1 { - let result = imageDimensions(url: message.attachments.first!.full.absoluteString) - if message.attachments.first!.type == .image || message.attachments.first!.type == .gif { - if message.attachments.first!.type != .gif { - AsyncImage(url: message.attachments.first!.full) { image in - image.resizable() - .interpolation(.low) - .scaledToFit() - .clipShape(RoundedRectangle(cornerRadius: 4)) - } placeholder: { - ProgressView() - } - .frame( - height: result.0 > result.1 - ? result.1 / (result.0 / (geometryProxy.size.width - 80)) - : UIScreen.main.bounds.height > UIScreen.main.bounds.width ? UIScreen.main.bounds.height / 2.5 : UIScreen.main.bounds.width / 2.5 - ) - } else { - if result.0 < result.1 { - GifImageView(message.attachments.first!.full) - .clipShape(RoundedRectangle(cornerRadius: 4)) - .frame( - width: result.1 > (UIScreen.main.bounds.height > UIScreen.main.bounds.width ? UIScreen.main.bounds.height / 2.5 : UIScreen.main.bounds.width / 2.5) - ? result.0 / (result.1 / (UIScreen.main.bounds.height > UIScreen.main.bounds.width ? UIScreen.main.bounds.height / 2.5 : UIScreen.main.bounds.width / 2.5)) - : result.0, - height: result.1 > (UIScreen.main.bounds.height > UIScreen.main.bounds.width ? UIScreen.main.bounds.height / 2.5 : UIScreen.main.bounds.width / 2.5) - ? UIScreen.main.bounds.height > UIScreen.main.bounds.width ? UIScreen.main.bounds.height / 2.5 : UIScreen.main.bounds.width / 2.5 - : result.1 - ) - } else { - GifImageView(message.attachments.first!.full) - .clipShape(RoundedRectangle(cornerRadius: 4)) - .frame( - height: result.1 / (result.0 / (geometryProxy.size.width - 80)) - ) - } - } - } else { - let result = imageDimensions(url: message.attachments.first!.full.absoluteString) - AsyncImage(url: message.attachments.first!.full) { image in - image.resizable() - .interpolation(.low) - .scaledToFit() - .clipShape(RoundedRectangle(cornerRadius: 4)) - } placeholder: { - ProgressView() - } - .frame( - height: result.0 > result.1 - ? result.1 / (result.0 / (geometryProxy.size.width - 80)) - : UIScreen.main.bounds.height > UIScreen.main.bounds.width ? UIScreen.main.bounds.height / 2.5 : UIScreen.main.bounds.width / 2.5 - ) - } - } else { - } + messageAttachments() } if !message.text.isEmpty { @@ -113,6 +59,56 @@ struct ChatBubbleView: View { } } + @ViewBuilder + func messageAttachments() -> some View { + if message.attachments.count == 1 { + if message.attachments.first!.type == .image || message.attachments.first!.type == .gif { + let result = imageDimensions(url: message.attachments.first!.full.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 < geometryProxy.size.height/2) { view in + view.frame(maxHeight: result.1) + } + .if(result.0 >= result.1 && result.0 >= geometryProxy.size.width - 110 && result.1 >= geometryProxy.size.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 && result.1 >= geometryProxy.size.height/2.5) { view in + view.frame( + maxWidth: result.0 * ((geometryProxy.size.height/2.5) / result.1), + maxHeight: geometryProxy.size.height/2.5 + ) + } + + if message.attachments.first!.type == .image { + AsyncImage(url: message.attachments.first!.full) { image in + image + .resizable() + .interpolation(.medium) + .aspectRatio(contentMode: .fill) + } placeholder: { + ProgressView() + } + .layoutPriority(-1) + } else { + GifImageView(message.attachments.first!.full) + .layoutPriority(-1) + .clipShape(RoundedRectangle(cornerRadius: 4)) + } + } + .clipShape(RoundedRectangle(cornerRadius: 4)) + .clipped() + } + } + } + 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? { diff --git a/Linphone/UI/Main/Conversations/Fragments/ConversationsListFragment.swift b/Linphone/UI/Main/Conversations/Fragments/ConversationsListFragment.swift index a49b22f71..de7a9476b 100644 --- a/Linphone/UI/Main/Conversations/Fragments/ConversationsListFragment.swift +++ b/Linphone/UI/Main/Conversations/Fragments/ConversationsListFragment.swift @@ -134,18 +134,16 @@ struct ConversationsListFragment: View { .listRowSeparator(.hidden) .background(.white) .onTapGesture { - withAnimation { - if conversationViewModel.displayedConversation != nil { - conversationViewModel.displayedConversation = nil - conversationViewModel.resetMessage() - conversationViewModel.changeDisplayedChatRoom(conversationModel: conversationsListViewModel.conversationsList[index]) - conversationViewModel.getMessages() - } else { - conversationViewModel.changeDisplayedChatRoom(conversationModel: conversationsListViewModel.conversationsList[index]) - } - conversationsListViewModel.conversationsList[index].markAsRead() - conversationsListViewModel.updateUnreadMessagesCount() + if conversationViewModel.displayedConversation != nil { + conversationViewModel.displayedConversation = nil + conversationViewModel.resetMessage() + conversationViewModel.changeDisplayedChatRoom(conversationModel: conversationsListViewModel.conversationsList[index]) + conversationViewModel.getMessages() + } else { + conversationViewModel.changeDisplayedChatRoom(conversationModel: conversationsListViewModel.conversationsList[index]) } + conversationsListViewModel.conversationsList[index].markAsRead() + conversationsListViewModel.updateUnreadMessagesCount() } .onLongPressGesture(minimumDuration: 0.2) { conversationsListViewModel.selectedConversation = conversationsListViewModel.conversationsList[index] From b46c2ef77859810088e5f2e46b8484b43d225009 Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Mon, 13 May 2024 11:40:15 +0200 Subject: [PATCH 223/486] Enable Remote Push --- Linphone/LinphoneApp.swift | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/Linphone/LinphoneApp.swift b/Linphone/LinphoneApp.swift index 0b018c617..2082758b3 100644 --- a/Linphone/LinphoneApp.swift +++ b/Linphone/LinphoneApp.swift @@ -29,8 +29,8 @@ class AppDelegate: NSObject, UIApplicationDelegate { 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") + Log.info("Forwarding remote push token to core") + core.didRegisterForRemotePushWithStringifiedToken(deviceTokenStr: tokenStr + ":remote") } } @@ -40,12 +40,11 @@ class AppDelegate: NSObject, UIApplicationDelegate { 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)*/ + completionHandler(UIBackgroundFetchResult.newData) } func applicationWillTerminate(_ application: UIApplication) { From 14daf5bd40fb6a0e5074c1f3758ce4c1c940f520 Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Mon, 13 May 2024 17:08:51 +0200 Subject: [PATCH 224/486] Fix message list animation --- .../Fragments/ConversationFragment.swift | 2 + .../Fragments/ConversationsListFragment.swift | 39 ++++++++++++++----- .../ViewModel/ConversationViewModel.swift | 32 +++++---------- 3 files changed, 41 insertions(+), 32 deletions(-) diff --git a/Linphone/UI/Main/Conversations/Fragments/ConversationFragment.swift b/Linphone/UI/Main/Conversations/Fragments/ConversationFragment.swift index 79ac61416..c8bd4af36 100644 --- a/Linphone/UI/Main/Conversations/Fragments/ConversationFragment.swift +++ b/Linphone/UI/Main/Conversations/Fragments/ConversationFragment.swift @@ -217,6 +217,7 @@ struct ConversationFragment: View { conversationViewModel.resetMessage() } } else { + /* ScrollViewReader { proxy in List { ForEach(0..() - @Published var conversationMessagesList: [LinphoneCustomEventLog] = [] - @Published var conversationMessagesSection: [MessagesSection] = [] @Published var conversationMessagesIds: [String] = [] + @Published var conversationMessagesSection: [MessagesSection] = [] init() {} @@ -97,13 +96,10 @@ class ConversationViewModel: ObservableObject { self.getUnreadMessagesCount() coreContext.doOnCoreQueue { _ in if self.displayedConversation != nil { - let historyEvents = self.displayedConversation!.chatRoom.getHistoryRangeEvents(begin: self.conversationMessagesList.count, end: self.conversationMessagesList.count + 30) + let historyEvents = self.displayedConversation!.chatRoom.getHistoryRangeEvents(begin: 0, end: 30) var conversationMessage: [Message] = [] historyEvents.enumerated().forEach { index, eventLog in - DispatchQueue.main.async { - self.conversationMessagesList.append(LinphoneCustomEventLog(eventLog: eventLog)) - } var attachmentList: [Attachment] = [] var contentText = "" @@ -130,12 +126,11 @@ class ConversationViewModel: ObservableObject { isOutgoing: eventLog.chatMessage?.isOutgoing ?? false, text: contentText, attachments: attachmentList)) - - DispatchQueue.main.async { - if index == historyEvents.count - 1 { - self.conversationMessagesSection.append(MessagesSection(date: Date(), rows: conversationMessage.reversed())) - self.conversationMessagesIds.append(UUID().uuidString) - } + } + + DispatchQueue.main.async { + if self.conversationMessagesSection.isEmpty { + self.conversationMessagesSection.append(MessagesSection(date: Date(), rows: conversationMessage.reversed())) } } } @@ -145,14 +140,11 @@ class ConversationViewModel: ObservableObject { func getOldMessages() { coreContext.doOnCoreQueue { _ in if self.displayedConversation != nil { - let historyEvents = self.displayedConversation!.chatRoom.getHistoryRangeEvents(begin: self.conversationMessagesList.count, end: self.conversationMessagesList.count + 30) - var conversationMessagesListTmp: [LinphoneCustomEventLog] = [] + let historyEvents = self.displayedConversation!.chatRoom.getHistoryRangeEvents(begin: self.conversationMessagesSection[0].rows.count, end: self.conversationMessagesSection[0].rows.count + 30) var conversationMessagesTmp: [Message] = [] historyEvents.reversed().forEach { eventLog in - conversationMessagesListTmp.insert(LinphoneCustomEventLog(eventLog: eventLog), at: 0) - - var attachmentList: [Attachment] = [] + let attachmentList: [Attachment] = [] var contentText = "" if eventLog.chatMessage != nil && !eventLog.chatMessage!.contents.isEmpty { @@ -175,7 +167,6 @@ class ConversationViewModel: ObservableObject { if !conversationMessagesTmp.isEmpty { DispatchQueue.main.async { - self.conversationMessagesList.insert(contentsOf: conversationMessagesListTmp, at: 0) self.conversationMessagesSection[0].rows.append(contentsOf: conversationMessagesTmp.reversed()) } } @@ -186,10 +177,6 @@ class ConversationViewModel: ObservableObject { func getNewMessages(eventLogs: [EventLog]) { var conversationMessage: [Message] = [] eventLogs.enumerated().forEach { index, eventLog in - DispatchQueue.main.async { - self.conversationMessagesList.append(LinphoneCustomEventLog(eventLog: eventLog)) - } - var attachmentList: [Attachment] = [] var contentText = "" @@ -230,7 +217,6 @@ class ConversationViewModel: ObservableObject { } func resetMessage() { - conversationMessagesList = [] conversationMessagesSection = [] } From 5beb5c088c221e01b3ff29f753b804b5fc2b5014 Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Tue, 14 May 2024 10:32:24 +0200 Subject: [PATCH 225/486] Joining the waiting room when calling a conference --- Linphone/TelecomManager/TelecomManager.swift | 15 +++++++++++ .../ContactInnerActionsFragment.swift | 4 +-- .../Fragments/ContactInnerFragment.swift | 4 +-- .../Fragments/ChatBubbleView.swift | 4 +-- .../Model/ConversationModel.swift | 4 +-- .../History/Fragments/DialerBottomSheet.swift | 2 +- .../Fragments/HistoryContactFragment.swift | 24 +++++------------ .../Fragments/HistoryListFragment.swift | 26 ++----------------- .../History/Fragments/StartCallFragment.swift | 6 ++--- 9 files changed, 32 insertions(+), 57 deletions(-) diff --git a/Linphone/TelecomManager/TelecomManager.swift b/Linphone/TelecomManager/TelecomManager.swift index 7a353d160..b730cae1c 100644 --- a/Linphone/TelecomManager/TelecomManager.swift +++ b/Linphone/TelecomManager/TelecomManager.swift @@ -162,6 +162,21 @@ class TelecomManager: ObservableObject { } } + 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()) + + meetingWaitingRoomDisplayed = true + 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 { diff --git a/Linphone/UI/Main/Contacts/Fragments/ContactInnerActionsFragment.swift b/Linphone/UI/Main/Contacts/Fragments/ContactInnerActionsFragment.swift index 9d50cfc1e..7a3d96ce9 100644 --- a/Linphone/UI/Main/Contacts/Fragments/ContactInnerActionsFragment.swift +++ b/Linphone/UI/Main/Contacts/Fragments/ContactInnerActionsFragment.swift @@ -93,9 +93,7 @@ struct ContactInnerActionsFragment: View { .background(.white) .onTapGesture { withAnimation { - telecomManager.doCallWithCore( - addr: contactsManager.lastSearch[contactViewModel.indexDisplayedFriend!].friend!.addresses[index], isVideo: false, isConference: false - ) + telecomManager.doCallOrJoinConf(address: contactsManager.lastSearch[contactViewModel.indexDisplayedFriend!].friend!.addresses[index]) } } .onLongPressGesture(minimumDuration: 0.2) { diff --git a/Linphone/UI/Main/Contacts/Fragments/ContactInnerFragment.swift b/Linphone/UI/Main/Contacts/Fragments/ContactInnerFragment.swift index 7974d0a88..47748c66f 100644 --- a/Linphone/UI/Main/Contacts/Fragments/ContactInnerFragment.swift +++ b/Linphone/UI/Main/Contacts/Fragments/ContactInnerFragment.swift @@ -158,7 +158,7 @@ struct ContactInnerFragment: View { Spacer() Button(action: { - telecomManager.doCallWithCore(addr: contactsManager.lastSearch[contactViewModel.indexDisplayedFriend!].friend!.address!, isVideo: false, isConference: false) + telecomManager.doCallOrJoinConf(address: contactsManager.lastSearch[contactViewModel.indexDisplayedFriend!].friend!.address!) }, label: { VStack { HStack(alignment: .center) { @@ -208,7 +208,7 @@ struct ContactInnerFragment: View { Spacer() Button(action: { - telecomManager.doCallWithCore(addr: contactsManager.lastSearch[contactViewModel.indexDisplayedFriend!].friend!.address!, isVideo: true, isConference: false) + telecomManager.doCallOrJoinConf(address: contactsManager.lastSearch[contactViewModel.indexDisplayedFriend!].friend!.address!, isVideo: true) }, label: { VStack { HStack(alignment: .center) { diff --git a/Linphone/UI/Main/Conversations/Fragments/ChatBubbleView.swift b/Linphone/UI/Main/Conversations/Fragments/ChatBubbleView.swift index 72efc4905..9622af92b 100644 --- a/Linphone/UI/Main/Conversations/Fragments/ChatBubbleView.swift +++ b/Linphone/UI/Main/Conversations/Fragments/ChatBubbleView.swift @@ -74,13 +74,13 @@ struct ChatBubbleView: View { .if(result.1 < geometryProxy.size.height/2) { view in view.frame(maxHeight: result.1) } - .if(result.0 >= result.1 && result.0 >= geometryProxy.size.width - 110 && result.1 >= geometryProxy.size.height/2.5) { view in + .if(result.0 >= result.1 && geometryProxy.size.width > 0 && result.0 >= geometryProxy.size.width - 110 && result.1 >= geometryProxy.size.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 && result.1 >= geometryProxy.size.height/2.5) { view in + .if(result.0 < result.1 && geometryProxy.size.width > 0 && result.1 >= geometryProxy.size.height/2.5) { view in view.frame( maxWidth: result.0 * ((geometryProxy.size.height/2.5) / result.1), maxHeight: geometryProxy.size.height/2.5 diff --git a/Linphone/UI/Main/Conversations/Model/ConversationModel.swift b/Linphone/UI/Main/Conversations/Model/ConversationModel.swift index 35aa5dd28..8fdc88ef4 100644 --- a/Linphone/UI/Main/Conversations/Model/ConversationModel.swift +++ b/Linphone/UI/Main/Conversations/Model/ConversationModel.swift @@ -128,9 +128,7 @@ class ConversationModel: ObservableObject { func call() { coreContext.doOnCoreQueue { _ in if self.chatRoom.peerAddress != nil { - TelecomManager.shared.doCallWithCore( - addr: self.chatRoom.peerAddress!, isVideo: false, isConference: false - ) + TelecomManager.shared.doCallOrJoinConf(address: self.chatRoom.peerAddress!) } } } diff --git a/Linphone/UI/Main/History/Fragments/DialerBottomSheet.swift b/Linphone/UI/Main/History/Fragments/DialerBottomSheet.swift index c07b8a3a1..7a5a3bda9 100644 --- a/Linphone/UI/Main/History/Fragments/DialerBottomSheet.swift +++ b/Linphone/UI/Main/History/Fragments/DialerBottomSheet.swift @@ -451,7 +451,7 @@ struct DialerBottomSheet: View { if !startCallViewModel.searchField.isEmpty { do { let address = try Factory.Instance.createAddress(addr: String("sip:" + startCallViewModel.searchField + "@" + startCallViewModel.domain)) - telecomManager.doCallWithCore(addr: address, isVideo: false, isConference: false) + telecomManager.doCallOrJoinConf(address: address) } catch { } diff --git a/Linphone/UI/Main/History/Fragments/HistoryContactFragment.swift b/Linphone/UI/Main/History/Fragments/HistoryContactFragment.swift index ed3a6b0fb..1121b86e0 100644 --- a/Linphone/UI/Main/History/Fragments/HistoryContactFragment.swift +++ b/Linphone/UI/Main/History/Fragments/HistoryContactFragment.swift @@ -413,13 +413,9 @@ struct HistoryContactFragment: View { if historyViewModel.displayedCallIsConference.isEmpty { Button(action: { if historyViewModel.displayedCall!.dir == .Outgoing && historyViewModel.displayedCall!.toAddress != nil { - telecomManager.doCallWithCore( - addr: historyViewModel.displayedCall!.toAddress!, isVideo: false, isConference: false - ) + telecomManager.doCallOrJoinConf(address: historyViewModel.displayedCall!.toAddress!) } else if historyViewModel.displayedCall!.dir == .Incoming && historyViewModel.displayedCall!.fromAddress != nil { - telecomManager.doCallWithCore( - addr: historyViewModel.displayedCall!.fromAddress!, isVideo: false, isConference: false - ) + telecomManager.doCallOrJoinConf(address: historyViewModel.displayedCall!.fromAddress!) } }, label: { VStack { @@ -473,13 +469,9 @@ struct HistoryContactFragment: View { Button(action: { if historyViewModel.displayedCall!.dir == .Outgoing && historyViewModel.displayedCall!.toAddress != nil { - telecomManager.doCallWithCore( - addr: historyViewModel.displayedCall!.toAddress!, isVideo: true, isConference: false - ) + telecomManager.doCallOrJoinConf(address: historyViewModel.displayedCall!.toAddress!, isVideo: true) } else if historyViewModel.displayedCall!.dir == .Incoming && historyViewModel.displayedCall!.fromAddress != nil { - telecomManager.doCallWithCore( - addr: historyViewModel.displayedCall!.fromAddress!, isVideo: true, isConference: false - ) + telecomManager.doCallOrJoinConf(address: historyViewModel.displayedCall!.fromAddress!, isVideo: true) } }, label: { VStack { @@ -511,9 +503,7 @@ struct HistoryContactFragment: View { telecomManager.meetingWaitingRoomSelected = meetingAddress } catch {} } else { - telecomManager.doCallWithCore( - addr: historyViewModel.displayedCall!.toAddress!, isVideo: false, isConference: false - ) + telecomManager.doCallOrJoinConf(address: historyViewModel.displayedCall!.toAddress!) } } else if historyViewModel.displayedCall!.fromAddress != nil { if historyViewModel.displayedCall!.fromAddress!.asStringUriOnly().hasPrefix("sip:conference-focus@sip.linphone.org") { @@ -524,9 +514,7 @@ struct HistoryContactFragment: View { telecomManager.meetingWaitingRoomSelected = meetingAddress } catch {} } else { - telecomManager.doCallWithCore( - addr: historyViewModel.displayedCall!.fromAddress!, isVideo: false, isConference: false - ) + telecomManager.doCallOrJoinConf(address: historyViewModel.displayedCall!.fromAddress!) } } } diff --git a/Linphone/UI/Main/History/Fragments/HistoryListFragment.swift b/Linphone/UI/Main/History/Fragments/HistoryListFragment.swift index cae8fa86b..f101a6802 100644 --- a/Linphone/UI/Main/History/Fragments/HistoryListFragment.swift +++ b/Linphone/UI/Main/History/Fragments/HistoryListFragment.swift @@ -257,31 +257,9 @@ struct HistoryListFragment: View { func doCall(index: Int) { if historyListViewModel.callLogs[index].dir == .Outgoing && historyListViewModel.callLogs[index].toAddress != nil { - if historyListViewModel.callLogs[index].toAddress!.asStringUriOnly().hasPrefix("sip:conference-focus@sip.linphone.org") { - do { - let meetingAddress = try Factory.Instance.createAddress(addr: historyListViewModel.callLogs[index].toAddress!.asStringUriOnly()) - - telecomManager.meetingWaitingRoomDisplayed = true - telecomManager.meetingWaitingRoomSelected = meetingAddress - } catch {} - } else { - telecomManager.doCallWithCore( - addr: historyListViewModel.callLogs[index].toAddress!, isVideo: false, isConference: false - ) - } + telecomManager.doCallOrJoinConf(address: historyListViewModel.callLogs[index].toAddress!) } else if historyListViewModel.callLogs[index].fromAddress != nil { - if historyListViewModel.callLogs[index].fromAddress!.asStringUriOnly().hasPrefix("sip:conference-focus@sip.linphone.org") { - do { - let meetingAddress = try Factory.Instance.createAddress(addr: historyListViewModel.callLogs[index].fromAddress!.asStringUriOnly()) - - telecomManager.meetingWaitingRoomDisplayed = true - telecomManager.meetingWaitingRoomSelected = meetingAddress - } catch {} - } else { - telecomManager.doCallWithCore( - addr: historyListViewModel.callLogs[index].fromAddress!, isVideo: false, isConference: false - ) - } + telecomManager.doCallOrJoinConf(address: historyListViewModel.callLogs[index].fromAddress!) } } } diff --git a/Linphone/UI/Main/History/Fragments/StartCallFragment.swift b/Linphone/UI/Main/History/Fragments/StartCallFragment.swift index 37a5f2a4f..d6c465356 100644 --- a/Linphone/UI/Main/History/Fragments/StartCallFragment.swift +++ b/Linphone/UI/Main/History/Fragments/StartCallFragment.swift @@ -219,7 +219,7 @@ struct StartCallFragment: View { withAnimation { isShowStartCallFragment.toggle() - telecomManager.doCallWithCore(addr: addr, isVideo: false, isConference: false) + telecomManager.doCallOrJoinConf(address: addr) } } }) @@ -304,9 +304,7 @@ struct StartCallFragment: View { withAnimation { isShowStartCallFragment.toggle() if contactsManager.lastSearchSuggestions[index].address != nil { - telecomManager.doCallWithCore( - addr: contactsManager.lastSearchSuggestions[index].address!, isVideo: false, isConference: false - ) + telecomManager.doCallOrJoinConf(address: contactsManager.lastSearchSuggestions[index].address!) } } } From d7a761561607b1ba4d78f6a00f27e98a11e2215e Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Wed, 15 May 2024 10:07:19 +0200 Subject: [PATCH 226/486] FIx avatar in conversation list --- Linphone/Contacts/ContactsManager.swift | 9 ++++---- .../Model/ConversationModel.swift | 23 ++++++++----------- .../ViewModel/ConversationViewModel.swift | 10 ++++++-- 3 files changed, 21 insertions(+), 21 deletions(-) diff --git a/Linphone/Contacts/ContactsManager.swift b/Linphone/Contacts/ContactsManager.swift index a228dbb5b..c81f8c0dd 100644 --- a/Linphone/Contacts/ContactsManager.swift +++ b/Linphone/Contacts/ContactsManager.swift @@ -306,13 +306,12 @@ final class ContactsManager: ObservableObject { let clonedAddress = address.clone() clonedAddress!.clean() let sipUri = clonedAddress!.asStringUriOnly() + if friendList != nil { var friend: Friend? - self.coreContext.doOnCoreQueue { _ in - friend = self.friendList!.friends.first(where: {$0.addresses.contains(where: {$0.asStringUriOnly() == sipUri})}) - if friend == nil { - friend = self.linphoneFriendList!.friends.first(where: {$0.addresses.contains(where: {$0.asStringUriOnly() == sipUri})}) - } + friend = self.friendList!.friends.first(where: {$0.addresses.contains(where: {$0.asStringUriOnly() == sipUri})}) + if friend == nil { + friend = self.linphoneFriendList!.friends.first(where: {$0.addresses.contains(where: {$0.asStringUriOnly() == sipUri})}) } return friend diff --git a/Linphone/UI/Main/Conversations/Model/ConversationModel.swift b/Linphone/UI/Main/Conversations/Model/ConversationModel.swift index 8fdc88ef4..0311c4e8e 100644 --- a/Linphone/UI/Main/Conversations/Model/ConversationModel.swift +++ b/Linphone/UI/Main/Conversations/Model/ConversationModel.swift @@ -221,19 +221,15 @@ class ConversationModel: ObservableObject { func refreshAvatarModel() { 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 - - if addressFriend != nil && !self.isGroup { - let avatarModelTmp = ContactsManager.shared.avatarListModel.first(where: { - $0.friend!.name == addressFriend!.name - && $0.friend!.address!.asStringUriOnly() == addressFriend!.address!.asStringUriOnly() - }) ?? ContactAvatarModel(friend: nil, name: self.subject, withPresence: false) - - DispatchQueue.main.async { - self.avatarModel = avatarModelTmp + if !self.isGroup { + if self.chatRoom.participants.first != nil && self.chatRoom.participants.first!.address != nil { + let avatarModelTmp = ContactAvatarModel.getAvatarModelFromAddress(address: self.chatRoom.participants.first!.address!) + let subjectTmp = avatarModelTmp.name + + DispatchQueue.main.async { + self.avatarModel = avatarModelTmp + self.subject = subjectTmp + } } } } @@ -242,7 +238,6 @@ class ConversationModel: ObservableObject { func downloadContent(chatMessage: ChatMessage, content: Content) { coreContext.doOnCoreQueue { _ in let result = chatMessage.downloadContent(content: content) - print("resultresult download \(result)") } } diff --git a/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift b/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift index 02d7fdd6c..20fd96d87 100644 --- a/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift +++ b/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift @@ -183,10 +183,16 @@ class ConversationViewModel: ObservableObject { if eventLog.chatMessage != nil && !eventLog.chatMessage!.contents.isEmpty { eventLog.chatMessage!.contents.forEach { content in if content.isText { - print("contentscontents text") contentText = content.utf8Text ?? "" } else { - print("contentscontents \(content.isText)") + if content.filePath == nil || content.filePath!.isEmpty { + self.downloadContent(chatMessage: eventLog.chatMessage!, content: content) + } else { + if URL(string: self.getNewFilePath(name: content.name ?? "")) != nil { + let attachment = Attachment(id: UUID().uuidString, url: URL(string: self.getNewFilePath(name: content.name ?? ""))!, type: (content.name?.lowercased().hasSuffix("gif"))! ? .gif : .image) + attachmentList.append(attachment) + } + } } } } From a011e7643b8749105f4d34ffa8993cf204414998 Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Wed, 15 May 2024 17:26:15 +0200 Subject: [PATCH 227/486] Add avatar to group chat Check first message received per participant --- Linphone/UI/Call/CallView.swift | 4 +- .../UI/Call/Fragments/CallsListFragment.swift | 2 +- .../MeetingWaitingRoomViewModel.swift | 6 +- .../Fragments/ContactInnerFragment.swift | 2 +- .../Fragments/EditContactFragment.swift | 7 +- .../Contacts/Model/ContactAvatarModel.swift | 9 +- Linphone/UI/Main/ContentView.swift | 2 +- .../Fragments/ChatBubbleView.swift | 116 ++++++++++++++---- .../Model/ConversationModel.swift | 18 ++- .../UI/Main/Conversations/Model/Message.swift | 26 +++- .../ViewModel/ConversationViewModel.swift | 104 ++++++++++++++-- .../Fragments/HistoryContactFragment.swift | 2 +- .../Fragments/HistoryListFragment.swift | 2 +- Linphone/Utils/MagicSearchSingleton.swift | 9 +- 14 files changed, 261 insertions(+), 48 deletions(-) diff --git a/Linphone/UI/Call/CallView.swift b/Linphone/UI/Call/CallView.swift index 47614097b..5d89945f1 100644 --- a/Linphone/UI/Call/CallView.swift +++ b/Linphone/UI/Call/CallView.swift @@ -377,7 +377,7 @@ struct CallView: View { && $0.friend!.name == addressFriend!.name && $0.friend!.address!.asStringUriOnly() == addressFriend!.address!.asStringUriOnly() }) - : ContactAvatarModel(friend: nil, name: "", withPresence: false) + : ContactAvatarModel(friend: nil, name: "", address: "", withPresence: false) if addressFriend != nil && addressFriend!.photo != nil && !addressFriend!.photo!.isEmpty { if contactAvatarModel != nil { @@ -732,7 +732,7 @@ struct CallView: View { && $0.friend!.name == addressFriend!.name && $0.friend!.address!.asStringUriOnly() == addressFriend!.address!.asStringUriOnly() }) - : ContactAvatarModel(friend: nil, name: "", withPresence: false) + : ContactAvatarModel(friend: nil, name: "", address: "", withPresence: false) if addressFriend != nil && addressFriend!.photo != nil && !addressFriend!.photo!.isEmpty { if contactAvatarModel != nil { diff --git a/Linphone/UI/Call/Fragments/CallsListFragment.swift b/Linphone/UI/Call/Fragments/CallsListFragment.swift index 83abbd81e..d80560e0c 100644 --- a/Linphone/UI/Call/Fragments/CallsListFragment.swift +++ b/Linphone/UI/Call/Fragments/CallsListFragment.swift @@ -226,7 +226,7 @@ struct CallsListFragment: View { && $0.friend!.name == addressFriend!.name && $0.friend!.address!.asStringUriOnly() == addressFriend!.address!.asStringUriOnly() }) - : ContactAvatarModel(friend: nil, name: "", withPresence: false) + : ContactAvatarModel(friend: nil, name: "", address: "", withPresence: false) if addressFriend != nil && addressFriend!.photo != nil && !addressFriend!.photo!.isEmpty { if contactAvatarModel != nil { diff --git a/Linphone/UI/Call/ViewModel/MeetingWaitingRoomViewModel.swift b/Linphone/UI/Call/ViewModel/MeetingWaitingRoomViewModel.swift index 080d9897e..681096d73 100644 --- a/Linphone/UI/Call/ViewModel/MeetingWaitingRoomViewModel.swift +++ b/Linphone/UI/Call/ViewModel/MeetingWaitingRoomViewModel.swift @@ -68,6 +68,8 @@ class MeetingWaitingRoomViewModel: ObservableObject { ? 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 { @@ -82,8 +84,8 @@ class MeetingWaitingRoomViewModel: ObservableObject { ? ContactsManager.shared.avatarListModel.first(where: { $0.friend!.name == friend!.name && $0.friend!.address!.asStringUriOnly() == core.defaultAccount!.contactAddress!.asStringUriOnly() - }) ?? ContactAvatarModel(friend: nil, name: userNameTmp, withPresence: false) - : ContactAvatarModel(friend: nil, name: userNameTmp, withPresence: false) + }) ?? 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) { diff --git a/Linphone/UI/Main/Contacts/Fragments/ContactInnerFragment.swift b/Linphone/UI/Main/Contacts/Fragments/ContactInnerFragment.swift index 47748c66f..87457c6c2 100644 --- a/Linphone/UI/Main/Contacts/Fragments/ContactInnerFragment.swift +++ b/Linphone/UI/Main/Contacts/Fragments/ContactInnerFragment.swift @@ -286,7 +286,7 @@ struct ContactInnerFragment: View { #Preview { ContactInnerFragment( - contactAvatarModel: ContactAvatarModel(friend: nil, name: "", withPresence: true), + contactAvatarModel: ContactAvatarModel(friend: nil, name: "", address: "", withPresence: true), contactViewModel: ContactViewModel(), editContactViewModel: EditContactViewModel(), isShowDeletePopup: .constant(false), diff --git a/Linphone/UI/Main/Contacts/Fragments/EditContactFragment.swift b/Linphone/UI/Main/Contacts/Fragments/EditContactFragment.swift index fdb38f566..3af2c475b 100644 --- a/Linphone/UI/Main/Contacts/Fragments/EditContactFragment.swift +++ b/Linphone/UI/Main/Contacts/Fragments/EditContactFragment.swift @@ -155,7 +155,12 @@ struct EditContactFragment: View { && !editContactViewModel.selectedEditFriend!.photo!.isEmpty && selectedImage == nil && !removedImage { Avatar(contactAvatarModel: - ContactAvatarModel(friend: editContactViewModel.selectedEditFriend!, name: editContactViewModel.selectedEditFriend?.name ?? "", withPresence: false), avatarSize: 100 + ContactAvatarModel( + friend: editContactViewModel.selectedEditFriend!, + name: editContactViewModel.selectedEditFriend?.name ?? "", + address: editContactViewModel.selectedEditFriend?.address?.asStringUriOnly() ?? "", + withPresence: false + ), avatarSize: 100 ) } else if selectedImage == nil { diff --git a/Linphone/UI/Main/Contacts/Model/ContactAvatarModel.swift b/Linphone/UI/Main/Contacts/Model/ContactAvatarModel.swift index 9f7ba90c0..e7b1d297b 100644 --- a/Linphone/UI/Main/Contacts/Model/ContactAvatarModel.swift +++ b/Linphone/UI/Main/Contacts/Model/ContactAvatarModel.swift @@ -27,6 +27,8 @@ class ContactAvatarModel: ObservableObject { let name: String + let address: String + let withPresence: Bool? @Published var lastPresenceInfo: String @@ -35,9 +37,10 @@ class ContactAvatarModel: ObservableObject { private var friendSuscription: AnyCancellable? - init(friend: Friend?, name: String, withPresence: Bool?) { + init(friend: Friend?, name: String, address: String, withPresence: Bool?) { self.friend = friend self.name = name + self.address = address self.withPresence = withPresence if friend != nil && withPresence == true { @@ -122,12 +125,12 @@ class ContactAvatarModel: ObservableObject { }) if avatarModel == nil { - avatarModel = ContactAvatarModel(friend: nil, name: addressFriend.name!, withPresence: false) + avatarModel = ContactAvatarModel(friend: nil, name: addressFriend.name!, address: address.asStringUriOnly(), withPresence: false) } return avatarModel! } else { let name = address.displayName != nil ? address.displayName! : address.username! - return ContactAvatarModel(friend: nil, name: name, withPresence: false) + return ContactAvatarModel(friend: nil, name: name, address: address.asStringUriOnly(), withPresence: false) } } } diff --git a/Linphone/UI/Main/ContentView.swift b/Linphone/UI/Main/ContentView.swift index 1bac741d9..259851373 100644 --- a/Linphone/UI/Main/ContentView.swift +++ b/Linphone/UI/Main/ContentView.swift @@ -728,7 +728,7 @@ struct ContentView: View { && $0.friend!.name == addressFriend!.name && $0.friend!.address!.asStringUriOnly() == addressFriend!.address!.asStringUriOnly() }) - : ContactAvatarModel(friend: nil, name: "", withPresence: false) + : ContactAvatarModel(friend: nil, name: "", address: "", withPresence: false) if contactAvatarModel != nil { HistoryContactFragment( diff --git a/Linphone/UI/Main/Conversations/Fragments/ChatBubbleView.swift b/Linphone/UI/Main/Conversations/Fragments/ChatBubbleView.swift index 9622af92b..b8697a70b 100644 --- a/Linphone/UI/Main/Conversations/Fragments/ChatBubbleView.swift +++ b/Linphone/UI/Main/Conversations/Fragments/ChatBubbleView.swift @@ -30,32 +30,106 @@ struct ChatBubbleView: View { var body: some View { VStack { - HStack { - if message.isOutgoing { - Spacer() - } - - VStack(alignment: message.isOutgoing ? .trailing : .leading) { - if !message.attachments.isEmpty { - messageAttachments() + if !message.text.isEmpty || !message.attachments.isEmpty { + HStack { + if message.isOutgoing { + Spacer() } - if !message.text.isEmpty { - Text(message.text) - .foregroundStyle(Color.grayMain2c700) - .default_text_style(styleSize: 16) + if conversationViewModel.displayedConversation != nil && conversationViewModel.displayedConversation!.isGroup && !message.isOutgoing && message.isFirstMessage { + VStack { + Avatar( + contactAvatarModel: conversationViewModel.participantConversationModel.first(where: {$0.address == message.address}) ?? + ContactAvatarModel(friend: nil, name: "??", address: "", withPresence: false), + avatarSize: 35 + ) + .padding(.top, 30) + + Spacer() + } + } else if conversationViewModel.displayedConversation != nil && conversationViewModel.displayedConversation!.isGroup && !message.isOutgoing { + VStack { + Avatar( + contactAvatarModel: ContactAvatarModel(friend: nil, name: "??", address: "", withPresence: false), + avatarSize: 35 + ) + + Spacer() + } + .hidden() + } + + VStack(alignment: .leading, spacing: 0) { + if conversationViewModel.displayedConversation != nil && conversationViewModel.displayedConversation!.isGroup && !message.isOutgoing && message.isFirstMessage { + Text(conversationViewModel.participantConversationModel.first(where: {$0.address == message.address})?.name ?? "") + .default_text_style(styleSize: 12) + .padding(.top, 10) + .padding(.bottom, 2) + } + ZStack { + if conversationViewModel.displayedConversation != nil && conversationViewModel.displayedConversation!.isGroup && message.isFirstMessage { + VStack { + if message.isOutgoing { + Spacer() + } + + HStack { + if message.isOutgoing { + Spacer() + } + + VStack { + } + .frame(width: 15, height: 15) + .background(message.isOutgoing ? Color.orangeMain100 : Color.grayMain2c100) + .clipShape(RoundedRectangle(cornerRadius: 2)) + + if !message.isOutgoing { + Spacer() + } + } + + if !message.isOutgoing { + Spacer() + } + } + } + + HStack { + if message.isOutgoing { + Spacer() + } + + VStack(alignment: message.isOutgoing ? .trailing : .leading) { + if !message.attachments.isEmpty { + messageAttachments() + } + + if !message.text.isEmpty { + Text(message.text) + .foregroundStyle(Color.grayMain2c700) + .default_text_style(styleSize: 16) + } + } + .padding(.all, 15) + .background(message.isOutgoing ? Color.orangeMain100 : Color.grayMain2c100) + .clipShape(RoundedRectangle(cornerRadius: 16)) + + if !message.isOutgoing { + Spacer() + } + } + } + .frame(maxWidth: .infinity) + } + + if !message.isOutgoing { + Spacer() } } - .padding(.all, 15) - .background(message.isOutgoing ? Color.orangeMain100 : Color.grayMain2c100) - .clipShape(RoundedRectangle(cornerRadius: 16)) - - if !message.isOutgoing { - Spacer() - } + .padding(.leading, message.isOutgoing ? 40 : 0) + .padding(.trailing, !message.isOutgoing ? 40 : 0) } - .padding(.leading, message.isOutgoing ? 40 : 0) - .padding(.trailing, !message.isOutgoing ? 40 : 0) } } diff --git a/Linphone/UI/Main/Conversations/Model/ConversationModel.swift b/Linphone/UI/Main/Conversations/Model/ConversationModel.swift index 0311c4e8e..24544bb50 100644 --- a/Linphone/UI/Main/Conversations/Model/ConversationModel.swift +++ b/Linphone/UI/Main/Conversations/Model/ConversationModel.swift @@ -90,7 +90,7 @@ class ConversationModel: ObservableObject { self.unreadMessagesCount = 0 - self.avatarModel = ContactAvatarModel(friend: nil, name: "", withPresence: false) + self.avatarModel = ContactAvatarModel(friend: nil, name: "", address: "", withPresence: false) //self.isBeingDeleted = MutableLiveData() @@ -199,13 +199,25 @@ class ConversationModel: ObservableObject { } } + 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: self.subject, withPresence: false) - : ContactAvatarModel(friend: nil, name: self.subject, withPresence: false) + ?? ContactAvatarModel( + friend: nil, + name: self.subject, + address: addressTmp, + withPresence: false + ) + : ContactAvatarModel( + friend: nil, + name: self.subject, + address: addressTmp, + withPresence: false + ) DispatchQueue.main.async { self.avatarModel = avatarModelTmp diff --git a/Linphone/UI/Main/Conversations/Model/Message.swift b/Linphone/UI/Main/Conversations/Model/Message.swift index ecc65b40f..7ed606a4b 100644 --- a/Linphone/UI/Main/Conversations/Model/Message.swift +++ b/Linphone/UI/Main/Conversations/Model/Message.swift @@ -61,6 +61,8 @@ public struct Message: Identifiable, Hashable { public var createdAt: Date public var isOutgoing: Bool + public var address: String + public var isFirstMessage: Bool public var text: String public var attachments: [Attachment] public var recording: Recording? @@ -71,6 +73,8 @@ public struct Message: Identifiable, Hashable { status: Status? = nil, createdAt: Date = Date(), isOutgoing: Bool, + address: String, + isFirstMessage: Bool = false, text: String = "", attachments: [Attachment] = [], recording: Recording? = nil, @@ -80,6 +84,8 @@ public struct Message: Identifiable, Hashable { self.status = status self.createdAt = createdAt self.isOutgoing = isOutgoing + self.isFirstMessage = isFirstMessage + self.address = address self.text = text self.attachments = attachments self.recording = recording @@ -111,6 +117,8 @@ public struct Message: Identifiable, Hashable { status: status, createdAt: draft.createdAt, isOutgoing: draft.isOutgoing, + address: draft.address, + isFirstMessage: draft.isFirstMessage, text: draft.text, attachments: attachments, recording: draft.recording, @@ -127,7 +135,7 @@ extension Message { extension Message: Equatable { public static func == (lhs: Message, rhs: Message) -> Bool { - lhs.id == rhs.id && lhs.status == rhs.status + lhs.id == rhs.id && lhs.status == rhs.status && lhs.isFirstMessage == rhs.isFirstMessage } } @@ -150,18 +158,24 @@ public struct ReplyMessage: Codable, Identifiable, Hashable { public var id: String + public var address: String + public var isFirstMessage: Bool public var text: String public var isOutgoing: Bool public var attachments: [Attachment] public var recording: Recording? public init(id: String, + address: String, + isFirstMessage: Bool = false, text: String = "", isOutgoing: Bool, attachments: [Attachment] = [], recording: Recording? = nil) { self.id = id + self.address = address + self.isFirstMessage = isFirstMessage self.text = text self.isOutgoing = isOutgoing self.attachments = attachments @@ -169,20 +183,22 @@ public struct ReplyMessage: Codable, Identifiable, Hashable { } func toMessage() -> Message { - Message(id: id, isOutgoing: isOutgoing, text: text, attachments: attachments, recording: recording) + Message(id: id, isOutgoing: isOutgoing, address: address, isFirstMessage: isFirstMessage, text: text, attachments: attachments, recording: recording) } } public extension Message { func toReplyMessage() -> ReplyMessage { - ReplyMessage(id: id, text: text, isOutgoing: isOutgoing, attachments: attachments, recording: recording) + ReplyMessage(id: id, address: address, isFirstMessage: isFirstMessage, text: text, isOutgoing: isOutgoing, attachments: attachments, recording: recording) } } public struct DraftMessage { public var id: String? public let isOutgoing: Bool + public let address: String + public let isFirstMessage: Bool public let text: String public let medias: [Media] public let recording: Recording? @@ -191,6 +207,8 @@ public struct DraftMessage { public init(id: String? = nil, isOutgoing: Bool, + address: String, + isFirstMessage: Bool, text: String, medias: [Media], recording: Recording?, @@ -198,6 +216,8 @@ public struct DraftMessage { createdAt: Date) { self.id = id self.isOutgoing = isOutgoing + self.address = address + self.isFirstMessage = isFirstMessage self.text = text self.medias = medias self.recording = recording diff --git a/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift b/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift index 20fd96d87..a717bd6fb 100644 --- a/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift +++ b/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift @@ -38,6 +38,7 @@ class ConversationViewModel: ObservableObject { @Published var conversationMessagesIds: [String] = [] @Published var conversationMessagesSection: [MessagesSection] = [] + @Published var participantConversationModel: [ContactAvatarModel] = [] init() {} @@ -91,9 +92,25 @@ class ConversationViewModel: ObservableObject { } } + func getParticipantConversationModel() { + coreContext.doOnCoreQueue { _ in + if self.displayedConversation != nil { + self.displayedConversation!.chatRoom.participants.forEach { participant in + if participant.address != nil { + let avatarModelTmp = ContactAvatarModel.getAvatarModelFromAddress(address: participant.address!) + DispatchQueue.main.async { + self.participantConversationModel.append(avatarModelTmp) + } + } + } + } + } + } + func getMessages() { self.getHistorySize() self.getUnreadMessagesCount() + self.getParticipantConversationModel() coreContext.doOnCoreQueue { _ in if self.displayedConversation != nil { let historyEvents = self.displayedConversation!.chatRoom.getHistoryRangeEvents(begin: 0, end: 30) @@ -121,11 +138,30 @@ class ConversationViewModel: ObservableObject { } } - conversationMessage.append(Message( - id: UUID().uuidString, - isOutgoing: eventLog.chatMessage?.isOutgoing ?? false, - text: contentText, - attachments: attachmentList)) + 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() + + 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 + + conversationMessage.append( + Message( + id: UUID().uuidString, + isOutgoing: eventLog.chatMessage?.isOutgoing ?? false, + address: addressCleaned?.asStringUriOnly() ?? "", + isFirstMessage: isFirstMessageTmp, + text: contentText, + attachments: attachmentList + ) + ) } DispatchQueue.main.async { @@ -143,7 +179,7 @@ class ConversationViewModel: ObservableObject { let historyEvents = self.displayedConversation!.chatRoom.getHistoryRangeEvents(begin: self.conversationMessagesSection[0].rows.count, end: self.conversationMessagesSection[0].rows.count + 30) var conversationMessagesTmp: [Message] = [] - historyEvents.reversed().forEach { eventLog in + historyEvents.enumerated().reversed().forEach { index, eventLog in let attachmentList: [Attachment] = [] var contentText = "" @@ -155,10 +191,26 @@ class ConversationViewModel: ObservableObject { } } + 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() + + 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 + conversationMessagesTmp.insert( Message( id: UUID().uuidString, isOutgoing: eventLog.chatMessage?.isOutgoing ?? false, + address: addressCleaned?.asStringUriOnly() ?? "", + isFirstMessage: isFirstMessageTmp, text: contentText, attachments: attachmentList ), at: 0 @@ -167,6 +219,9 @@ class ConversationViewModel: ObservableObject { if !conversationMessagesTmp.isEmpty { DispatchQueue.main.async { + if self.conversationMessagesSection[0].rows.last?.address == conversationMessagesTmp.last?.address { + self.conversationMessagesSection[0].rows[self.conversationMessagesSection[0].rows.count - 1].isFirstMessage = false + } self.conversationMessagesSection[0].rows.append(contentsOf: conversationMessagesTmp.reversed()) } } @@ -175,7 +230,6 @@ class ConversationViewModel: ObservableObject { } func getNewMessages(eventLogs: [EventLog]) { - var conversationMessage: [Message] = [] eventLogs.enumerated().forEach { index, eventLog in var attachmentList: [Attachment] = [] var contentText = "" @@ -197,14 +251,50 @@ class ConversationViewModel: ObservableObject { } } + 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() + + let isFirstMessageIncomingTmp = index > 0 + ? addressPrecCleaned?.asStringUriOnly() != addressCleaned?.asStringUriOnly() + : ( + self.conversationMessagesSection.isEmpty || self.conversationMessagesSection[0].rows.isEmpty + ? true + : self.conversationMessagesSection[0].rows[0].address != addressCleaned?.asStringUriOnly() + ) + + let isFirstMessageOutgoingTmp = index <= eventLogs.count - 2 + ? addressNextCleaned?.asStringUriOnly() == addressCleaned?.asStringUriOnly() + : ( + self.conversationMessagesSection.isEmpty || self.conversationMessagesSection[0].rows.isEmpty + ? true + : !self.conversationMessagesSection[0].rows[0].isOutgoing || self.conversationMessagesSection[0].rows[0].address == addressCleaned?.asStringUriOnly() + ) + + let isFirstMessageTmp = (eventLog.chatMessage?.isOutgoing ?? false) ? isFirstMessageOutgoingTmp : isFirstMessageIncomingTmp + let message = Message( id: UUID().uuidString, isOutgoing: eventLog.chatMessage?.isOutgoing ?? false, + address: addressCleaned?.asStringUriOnly() ?? "", + isFirstMessage: isFirstMessageTmp, text: contentText, attachments: attachmentList ) DispatchQueue.main.async { + if !self.conversationMessagesSection.isEmpty + && !self.conversationMessagesSection[0].rows.isEmpty + && self.conversationMessagesSection[0].rows[0].isOutgoing + && (self.conversationMessagesSection[0].rows[0].address == message.address) { + self.conversationMessagesSection[0].rows[0].isFirstMessage = false + } + if self.conversationMessagesSection.isEmpty { self.conversationMessagesSection.append(MessagesSection(date: Date(), rows: [message])) } else { diff --git a/Linphone/UI/Main/History/Fragments/HistoryContactFragment.swift b/Linphone/UI/Main/History/Fragments/HistoryContactFragment.swift index 1121b86e0..f41b44a91 100644 --- a/Linphone/UI/Main/History/Fragments/HistoryContactFragment.swift +++ b/Linphone/UI/Main/History/Fragments/HistoryContactFragment.swift @@ -628,7 +628,7 @@ struct HistoryContactFragment: View { #Preview { HistoryContactFragment( - contactAvatarModel: ContactAvatarModel(friend: nil, name: "", withPresence: false), + contactAvatarModel: ContactAvatarModel(friend: nil, name: "", address: "", withPresence: false), historyViewModel: HistoryViewModel(), historyListViewModel: HistoryListViewModel(), contactViewModel: ContactViewModel(), diff --git a/Linphone/UI/Main/History/Fragments/HistoryListFragment.swift b/Linphone/UI/Main/History/Fragments/HistoryListFragment.swift index f101a6802..57b1fa6b1 100644 --- a/Linphone/UI/Main/History/Fragments/HistoryListFragment.swift +++ b/Linphone/UI/Main/History/Fragments/HistoryListFragment.swift @@ -49,7 +49,7 @@ struct HistoryListFragment: View { && $0.friend!.name == addressFriend!.name && $0.friend!.address!.asStringUriOnly() == addressFriend!.address!.asStringUriOnly() }) - : ContactAvatarModel(friend: nil, name: "", withPresence: false) + : ContactAvatarModel(friend: nil, name: "", address: "", withPresence: false) if addressFriend != nil && addressFriend!.photo != nil && !addressFriend!.photo!.isEmpty { if contactAvatarModel != nil { diff --git a/Linphone/Utils/MagicSearchSingleton.swift b/Linphone/Utils/MagicSearchSingleton.swift index 8b7352d08..cf23e5905 100644 --- a/Linphone/Utils/MagicSearchSingleton.swift +++ b/Linphone/Utils/MagicSearchSingleton.swift @@ -86,7 +86,14 @@ final class MagicSearchSingleton: ObservableObject { self.contactsManager.lastSearch.forEach { searchResult in if searchResult.friend != nil { - self.contactsManager.avatarListModel.append(ContactAvatarModel(friend: searchResult.friend!, name: searchResult.friend?.name ?? "", withPresence: true)) + self.contactsManager.avatarListModel.append( + ContactAvatarModel( + friend: searchResult.friend!, + name: searchResult.friend?.name ?? "", + address: searchResult.friend?.address?.clone()?.asStringUriOnly() ?? "", + withPresence: true + ) + ) } } From 415cf274b1dd878b34361be3609135ae7fcc39d5 Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Fri, 17 May 2024 11:17:00 +0200 Subject: [PATCH 228/486] Fix conversation for iOS 15 --- .../Fragments/ConversationFragment.swift | 127 ++++++++++++------ .../Main/Conversations/Fragments/UIList.swift | 19 +-- .../ViewModel/ConversationViewModel.swift | 1 - Linphone/Utils/Avatar.swift | 2 + 4 files changed, 95 insertions(+), 54 deletions(-) diff --git a/Linphone/UI/Main/Conversations/Fragments/ConversationFragment.swift b/Linphone/UI/Main/Conversations/Fragments/ConversationFragment.swift index c8bd4af36..011f85a35 100644 --- a/Linphone/UI/Main/Conversations/Fragments/ConversationFragment.swift +++ b/Linphone/UI/Main/Conversations/Fragments/ConversationFragment.swift @@ -42,6 +42,8 @@ struct ConversationFragment: View { @StateObject private var viewModel = ChatViewModel() @StateObject private var paginationState = PaginationState() + @State private var displayFloatingButton = false + var body: some View { NavigationView { GeometryReader { geometry in @@ -158,8 +160,7 @@ struct ConversationFragment: View { isScrolledToBottom: $isScrolledToBottom, showMessageMenuOnLongPress: showMessageMenuOnLongPress, geometryProxy: geometry, - sections: conversationViewModel.conversationMessagesSection, - ids: conversationViewModel.conversationMessagesIds + sections: conversationViewModel.conversationMessagesSection ) if !isScrolledToBottom { @@ -217,56 +218,99 @@ struct ConversationFragment: View { conversationViewModel.resetMessage() } } else { - /* ScrollViewReader { proxy in - List { - ForEach(0.. conversationViewModel.conversationMessagesList.count { - DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { - conversationViewModel.getOldMessages() - } - } - } + 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.2) { + conversationViewModel.getOldMessages() + } + } + + if index == 0 { + displayFloatingButton = false + } + } + .onDisappear { + if index == 0 { + displayFloatingButton = true + } + } + } } } + .scaleEffect(x: 1, y: -1, anchor: .center) + .listStyle(.plain) + + if displayFloatingButton { + Button { + if conversationViewModel.conversationMessagesSection.first != nil && conversationViewModel.conversationMessagesSection.first!.rows.first != nil { + withAnimation { + proxy.scrollTo(conversationViewModel.conversationMessagesSection.first!.rows.first!.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() + } } - .scaleEffect(x: 1, y: -1, anchor: .center) - .listStyle(.plain) .onTapGesture { UIApplication.shared.endEditing() } .onAppear { conversationViewModel.getMessages() } - .onChange(of: conversationViewModel.conversationMessagesList) { _ in - /* - DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { - if conversationViewModel.conversationMessagesList.count <= 30 { - proxy.scrollTo( - conversationViewModel.conversationMessagesList.first, anchor: .top - ) - } else if conversationViewModel.conversationMessagesList.count >= conversationViewModel.displayedConversationHistorySize { - proxy.scrollTo( - conversationViewModel.conversationMessagesList[conversationViewModel.displayedConversationHistorySize%30], anchor: .top - ) - } else { - proxy.scrollTo(30, anchor: .top) - } - } - */ - } .onDisappear { conversationViewModel.resetMessage() } } - */ } HStack(spacing: 0) { @@ -399,6 +443,13 @@ struct ConversationFragment: View { } } +struct ScrollOffsetPreferenceKey: PreferenceKey { + static var defaultValue: CGPoint = .zero + + static func reduce(value: inout CGPoint, nextValue: () -> CGPoint) { + } +} + extension UIApplication { func endEditing() { sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil) diff --git a/Linphone/UI/Main/Conversations/Fragments/UIList.swift b/Linphone/UI/Main/Conversations/Fragments/UIList.swift index 14fdfbf56..6bae3da69 100644 --- a/Linphone/UI/Main/Conversations/Fragments/UIList.swift +++ b/Linphone/UI/Main/Conversations/Fragments/UIList.swift @@ -35,7 +35,6 @@ struct UIList: UIViewRepresentable { let showMessageMenuOnLongPress: Bool let geometryProxy: GeometryProxy let sections: [MessagesSection] - let ids: [String] @State private var isScrolledToTop = false @@ -153,7 +152,6 @@ struct UIList: UIViewRepresentable { // apply the rest of the changes to table's dataSource, i.e. inserts //print("5 apply inserts") context.coordinator.sections = sections - context.coordinator.ids = ids tableView.beginUpdates() for operation in insertOperations { @@ -164,7 +162,6 @@ struct UIList: UIViewRepresentable { updateSemaphore.signal() } } else { - context.coordinator.ids = ids updateSemaphore.signal() } } @@ -312,8 +309,7 @@ struct UIList: UIViewRepresentable { isScrolledToTop: $isScrolledToTop, showMessageMenuOnLongPress: showMessageMenuOnLongPress, geometryProxy: geometryProxy, - sections: sections, - ids: ids + sections: sections ) } @@ -329,9 +325,8 @@ struct UIList: UIViewRepresentable { let showMessageMenuOnLongPress: Bool let geometryProxy: GeometryProxy var sections: [MessagesSection] - var ids: [String] - init(conversationViewModel: ConversationViewModel, viewModel: ChatViewModel, paginationState: PaginationState, isScrolledToBottom: Binding, isScrolledToTop: Binding, showMessageMenuOnLongPress: Bool, geometryProxy: GeometryProxy, sections: [MessagesSection], ids: [String]) { + init(conversationViewModel: ConversationViewModel, viewModel: ChatViewModel, paginationState: PaginationState, isScrolledToBottom: Binding, isScrolledToTop: Binding, showMessageMenuOnLongPress: Bool, geometryProxy: GeometryProxy, sections: [MessagesSection]) { self.conversationViewModel = conversationViewModel self.viewModel = viewModel self.paginationState = paginationState @@ -340,7 +335,6 @@ struct UIList: UIViewRepresentable { self.showMessageMenuOnLongPress = showMessageMenuOnLongPress self.geometryProxy = geometryProxy self.sections = sections - self.ids = ids } func numberOfSections(in tableView: UITableView) -> Int { @@ -384,8 +378,6 @@ struct UIList: UIViewRepresentable { } .minSize(width: 0, height: 0) .margins(.all, 0) - } else { - // Fallback on earlier versions } tableViewCell.transform = CGAffineTransformMakeScale(1, -1) @@ -395,7 +387,7 @@ struct UIList: UIViewRepresentable { func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) { let row = sections[indexPath.section].rows[indexPath.row] - paginationState.handle(row, ids: ids) + paginationState.handle(row) } func scrollViewDidScroll(_ scrollView: UIScrollView) { @@ -452,13 +444,10 @@ final class PaginationState: ObservableObject { self.offset = offset } - func handle(_ message: Message, ids: [String]) { + func handle(_ message: Message) { guard shouldHandlePagination else { return } - if ids.prefix(offset + 1).contains(message.id) { - onEvent?(message) - } } } diff --git a/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift b/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift index a717bd6fb..ba6ffab8a 100644 --- a/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift +++ b/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift @@ -36,7 +36,6 @@ class ConversationViewModel: ObservableObject { private var chatRoomSuscriptions = Set() - @Published var conversationMessagesIds: [String] = [] @Published var conversationMessagesSection: [MessagesSection] = [] @Published var participantConversationModel: [ContactAvatarModel] = [] diff --git a/Linphone/Utils/Avatar.swift b/Linphone/Utils/Avatar.swift index 768122219..deb0c4724 100644 --- a/Linphone/Utils/Avatar.swift +++ b/Linphone/Utils/Avatar.swift @@ -21,6 +21,7 @@ import SwiftUI import linphonesw struct Avatar: View { + @State var id = UUID() private var contactsManager = ContactsManager.shared @@ -73,6 +74,7 @@ struct Avatar: View { EmptyView() } } + .id(id) } else if !contactAvatarModel.name.isEmpty { Image(uiImage: contactsManager.textToImage( firstName: contactAvatarModel.name, From ae19bad388b7b76f7ae077cb0cd1ff52ffdfd888 Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Fri, 17 May 2024 15:06:20 +0200 Subject: [PATCH 229/486] Add IMDN and Date in ChatBubbleView --- .../Fragments/ChatBubbleView.swift | 24 ++++++ .../UI/Main/Conversations/Model/Message.swift | 19 ++++- .../ViewModel/ConversationViewModel.swift | 85 ++++++++++++++++++- 3 files changed, 125 insertions(+), 3 deletions(-) diff --git a/Linphone/UI/Main/Conversations/Fragments/ChatBubbleView.swift b/Linphone/UI/Main/Conversations/Fragments/ChatBubbleView.swift index b8697a70b..5b9250c7b 100644 --- a/Linphone/UI/Main/Conversations/Fragments/ChatBubbleView.swift +++ b/Linphone/UI/Main/Conversations/Fragments/ChatBubbleView.swift @@ -110,6 +110,30 @@ struct ChatBubbleView: View { .foregroundStyle(Color.grayMain2c700) .default_text_style(styleSize: 16) } + + HStack(alignment: .center) { + Text(conversationViewModel.getMessageTime(startDate: message.dateReceived)) + .foregroundStyle(Color.grayMain2c500) + .default_text_style_300(styleSize: 14) + .padding(.top, 1) + + if (conversationViewModel.displayedConversation != nil && conversationViewModel.displayedConversation!.isGroup) || message.isOutgoing { + if message.status == .sending { + ProgressView() + .progressViewStyle(CircularProgressViewStyle(tint: .orangeMain500)) + .frame(width: 15, height: 15) + .padding(.top, 1) + } else if message.status != nil { + Image(conversationViewModel.getImageIMDN(status: message.status!)) + .renderingMode(.template) + .resizable() + .foregroundStyle(Color.orangeMain500) + .frame(width: 15, height: 15) + .padding(.top, 1) + } + } + } + .padding(.top, -4) } .padding(.all, 15) .background(message.isOutgoing ? Color.orangeMain100 : Color.grayMain2c100) diff --git a/Linphone/UI/Main/Conversations/Model/Message.swift b/Linphone/UI/Main/Conversations/Model/Message.swift index 7ed606a4b..adbd9bc78 100644 --- a/Linphone/UI/Main/Conversations/Model/Message.swift +++ b/Linphone/UI/Main/Conversations/Model/Message.swift @@ -24,6 +24,7 @@ public struct Message: Identifiable, Hashable { public enum Status: Equatable, Hashable { case sending case sent + case received case read case error(DraftMessage) @@ -33,6 +34,8 @@ public struct Message: Identifiable, Hashable { return hasher.combine("sending") case .sent: return hasher.combine("sent") + case .received: + return hasher.combine("received") case .read: return hasher.combine("read") case .error: @@ -46,6 +49,8 @@ public struct Message: Identifiable, Hashable { return true case (.sent, .sent): return true + case (.received, .received): + return true case (.read, .read): return true case ( .error(_), .error(_)): @@ -60,6 +65,7 @@ public struct Message: Identifiable, Hashable { 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 @@ -73,6 +79,7 @@ public struct Message: Identifiable, Hashable { status: Status? = nil, createdAt: Date = Date(), isOutgoing: Bool, + dateReceived: time_t, address: String, isFirstMessage: Bool = false, text: String = "", @@ -84,6 +91,7 @@ public struct Message: Identifiable, Hashable { self.status = status self.createdAt = createdAt self.isOutgoing = isOutgoing + self.dateReceived = dateReceived self.isFirstMessage = isFirstMessage self.address = address self.text = text @@ -117,6 +125,7 @@ public struct Message: Identifiable, Hashable { status: status, createdAt: draft.createdAt, isOutgoing: draft.isOutgoing, + dateReceived: draft.dateReceived, address: draft.address, isFirstMessage: draft.isFirstMessage, text: draft.text, @@ -162,6 +171,7 @@ public struct ReplyMessage: Codable, Identifiable, Hashable { public var isFirstMessage: Bool public var text: String public var isOutgoing: Bool + public var dateReceived: time_t public var attachments: [Attachment] public var recording: Recording? @@ -170,6 +180,7 @@ public struct ReplyMessage: Codable, Identifiable, Hashable { isFirstMessage: Bool = false, text: String = "", isOutgoing: Bool, + dateReceived: time_t, attachments: [Attachment] = [], recording: Recording? = nil) { @@ -178,25 +189,27 @@ public struct ReplyMessage: Codable, Identifiable, Hashable { self.isFirstMessage = isFirstMessage self.text = text self.isOutgoing = isOutgoing + self.dateReceived = dateReceived self.attachments = attachments self.recording = recording } func toMessage() -> Message { - Message(id: id, isOutgoing: isOutgoing, address: address, isFirstMessage: isFirstMessage, text: text, attachments: attachments, recording: recording) + 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, attachments: attachments, recording: recording) + 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 @@ -207,6 +220,7 @@ public struct DraftMessage { public init(id: String? = nil, isOutgoing: Bool, + dateReceived: time_t, address: String, isFirstMessage: Bool, text: String, @@ -216,6 +230,7 @@ public struct DraftMessage { createdAt: Date) { self.id = id self.isOutgoing = isOutgoing + self.dateReceived = dateReceived self.address = address self.isFirstMessage = isFirstMessage self.text = text diff --git a/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift b/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift index ba6ffab8a..390276357 100644 --- a/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift +++ b/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift @@ -31,7 +31,6 @@ class ConversationViewModel: ObservableObject { @Published var displayedConversationHistorySize: Int = 0 @Published var displayedConversationUnreadMessagesCount: Int = 0 - @Published var messageText: String = "" private var chatRoomSuscriptions = Set() @@ -151,10 +150,26 @@ class ConversationViewModel: ObservableObject { 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 + default: + statusTmp = nil + } + conversationMessage.append( Message( id: UUID().uuidString, + status: statusTmp, isOutgoing: eventLog.chatMessage?.isOutgoing ?? false, + dateReceived: eventLog.chatMessage?.time ?? 0, address: addressCleaned?.asStringUriOnly() ?? "", isFirstMessage: isFirstMessageTmp, text: contentText, @@ -204,10 +219,26 @@ class ConversationViewModel: ObservableObject { 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 + default: + statusTmp = nil + } + conversationMessagesTmp.insert( Message( id: UUID().uuidString, + status: statusTmp, isOutgoing: eventLog.chatMessage?.isOutgoing ?? false, + dateReceived: eventLog.chatMessage?.time ?? 0, address: addressCleaned?.asStringUriOnly() ?? "", isFirstMessage: isFirstMessageTmp, text: contentText, @@ -277,9 +308,25 @@ class ConversationViewModel: ObservableObject { 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 + default: + statusTmp = nil + } + let message = Message( id: UUID().uuidString, + status: statusTmp, isOutgoing: eventLog.chatMessage?.isOutgoing ?? false, + dateReceived: eventLog.chatMessage?.time ?? 0, address: addressCleaned?.asStringUriOnly() ?? "", isFirstMessage: isFirstMessageTmp, text: contentText, @@ -426,6 +473,42 @@ class ConversationViewModel: ObservableObject { func getNewFilePath(name: String) -> String { return "file://" + Factory.Instance.getDownloadDir(context: nil) + name } + + 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 "" + } + } + } struct LinphoneCustomEventLog: Hashable { var id = UUID() From 84ad957568a0b98b0f27529bad69b58f3734936b Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Mon, 20 May 2024 15:21:05 +0200 Subject: [PATCH 230/486] Add multi image message --- .../Fragments/ChatBubbleView.swift | 37 ++++++++++++++++++- .../ViewModel/ConversationViewModel.swift | 15 ++++++-- 2 files changed, 48 insertions(+), 4 deletions(-) diff --git a/Linphone/UI/Main/Conversations/Fragments/ChatBubbleView.swift b/Linphone/UI/Main/Conversations/Fragments/ChatBubbleView.swift index 5b9250c7b..557637086 100644 --- a/Linphone/UI/Main/Conversations/Fragments/ChatBubbleView.swift +++ b/Linphone/UI/Main/Conversations/Fragments/ChatBubbleView.swift @@ -194,9 +194,11 @@ struct ChatBubbleView: View { } placeholder: { ProgressView() } + .id(UUID()) .layoutPriority(-1) - } else { + } else if message.attachments.first!.type == .gif { GifImageView(message.attachments.first!.full) + .id(UUID()) .layoutPriority(-1) .clipShape(RoundedRectangle(cornerRadius: 4)) } @@ -204,6 +206,39 @@ struct ChatBubbleView: View { .clipShape(RoundedRectangle(cornerRadius: 4)) .clipped() } + } else if message.attachments.count > 1 { + let isGroup = conversationViewModel.displayedConversation != nil && conversationViewModel.displayedConversation!.isGroup + LazyVGrid(columns: [ + GridItem(.adaptive(minimum: 120), spacing: 1) + ], spacing: 3) { + ForEach(message.attachments) { attachment in + if attachment.type == .image || attachment.type == .gif { + ZStack { + Rectangle() + .fill(Color(.white)) + .frame(width: 120, height: 120) + + AsyncImage(url: attachment.full) { image in + image + .resizable() + .interpolation(.medium) + .aspectRatio(contentMode: .fill) + } placeholder: { + ProgressView() + } + .id(UUID()) + .layoutPriority(-1) + } + .clipShape(RoundedRectangle(cornerRadius: 4)) + .clipped() + } + } + } + .frame( + width: geometryProxy.size.width > 0 && CGFloat(122 * message.attachments.count) > geometryProxy.size.width - 110 - (isGroup ? 40 : 0) + ? 122 * floor(CGFloat(geometryProxy.size.width - 110 - (isGroup ? 40 : 0)) / 122) + : CGFloat(122 * message.attachments.count) + ) } } diff --git a/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift b/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift index 390276357..f34753f70 100644 --- a/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift +++ b/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift @@ -194,13 +194,22 @@ class ConversationViewModel: ObservableObject { var conversationMessagesTmp: [Message] = [] historyEvents.enumerated().reversed().forEach { index, eventLog in - let attachmentList: [Attachment] = [] + 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) + } else { + if URL(string: self.getNewFilePath(name: content.name ?? "")) != nil { + let attachment = Attachment(id: UUID().uuidString, url: URL(string: self.getNewFilePath(name: content.name ?? ""))!, type: (content.name?.lowercased().hasSuffix("gif"))! ? .gif : .image) + attachmentList.append(attachment) + } + } } } } @@ -458,7 +467,7 @@ class ConversationViewModel: ObservableObject { let contentName = content.name if contentName != nil { let isImage = FileUtil.isExtensionImage(path: contentName!) - let file = FileUtil.getFileStoragePath(fileName: contentName!, isImage: isImage) + let file = FileUtil.getFileStoragePath(fileName: contentName!.addingPercentEncoding(withAllowedCharacters: .urlHostAllowed) ?? "", isImage: isImage) content.filePath = file Log.info( "[ConversationViewModel] File \(contentName) will be downloaded at \(content.filePath)" @@ -471,7 +480,7 @@ class ConversationViewModel: ObservableObject { } func getNewFilePath(name: String) -> String { - return "file://" + Factory.Instance.getDownloadDir(context: nil) + name + return "file://" + Factory.Instance.getDownloadDir(context: nil) + (name.addingPercentEncoding(withAllowedCharacters: .urlHostAllowed) ?? "") } func getMessageTime(startDate: time_t) -> String { From 0682489645ef19f361869b4b4e2aba376181cc2c Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Tue, 21 May 2024 10:29:22 +0200 Subject: [PATCH 231/486] Add video preview in message bubble --- .../play-fill.imageset/Contents.json | 21 +++++++ .../play-fill.imageset/play-fill.svg | 1 + .../Fragments/ChatBubbleView.swift | 56 ++++++++++++------- .../ViewModel/ConversationViewModel.swift | 51 ++++++++++++++++- 4 files changed, 107 insertions(+), 22 deletions(-) create mode 100644 Linphone/Assets.xcassets/play-fill.imageset/Contents.json create mode 100644 Linphone/Assets.xcassets/play-fill.imageset/play-fill.svg 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/UI/Main/Conversations/Fragments/ChatBubbleView.swift b/Linphone/UI/Main/Conversations/Fragments/ChatBubbleView.swift index 557637086..d4f18a7c6 100644 --- a/Linphone/UI/Main/Conversations/Fragments/ChatBubbleView.swift +++ b/Linphone/UI/Main/Conversations/Fragments/ChatBubbleView.swift @@ -160,7 +160,7 @@ struct ChatBubbleView: View { @ViewBuilder func messageAttachments() -> some View { if message.attachments.count == 1 { - if message.attachments.first!.type == .image || message.attachments.first!.type == .gif { + if message.attachments.first!.type == .image || message.attachments.first!.type == .gif || message.attachments.first!.type == .video { let result = imageDimensions(url: message.attachments.first!.full.absoluteString) ZStack { Rectangle() @@ -185,12 +185,22 @@ struct ChatBubbleView: View { ) } - if message.attachments.first!.type == .image { + if message.attachments.first!.type == .image || message.attachments.first!.type == .video { AsyncImage(url: message.attachments.first!.full) { image in - image - .resizable() - .interpolation(.medium) - .aspectRatio(contentMode: .fill) + ZStack { + image + .resizable() + .interpolation(.medium) + .aspectRatio(contentMode: .fill) + + if message.attachments.first!.type == .video { + Image("play-fill") + .renderingMode(.template) + .resizable() + .foregroundStyle(.white) + .frame(width: 40, height: 40, alignment: .leading) + } + } } placeholder: { ProgressView() } @@ -212,26 +222,34 @@ struct ChatBubbleView: View { GridItem(.adaptive(minimum: 120), spacing: 1) ], spacing: 3) { ForEach(message.attachments) { attachment in - if attachment.type == .image || attachment.type == .gif { - ZStack { - Rectangle() - .fill(Color(.white)) - .frame(width: 120, height: 120) - - AsyncImage(url: attachment.full) { image in + ZStack { + Rectangle() + .fill(Color(.white)) + .frame(width: 120, height: 120) + + AsyncImage(url: attachment.full) { image in + ZStack { image .resizable() .interpolation(.medium) .aspectRatio(contentMode: .fill) - } placeholder: { - ProgressView() + + if attachment.type == .video { + Image("play-fill") + .renderingMode(.template) + .resizable() + .foregroundStyle(.white) + .frame(width: 40, height: 40, alignment: .leading) + } } - .id(UUID()) - .layoutPriority(-1) + } placeholder: { + ProgressView() } - .clipShape(RoundedRectangle(cornerRadius: 4)) - .clipped() + .id(UUID()) + .layoutPriority(-1) } + .clipShape(RoundedRectangle(cornerRadius: 4)) + .clipped() } } .frame( diff --git a/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift b/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift index f34753f70..7dcefdf2f 100644 --- a/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift +++ b/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift @@ -127,9 +127,28 @@ class ConversationViewModel: ObservableObject { if content.filePath == nil || content.filePath!.isEmpty { self.downloadContent(chatMessage: eventLog.chatMessage!, content: content) } else { - if URL(string: self.getNewFilePath(name: content.name ?? "")) != nil { - let attachment = Attachment(id: UUID().uuidString, url: URL(string: self.getNewFilePath(name: content.name ?? ""))!, type: (content.name?.lowercased().hasSuffix("gif"))! ? .gif : .image) - attachmentList.append(attachment) + if content.type != "video" { + let path = URL(string: self.getNewFilePath(name: content.name ?? "")) + if path != nil { + let attachment = + Attachment( + id: UUID().uuidString, + url: path!, + type: (content.name?.lowercased().hasSuffix("gif"))! ? .gif : .image + ) + attachmentList.append(attachment) + } + } else if content.type == "video" { + let path = URL(string: self.generateThumbnail(name: content.name ?? "")) + if path != nil { + let attachment = + Attachment( + id: UUID().uuidString, + url: path!, + type: .video + ) + attachmentList.append(attachment) + } } } } @@ -483,6 +502,32 @@ class ConversationViewModel: ObservableObject { return "file://" + Factory.Instance.getDownloadDir(context: nil) + (name.addingPercentEncoding(withAllowedCharacters: .urlHostAllowed) ?? "") } + func generateThumbnail(name: String) -> String { + do { + let path = URL(string: "file://" + Factory.Instance.getDownloadDir(context: nil) + (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 = URL(string: "file://" + Factory.Instance.getDownloadDir(context: nil) + "preview_" + (name.addingPercentEncoding(withAllowedCharacters: .urlHostAllowed) ?? "") + ".png") + + if urlName != nil { + let decodedData: () = 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) From 472bf469386f75374bfb06e15dc5563ea311e787 Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Tue, 21 May 2024 17:25:02 +0200 Subject: [PATCH 232/486] Add photo picker to conversation view --- .../Fragments/ChatBubbleView.swift | 126 +++++++++---- .../Fragments/ConversationFragment.swift | 167 +++++++++++++++++- Linphone/Utils/Extensions/ViewExtension.swift | 2 + Linphone/Utils/PhotoPicker.swift | 89 +++++++++- 4 files changed, 342 insertions(+), 42 deletions(-) diff --git a/Linphone/UI/Main/Conversations/Fragments/ChatBubbleView.swift b/Linphone/UI/Main/Conversations/Fragments/ChatBubbleView.swift index d4f18a7c6..9da8dc9a4 100644 --- a/Linphone/UI/Main/Conversations/Fragments/ChatBubbleView.swift +++ b/Linphone/UI/Main/Conversations/Fragments/ChatBubbleView.swift @@ -161,7 +161,7 @@ struct ChatBubbleView: View { func messageAttachments() -> some View { if message.attachments.count == 1 { if message.attachments.first!.type == .image || message.attachments.first!.type == .gif || message.attachments.first!.type == .video { - let result = imageDimensions(url: message.attachments.first!.full.absoluteString) + let result = imageDimensions(url: message.attachments.first!.thumbnail.absoluteString) ZStack { Rectangle() .fill(Color(.white)) @@ -186,31 +186,59 @@ struct ChatBubbleView: View { } if message.attachments.first!.type == .image || message.attachments.first!.type == .video { - AsyncImage(url: message.attachments.first!.full) { image in - ZStack { - image - .resizable() - .interpolation(.medium) - .aspectRatio(contentMode: .fill) - - if message.attachments.first!.type == .video { - Image("play-fill") - .renderingMode(.template) + if #available(iOS 16.0, *) { + AsyncImage(url: message.attachments.first!.thumbnail) { image in + ZStack { + image .resizable() - .foregroundStyle(.white) - .frame(width: 40, height: 40, alignment: .leading) + .interpolation(.medium) + .aspectRatio(contentMode: .fill) + + if message.attachments.first!.type == .video { + Image("play-fill") + .renderingMode(.template) + .resizable() + .foregroundStyle(.white) + .frame(width: 40, height: 40, alignment: .leading) + } } + } placeholder: { + ProgressView() + } + .layoutPriority(-1) + } else { + AsyncImage(url: message.attachments.first!.thumbnail) { image in + ZStack { + image + .resizable() + .interpolation(.medium) + .aspectRatio(contentMode: .fill) + + if message.attachments.first!.type == .video { + Image("play-fill") + .renderingMode(.template) + .resizable() + .foregroundStyle(.white) + .frame(width: 40, height: 40, alignment: .leading) + } + } + } placeholder: { + ProgressView() } - } placeholder: { - ProgressView() - } - .id(UUID()) - .layoutPriority(-1) - } else if message.attachments.first!.type == .gif { - GifImageView(message.attachments.first!.full) .id(UUID()) .layoutPriority(-1) - .clipShape(RoundedRectangle(cornerRadius: 4)) + } + } else if message.attachments.first!.type == .gif { + if #available(iOS 16.0, *) { + GifImageView(message.attachments.first!.thumbnail) + .layoutPriority(-1) + .clipShape(RoundedRectangle(cornerRadius: 4)) + } else { + GifImageView(message.attachments.first!.thumbnail) + .id(UUID()) + .layoutPriority(-1) + .clipShape(RoundedRectangle(cornerRadius: 4)) + } } } .clipShape(RoundedRectangle(cornerRadius: 4)) @@ -227,29 +255,51 @@ struct ChatBubbleView: View { .fill(Color(.white)) .frame(width: 120, height: 120) - AsyncImage(url: attachment.full) { image in - ZStack { - image - .resizable() - .interpolation(.medium) - .aspectRatio(contentMode: .fill) - - if attachment.type == .video { - Image("play-fill") - .renderingMode(.template) + if #available(iOS 16.0, *) { + AsyncImage(url: attachment.thumbnail) { image in + ZStack { + image .resizable() - .foregroundStyle(.white) - .frame(width: 40, height: 40, alignment: .leading) + .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() } - } 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) } - .id(UUID()) - .layoutPriority(-1) } .clipShape(RoundedRectangle(cornerRadius: 4)) - .clipped() + .contentShape(Rectangle()) } } .frame( diff --git a/Linphone/UI/Main/Conversations/Fragments/ConversationFragment.swift b/Linphone/UI/Main/Conversations/Fragments/ConversationFragment.swift index 011f85a35..aee68444a 100644 --- a/Linphone/UI/Main/Conversations/Fragments/ConversationFragment.swift +++ b/Linphone/UI/Main/Conversations/Fragments/ConversationFragment.swift @@ -44,6 +44,13 @@ struct ConversationFragment: View { @State private var displayFloatingButton = false + @State private var isShowPhotoLibrary = false + @State private var isShowCamera = false + + @State private var mediasToSend: [Attachment] = [] + @State private var mediasIsLoading = false + @State private var maxMediaCount = 12 + var body: some View { NavigationView { GeometryReader { geometry in @@ -313,6 +320,93 @@ struct ConversationFragment: View { } } + if !mediasToSend.isEmpty || mediasIsLoading { + ZStack(alignment: .top) { + HStack { + if mediasIsLoading { + HStack { + Spacer() + + ProgressView() + + Spacer() + } + .frame(height: 120) + } + + LazyVGrid(columns: [ + GridItem(.adaptive(minimum: 100), spacing: 1) + ], spacing: 3) { + ForEach(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 mediasToSend.count == 1 { + withAnimation { + mediasToSend = [] + } + } else { + guard let index = self.mediasToSend.firstIndex(of: attachment) else { return } + self.mediasToSend.remove(at: index) + } + } + } + .clipShape(RoundedRectangle(cornerRadius: 4)) + .contentShape(Rectangle()) + } + } + .frame( + width: geometry.size.width > 0 && CGFloat(102 * mediasToSend.count) > geometry.size.width - 20 + ? 102 * floor(CGFloat(geometry.size.width - 20) / 102) + : CGFloat(102 * mediasToSend.count) + ) + } + .frame(maxWidth: .infinity) + .padding(.all, mediasToSend.isEmpty ? 0 : 10) + .background(Color.gray100) + + if !mediasIsLoading { + HStack { + Spacer() + + Button(action: { + withAnimation { + mediasToSend = [] + } + }, label: { + Image("x") + .resizable() + .frame(width: 30, height: 30, alignment: .leading) + .padding(.all, 10) + }) + } + } + } + .transition(.move(edge: .bottom)) + } + HStack(spacing: 0) { Button { } label: { @@ -327,26 +421,31 @@ struct ConversationFragment: View { .padding(.horizontal, isMessageTextFocused ? 0 : 2) Button { + self.isShowPhotoLibrary = true + self.mediasIsLoading = true } label: { Image("paperclip") .renderingMode(.template) .resizable() - .foregroundStyle(Color.grayMain2c500) + .foregroundStyle(maxMediaCount <= 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(maxMediaCount <= mediasToSend.count || mediasIsLoading) } .padding(.horizontal, isMessageTextFocused ? 0 : 2) Button { + self.isShowCamera = true } label: { Image("camera") .renderingMode(.template) .resizable() - .foregroundStyle(Color.grayMain2c500) + .foregroundStyle(maxMediaCount <= 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(maxMediaCount <= mediasToSend.count || mediasIsLoading) } .padding(.horizontal, isMessageTextFocused ? 0 : 2) @@ -437,6 +536,28 @@ struct ConversationFragment: View { .onDisappear { conversationViewModel.removeConversationDelegate() } + .sheet(isPresented: $isShowPhotoLibrary) { + PhotoPicker(filter: nil, limit: maxMediaCount - mediasToSend.count) { results in + PhotoPicker.convertToAttachmentArray(fromResults: results) { mediasOrNil, errorOrNil in + if let error = errorOrNil { + print(error) + } + + if let medias = mediasOrNil { + mediasToSend.append(contentsOf: medias) + } + + self.mediasIsLoading = false + } + } + .edgesIgnoringSafeArea(.all) + } + /* + .fullScreenCover(isPresented: $isShowCamera) { + ImagePicker(selectedImage: self.$image, sourceType: .camera) + .edgesIgnoringSafeArea(.all) + } + */ } } .navigationViewStyle(.stack) @@ -456,6 +577,48 @@ extension UIApplication { } } +struct ImagePicker: UIViewControllerRepresentable { + @Binding var selectedImage: UIImage + @Environment(\.presentationMode) private var presentationMode + + var sourceType: UIImagePickerController.SourceType = .photoLibrary + + final class Coordinator: NSObject, UIImagePickerControllerDelegate, UINavigationControllerDelegate { + + var parent: ImagePicker + + init(_ parent: ImagePicker) { + self.parent = parent + } + + func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any]) { + + if let image = info[UIImagePickerController.InfoKey.originalImage] as? UIImage { + parent.selectedImage = image + } + + parent.presentationMode.wrappedValue.dismiss() + } + } + + func makeUIViewController(context: UIViewControllerRepresentableContext) -> UIImagePickerController { + let imagePicker = UIImagePickerController() + imagePicker.allowsEditing = false + imagePicker.sourceType = sourceType + imagePicker.delegate = context.coordinator + + return imagePicker + } + + func updateUIViewController(_ uiViewController: UIImagePickerController, context: UIViewControllerRepresentableContext) { + + } + + func makeCoordinator() -> Coordinator { + Coordinator(self) + } +} + /* #Preview { ConversationFragment(conversationViewModel: ConversationViewModel(), conversationsListViewModel: ConversationsListViewModel(), sections: [MessagesSection], ids: [""]) diff --git a/Linphone/Utils/Extensions/ViewExtension.swift b/Linphone/Utils/Extensions/ViewExtension.swift index 5f9765b89..ef1dbeff7 100644 --- a/Linphone/Utils/Extensions/ViewExtension.swift +++ b/Linphone/Utils/Extensions/ViewExtension.swift @@ -32,4 +32,6 @@ extension View { self } } + + func apply(@ViewBuilder _ block: (Self) -> V) -> V { block(self) } } diff --git a/Linphone/Utils/PhotoPicker.swift b/Linphone/Utils/PhotoPicker.swift index 8e4c951aa..6811d85b7 100644 --- a/Linphone/Utils/PhotoPicker.swift +++ b/Linphone/Utils/PhotoPicker.swift @@ -23,14 +23,16 @@ import PhotosUI struct PhotoPicker: UIViewControllerRepresentable { typealias UIViewControllerType = PHPickerViewController - let filter: PHPickerFilter + let filter: PHPickerFilter? var limit: Int = 0 let onComplete: ([PHPickerResult]) -> Void func makeUIViewController(context: Context) -> PHPickerViewController { var configuration = PHPickerConfiguration() - configuration.filter = filter + if filter != nil { + configuration.filter = filter + } configuration.selectionLimit = limit let controller = PHPickerViewController(configuration: configuration) @@ -63,6 +65,89 @@ struct PhotoPicker: UIViewControllerRepresentable { } } + 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, url: urlImage!, type: .image) + medias.append(attachment) + } + } catch { + + } + } + + 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, thumbnail: urlThumbnail, full: urlImage!, type: .video) + medias.append(attachment) + } + } catch { + + } + } + 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) ?? "")) + + let decodedData: () = 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") + + let decodedData: () = 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 { From d0feb5b0474b7d8796ef698cbcbe258d0a438d46 Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Wed, 22 May 2024 14:21:42 +0200 Subject: [PATCH 233/486] Taking photos with camera in conversation view --- .../Fragments/ConversationFragment.swift | 141 +++++++++++------- .../ViewModel/ConversationViewModel.swift | 18 ++- 2 files changed, 102 insertions(+), 57 deletions(-) diff --git a/Linphone/UI/Main/Conversations/Fragments/ConversationFragment.swift b/Linphone/UI/Main/Conversations/Fragments/ConversationFragment.swift index aee68444a..c5fbffee4 100644 --- a/Linphone/UI/Main/Conversations/Fragments/ConversationFragment.swift +++ b/Linphone/UI/Main/Conversations/Fragments/ConversationFragment.swift @@ -334,54 +334,56 @@ struct ConversationFragment: View { .frame(height: 120) } - LazyVGrid(columns: [ - GridItem(.adaptive(minimum: 100), spacing: 1) - ], spacing: 3) { + if !mediasIsLoading { + LazyVGrid(columns: [ + GridItem(.adaptive(minimum: 100), spacing: 1) + ], spacing: 3) { ForEach(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) + ZStack { + Rectangle() + .fill(Color(.white)) + .frame(width: 100, height: 100) + + AsyncImage(url: attachment.thumbnail) { image in + ZStack { + image .resizable() - .foregroundStyle(.white) - .frame(width: 40, height: 40, alignment: .leading) + .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 mediasToSend.count == 1 { + withAnimation { + mediasToSend = [] + } + } else { + guard let index = self.mediasToSend.firstIndex(of: attachment) else { return } + self.mediasToSend.remove(at: index) } } - } placeholder: { - ProgressView() } - .layoutPriority(-1) - .onTapGesture { - if mediasToSend.count == 1 { - withAnimation { - mediasToSend = [] - } - } else { - guard let index = self.mediasToSend.firstIndex(of: attachment) else { return } - self.mediasToSend.remove(at: index) - } - } + .clipShape(RoundedRectangle(cornerRadius: 4)) + .contentShape(Rectangle()) } - .clipShape(RoundedRectangle(cornerRadius: 4)) - .contentShape(Rectangle()) } + .frame( + width: geometry.size.width > 0 && CGFloat(102 * mediasToSend.count) > geometry.size.width - 20 + ? 102 * floor(CGFloat(geometry.size.width - 20) / 102) + : CGFloat(102 * mediasToSend.count) + ) } - .frame( - width: geometry.size.width > 0 && CGFloat(102 * mediasToSend.count) > geometry.size.width - 20 - ? 102 * floor(CGFloat(geometry.size.width - 20) / 102) - : CGFloat(102 * mediasToSend.count) - ) } .frame(maxWidth: .infinity) .padding(.all, mediasToSend.isEmpty ? 0 : 10) @@ -552,12 +554,10 @@ struct ConversationFragment: View { } .edgesIgnoringSafeArea(.all) } - /* .fullScreenCover(isPresented: $isShowCamera) { - ImagePicker(selectedImage: self.$image, sourceType: .camera) + ImagePicker(conversationViewModel: conversationViewModel, selectedMedia: self.$mediasToSend) .edgesIgnoringSafeArea(.all) } - */ } } .navigationViewStyle(.stack) @@ -578,10 +578,9 @@ extension UIApplication { } struct ImagePicker: UIViewControllerRepresentable { - @Binding var selectedImage: UIImage + @ObservedObject var conversationViewModel: ConversationViewModel + @Binding var selectedMedia: [Attachment] @Environment(\.presentationMode) private var presentationMode - - var sourceType: UIImagePickerController.SourceType = .photoLibrary final class Coordinator: NSObject, UIImagePickerControllerDelegate, UINavigationControllerDelegate { @@ -592,9 +591,49 @@ struct ImagePicker: UIViewControllerRepresentable { } func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any]) { - - if let image = info[UIImagePickerController.InfoKey.originalImage] as? UIImage { - parent.selectedImage = image + let mediaType = info[UIImagePickerController.InfoKey.mediaType] as? String + switch mediaType { + case "public.image": + let image = info[UIImagePickerController.InfoKey.originalImage] as? UIImage + + let date = Date() + let df = DateFormatter() + df.dateFormat = "yyyy-MM-dd-HHmmss" + let dateString = df.string(from: date) + + let path = FileManager.default.temporaryDirectory.appendingPathComponent((dateString.addingPercentEncoding(withAllowedCharacters: .urlHostAllowed) ?? "") + ".jpeg") + + if image != nil { + let data = image!.jpegData(compressionQuality: 1) + if data != nil { + do { + let decodedData: () = try data!.write(to: path) + let attachment = Attachment(id: UUID().uuidString, url: path, type: .image) + parent.selectedMedia.append(attachment) + } catch { + } + } + } + case "public.movie": + let videoUrl = info[UIImagePickerController.InfoKey.mediaURL] as? URL + if videoUrl != nil { + let name = videoUrl!.lastPathComponent + let path = videoUrl!.deletingLastPathComponent() + let pathThumbnail = URL(string: parent.conversationViewModel.generateThumbnail(name: name, pathThumbnail: path)) + + if pathThumbnail != nil { + let attachment = + Attachment( + id: UUID().uuidString, + thumbnail: pathThumbnail!, + full: videoUrl!, + type: .video + ) + parent.selectedMedia.append(attachment) + } + } + default: + Log.info("Mismatched type: \(mediaType)") } parent.presentationMode.wrappedValue.dismiss() @@ -603,8 +642,8 @@ struct ImagePicker: UIViewControllerRepresentable { func makeUIViewController(context: UIViewControllerRepresentableContext) -> UIImagePickerController { let imagePicker = UIImagePickerController() - imagePicker.allowsEditing = false - imagePicker.sourceType = sourceType + imagePicker.sourceType = .camera + imagePicker.mediaTypes = ["public.image", "public.movie"] imagePicker.delegate = context.coordinator return imagePicker diff --git a/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift b/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift index 7dcefdf2f..adb223b06 100644 --- a/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift +++ b/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift @@ -139,12 +139,14 @@ class ConversationViewModel: ObservableObject { attachmentList.append(attachment) } } else if content.type == "video" { - let path = URL(string: self.generateThumbnail(name: content.name ?? "")) - if path != nil { + 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, - url: path!, + thumbnail: pathThumbnail!, + full: path!, type: .video ) attachmentList.append(attachment) @@ -502,9 +504,11 @@ class ConversationViewModel: ObservableObject { return "file://" + Factory.Instance.getDownloadDir(context: nil) + (name.addingPercentEncoding(withAllowedCharacters: .urlHostAllowed) ?? "") } - func generateThumbnail(name: String) -> String { + func generateThumbnail(name: String, pathThumbnail: URL? = nil) -> String { do { - let path = URL(string: "file://" + Factory.Instance.getDownloadDir(context: nil) + (name.addingPercentEncoding(withAllowedCharacters: .urlHostAllowed) ?? "")) + let path = pathThumbnail == nil + ? URL(string: "file://" + Factory.Instance.getDownloadDir(context: nil) + (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 @@ -515,7 +519,9 @@ class ConversationViewModel: ObservableObject { return "" } - let urlName = URL(string: "file://" + Factory.Instance.getDownloadDir(context: nil) + "preview_" + (name.addingPercentEncoding(withAllowedCharacters: .urlHostAllowed) ?? "") + ".png") + let urlName = pathThumbnail == nil + ? URL(string: "file://" + Factory.Instance.getDownloadDir(context: nil) + "preview_" + (name.addingPercentEncoding(withAllowedCharacters: .urlHostAllowed) ?? "") + ".png") + : pathThumbnail!.appendingPathComponent("preview_" + (name.addingPercentEncoding(withAllowedCharacters: .urlHostAllowed) ?? "") + ".png") if urlName != nil { let decodedData: () = try data.write(to: urlName!) From e7707c0b2b1f630f87af99412625c14db609061f Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Wed, 22 May 2024 17:25:17 +0200 Subject: [PATCH 234/486] Send media --- .../Fragments/ConversationFragment.swift | 38 +++++------ .../ViewModel/ConversationViewModel.swift | 67 ++++++++++++------- 2 files changed, 62 insertions(+), 43 deletions(-) diff --git a/Linphone/UI/Main/Conversations/Fragments/ConversationFragment.swift b/Linphone/UI/Main/Conversations/Fragments/ConversationFragment.swift index c5fbffee4..53baddd70 100644 --- a/Linphone/UI/Main/Conversations/Fragments/ConversationFragment.swift +++ b/Linphone/UI/Main/Conversations/Fragments/ConversationFragment.swift @@ -47,9 +47,7 @@ struct ConversationFragment: View { @State private var isShowPhotoLibrary = false @State private var isShowCamera = false - @State private var mediasToSend: [Attachment] = [] @State private var mediasIsLoading = false - @State private var maxMediaCount = 12 var body: some View { NavigationView { @@ -320,7 +318,7 @@ struct ConversationFragment: View { } } - if !mediasToSend.isEmpty || mediasIsLoading { + if !conversationViewModel.mediasToSend.isEmpty || mediasIsLoading { ZStack(alignment: .top) { HStack { if mediasIsLoading { @@ -338,7 +336,7 @@ struct ConversationFragment: View { LazyVGrid(columns: [ GridItem(.adaptive(minimum: 100), spacing: 1) ], spacing: 3) { - ForEach(mediasToSend, id: \.id) { attachment in + ForEach(conversationViewModel.mediasToSend, id: \.id) { attachment in ZStack { Rectangle() .fill(Color(.white)) @@ -364,13 +362,13 @@ struct ConversationFragment: View { } .layoutPriority(-1) .onTapGesture { - if mediasToSend.count == 1 { + if conversationViewModel.mediasToSend.count == 1 { withAnimation { - mediasToSend = [] + conversationViewModel.mediasToSend.removeAll() } } else { - guard let index = self.mediasToSend.firstIndex(of: attachment) else { return } - self.mediasToSend.remove(at: index) + guard let index = self.conversationViewModel.mediasToSend.firstIndex(of: attachment) else { return } + self.conversationViewModel.mediasToSend.remove(at: index) } } } @@ -379,14 +377,14 @@ struct ConversationFragment: View { } } .frame( - width: geometry.size.width > 0 && CGFloat(102 * mediasToSend.count) > geometry.size.width - 20 + width: geometry.size.width > 0 && CGFloat(102 * conversationViewModel.mediasToSend.count) > geometry.size.width - 20 ? 102 * floor(CGFloat(geometry.size.width - 20) / 102) - : CGFloat(102 * mediasToSend.count) + : CGFloat(102 * conversationViewModel.mediasToSend.count) ) } } .frame(maxWidth: .infinity) - .padding(.all, mediasToSend.isEmpty ? 0 : 10) + .padding(.all, conversationViewModel.mediasToSend.isEmpty ? 0 : 10) .background(Color.gray100) if !mediasIsLoading { @@ -395,7 +393,7 @@ struct ConversationFragment: View { Button(action: { withAnimation { - mediasToSend = [] + conversationViewModel.mediasToSend.removeAll() } }, label: { Image("x") @@ -429,11 +427,11 @@ struct ConversationFragment: View { Image("paperclip") .renderingMode(.template) .resizable() - .foregroundStyle(maxMediaCount <= mediasToSend.count || mediasIsLoading ? Color.grayMain2c300 : Color.grayMain2c500) + .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(maxMediaCount <= mediasToSend.count || mediasIsLoading) + .disabled(conversationViewModel.maxMediaCount <= conversationViewModel.mediasToSend.count || mediasIsLoading) } .padding(.horizontal, isMessageTextFocused ? 0 : 2) @@ -443,11 +441,11 @@ struct ConversationFragment: View { Image("camera") .renderingMode(.template) .resizable() - .foregroundStyle(maxMediaCount <= mediasToSend.count || mediasIsLoading ? Color.grayMain2c300 : Color.grayMain2c500) + .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(maxMediaCount <= mediasToSend.count || mediasIsLoading) + .disabled(conversationViewModel.maxMediaCount <= conversationViewModel.mediasToSend.count || mediasIsLoading) } .padding(.horizontal, isMessageTextFocused ? 0 : 2) @@ -479,7 +477,7 @@ struct ConversationFragment: View { } } - if conversationViewModel.messageText.isEmpty { + if conversationViewModel.messageText.isEmpty && conversationViewModel.mediasToSend.isEmpty { Button { } label: { Image("microphone") @@ -539,14 +537,14 @@ struct ConversationFragment: View { conversationViewModel.removeConversationDelegate() } .sheet(isPresented: $isShowPhotoLibrary) { - PhotoPicker(filter: nil, limit: maxMediaCount - mediasToSend.count) { results in + 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 { - mediasToSend.append(contentsOf: medias) + conversationViewModel.mediasToSend.append(contentsOf: medias) } self.mediasIsLoading = false @@ -555,7 +553,7 @@ struct ConversationFragment: View { .edgesIgnoringSafeArea(.all) } .fullScreenCover(isPresented: $isShowCamera) { - ImagePicker(conversationViewModel: conversationViewModel, selectedMedia: self.$mediasToSend) + ImagePicker(conversationViewModel: conversationViewModel, selectedMedia: self.$conversationViewModel.mediasToSend) .edgesIgnoringSafeArea(.all) } } diff --git a/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift b/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift index adb223b06..ce8b14d5a 100644 --- a/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift +++ b/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift @@ -38,6 +38,9 @@ class ConversationViewModel: ObservableObject { @Published var conversationMessagesSection: [MessagesSection] = [] @Published var participantConversationModel: [ContactAvatarModel] = [] + @Published var mediasToSend: [Attachment] = [] + var maxMediaCount = 12 + init() {} func addConversationDelegate() { @@ -422,30 +425,45 @@ class ConversationViewModel: ObservableObject { Log.e("$TAG Voice recording content couldn't be created!") } } else { - for (attachment in attachments.value.orEmpty()) { - val content = Factory.instance().createContent() - - content.type = when (attachment.mimeType) { - FileUtils.MimeType.Image -> "image" - FileUtils.MimeType.Audio -> "audio" - FileUtils.MimeType.Video -> "video" - FileUtils.MimeType.Pdf -> "application" - FileUtils.MimeType.PlainText -> "text" - else -> "file" - } - content.subtype = if (attachment.mimeType == FileUtils.MimeType.PlainText) { - "plain" - } else { - FileUtils.getExtensionFromFileName(attachment.fileName) - } - content.name = attachment.fileName - // Let the file body handler take care of the upload - content.filePath = attachment.file - - message.addFileContent(content) - } - } */ + 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 + + let filePathTmp = attachment.full.absoluteString + content.filePath = String(filePathTmp.dropFirst(7)) + + if message != nil { + message!.addFileContent(content: content) + } + } catch { + } + } + //} if message != nil && !message!.contents.isEmpty { Log.info("[ConversationViewModel] Sending message") @@ -455,6 +473,9 @@ class ConversationViewModel: ObservableObject { Log.info("[ConversationViewModel] Message sent, re-setting defaults") DispatchQueue.main.async { + withAnimation { + self.mediasToSend.removeAll() + } self.messageText = "" } From e94065ee2ec91b36c01dc0ac817582d6bf6c43cb Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Thu, 23 May 2024 11:37:10 +0200 Subject: [PATCH 235/486] Fix onCallStateChanged crash --- Linphone/TelecomManager/TelecomManager.swift | 140 +++++++++++-------- 1 file changed, 79 insertions(+), 61 deletions(-) diff --git a/Linphone/TelecomManager/TelecomManager.swift b/Linphone/TelecomManager/TelecomManager.swift index b730cae1c..38c0a2e16 100644 --- a/Linphone/TelecomManager/TelecomManager.swift +++ b/Linphone/TelecomManager/TelecomManager.swift @@ -114,8 +114,10 @@ class TelecomManager: ObservableObject { setHeldOtherCalls(core: core, exceptCallid: "") requestTransaction(transaction, action: "startCall") - withAnimation { - self.callDisplayed = true + DispatchQueue.main.async { + withAnimation { + self.callDisplayed = true + } } } else { try doCall(core: core, addr: addr!, isSas: isSas, isVideo: isVideo, isConference: isConference) @@ -397,98 +399,114 @@ class TelecomManager: ObservableObject { if cstate == .PushIncomingReceived { Log.info("PushIncomingReceived on TelecomManager -- Ignore, should be processed by a the dedicated CoreDelegate for callkit display") } else { + let oldRemoteConfVideo = self.remoteConfVideo - DispatchQueue.main.async { - let oldRemoteConfVideo = self.remoteConfVideo - - if call.conference != nil { - if call.conference!.activeSpeakerParticipantDevice != nil { - let direction = call.conference?.activeSpeakerParticipantDevice!.getStreamCapability(streamType: StreamType.Video) + if call.conference != nil { + if call.conference!.activeSpeakerParticipantDevice != nil { + let direction = call.conference?.activeSpeakerParticipantDevice!.getStreamCapability(streamType: StreamType.Video) + + DispatchQueue.main.async { self.remoteConfVideo = false DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { self.remoteConfVideo = 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) + } + } 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) + + DispatchQueue.main.async { self.remoteConfVideo = false DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { self.remoteConfVideo = 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) + } + } 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) + + DispatchQueue.main.async { self.remoteConfVideo = false DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { self.remoteConfVideo = direction == .SendRecv || direction == .SendOnly } - } else { - self.remoteConfVideo = false } } else { + DispatchQueue.main.async { + self.remoteConfVideo = false + } + } + } else { + DispatchQueue.main.async { self.remoteConfVideo = false DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { self.remoteConfVideo = call.currentParams!.videoEnabled && call.currentParams!.videoDirection == .SendRecv || call.currentParams!.videoDirection == .RecvOnly } } + } + + /* + if self.remoteConfVideo && self.remoteConfVideo != oldRemoteConfVideo { + do { + try AVAudioSession.sharedInstance().overrideOutputAudioPort(.speaker) + } catch _ { + } + } + */ + + if self.remoteConfVideo { + Log.info("[Call] Remote video is activated") + } + + let isRecordingByRemoteTmp = call.remoteParams?.isRecording ?? false + + if isRecordingByRemoteTmp && ToastViewModel.shared.toastMessage.isEmpty { - /* - if self.remoteConfVideo && self.remoteConfVideo != oldRemoteConfVideo { - do { - try AVAudioSession.sharedInstance().overrideOutputAudioPort(.speaker) - } catch _ { - + 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! } } - */ - if self.remoteConfVideo { - Log.info("[Call] Remote video is activated") - } - - self.isRecordingByRemote = call.remoteParams?.isRecording ?? false - - if self.isRecordingByRemote && 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 !self.isRecordingByRemote && ToastViewModel.shared.toastMessage.contains("is recording") { - - withAnimation { - ToastViewModel.shared.toastMessage = "" - ToastViewModel.shared.displayToast = false - } - - Log.info("[Call] Recording is stopped by \(call.remoteAddress!.asStringUriOnly())") + 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 } - - switch call.state { - case Call.State.PausedByRemote: + Log.info("[Call] Recording is stopped by \(call.remoteAddress!.asStringUriOnly())") + } + + switch call.state { + case Call.State.PausedByRemote: + DispatchQueue.main.async { self.isPausedByRemote = true - default: + } + default: + DispatchQueue.main.async { self.isPausedByRemote = false } - - if cstate == Call.State.Connected { + } + + if cstate == Call.State.Connected { + DispatchQueue.main.async { self.callConnected = true - self.meetingWaitingRoomSelected = nil self.meetingWaitingRoomDisplayed = false } From 476f1f22efa9d0dffcb0c4c7f7411144e3a6308a Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Thu, 23 May 2024 14:04:42 +0200 Subject: [PATCH 236/486] Fix sending multimedia messages --- .../ViewModel/ConversationViewModel.swift | 26 +++++++++++++++---- 1 file changed, 21 insertions(+), 5 deletions(-) diff --git a/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift b/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift index ce8b14d5a..add138ae3 100644 --- a/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift +++ b/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift @@ -46,7 +46,7 @@ class ConversationViewModel: ObservableObject { func addConversationDelegate() { coreContext.doOnCoreQueue { _ in if self.displayedConversation != nil { - self.chatRoomSuscriptions.insert(self.displayedConversation?.chatRoom.publisher?.onChatMessageSent?.postOnCoreQueue { (cbValue: (chatRoom: ChatRoom, eventLog: EventLog)) in + self.chatRoomSuscriptions.insert(self.displayedConversation?.chatRoom.publisher?.onChatMessageSending?.postOnCoreQueue { (cbValue: (chatRoom: ChatRoom, eventLog: EventLog)) in self.getNewMessages(eventLogs: [cbValue.eventLog]) }) @@ -454,11 +454,27 @@ class ConversationViewModel: ObservableObject { content.name = attachment.full.lastPathComponent - let filePathTmp = attachment.full.absoluteString - content.filePath = String(filePathTmp.dropFirst(7)) - if message != nil { - message!.addFileContent(content: content) + + let path = FileManager.default.temporaryDirectory.appendingPathComponent((attachment.full.lastPathComponent.addingPercentEncoding(withAllowedCharacters: .urlHostAllowed) ?? "")) + let newPath = URL(string: "file://" + Factory.Instance.getDownloadDir(context: nil) + (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 { } From 57c8efe310667a1d6efe536889a0267b017d0124 Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Fri, 24 May 2024 11:16:08 +0200 Subject: [PATCH 237/486] Disable FEC --- Linphone/Core/CoreContext.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Linphone/Core/CoreContext.swift b/Linphone/Core/CoreContext.swift index 3a345aee7..81550b380 100644 --- a/Linphone/Core/CoreContext.swift +++ b/Linphone/Core/CoreContext.swift @@ -111,7 +111,7 @@ final class CoreContext: ObservableObject { self.mCore.videoDisplayEnabled = true self.mCore.videoPreviewEnabled = false - self.mCore.fecEnabled = true + self.mCore.fecEnabled = false self.mCoreSuscriptions.insert(self.mCore.publisher?.onGlobalStateChanged?.postOnMainQueue { (cbVal: (core: Core, state: GlobalState, message: String)) in if cbVal.state == GlobalState.On { From 9f3aeb63ac5be53122f54929777a49292a46cb48 Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Fri, 24 May 2024 14:37:08 +0200 Subject: [PATCH 238/486] Fix orientation image --- Linphone/UI/Main/Conversations/Fragments/ChatBubbleView.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Linphone/UI/Main/Conversations/Fragments/ChatBubbleView.swift b/Linphone/UI/Main/Conversations/Fragments/ChatBubbleView.swift index 9da8dc9a4..868c16330 100644 --- a/Linphone/UI/Main/Conversations/Fragments/ChatBubbleView.swift +++ b/Linphone/UI/Main/Conversations/Fragments/ChatBubbleView.swift @@ -315,7 +315,8 @@ struct ChatBubbleView: View { if let imageProperties = CGImageSourceCopyPropertiesAtIndex(imageSource, 0, nil) as Dictionary? { let pixelWidth = imageProperties[kCGImagePropertyPixelWidth] as? CGFloat let pixelHeight = imageProperties[kCGImagePropertyPixelHeight] as? CGFloat - return (pixelWidth ?? 0, pixelHeight ?? 0) + let orientation = imageProperties[kCGImagePropertyOrientation] as? Int + return orientation != nil && orientation == 6 ? (pixelHeight ?? 0, pixelWidth ?? 0) : (pixelWidth ?? 0, pixelHeight ?? 0) } } return (0, 0) From ad701fb9527e137cd06d9f92c82d7c61f5d53f35 Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Mon, 27 May 2024 17:29:03 +0200 Subject: [PATCH 239/486] Refactor history views --- Linphone.xcodeproj/project.pbxproj | 12 + Linphone/UI/Main/ContentView.swift | 17 +- .../Fragments/HistoryContactFragment.swift | 846 +++++++----------- .../Fragments/HistoryListBottomSheet.swift | 39 +- .../Fragments/HistoryListFragment.swift | 161 +--- .../UI/Main/History/Model/HistoryModel.swift | 99 ++ .../ViewModel/HistoryListViewModel.swift | 83 +- .../History/ViewModel/HistoryViewModel.swift | 18 +- 8 files changed, 536 insertions(+), 739 deletions(-) create mode 100644 Linphone/UI/Main/History/Model/HistoryModel.swift diff --git a/Linphone.xcodeproj/project.pbxproj b/Linphone.xcodeproj/project.pbxproj index 2ec02b416..24027cc55 100644 --- a/Linphone.xcodeproj/project.pbxproj +++ b/Linphone.xcodeproj/project.pbxproj @@ -86,6 +86,7 @@ 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 */; }; D76005F62B0798B00054B79A /* IntExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = D76005F52B0798B00054B79A /* IntExtension.swift */; }; @@ -244,6 +245,7 @@ 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 = ""; }; D76005F52B0798B00054B79A /* IntExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IntExtension.swift; sourceTree = ""; }; @@ -595,6 +597,14 @@ path = Fragments; sourceTree = ""; }; + D74DA0102C047EE300A8561D /* Model */ = { + isa = PBXGroup; + children = ( + D74DA0112C047F0700A8561D /* HistoryModel.swift */, + ); + path = Model; + sourceTree = ""; + }; D75759302B56D3CE00E7AC10 /* Fragments */ = { isa = PBXGroup; children = ( @@ -669,6 +679,7 @@ isa = PBXGroup; children = ( D72992372ADD7F1C003AF125 /* Fragments */, + D74DA0102C047EE300A8561D /* Model */, D72250612ADE95E4008FB426 /* ViewModel */, D7A03FBF2ACC2E390081A588 /* HistoryView.swift */, ); @@ -1047,6 +1058,7 @@ D726E43F2B19E56F0083C415 /* StartCallViewModel.swift in Sources */, D70A26EE2B7CF60B006CC8FC /* ConversationsListBottomSheet.swift in Sources */, D7D1698C2AE66FA500109A5C /* MagicSearchSingleton.swift in Sources */, + D74DA0122C047F0700A8561D /* HistoryModel.swift in Sources */, D72250692ADFBF2D008FB426 /* SideMenu.swift in Sources */, D7CEE0352B7A210300FD79B7 /* ConversationsView.swift in Sources */, D717071E2AC5922E0037746F /* ColorExtension.swift in Sources */, diff --git a/Linphone/UI/Main/ContentView.swift b/Linphone/UI/Main/ContentView.swift index 259851373..f71015354 100644 --- a/Linphone/UI/Main/ContentView.swift +++ b/Linphone/UI/Main/ContentView.swift @@ -718,21 +718,9 @@ struct ContentView: View { .background(Color.gray100) .ignoresSafeArea(.keyboard) } else if self.index == 1 { - let fromAddressFriend = historyViewModel.displayedCall != nil ? contactsManager.getFriendWithAddress(address: historyViewModel.displayedCall!.fromAddress!) : nil - let toAddressFriend = historyViewModel.displayedCall != nil ? contactsManager.getFriendWithAddress(address: historyViewModel.displayedCall!.toAddress!) : nil - let addressFriend = historyViewModel.displayedCall != nil ? (historyViewModel.displayedCall!.dir == .Incoming ? fromAddressFriend : toAddressFriend) : nil - - let contactAvatarModel = addressFriend != nil - ? ContactsManager.shared.avatarListModel.first(where: { - ($0.friend!.consolidatedPresence == .Online || $0.friend!.consolidatedPresence == .Busy) - && $0.friend!.name == addressFriend!.name - && $0.friend!.address!.asStringUriOnly() == addressFriend!.address!.asStringUriOnly() - }) - : ContactAvatarModel(friend: nil, name: "", address: "", withPresence: false) - - if contactAvatarModel != nil { + if historyViewModel.displayedCall!.avatarModel != nil { HistoryContactFragment( - contactAvatarModel: contactAvatarModel!, + contactAvatarModel: historyViewModel.displayedCall!.avatarModel!, historyViewModel: historyViewModel, historyListViewModel: historyListViewModel, contactViewModel: contactViewModel, @@ -984,6 +972,7 @@ struct ContentView: View { } .onReceive(pub) { _ in conversationsListViewModel.refreshContactAvatarModel() + historyListViewModel.refreshHistoryAvatarModel() } } .overlay { diff --git a/Linphone/UI/Main/History/Fragments/HistoryContactFragment.swift b/Linphone/UI/Main/History/Fragments/HistoryContactFragment.swift index f41b44a91..aef551046 100644 --- a/Linphone/UI/Main/History/Fragments/HistoryContactFragment.swift +++ b/Linphone/UI/Main/History/Fragments/HistoryContactFragment.swift @@ -76,551 +76,381 @@ struct HistoryContactFragment: View { Spacer() Menu { - let fromAddressFriend = historyViewModel.displayedCall != nil ? contactsManager.getFriendWithAddress(address: historyViewModel.displayedCall!.fromAddress!) : nil - let toAddressFriend = historyViewModel.displayedCall != nil ? contactsManager.getFriendWithAddress(address: historyViewModel.displayedCall!.toAddress!) : nil - let addressFriend = historyViewModel.displayedCall != nil ? (historyViewModel.displayedCall!.dir == .Incoming ? fromAddressFriend : toAddressFriend) : nil - - if historyViewModel.displayedCallIsConference.isEmpty { + if historyViewModel.displayedCall != nil && !historyViewModel.displayedCall!.isConf { Button { isMenuOpen = false - if contactsManager.getFriendWithAddress( - address: historyViewModel.displayedCall != nil && historyViewModel.displayedCall!.dir == .Outgoing - ? historyViewModel.displayedCall!.toAddress! - : historyViewModel.displayedCall!.fromAddress! - ) != nil { - let addressCall = historyViewModel.displayedCall != nil && historyViewModel.displayedCall!.dir == .Outgoing - ? historyViewModel.displayedCall!.toAddress! - : historyViewModel.displayedCall!.fromAddress! + if historyViewModel.displayedCall != nil && historyViewModel.displayedCall!.addressFriend != nil { + let addressCall = historyViewModel.displayedCall!.addressFriend!.address - let friendIndex = contactsManager.lastSearch.firstIndex( - where: {$0.friend!.addresses.contains(where: {$0.asStringUriOnly() == addressCall.asStringUriOnly()})}) - if friendIndex != nil { - - withAnimation { - historyViewModel.displayedCall = nil - indexPage = 0 + if addressCall != nil { + let friendIndex = contactsManager.lastSearch.firstIndex( + where: {$0.friend!.addresses.contains(where: {$0.asStringUriOnly() == addressCall!.asStringUriOnly()})}) + if friendIndex != nil { - contactViewModel.indexDisplayedFriend = friendIndex + withAnimation { + historyViewModel.displayedCall = nil + indexPage = 0 + + contactViewModel.indexDisplayedFriend = friendIndex + } } } } else { - let addressCall = historyViewModel.displayedCall != nil && historyViewModel.displayedCall!.dir == .Outgoing - ? historyViewModel.displayedCall!.toAddress! - : historyViewModel.displayedCall!.fromAddress! - withAnimation { historyViewModel.displayedCall = nil indexPage = 0 isShowEditContactFragment.toggle() editContactViewModel.sipAddresses.removeAll() - editContactViewModel.sipAddresses.append(String(addressCall.asStringUriOnly().dropFirst(4))) + editContactViewModel.sipAddresses.append(String(historyViewModel.displayedCall?.address.dropFirst(4) ?? "")) editContactViewModel.sipAddresses.append("") } } } label: { HStack { - Text(addressFriend != nil ? "See contact" : "Add to contacts") + Text(historyViewModel.displayedCall!.addressFriend != nil ? "See contact" : "Add to contacts") Spacer() - Image(addressFriend != nil ? "user-circle" : "plus-circle") + Image(historyViewModel.displayedCall!.addressFriend != nil ? "user-circle" : "plus-circle") .resizable() .frame(width: 25, height: 25, alignment: .leading) .padding(.all, 10) } } } + + Button { + isMenuOpen = false - Button { - isMenuOpen = false - - if historyViewModel.displayedCall != nil && historyViewModel.displayedCall!.dir == .Outgoing { - UIPasteboard.general.setValue( - historyViewModel.displayedCall!.toAddress!.asStringUriOnly().dropFirst(4), - forPasteboardType: UTType.plainText.identifier - ) - } else { - UIPasteboard.general.setValue( - historyViewModel.displayedCall!.fromAddress!.asStringUriOnly().dropFirst(4), - forPasteboardType: UTType.plainText.identifier - ) - } - - ToastViewModel.shared.toastMessage = "Success_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) - } + 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 + ) } - Button(role: .destructive) { - isMenuOpen = false - - if historyViewModel.displayedCall != nil && historyViewModel.displayedCall!.dir == .Outgoing { - historyListViewModel.callLogsAddressToDelete = historyViewModel.displayedCall!.toAddress!.asStringUriOnly() - } else { - historyListViewModel.callLogsAddressToDelete = historyViewModel.displayedCall!.fromAddress!.asStringUriOnly() - } - - isShowDeleteAllHistoryPopup.toggle() - - } label: { - HStack { - Text("Delete history") - Spacer() - Image("trash-simple-red") - .resizable() - .frame(width: 25, height: 25, alignment: .leading) - .padding(.all, 10) - } - } + ToastViewModel.shared.toastMessage = "Success_copied_into_clipboard" + ToastViewModel.shared.displayToast.toggle() + } label: { - Image("dots-three-vertical") - .renderingMode(.template) - .resizable() - .foregroundStyle(Color.orangeMain500) - .frame(width: 25, height: 25, alignment: .leading) - .padding(.all, 10) + HStack { + Text("Copy SIP address") + Spacer() + Image("copy") + .resizable() + .frame(width: 25, height: 25, alignment: .leading) + .padding(.all, 10) + } } - .padding(.leading) - .onTapGesture { - isMenuOpen = true + + 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: { + + }, label: { + VStack { + HStack(alignment: .center) { + Image("chat-teardrop-text") + .renderingMode(.template) + .resizable() + //.foregroundStyle(Color.grayMain2c600) + .foregroundStyle(Color.grayMain2c300) + .frame(width: 25, height: 25) + .onTapGesture { + withAnimation { + + } + } + } + .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("Rejoindre") + .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.. 1 - ? historyViewModel.displayedCall!.toAddress!.displayName!.components(separatedBy: " ")[1] - : "")) - .resizable() - .frame(width: 100, height: 100) - .clipShape(Circle()) - - Text(historyViewModel.displayedCall!.toAddress!.displayName!) - .foregroundStyle(Color.grayMain2c700) - .multilineTextAlignment(.center) - .default_text_style(styleSize: 14) - .frame(maxWidth: .infinity) - .padding(.top, 10) - - Text(historyViewModel.displayedCall!.toAddress!.asStringUriOnly()) - .foregroundStyle(Color.grayMain2c700) - .multilineTextAlignment(.center) - .default_text_style(styleSize: 14) - .frame(maxWidth: .infinity) - .padding(.top, 5) - - Text("") - .multilineTextAlignment(.center) - .default_text_style_300(styleSize: 12) - .frame(maxWidth: .infinity) - .frame(height: 20) - } else { - Image(uiImage: contactsManager.textToImage( - firstName: historyViewModel.displayedCall!.toAddress!.username ?? "Username Error", - lastName: historyViewModel.displayedCall!.toAddress!.username!.components(separatedBy: " ").count > 1 - ? historyViewModel.displayedCall!.toAddress!.username!.components(separatedBy: " ")[1] - : "")) - .resizable() - .frame(width: 100, height: 100) - .clipShape(Circle()) - - Text(historyViewModel.displayedCall!.toAddress!.username ?? "Username Error") - .foregroundStyle(Color.grayMain2c700) - .multilineTextAlignment(.center) - .default_text_style(styleSize: 14) - .frame(maxWidth: .infinity) - .padding(.top, 10) - - Text(historyViewModel.displayedCall!.toAddress!.asStringUriOnly()) - .foregroundStyle(Color.grayMain2c700) - .multilineTextAlignment(.center) - .default_text_style(styleSize: 14) - .frame(maxWidth: .infinity) - .padding(.top, 5) - - Text("") - .multilineTextAlignment(.center) - .default_text_style_300(styleSize: 12) - .frame(maxWidth: .infinity) - .frame(height: 20) - } - - } else if historyViewModel.displayedCall!.fromAddress != nil { - if historyViewModel.displayedCall!.fromAddress!.displayName != nil { - Image(uiImage: contactsManager.textToImage( - firstName: historyViewModel.displayedCall!.fromAddress!.displayName!, - lastName: historyViewModel.displayedCall!.fromAddress!.displayName!.components(separatedBy: " ").count > 1 - ? historyViewModel.displayedCall!.fromAddress!.displayName!.components(separatedBy: " ")[1] - : "")) - .resizable() - .frame(width: 100, height: 100) - .clipShape(Circle()) - - Text(historyViewModel.displayedCall!.fromAddress!.displayName!) - .foregroundStyle(Color.grayMain2c700) - .multilineTextAlignment(.center) - .default_text_style(styleSize: 14) - .frame(maxWidth: .infinity) - .padding(.top, 10) - - Text(historyViewModel.displayedCall!.fromAddress!.asStringUriOnly()) - .foregroundStyle(Color.grayMain2c700) - .multilineTextAlignment(.center) - .default_text_style(styleSize: 14) - .frame(maxWidth: .infinity) - .padding(.top, 5) - - Text("") - .multilineTextAlignment(.center) - .default_text_style_300(styleSize: 12) - .frame(maxWidth: .infinity) - .frame(height: 20) - } else { - Image(uiImage: contactsManager.textToImage( - firstName: historyViewModel.displayedCall!.fromAddress!.username ?? "Username Error", - lastName: historyViewModel.displayedCall!.fromAddress!.username!.components(separatedBy: " ").count > 1 - ? historyViewModel.displayedCall!.fromAddress!.username!.components(separatedBy: " ")[1] - : "")) - .resizable() - .frame(width: 100, height: 100) - .clipShape(Circle()) - - Text(historyViewModel.displayedCall!.fromAddress!.username ?? "Username Error") - .foregroundStyle(Color.grayMain2c700) - .multilineTextAlignment(.center) - .default_text_style(styleSize: 14) - .frame(maxWidth: .infinity) - .padding(.top, 10) - - Text(historyViewModel.displayedCall!.fromAddress!.asStringUriOnly()) - .foregroundStyle(Color.grayMain2c700) - .multilineTextAlignment(.center) - .default_text_style(styleSize: 14) - .frame(maxWidth: .infinity) - .padding(.top, 5) - - Text("") - .multilineTextAlignment(.center) - .default_text_style_300(styleSize: 12) - .frame(maxWidth: .infinity) - .frame(height: 20) - } - } - } - - if historyViewModel.displayedCall != nil - && addressFriend != nil - && addressFriend!.name != nil { - Text((addressFriend!.name)!) - .foregroundStyle(Color.grayMain2c700) - .multilineTextAlignment(.center) - .default_text_style(styleSize: 14) - .frame(maxWidth: .infinity) - .padding(.top, 10) - - if historyViewModel.displayedCall!.dir == .Outgoing && historyViewModel.displayedCall!.toAddress != nil { - Text(historyViewModel.displayedCall!.toAddress!.asStringUriOnly()) - .foregroundStyle(Color.grayMain2c700) - .multilineTextAlignment(.center) - .default_text_style(styleSize: 14) - .frame(maxWidth: .infinity) - .padding(.top, 5) - } else if historyViewModel.displayedCall!.fromAddress != nil { - Text(historyViewModel.displayedCall!.fromAddress!.asStringUriOnly()) - .foregroundStyle(Color.grayMain2c700) - .multilineTextAlignment(.center) - .default_text_style(styleSize: 14) - .frame(maxWidth: .infinity) - .padding(.top, 5) - } - - 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 { - 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.displayedCallIsConference ?? "") - .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.displayedCallIsConference.isEmpty { - Button(action: { - if historyViewModel.displayedCall!.dir == .Outgoing && historyViewModel.displayedCall!.toAddress != nil { - telecomManager.doCallOrJoinConf(address: historyViewModel.displayedCall!.toAddress!) - } else if historyViewModel.displayedCall!.dir == .Incoming && historyViewModel.displayedCall!.fromAddress != nil { - telecomManager.doCallOrJoinConf(address: historyViewModel.displayedCall!.fromAddress!) - } - }, 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: { - - }, label: { - VStack { - HStack(alignment: .center) { - Image("chat-teardrop-text") - .renderingMode(.template) - .resizable() - //.foregroundStyle(Color.grayMain2c600) - .foregroundStyle(Color.grayMain2c300) - .frame(width: 25, height: 25) - .onTapGesture { - withAnimation { - - } - } - } - .padding(16) - .background(Color.grayMain2c200) - .cornerRadius(40) - - Text("Message") - .default_text_style(styleSize: 14) - .frame(minWidth: 80) - } - }) - - Spacer() - - Button(action: { - if historyViewModel.displayedCall!.dir == .Outgoing && historyViewModel.displayedCall!.toAddress != nil { - telecomManager.doCallOrJoinConf(address: historyViewModel.displayedCall!.toAddress!, isVideo: true) - } else if historyViewModel.displayedCall!.dir == .Incoming && historyViewModel.displayedCall!.fromAddress != nil { - telecomManager.doCallOrJoinConf(address: historyViewModel.displayedCall!.fromAddress!, 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!.dir == .Outgoing && historyViewModel.displayedCall!.toAddress != nil { - if historyViewModel.displayedCall!.toAddress!.asStringUriOnly().hasPrefix("sip:conference-focus@sip.linphone.org") { - do { - let meetingAddress = try Factory.Instance.createAddress(addr: historyViewModel.displayedCall!.toAddress!.asStringUriOnly()) - - telecomManager.meetingWaitingRoomDisplayed = true - telecomManager.meetingWaitingRoomSelected = meetingAddress - } catch {} - } else { - telecomManager.doCallOrJoinConf(address: historyViewModel.displayedCall!.toAddress!) - } - } else if historyViewModel.displayedCall!.fromAddress != nil { - if historyViewModel.displayedCall!.fromAddress!.asStringUriOnly().hasPrefix("sip:conference-focus@sip.linphone.org") { - do { - let meetingAddress = try Factory.Instance.createAddress(addr: historyViewModel.displayedCall!.fromAddress!.asStringUriOnly()) - - telecomManager.meetingWaitingRoomDisplayed = true - telecomManager.meetingWaitingRoomSelected = meetingAddress - } catch {} - } else { - telecomManager.doCallOrJoinConf(address: historyViewModel.displayedCall!.fromAddress!) - } - } - } - }, 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("Rejoindre") - .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!.dir == .Incoming ? historyViewModel.displayedCall!.fromAddress!.asStringUriOnly() - : historyViewModel.displayedCall!.toAddress!.asStringUriOnly()) : nil - - let callLogsFilter = historyListViewModel.callLogs.filter({ $0.dir == .Incoming - ? $0.fromAddress!.asStringUriOnly() == addressFriend - : $0.toAddress!.asStringUriOnly() == addressFriend }) - - ForEach(0.. 1 - ? historyListViewModel.callLogs[index].toAddress!.displayName!.components(separatedBy: " ")[1] - : "")) - .resizable() - .frame(width: 50, height: 50) - .clipShape(Circle()) - - } else if historyListViewModel.callLogs[index].toAddress!.username != nil { - Image(uiImage: contactsManager.textToImage( - firstName: historyListViewModel.callLogs[index].toAddress!.username!, - lastName: historyListViewModel.callLogs[index].toAddress!.username!.components(separatedBy: " ").count > 1 - ? historyListViewModel.callLogs[index].toAddress!.username!.components(separatedBy: " ")[1] - : "")) - .resizable() - .frame(width: 50, height: 50) - .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()) - } - } else if historyListViewModel.callLogs[index].fromAddress != nil { - if historyListViewModel.callLogs[index].fromAddress!.displayName != nil { - Image(uiImage: contactsManager.textToImage( - firstName: historyListViewModel.callLogs[index].fromAddress!.displayName!, - lastName: historyListViewModel.callLogs[index].fromAddress!.displayName!.components(separatedBy: " ").count > 1 - ? historyListViewModel.callLogs[index].fromAddress!.displayName!.components(separatedBy: " ")[1] - : "")) - .resizable() - .frame(width: 50, height: 50) - .clipShape(Circle()) - } else if historyListViewModel.callLogs[index].fromAddress!.username != nil { - Image(uiImage: contactsManager.textToImage( - firstName: historyListViewModel.callLogs[index].fromAddress!.username!, - lastName: historyListViewModel.callLogs[index].fromAddress!.username!.components(separatedBy: " ").count > 1 - ? historyListViewModel.callLogs[index].fromAddress!.username!.components(separatedBy: " ")[1] - : "")) - .resizable() - .frame(width: 50, height: 50) - .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()) - } + if !historyListViewModel.callLogs[index].addressName.isEmpty { + Image(uiImage: contactsManager.textToImage( + firstName: historyListViewModel.callLogs[index].addressName, + lastName: historyListViewModel.callLogs[index].addressName.components(separatedBy: " ").count > 1 + ? historyListViewModel.callLogs[index].addressName.components(separatedBy: " ")[1] + : "")) + .resizable() + .frame(width: 50, height: 50) + .clipShape(Circle()) } else { - Image("profil-picture-default") - .resizable() - .frame(width: 50, height: 50) - .clipShape(Circle()) + 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 { @@ -146,47 +79,24 @@ struct HistoryListFragment: View { VStack(spacing: 0) { Spacer() - if historyListViewModel.callLogsIsConference[index].isEmpty { - let fromAddressFriend = contactsManager.getFriendWithAddress(address: historyListViewModel.callLogs[index].fromAddress!) - let toAddressFriend = contactsManager.getFriendWithAddress(address: historyListViewModel.callLogs[index].toAddress!) - let addressFriend = historyListViewModel.callLogs[index].dir == .Incoming ? fromAddressFriend : toAddressFriend - - if addressFriend != nil { - Text(addressFriend!.name!) - .default_text_style(styleSize: 14) - .frame(maxWidth: .infinity, alignment: .leading) - .lineLimit(1) - } else { - if historyListViewModel.callLogs[index].dir == .Outgoing && historyListViewModel.callLogs[index].toAddress != nil { - Text(historyListViewModel.callLogs[index].toAddress!.displayName != nil - ? historyListViewModel.callLogs[index].toAddress!.displayName! - : historyListViewModel.callLogs[index].toAddress!.username ?? "") - .default_text_style(styleSize: 14) - .frame(maxWidth: .infinity, alignment: .leading) - .lineLimit(1) - } else if historyListViewModel.callLogs[index].fromAddress != nil { - Text(historyListViewModel.callLogs[index].fromAddress!.displayName != nil - ? historyListViewModel.callLogs[index].fromAddress!.displayName! - : historyListViewModel.callLogs[index].fromAddress!.username ?? "") - .default_text_style(styleSize: 14) - .frame(maxWidth: .infinity, alignment: .leading) - .lineLimit(1) - } - } + 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.callLogsIsConference[index]) + 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, callDir: historyListViewModel.callLogs[index].dir)) + Image(historyListViewModel.getCallIconResId(callStatus: historyListViewModel.callLogs[index].status, isOutgoing: historyListViewModel.callLogs[index].isOutgoing)) .resizable() .frame( - width: historyListViewModel.getCallIconResId(callStatus: historyListViewModel.callLogs[index].status, callDir: historyListViewModel.callLogs[index].dir).contains("rejected") ? 12 : 8, - height: historyListViewModel.getCallIconResId(callStatus: historyListViewModel.callLogs[index].status, callDir: historyListViewModel.callLogs[index].dir).contains("rejected") ? 6 : 8) - + 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) @@ -197,7 +107,7 @@ struct HistoryListFragment: View { Spacer() } - if historyListViewModel.callLogsIsConference[index].isEmpty { + if !historyListViewModel.callLogs[index].isConf { Image("phone") .resizable() .frame(width: 25, height: 25) @@ -223,7 +133,6 @@ struct HistoryListFragment: View { .onTapGesture { withAnimation { historyViewModel.displayedCall = historyListViewModel.callLogs[index] - historyViewModel.getConferenceSubject() } } .onLongPressGesture(minimumDuration: 0.2) { @@ -256,11 +165,7 @@ struct HistoryListFragment: View { } func doCall(index: Int) { - if historyListViewModel.callLogs[index].dir == .Outgoing && historyListViewModel.callLogs[index].toAddress != nil { - telecomManager.doCallOrJoinConf(address: historyListViewModel.callLogs[index].toAddress!) - } else if historyListViewModel.callLogs[index].fromAddress != nil { - telecomManager.doCallOrJoinConf(address: historyListViewModel.callLogs[index].fromAddress!) - } + telecomManager.doCallOrJoinConf(address: historyListViewModel.callLogs[index].addressLinphone) } } diff --git a/Linphone/UI/Main/History/Model/HistoryModel.swift b/Linphone/UI/Main/History/Model/HistoryModel.swift new file mode 100644 index 000000000..cc356f2f3 --- /dev/null +++ b/Linphone/UI/Main/History/Model/HistoryModel.swift @@ -0,0 +1,99 @@ +/* + * 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]" + + let callLog: CallLog + + let 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? = nil + @Published var avatarModel: ContactAvatarModel? = nil + + init(callLog: CallLog) { + self.callLog = callLog + self.id = callLog.callId ?? "" + self.subject = callLog.conferenceInfo != nil && callLog.conferenceInfo!.subject != nil ? callLog.conferenceInfo!.subject! : "" + self.isConf = callLog.conferenceInfo != nil + + let addressLinphoneTmp = callLog.dir == .Outgoing && callLog.toAddress != nil ? callLog.toAddress! : callLog.fromAddress! + self.addressLinphone = addressLinphoneTmp + //let addressLinphone = callLog.dir == .Outgoing && callLog.toAddress != nil ? callLog.toAddress! : callLog.fromAddress! + self.address = addressLinphoneTmp.asStringUriOnly() + + let addressNameTmp = callLog.conferenceInfo != nil && callLog.conferenceInfo!.subject != nil + ? callLog.conferenceInfo!.subject! + : (addressLinphoneTmp.username != nil ? addressLinphoneTmp.username ?? "" : addressLinphoneTmp.displayName ?? "") + + self.addressName = addressNameTmp + + self.isOutgoing = callLog.dir == .Outgoing + + self.status = callLog.status + + self.startDate = callLog.startDate + + self.duration = callLog.duration + + 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 index 480c3c3f2..3fb1db0f8 100644 --- a/Linphone/UI/Main/History/ViewModel/HistoryListViewModel.swift +++ b/Linphone/UI/Main/History/ViewModel/HistoryListViewModel.swift @@ -24,9 +24,8 @@ class HistoryListViewModel: ObservableObject { private var coreContext = CoreContext.shared - @Published var callLogs: [CallLog] = [] - @Published var callLogsIsConference: [String] = [] - var callLogsTmp: [CallLog] = [] + @Published var callLogs: [HistoryModel] = [] + var callLogsTmp: [HistoryModel] = [] var callLogsAddressToDelete = "" var callLogSubscription: AnyCancellable? @@ -43,14 +42,13 @@ class HistoryListViewModel: ObservableObject { let account = core.defaultAccount let logs = account?.callLogs != nil ? account!.callLogs : core.callLogs - var callLogsBis: [CallLog] = [] - var callLogsIsConferenceBis: [String] = [] - var callLogsTmpBis: [CallLog] = [] + var callLogsBis: [HistoryModel] = [] + var callLogsTmpBis: [HistoryModel] = [] logs.forEach { log in - callLogsBis.append(log) - callLogsIsConferenceBis.append(log.conferenceInfo != nil && log.conferenceInfo!.subject != nil ? log.conferenceInfo!.subject! : "") - callLogsTmpBis.append(log) + let history = HistoryModel(callLog: log) + callLogsBis.append(history) + callLogsTmpBis.append(history) } DispatchQueue.main.async { @@ -58,7 +56,6 @@ class HistoryListViewModel: ObservableObject { self.callLogsTmp.removeAll() self.callLogs = callLogsBis - self.callLogsIsConference = callLogsIsConferenceBis self.callLogsTmp = callLogsTmpBis } @@ -66,14 +63,13 @@ class HistoryListViewModel: ObservableObject { let account = core.defaultAccount let logs = account?.callLogs != nil ? account!.callLogs : core.callLogs - var callLogsBis: [CallLog] = [] - var callLogsIsConferenceBis: [String] = [] - var callLogsTmpBis: [CallLog] = [] + var callLogsBis: [HistoryModel] = [] + var callLogsTmpBis: [HistoryModel] = [] logs.forEach { log in - callLogsBis.append(log) - callLogsIsConferenceBis.append(log.conferenceInfo != nil && log.conferenceInfo!.subject != nil ? log.conferenceInfo!.subject! : "") - callLogsTmpBis.append(log) + let history = HistoryModel(callLog: log) + callLogsBis.append(history) + callLogsTmpBis.append(history) } DispatchQueue.main.async { @@ -81,7 +77,6 @@ class HistoryListViewModel: ObservableObject { self.callLogsTmp.removeAll() self.callLogs = callLogsBis - self.callLogsIsConference = callLogsIsConferenceBis self.callLogsTmp = callLogsTmpBis } @@ -123,24 +118,24 @@ class HistoryListViewModel: ObservableObject { } } - func getCallIconResId(callStatus: Call.Status, callDir: Call.Dir) -> String { + func getCallIconResId(callStatus: Call.Status, isOutgoing: Bool) -> String { switch callStatus { case Call.Status.Missed: - if callDir == .Outgoing { + if isOutgoing { "outgoing-call-missed" } else { "incoming-call-missed" } case Call.Status.Success: - if callDir == .Outgoing { + if isOutgoing { "outgoing-call" } else { "incoming-call" } default: - if callDir == .Outgoing { + if isOutgoing { "outgoing-call-rejected" } else { "incoming-call-rejected" @@ -148,24 +143,24 @@ class HistoryListViewModel: ObservableObject { } } - func getCallText(callStatus: Call.Status, callDir: Call.Dir) -> String { + func getCallText(callStatus: Call.Status, isOutgoing: Bool) -> String { switch callStatus { case Call.Status.Missed: - if callDir == .Outgoing { + if isOutgoing { "Outgoing Call" } else { "Missed Call" } case Call.Status.Success: - if callDir == .Outgoing { + if isOutgoing { "Outgoing Call" } else { "Incoming Call" } default: - if callDir == .Outgoing { + if isOutgoing { "Outgoing Call" } else { "Incoming Call" @@ -200,18 +195,8 @@ class HistoryListViewModel: ObservableObject { func filterCallLogs(filter: String) { callLogs.removeAll() callLogsTmp.forEach { callLog in - if callLog.dir == .Outgoing && callLog.toAddress != nil { - if callLog.toAddress!.username != nil && callLog.toAddress!.username!.contains(filter) { - callLogs.append(callLog) - } else if callLog.toAddress!.displayName != nil && callLog.toAddress!.displayName!.contains(filter) { - callLogs.append(callLog) - } - } else if callLog.fromAddress != nil { - if callLog.fromAddress!.username != nil && callLog.fromAddress!.username!.contains(filter) { - callLogs.append(callLog) - } else if callLog.fromAddress!.displayName != nil && callLog.fromAddress!.displayName!.contains(filter) { - callLogs.append(callLog) - } + if callLog.addressName.contains(filter) { + callLogs.append(callLog) } } } @@ -242,20 +227,26 @@ class HistoryListViewModel: ObservableObject { } func removeCallLogsWithAddress() { - self.callLogs.filter { $0.toAddress!.asStringUriOnly() == callLogsAddressToDelete || $0.fromAddress!.asStringUriOnly() == callLogsAddressToDelete }.forEach { callLog in - removeCallLog(callLog: callLog) - - coreContext.doOnCoreQueue { core in - core.removeCallLog(callLog: callLog) - } + self.callLogs.filter { $0.address == callLogsAddressToDelete || $0.address == callLogsAddressToDelete }.forEach { historyModel in + removeCallLog(historyModel: historyModel) } } - func removeCallLog(callLog: CallLog) { - let index = self.callLogs.firstIndex(where: {$0.callId == callLog.callId}) + 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.callId == callLog.callId}) + 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 index cfbdf6d27..95be5eb66 100644 --- a/Linphone/UI/Main/History/ViewModel/HistoryViewModel.swift +++ b/Linphone/UI/Main/History/ViewModel/HistoryViewModel.swift @@ -22,23 +22,9 @@ import linphonesw class HistoryViewModel: ObservableObject { - @Published var displayedCall: CallLog? - @Published var displayedCallIsConference: String = "" + @Published var displayedCall: HistoryModel? - var selectedCall: CallLog? + var selectedCall: HistoryModel? init() {} - - func getConferenceSubject() { - CoreContext.shared.doOnCoreQueue { core in - var displayedCallIsConferenceTmp = "" - if self.displayedCall?.conferenceInfo != nil { - displayedCallIsConferenceTmp = self.displayedCall?.conferenceInfo?.subject ?? "" - } - - DispatchQueue.main.async { - self.displayedCallIsConference = displayedCallIsConferenceTmp - } - } - } } From fad30689b4ead7cf2168304752ea8b19ce851d30 Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Tue, 28 May 2024 16:57:40 +0200 Subject: [PATCH 240/486] Fix outgoing video call --- Linphone/TelecomManager/ProviderDelegate.swift | 2 +- Linphone/TelecomManager/TelecomManager.swift | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Linphone/TelecomManager/ProviderDelegate.swift b/Linphone/TelecomManager/ProviderDelegate.swift index 1c7fb444e..7a56be324 100644 --- a/Linphone/TelecomManager/ProviderDelegate.swift +++ b/Linphone/TelecomManager/ProviderDelegate.swift @@ -329,7 +329,7 @@ extension ProviderDelegate: CXProviderDelegate { CoreContext.shared.doOnCoreQueue { core in do { core.configureAudioSession() - try TelecomManager.shared.doCall(core: core, addr: addr!, isSas: callInfo?.sasEnabled ?? false, isVideo: ((callInfo?.videoEnabled ?? false) && core.videoPreviewEnabled), isConference: callInfo?.isConference ?? false) + 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)") diff --git a/Linphone/TelecomManager/TelecomManager.swift b/Linphone/TelecomManager/TelecomManager.swift index 38c0a2e16..f31aaf261 100644 --- a/Linphone/TelecomManager/TelecomManager.swift +++ b/Linphone/TelecomManager/TelecomManager.swift @@ -239,7 +239,7 @@ class TelecomManager: ObservableObject { if isConference { lcallParams.videoEnabled = true - lcallParams.videoDirection = isVideo ? MediaDirection.SendRecv : MediaDirection.RecvOnly + 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 From 7dca3300e19f4e8815e1fc5a25293af4540f2e6e Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Wed, 29 May 2024 16:41:35 +0200 Subject: [PATCH 241/486] Fix avatar crash --- Linphone/Contacts/ContactsManager.swift | 35 ++- Linphone/TelecomManager/TelecomManager.swift | 244 +++++++++--------- Linphone/UI/Call/CallView.swift | 112 +------- .../UI/Call/Fragments/CallsListFragment.swift | 57 +--- Linphone/UI/Call/Model/ParticipantModel.swift | 20 +- .../UI/Call/ViewModel/CallViewModel.swift | 160 +++++++----- .../MeetingWaitingRoomViewModel.swift | 119 ++++----- .../Contacts/Model/ContactAvatarModel.swift | 34 ++- .../Model/ConversationModel.swift | 155 +++++------ .../ViewModel/ConversationViewModel.swift | 8 +- .../ConversationsListViewModel.swift | 38 +-- .../UI/Main/History/Model/HistoryModel.swift | 42 +-- .../ViewModel/ScheduleMeetingViewModel.swift | 10 +- .../Viewmodel/AddParticipantsViewModel.swift | 4 +- Linphone/Utils/Avatar.swift | 2 +- 15 files changed, 499 insertions(+), 541 deletions(-) diff --git a/Linphone/Contacts/ContactsManager.swift b/Linphone/Contacts/ContactsManager.swift index c81f8c0dd..eba36a0be 100644 --- a/Linphone/Contacts/ContactsManager.swift +++ b/Linphone/Contacts/ContactsManager.swift @@ -302,21 +302,28 @@ final class ContactsManager: ObservableObject { } } - func getFriendWithAddress(address: Address) -> Friend? { - let clonedAddress = address.clone() - clonedAddress!.clean() - let sipUri = clonedAddress!.asStringUriOnly() - - if friendList != nil { - var friend: Friend? - friend = self.friendList!.friends.first(where: {$0.addresses.contains(where: {$0.asStringUriOnly() == sipUri})}) - if friend == nil { - friend = self.linphoneFriendList!.friends.first(where: {$0.addresses.contains(where: {$0.asStringUriOnly() == sipUri})}) + + func getFriendWithAddress(address: Address?, completion: @escaping (Friend?) -> Void) { + self.coreContext.doOnCoreQueue { core in + if address != nil { + let clonedAddress = address!.clone() + clonedAddress!.clean() + let sipUri = clonedAddress!.asStringUriOnly() + + if self.friendList != nil { + var friend: Friend? + friend = self.friendList!.friends.first(where: {$0.addresses.contains(where: {$0.asStringUriOnly() == sipUri})}) + if friend == nil { + friend = self.linphoneFriendList!.friends.first(where: {$0.addresses.contains(where: {$0.asStringUriOnly() == sipUri})}) + } + + completion(friend) + } else { + completion(nil) + } + } else { + completion(nil) } - - return friend - } else { - return nil } } } diff --git a/Linphone/TelecomManager/TelecomManager.swift b/Linphone/TelecomManager/TelecomManager.swift index f31aaf261..f28b4cf6a 100644 --- a/Linphone/TelecomManager/TelecomManager.swift +++ b/Linphone/TelecomManager/TelecomManager.swift @@ -340,20 +340,25 @@ class TelecomManager: ObservableObject { providerDelegate.reportIncomingCall(call: call, uuid: uuid, handle: handle, hasVideo: hasVideo, displayName: displayName) } - func incomingDisplayName(call: Call) -> String { - if call.remoteAddress != nil { - let friend = ContactsManager.shared.getFriendWithAddress(address: call.remoteAddress!) - if friend != nil && friend!.address != nil && friend!.address!.displayName != nil { - return friend!.address!.displayName! - } else { - if call.remoteAddress!.displayName != nil { - return call.remoteAddress!.displayName! - } else if call.remoteAddress!.username != nil { - return call.remoteAddress!.username! + + func incomingDisplayName(call: Call, completion: @escaping (String) -> Void) { + CoreContext.shared.doOnCoreQueue { core in + if call.remoteAddress != nil { + ContactsManager.shared.getFriendWithAddress(address: call.remoteAddress!) { friendResult in + let friend = friendResult + if friend != nil && friend!.address != nil && friend!.address!.displayName != nil { + completion(friend!.address!.displayName!) + } else { + if call.remoteAddress!.displayName != nil { + completion(call.remoteAddress!.displayName!) + } else if call.remoteAddress!.username != nil { + completion(call.remoteAddress!.username!) + } + } } } + completion("IncomingDisplayName") } - return "IncomingDisplayName" } static func callKitEnabled(core: Core) -> Bool { @@ -464,14 +469,16 @@ class TelecomManager: ObservableObject { 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! + ContactsManager.shared.getFriendWithAddress(address: call.remoteAddress!) { friendResult in + let friend = friendResult + 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! + } } } @@ -526,67 +533,69 @@ class TelecomManager: ObservableObject { switch cstate { case .IncomingReceived: let addr = call.remoteAddress - let displayName = incomingDisplayName(call: call) -#if targetEnvironment(simulator) - DispatchQueue.main.async { - self.outgoingCallStarted = false - self.callStarted = false - if self.callInProgress == false { - withAnimation { - self.callInProgress = true - self.callDisplayed = true + 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 call.replacedCall != nil { + self.endCallKitReplacedCall = false + + let uuid = self.providerDelegate.uuids["\(TelecomManager.uuidReplacedCall ?? "")"] + let callInfo = self.providerDelegate.callInfos[uuid!] + callInfo!.callId = self.referedToCall ?? "" + if callInfo != nil && uuid != nil && addr != nil { + self.providerDelegate.callInfos.updateValue(callInfo!, forKey: uuid!) + self.providerDelegate.uuids.removeValue(forKey: callId) + self.providerDelegate.uuids.updateValue(uuid!, forKey: callInfo!.callId) + self.providerDelegate.updateCall(uuid: uuid!, handle: addr!.asStringUriOnly(), hasVideo: self.remoteConfVideo, displayName: displayName) + } + } else if TelecomManager.callKitEnabled(core: core) { + /* + let isConference = isConferenceCall(call: call) + let isEarlyConference = isConference && CallsViewModel.shared.currentCallData.value??.isConferenceCall.value != true // Conference info not be received yet. + if (isEarlyConference) { + CallsViewModel.shared.currentCallData.readCurrentAndObserve { _ in + let uuid = providerDelegate.uuids["\(callId)"] + if (uuid != nil) { + displayName = "\(VoipTexts.conference_incoming_title): \(CallsViewModel.shared.currentCallData.value??.remoteConferenceSubject.value ?? "") (\(CallsViewModel.shared.currentCallData.value??.conferenceParticipantsCountLabel.value ?? ""))" + providerDelegate.updateCall(uuid: uuid!, handle: addr!.asStringUriOnly(), hasVideo: video, displayName: displayName) + } + } + } + */ + let uuid = self.providerDelegate.uuids["\(callId)"] + if call.replacedCall == nil { + 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 { + self.displayIncomingCall(call: call, handle: addr!.asStringUriOnly(), hasVideo: self.remoteConfVideo, callId: callId, displayName: displayName) + } + } /* else if UIApplication.shared.applicationState != .active { + // not support callkit , use notif + let content = UNMutableNotificationContent() + content.title = NSLocalizedString("Incoming call", comment: "") + content.body = displayName + content.sound = UNNotificationSound.init(named: UNNotificationSoundName.init("notes_of_the_optimistic.caf")) + content.categoryIdentifier = "call_cat" + content.userInfo = ["CallId": callId] + let req = UNNotificationRequest.init(identifier: "call_request", content: content, trigger: nil) + UNUserNotificationCenter.current().add(req, withCompletionHandler: nil) + } */ } -#endif - if call.replacedCall != nil { - endCallKitReplacedCall = false - - let uuid = providerDelegate.uuids["\(TelecomManager.uuidReplacedCall ?? "")"] - let callInfo = providerDelegate.callInfos[uuid!] - callInfo!.callId = referedToCall ?? "" - if callInfo != nil && uuid != nil && addr != nil { - providerDelegate.callInfos.updateValue(callInfo!, forKey: uuid!) - providerDelegate.uuids.removeValue(forKey: callId) - providerDelegate.uuids.updateValue(uuid!, forKey: callInfo!.callId) - providerDelegate.updateCall(uuid: uuid!, handle: addr!.asStringUriOnly(), hasVideo: remoteConfVideo, displayName: displayName) - } - } else if TelecomManager.callKitEnabled(core: core) { - /* - let isConference = isConferenceCall(call: call) - let isEarlyConference = isConference && CallsViewModel.shared.currentCallData.value??.isConferenceCall.value != true // Conference info not be received yet. - if (isEarlyConference) { - CallsViewModel.shared.currentCallData.readCurrentAndObserve { _ in - let uuid = providerDelegate.uuids["\(callId)"] - if (uuid != nil) { - displayName = "\(VoipTexts.conference_incoming_title): \(CallsViewModel.shared.currentCallData.value??.remoteConferenceSubject.value ?? "") (\(CallsViewModel.shared.currentCallData.value??.conferenceParticipantsCountLabel.value ?? ""))" - providerDelegate.updateCall(uuid: uuid!, handle: addr!.asStringUriOnly(), hasVideo: video, displayName: displayName) - } - } - } - */ - let uuid = providerDelegate.uuids["\(callId)"] - if call.replacedCall == nil { - TelecomManager.uuidReplacedCall = callId - } - - if uuid != nil { - // Tha app is now registered, updated the call already existed. - providerDelegate.updateCall(uuid: uuid!, handle: addr!.asStringUriOnly(), hasVideo: remoteConfVideo, displayName: displayName) - } else { - displayIncomingCall(call: call, handle: addr!.asStringUriOnly(), hasVideo: remoteConfVideo, callId: callId, displayName: displayName) - } - } /* else if UIApplication.shared.applicationState != .active { - // not support callkit , use notif - let content = UNMutableNotificationContent() - content.title = NSLocalizedString("Incoming call", comment: "") - content.body = displayName - content.sound = UNNotificationSound.init(named: UNNotificationSoundName.init("notes_of_the_optimistic.caf")) - content.categoryIdentifier = "call_cat" - content.userInfo = ["CallId": callId] - let req = UNNotificationRequest.init(identifier: "call_request", content: content, trigger: nil) - UNUserNotificationCenter.current().add(req, withCompletionHandler: nil) - } */ case .StreamsRunning: if TelecomManager.callKitEnabled(core: core) { @@ -660,52 +669,53 @@ class TelecomManager: ObservableObject { } //if core.callsNb == 0 { - 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 - } - } - } - } - + self.incomingDisplayName(call: call) { displayNameResult in var displayName = "Unknown" if call.dir == .Incoming { - displayName = self.incomingDisplayName(call: call) + displayName = displayNameResult } else { // if let addr = call.remoteAddress, let contactName = FastAddressBook.displayName(for: addr.getCobject) { displayName = "TODOContactName" } - - 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) + 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 + } + } + } + } - // 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 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)") + } } } } diff --git a/Linphone/UI/Call/CallView.swift b/Linphone/UI/Call/CallView.swift index 5d89945f1..82cdad0fe 100644 --- a/Linphone/UI/Call/CallView.swift +++ b/Linphone/UI/Call/CallView.swift @@ -368,49 +368,8 @@ struct CallView: View { .frame(width: 206, height: 206) } - if callViewModel.remoteAddress != nil { - let addressFriend = contactsManager.getFriendWithAddress(address: callViewModel.remoteAddress!) - - let contactAvatarModel = addressFriend != nil - ? ContactsManager.shared.avatarListModel.first(where: { - ($0.friend!.consolidatedPresence == .Online || $0.friend!.consolidatedPresence == .Busy) - && $0.friend!.name == addressFriend!.name - && $0.friend!.address!.asStringUriOnly() == addressFriend!.address!.asStringUriOnly() - }) - : ContactAvatarModel(friend: nil, name: "", address: "", withPresence: false) - - if addressFriend != nil && addressFriend!.photo != nil && !addressFriend!.photo!.isEmpty { - if contactAvatarModel != nil { - Avatar(contactAvatarModel: contactAvatarModel!, avatarSize: 200, hidePresence: true) - } - } else { - if callViewModel.remoteAddress!.displayName != nil { - Image(uiImage: contactsManager.textToImage( - firstName: callViewModel.remoteAddress!.displayName!, - lastName: callViewModel.remoteAddress!.displayName!.components(separatedBy: " ").count > 1 - ? callViewModel.remoteAddress!.displayName!.components(separatedBy: " ")[1] - : "")) - .resizable() - .frame(width: 200, height: 200) - .clipShape(Circle()) - - } else { - Image(uiImage: contactsManager.textToImage( - firstName: callViewModel.remoteAddress!.username ?? "Username Error", - lastName: callViewModel.remoteAddress!.username!.components(separatedBy: " ").count > 1 - ? callViewModel.remoteAddress!.username!.components(separatedBy: " ")[1] - : "")) - .resizable() - .frame(width: 200, height: 200) - .clipShape(Circle()) - } - - } - } else { - Image("profil-picture-default") - .resizable() - .frame(width: 200, height: 200) - .clipShape(Circle()) + if callViewModel.avatarModel != nil { + Avatar(contactAvatarModel: callViewModel.avatarModel!, avatarSize: 200, hidePresence: true) } if callViewModel.isRemoteDeviceTrusted { @@ -722,66 +681,13 @@ struct CallView: View { VStack { Spacer() HStack { - ZStack { - if callViewModel.activeSpeakerParticipant?.address != nil { - let addressFriend = contactsManager.getFriendWithAddress(address: callViewModel.activeSpeakerParticipant!.address) - - let contactAvatarModel = addressFriend != nil - ? ContactsManager.shared.avatarListModel.first(where: { - ($0.friend!.consolidatedPresence == .Online || $0.friend!.consolidatedPresence == .Busy) - && $0.friend!.name == addressFriend!.name - && $0.friend!.address!.asStringUriOnly() == addressFriend!.address!.asStringUriOnly() - }) - : ContactAvatarModel(friend: nil, name: "", address: "", withPresence: false) - - if addressFriend != nil && addressFriend!.photo != nil && !addressFriend!.photo!.isEmpty { - if contactAvatarModel != nil { - Avatar(contactAvatarModel: contactAvatarModel!, avatarSize: 200, hidePresence: true) - .onAppear { - DispatchQueue.main.asyncAfter(deadline: .now() + 1) { - displayVideo = true - } - } - } - } else { - if callViewModel.activeSpeakerParticipant!.address.displayName != nil { - Image(uiImage: contactsManager.textToImage( - firstName: callViewModel.activeSpeakerParticipant!.address.displayName!, - lastName: callViewModel.activeSpeakerParticipant!.address.displayName!.components(separatedBy: " ").count > 1 - ? callViewModel.activeSpeakerParticipant!.address.displayName!.components(separatedBy: " ")[1] - : "")) - .resizable() - .frame(width: 200, height: 200) - .clipShape(Circle()) - .onAppear { - DispatchQueue.main.asyncAfter(deadline: .now() + 1) { - displayVideo = true - } - } - - } else { - Image(uiImage: contactsManager.textToImage( - firstName: callViewModel.activeSpeakerParticipant!.address.username ?? "Username Error", - lastName: callViewModel.activeSpeakerParticipant!.address.username!.components(separatedBy: " ").count > 1 - ? callViewModel.activeSpeakerParticipant!.address.username!.components(separatedBy: " ")[1] - : "")) - .resizable() - .frame(width: 200, height: 200) - .clipShape(Circle()) - .onAppear { - DispatchQueue.main.asyncAfter(deadline: .now() + 1) { - displayVideo = true - } - } - } - - } - } else { - Image("profil-picture-default") - .resizable() - .frame(width: 200, height: 200) - .clipShape(Circle()) - } + if callViewModel.activeSpeakerParticipant != nil { + Avatar(contactAvatarModel: callViewModel.activeSpeakerParticipant!.avatarModel, avatarSize: 200, hidePresence: true) + .onAppear { + DispatchQueue.main.asyncAfter(deadline: .now() + 1) { + displayVideo = true + } + } } } diff --git a/Linphone/UI/Call/Fragments/CallsListFragment.swift b/Linphone/UI/Call/Fragments/CallsListFragment.swift index d80560e0c..171f11696 100644 --- a/Linphone/UI/Call/Fragments/CallsListFragment.swift +++ b/Linphone/UI/Call/Fragments/CallsListFragment.swift @@ -218,57 +218,28 @@ struct CallsListFragment: View { HStack { HStack { if callViewModel.calls[index].callLog != nil && callViewModel.calls[index].callLog!.remoteAddress != nil { - let addressFriend = contactsManager.getFriendWithAddress(address: callViewModel.calls[index].callLog!.remoteAddress!) - - let contactAvatarModel = addressFriend != nil - ? ContactsManager.shared.avatarListModel.first(where: { - ($0.friend!.consolidatedPresence == .Online || $0.friend!.consolidatedPresence == .Busy) - && $0.friend!.name == addressFriend!.name - && $0.friend!.address!.asStringUriOnly() == addressFriend!.address!.asStringUriOnly() - }) - : ContactAvatarModel(friend: nil, name: "", address: "", withPresence: false) - - if addressFriend != nil && addressFriend!.photo != nil && !addressFriend!.photo!.isEmpty { - if contactAvatarModel != nil { - Avatar(contactAvatarModel: contactAvatarModel!, avatarSize: 50) - } else { - Image("profil-picture-default") - .resizable() - .frame(width: 50, height: 50) - .clipShape(Circle()) - } + if callViewModel.callsContactAvatarModel[index] != nil && callViewModel.calls[index].callLog?.conferenceInfo == nil { + Avatar(contactAvatarModel: callViewModel.callsContactAvatarModel[index]!, avatarSize: 50) } else { - if callViewModel.calls[index].callLog!.remoteAddress!.displayName != nil { - Image(uiImage: contactsManager.textToImage( - firstName: callViewModel.calls[index].callLog!.remoteAddress!.displayName!, - lastName: callViewModel.calls[index].callLog!.remoteAddress!.displayName!.components(separatedBy: " ").count > 1 - ? callViewModel.calls[index].callLog!.remoteAddress!.displayName!.components(separatedBy: " ")[1] - : "")) - .resizable() - .frame(width: 50, height: 50) - .clipShape(Circle()) - - } else { - Image(uiImage: contactsManager.textToImage( - firstName: callViewModel.calls[index].callLog!.remoteAddress!.username ?? "Username Error", - lastName: callViewModel.calls[index].callLog!.remoteAddress!.username!.components(separatedBy: " ").count > 1 - ? callViewModel.calls[index].callLog!.remoteAddress!.username!.components(separatedBy: " ")[1] - : "")) - .resizable() - .frame(width: 50, height: 50) - .clipShape(Circle()) + 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()) } - if addressFriend != nil { - Text(addressFriend!.name!) + if callViewModel.calls[index].callLog?.conferenceInfo == nil { + Text(callViewModel.callsContactAvatarModel[index]!.name) .default_text_style(styleSize: 16) .frame(maxWidth: .infinity, alignment: .leading) .lineLimit(1) } else { - Text(callViewModel.calls[index].callLog!.remoteAddress!.displayName != nil - ? callViewModel.calls[index].callLog!.remoteAddress!.displayName! - : callViewModel.calls[index].callLog!.remoteAddress!.username!) + Text(callViewModel.calls[index].callLog!.conferenceInfo!.subject ?? "Conference Name error") .default_text_style(styleSize: 16) .frame(maxWidth: .infinity, alignment: .leading) .lineLimit(1) diff --git a/Linphone/UI/Call/Model/ParticipantModel.swift b/Linphone/UI/Call/Model/ParticipantModel.swift index 0a1ec61d4..921fe079e 100644 --- a/Linphone/UI/Call/Model/ParticipantModel.swift +++ b/Linphone/UI/Call/Model/ParticipantModel.swift @@ -39,18 +39,26 @@ class ParticipantModel: ObservableObject { self.sipUri = address.asStringUriOnly() - if let addressFriend = ContactsManager.shared.getFriendWithAddress(address: self.address) { - self.name = addressFriend.name! - } else { - self.name = address.displayName != nil ? address.displayName! : address.username! - } + self.name = "" - self.avatarModel = ContactAvatarModel.getAvatarModelFromAddress(address: self.address) + 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.getFriendWithAddress(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 index 605868fdc..65834b7f6 100644 --- a/Linphone/UI/Call/ViewModel/CallViewModel.swift +++ b/Linphone/UI/Call/ViewModel/CallViewModel.swift @@ -62,6 +62,7 @@ class CallViewModel: ObservableObject { private var mConferenceSuscriptions = Set() @Published var calls: [Call] = [] + @Published var callsContactAvatarModel: [ContactAvatarModel?] = [] let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect() @@ -137,14 +138,25 @@ class CallViewModel: ObservableObject { 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 = self.currentCall!.remoteAddress!.username! + ContactsManager.shared.getFriendWithAddress(address: self.currentCall!.remoteAddress) { friendResult in + let friend = friendResult + 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 = self.currentCall!.remoteAddress!.username! + } + } + DispatchQueue.main.async { + self.displayName = displayNameTmp + } + } + + ContactAvatarModel.getAvatarModelFromAddress(address: self.currentCall!.remoteAddress!) { avatarResult in + DispatchQueue.main.async { + self.avatarModel = avatarResult } } } @@ -171,7 +183,6 @@ class CallViewModel: ObservableObject { self.remoteAddress = remoteAddressTmp self.displayName = displayNameTmp - //self.avatarModel = ??? self.micMutted = micMuttedTmp self.isRecording = isRecordingTmp self.isPaused = isPausedTmp @@ -224,9 +235,17 @@ class CallViewModel: ObservableObject { } func getCallsList() { + self.callsContactAvatarModel.removeAll() + self.calls.removeAll() coreContext.doOnCoreQueue { core in - DispatchQueue.main.async { - self.calls = core.calls + 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) + } + } } } } @@ -272,14 +291,16 @@ class CallViewModel: ObservableObject { 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! + ContactsManager.shared.getFriendWithAddress(address: activeSpeakerParticipantTmp?.address) { friendResult in + let friend = friendResult + 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! + } } } } @@ -346,46 +367,48 @@ class CallViewModel: ObservableObject { isMuted: cbValue.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) { - - cbValue.conference.participantDeviceList.forEach({ participantDevice in - if participantDevice.address != nil && !cbValue.conference.isMe(uri: participantDevice.address!.clone()!) { - if !cbValue.conference.isMe(uri: participantDevice.address!.clone()!) { - let isAdmin = cbValue.conference.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 - ) - ) - } + ContactsManager.shared.getFriendWithAddress(address: activeSpeakerParticipantTmp.address) { friendResult in + var activeSpeakerNameTmp = "" + let friend = friendResult + 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 { - self.activeSpeakerParticipant = activeSpeakerParticipantTmp - self.activeSpeakerName = activeSpeakerNameTmp + } + + var participantListTmp: [ParticipantModel] = [] if (activeSpeakerParticipantBis != nil && !activeSpeakerParticipantBis!.address.equal(address2: activeSpeakerParticipantTmp.address)) - || ( activeSpeakerParticipantBis == nil) { - self.participantList = participantListTmp + || ( activeSpeakerParticipantBis == nil) { + + cbValue.conference.participantDeviceList.forEach({ participantDevice in + if participantDevice.address != nil && !cbValue.conference.isMe(uri: participantDevice.address!.clone()!) { + if !cbValue.conference.isMe(uri: participantDevice.address!.clone()!) { + let isAdmin = cbValue.conference.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.activeSpeakerParticipant = activeSpeakerParticipantTmp + self.activeSpeakerName = activeSpeakerNameTmp + if (activeSpeakerParticipantBis != nil && !activeSpeakerParticipantBis!.address.equal(address2: activeSpeakerParticipantTmp.address)) + || ( activeSpeakerParticipantBis == nil) { + self.participantList = participantListTmp + } } } } @@ -441,14 +464,21 @@ class CallViewModel: ObservableObject { } 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! + ContactsManager.shared.getFriendWithAddress(address: activeSpeakerParticipantTmp?.address) { friendResult in + let friend = friendResult + 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 + } } } } diff --git a/Linphone/UI/Call/ViewModel/MeetingWaitingRoomViewModel.swift b/Linphone/UI/Call/ViewModel/MeetingWaitingRoomViewModel.swift index 681096d73..ec0eaf252 100644 --- a/Linphone/UI/Call/ViewModel/MeetingWaitingRoomViewModel.swift +++ b/Linphone/UI/Call/ViewModel/MeetingWaitingRoomViewModel.swift @@ -64,68 +64,69 @@ class MeetingWaitingRoomViewModel: ObservableObject { 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!.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 + ContactsManager.shared.getFriendWithAddress(address: core.defaultAccount?.contactAddress) { friendResult in + let friend = core.defaultAccount != nil && core.defaultAccount!.contactAddress != nil + ? friendResult + : nil + + let addressTmp = friend?.address?.asStringUriOnly() ?? "" + + if friend != nil && friend!.address != nil && friend!.address!.displayName != nil { + userNameTmp = friend!.address!.displayName! + } else { + if core.defaultAccount!.contactAddress!.displayName != nil { + userNameTmp = core.defaultAccount!.contactAddress!.displayName! + } else if core.defaultAccount!.contactAddress!.username != nil { + userNameTmp = core.defaultAccount!.contactAddress!.username! + } } - self.userName = userNameTmp - self.avatarModel = avatarModelTmp - self.micMutted = micMuttedTmp - self.meetingDate = meetingDateTmp + 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 + } } - } } } diff --git a/Linphone/UI/Main/Contacts/Model/ContactAvatarModel.swift b/Linphone/UI/Main/Contacts/Model/ContactAvatarModel.swift index e7b1d297b..9236d41df 100644 --- a/Linphone/UI/Main/Contacts/Model/ContactAvatarModel.swift +++ b/Linphone/UI/Main/Contacts/Model/ContactAvatarModel.swift @@ -117,20 +117,28 @@ class ContactAvatarModel: ObservableObject { } } - static func getAvatarModelFromAddress(address: Address) -> ContactAvatarModel { - if let addressFriend = ContactsManager.shared.getFriendWithAddress(address: address) { - var avatarModel = ContactsManager.shared.avatarListModel.first(where: { - $0.friend!.name == addressFriend.name - && $0.friend!.address!.asStringUriOnly() == address.asStringUriOnly() - }) - - if avatarModel == nil { - avatarModel = ContactAvatarModel(friend: nil, name: addressFriend.name!, address: address.asStringUriOnly(), withPresence: false) + + static func getAvatarModelFromAddress(address: Address, completion: @escaping (ContactAvatarModel) -> Void) { + ContactsManager.shared.getFriendWithAddress(address: address) { resultFriend in + if let addressFriend = resultFriend { + if addressFriend.address != nil { + var avatarModel = ContactsManager.shared.avatarListModel.first(where: { + $0.friend!.name == addressFriend.name + && $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)) } - return avatarModel! - } else { - let name = address.displayName != nil ? address.displayName! : address.username! - return ContactAvatarModel(friend: nil, name: name, address: address.asStringUriOnly(), withPresence: false) } } } diff --git a/Linphone/UI/Main/Conversations/Model/ConversationModel.swift b/Linphone/UI/Main/Conversations/Model/ConversationModel.swift index 24544bb50..439e22fa9 100644 --- a/Linphone/UI/Main/Conversations/Model/ConversationModel.swift +++ b/Linphone/UI/Main/Conversations/Model/ConversationModel.swift @@ -137,40 +137,42 @@ class ConversationModel: ObservableObject { 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! + ": " + self.contactsManager.getFriendWithAddress(address: lastMessage!.fromAddress) { friendResult in + var fromAddressFriend = lastMessage!.fromAddress != nil + ? friendResult?.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 = "" + fromAddressFriend! += ": " } + } else { - fromAddressFriend! += ": " + fromAddressFriend = nil } - } 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 + let lastMessageTextTmp = (fromAddressFriend ?? "") + + (lastMessage!.contents.first(where: {$0.isText == true})?.utf8Text ?? (lastMessage!.contents.first(where: {$0.isFile == true || $0.isFileTransfer == true})?.name ?? "")) - self.lastMessageIsOutgoing = lastMessageIsOutgoingTmp + let lastMessageIsOutgoingTmp = lastMessage?.isOutgoing ?? false - self.lastMessageState = lastMessageStateTmp + let lastMessageStateTmp = lastMessage?.state.rawValue ?? 0 + + DispatchQueue.main.async { + self.lastMessageText = lastMessageTextTmp + + self.lastMessageIsOutgoing = lastMessageIsOutgoingTmp + + self.lastMessageState = lastMessageStateTmp + } } } } @@ -178,49 +180,50 @@ class ConversationModel: ObservableObject { 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 - - if self.isGroup { - self.subject = self.chatRoom.subject! - } else if addressFriend != nil { - self.subject = addressFriend!.name! - } else { - if self.chatRoom.participants.first != nil - && self.chatRoom.participants.first!.address != nil { - - self.subject = self.chatRoom.participants.first!.address!.displayName != nil - ? self.chatRoom.participants.first!.address!.displayName! - : self.chatRoom.participants.first!.address!.username! - + self.contactsManager.getFriendWithAddress(address: self.chatRoom.participants.first?.address) { friendResult in + let addressFriend = + (self.chatRoom.participants.first != nil && self.chatRoom.participants.first!.address != nil) + ? friendResult + : nil + + if self.isGroup { + self.subject = self.chatRoom.subject! + } else if addressFriend != nil { + self.subject = addressFriend!.name! + } else { + if self.chatRoom.participants.first != nil + && self.chatRoom.participants.first!.address != nil { + + self.subject = 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: self.subject, + address: addressTmp, + withPresence: false + ) + : ContactAvatarModel( + friend: nil, + name: self.subject, + address: addressTmp, + withPresence: false + ) + + DispatchQueue.main.async { + self.avatarModel = avatarModelTmp } - } - - 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: self.subject, - address: addressTmp, - withPresence: false - ) - : ContactAvatarModel( - friend: nil, - name: self.subject, - address: addressTmp, - withPresence: false - ) - - DispatchQueue.main.async { - self.avatarModel = avatarModelTmp } } } @@ -235,12 +238,14 @@ class ConversationModel: ObservableObject { coreContext.doOnCoreQueue { _ in if !self.isGroup { if self.chatRoom.participants.first != nil && self.chatRoom.participants.first!.address != nil { - let avatarModelTmp = ContactAvatarModel.getAvatarModelFromAddress(address: self.chatRoom.participants.first!.address!) - let subjectTmp = avatarModelTmp.name - - DispatchQueue.main.async { - self.avatarModel = avatarModelTmp - self.subject = subjectTmp + 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 + } } } } diff --git a/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift b/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift index add138ae3..98b228650 100644 --- a/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift +++ b/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift @@ -98,9 +98,11 @@ class ConversationViewModel: ObservableObject { if self.displayedConversation != nil { self.displayedConversation!.chatRoom.participants.forEach { participant in if participant.address != nil { - let avatarModelTmp = ContactAvatarModel.getAvatarModelFromAddress(address: participant.address!) - DispatchQueue.main.async { - self.participantConversationModel.append(avatarModelTmp) + ContactAvatarModel.getAvatarModelFromAddress(address: participant.address!) { avatarResult in + let avatarModelTmp = avatarResult + DispatchQueue.main.async { + self.participantConversationModel.append(avatarModelTmp) + } } } } diff --git a/Linphone/UI/Main/Conversations/ViewModel/ConversationsListViewModel.swift b/Linphone/UI/Main/Conversations/ViewModel/ConversationsListViewModel.swift index c21a2d749..838dfa326 100644 --- a/Linphone/UI/Main/Conversations/ViewModel/ConversationsListViewModel.swift +++ b/Linphone/UI/Main/Conversations/ViewModel/ConversationsListViewModel.swift @@ -148,29 +148,33 @@ class ConversationsListViewModel: ObservableObject { } } - func getContentTextMessage(message: ChatMessage) -> String { - var fromAddressFriend = message.fromAddress != nil - ? contactsManager.getFriendWithAddress(address: message.fromAddress!)?.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! + ": " + func getContentTextMessage(message: ChatMessage, completion: @escaping (String) -> Void) { + contactsManager.getFriendWithAddress(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 = "" + fromAddressFriend! += ": " } + } else { - fromAddressFriend! += ": " + fromAddressFriend = nil } - } else { - fromAddressFriend = nil + completion( + (fromAddressFriend ?? "") + (message.contents.first(where: {$0.isText == true})?.utf8Text ?? (message.contents.first(where: {$0.isFile == true || $0.isFileTransfer == true})?.name ?? "")) + ) } - - return (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 { diff --git a/Linphone/UI/Main/History/Model/HistoryModel.swift b/Linphone/UI/Main/History/Model/HistoryModel.swift index cc356f2f3..57e353197 100644 --- a/Linphone/UI/Main/History/Model/HistoryModel.swift +++ b/Linphone/UI/Main/History/Model/HistoryModel.swift @@ -71,27 +71,29 @@ class HistoryModel: ObservableObject { 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 { + ContactsManager.shared.getFriendWithAddress(address: self.callLog.dir == .Outgoing ? self.callLog.toAddress! : self.callLog.fromAddress!) { friendResult in + let addressFriendTmp = friendResult + if addressFriendTmp != nil { 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) + + 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/Meetings/ViewModel/ScheduleMeetingViewModel.swift b/Linphone/UI/Main/Meetings/ViewModel/ScheduleMeetingViewModel.swift index 86c9d6e4e..0a43e13fc 100644 --- a/Linphone/UI/Main/Meetings/ViewModel/ScheduleMeetingViewModel.swift +++ b/Linphone/UI/Main/Meetings/ViewModel/ScheduleMeetingViewModel.swift @@ -257,9 +257,11 @@ class ScheduleMeetingViewModel: ObservableObject { var list: [SelectedAddressModel] = [] for partInfo in conferenceInfo.participantInfos { if let addr = partInfo.address { - let avatarModel = ContactAvatarModel.getAvatarModelFromAddress(address: addr) - list.append(SelectedAddressModel(addr: addr, avModel: avatarModel)) - Log.info("\(ScheduleMeetingViewModel.TAG) Loaded participant \(addr.asStringUriOnly())") + ContactAvatarModel.getAvatarModelFromAddress(address: addr) { avatarResult in + let avatarModel = avatarResult + self.participants.append(SelectedAddressModel(addr: addr, avModel: avatarModel)) + Log.info("\(ScheduleMeetingViewModel.TAG) Loaded participant \(addr.asStringUriOnly())") + } } } Log.info("\(ScheduleMeetingViewModel.TAG) \(list.count) participants loaded from found conference info") @@ -271,7 +273,7 @@ class ScheduleMeetingViewModel: ObservableObject { self.computeDateLabels() self.computeTimeLabels() self.updateTimezone() - self.participants = list + //self.participants = list } } else { diff --git a/Linphone/UI/Main/Viewmodel/AddParticipantsViewModel.swift b/Linphone/UI/Main/Viewmodel/AddParticipantsViewModel.swift index d8140d3a7..616692b07 100644 --- a/Linphone/UI/Main/Viewmodel/AddParticipantsViewModel.swift +++ b/Linphone/UI/Main/Viewmodel/AddParticipantsViewModel.swift @@ -31,7 +31,9 @@ class AddParticipantsViewModel: ObservableObject { participantsToAdd.remove(at: idx) } else { Log.info("[\(AddParticipantsViewModel.TAG)] Adding participant \(addr.asStringUriOnly()) to selection") - participantsToAdd.append(SelectedAddressModel(addr: addr, avModel: ContactAvatarModel.getAvatarModelFromAddress(address: addr))) + ContactAvatarModel.getAvatarModelFromAddress(address: addr) { avatarResult in + self.participantsToAdd.append(SelectedAddressModel(addr: addr, avModel: avatarResult)) + } } } diff --git a/Linphone/Utils/Avatar.swift b/Linphone/Utils/Avatar.swift index deb0c4724..bb7a6d028 100644 --- a/Linphone/Utils/Avatar.swift +++ b/Linphone/Utils/Avatar.swift @@ -37,7 +37,7 @@ struct Avatar: View { } var body: some View { - if contactAvatarModel.friend != nil { + if contactAvatarModel.friend != nil && contactAvatarModel.friend!.photo != nil { AsyncImage(url: ContactsManager.shared.getImagePath(friendPhotoPath: contactAvatarModel.friend!.photo!)) { image in switch image { case .empty: From 00a7f305a5497f52dd085d9bf98d52f361d92bde Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Thu, 30 May 2024 11:50:59 +0200 Subject: [PATCH 242/486] Check core state before using an asynchronous function Fix markAsRead crash --- Linphone/Contacts/ContactsManager.swift | 3 +-- Linphone/Core/CoreContext.swift | 4 +++- .../Main/Conversations/Model/ConversationModel.swift | 10 +++++++--- .../ViewModel/ConversationViewModel.swift | 9 +++++++-- 4 files changed, 18 insertions(+), 8 deletions(-) diff --git a/Linphone/Contacts/ContactsManager.swift b/Linphone/Contacts/ContactsManager.swift index eba36a0be..ae186df6f 100644 --- a/Linphone/Contacts/ContactsManager.swift +++ b/Linphone/Contacts/ContactsManager.swift @@ -145,8 +145,7 @@ final class ContactsManager: ObservableObject { MagicSearchSingleton.shared.searchForContacts(sourceFlags: MagicSearch.Source.Friends.rawValue | MagicSearch.Source.LdapServers.rawValue) self.friendListSuscription = nil } - - + MagicSearchSingleton.shared.searchForContacts(sourceFlags: MagicSearch.Source.Friends.rawValue | MagicSearch.Source.LdapServers.rawValue) } } diff --git a/Linphone/Core/CoreContext.swift b/Linphone/Core/CoreContext.swift index 81550b380..f3581d75f 100644 --- a/Linphone/Core/CoreContext.swift +++ b/Linphone/Core/CoreContext.swift @@ -64,7 +64,9 @@ final class CoreContext: ObservableObject { } } else { coreQueue.async { - lambda(self.mCore) + if self.mCore.globalState != .Off { + lambda(self.mCore) + } } } } diff --git a/Linphone/UI/Main/Conversations/Model/ConversationModel.swift b/Linphone/UI/Main/Conversations/Model/ConversationModel.swift index 439e22fa9..d98124fb4 100644 --- a/Linphone/UI/Main/Conversations/Model/ConversationModel.swift +++ b/Linphone/UI/Main/Conversations/Model/ConversationModel.swift @@ -109,11 +109,15 @@ class ConversationModel: ObservableObject { func markAsRead() { coreContext.doOnCoreQueue { _ in - self.chatRoom.markAsRead() - let unreadMessagesCountTmp = self.chatRoom.unreadMessagesCount + if unreadMessagesCountTmp > 0 { + self.chatRoom.markAsRead() + } + DispatchQueue.main.async { - self.unreadMessagesCount = unreadMessagesCountTmp + if self.unreadMessagesCount != unreadMessagesCountTmp { + self.unreadMessagesCount = unreadMessagesCountTmp + } } } } diff --git a/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift b/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift index 98b228650..43acefef0 100644 --- a/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift +++ b/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift @@ -85,10 +85,15 @@ class ConversationViewModel: ObservableObject { func markAsRead() { coreContext.doOnCoreQueue { _ in - self.displayedConversation!.chatRoom.markAsRead() let unreadMessagesCount = self.displayedConversation!.chatRoom.unreadMessagesCount + + if unreadMessagesCount > 0 { + self.displayedConversation!.chatRoom.markAsRead() + } DispatchQueue.main.async { - self.displayedConversationUnreadMessagesCount = unreadMessagesCount + if self.displayedConversationUnreadMessagesCount != unreadMessagesCount { + self.displayedConversationUnreadMessagesCount = unreadMessagesCount + } } } } From 45714fa633c06a1b171d7ce4ad56e98453399c75 Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Thu, 30 May 2024 14:21:23 +0200 Subject: [PATCH 243/486] Fix image resizing when keyboard is open --- .../Main/Conversations/Fragments/ChatBubbleView.swift | 10 +++++----- .../Conversations/Fragments/ConversationFragment.swift | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/Linphone/UI/Main/Conversations/Fragments/ChatBubbleView.swift b/Linphone/UI/Main/Conversations/Fragments/ChatBubbleView.swift index 868c16330..9767bccfb 100644 --- a/Linphone/UI/Main/Conversations/Fragments/ChatBubbleView.swift +++ b/Linphone/UI/Main/Conversations/Fragments/ChatBubbleView.swift @@ -169,19 +169,19 @@ struct ChatBubbleView: View { .if(result.0 < geometryProxy.size.width - 110) { view in view.frame(maxWidth: result.0) } - .if(result.1 < geometryProxy.size.height/2) { view in + .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 >= geometryProxy.size.height/2.5) { view in + .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 >= geometryProxy.size.height/2.5) { view in + .if(result.0 < result.1 && geometryProxy.size.width > 0 && result.1 >= UIScreen.main.bounds.height/2.5) { view in view.frame( - maxWidth: result.0 * ((geometryProxy.size.height/2.5) / result.1), - maxHeight: geometryProxy.size.height/2.5 + maxWidth: result.0 * ((UIScreen.main.bounds.height/2.5) / result.1), + maxHeight: UIScreen.main.bounds.height/2.5 ) } diff --git a/Linphone/UI/Main/Conversations/Fragments/ConversationFragment.swift b/Linphone/UI/Main/Conversations/Fragments/ConversationFragment.swift index 53baddd70..265637b1d 100644 --- a/Linphone/UI/Main/Conversations/Fragments/ConversationFragment.swift +++ b/Linphone/UI/Main/Conversations/Fragments/ConversationFragment.swift @@ -65,7 +65,7 @@ struct ConversationFragment: View { Image("caret-left") .renderingMode(.template) .resizable() - .foregroundStyle(Color.grayMain2c500) + .foregroundStyle(Color.orangeMain500) .frame(width: 25, height: 25, alignment: .leading) .padding(.all, 10) .padding(.top, 4) From c69ca4c9718d7cbf34a9c63e935c5f798faff50f Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Thu, 30 May 2024 14:33:03 +0200 Subject: [PATCH 244/486] Enable FEC --- Linphone/Core/CoreContext.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Linphone/Core/CoreContext.swift b/Linphone/Core/CoreContext.swift index f3581d75f..58387edf7 100644 --- a/Linphone/Core/CoreContext.swift +++ b/Linphone/Core/CoreContext.swift @@ -113,7 +113,7 @@ final class CoreContext: ObservableObject { self.mCore.videoDisplayEnabled = true self.mCore.videoPreviewEnabled = false - self.mCore.fecEnabled = false + self.mCore.fecEnabled = true self.mCoreSuscriptions.insert(self.mCore.publisher?.onGlobalStateChanged?.postOnMainQueue { (cbVal: (core: Core, state: GlobalState, message: String)) in if cbVal.state == GlobalState.On { From 1e16dbaa619cc36e31336e235916fe415e495b49 Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Thu, 30 May 2024 16:45:55 +0200 Subject: [PATCH 245/486] Run getFriendWithAddress in the core queue if necessary --- Linphone/Contacts/ContactsManager.swift | 41 ++--- Linphone/Core/CoreContext.swift | 2 + Linphone/TelecomManager/TelecomManager.swift | 36 ++--- Linphone/UI/Call/Model/ParticipantModel.swift | 2 +- .../UI/Call/ViewModel/CallViewModel.swift | 140 +++++++++--------- .../MeetingWaitingRoomViewModel.swift | 118 ++++++++------- .../Contacts/Model/ContactAvatarModel.swift | 2 +- .../Model/ConversationModel.swift | 139 +++++++++-------- .../ConversationsListViewModel.swift | 2 +- .../UI/Main/History/Model/HistoryModel.swift | 42 +++--- 10 files changed, 254 insertions(+), 270 deletions(-) diff --git a/Linphone/Contacts/ContactsManager.swift b/Linphone/Contacts/ContactsManager.swift index ae186df6f..2c8246828 100644 --- a/Linphone/Contacts/ContactsManager.swift +++ b/Linphone/Contacts/ContactsManager.swift @@ -301,28 +301,31 @@ final class ContactsManager: ObservableObject { } } - - func getFriendWithAddress(address: Address?, completion: @escaping (Friend?) -> Void) { - self.coreContext.doOnCoreQueue { core in - if address != nil { - let clonedAddress = address!.clone() - clonedAddress!.clean() - let sipUri = clonedAddress!.asStringUriOnly() - - if self.friendList != nil { - var friend: Friend? - friend = self.friendList!.friends.first(where: {$0.addresses.contains(where: {$0.asStringUriOnly() == sipUri})}) - if friend == nil { - friend = self.linphoneFriendList!.friends.first(where: {$0.addresses.contains(where: {$0.asStringUriOnly() == sipUri})}) - } - - completion(friend) - } else { - completion(nil) + func getFriendWithAddress(address: Address?) -> Friend? { + if address != nil { + let clonedAddress = address!.clone() + clonedAddress!.clean() + let sipUri = clonedAddress!.asStringUriOnly() + + if self.friendList != nil { + var friend: Friend? + friend = self.friendList!.friends.first(where: {$0.addresses.contains(where: {$0.asStringUriOnly() == sipUri})}) + if friend == nil { + friend = self.linphoneFriendList!.friends.first(where: {$0.addresses.contains(where: {$0.asStringUriOnly() == sipUri})}) } + + return friend } else { - completion(nil) + return nil } + } else { + return nil + } + } + + func getFriendWithAddressInCoreQueue(address: Address?, completion: @escaping (Friend?) -> Void) { + self.coreContext.doOnCoreQueue { core in + completion(self.getFriendWithAddress(address: address)) } } } diff --git a/Linphone/Core/CoreContext.swift b/Linphone/Core/CoreContext.swift index 58387edf7..2bc9c37ca 100644 --- a/Linphone/Core/CoreContext.swift +++ b/Linphone/Core/CoreContext.swift @@ -66,6 +66,8 @@ final class CoreContext: ObservableObject { coreQueue.async { if self.mCore.globalState != .Off { lambda(self.mCore) + } else { + Log.warn("Doesn't run the asynchronous function because the core is off") } } } diff --git a/Linphone/TelecomManager/TelecomManager.swift b/Linphone/TelecomManager/TelecomManager.swift index f28b4cf6a..62bbc397d 100644 --- a/Linphone/TelecomManager/TelecomManager.swift +++ b/Linphone/TelecomManager/TelecomManager.swift @@ -344,16 +344,14 @@ class TelecomManager: ObservableObject { func incomingDisplayName(call: Call, completion: @escaping (String) -> Void) { CoreContext.shared.doOnCoreQueue { core in if call.remoteAddress != nil { - ContactsManager.shared.getFriendWithAddress(address: call.remoteAddress!) { friendResult in - let friend = friendResult - if friend != nil && friend!.address != nil && friend!.address!.displayName != nil { - completion(friend!.address!.displayName!) - } else { - if call.remoteAddress!.displayName != nil { - completion(call.remoteAddress!.displayName!) - } else if call.remoteAddress!.username != nil { - completion(call.remoteAddress!.username!) - } + let friend = ContactsManager.shared.getFriendWithAddress(address: call.remoteAddress!) + if friend != nil && friend!.address != nil && friend!.address!.displayName != nil { + completion(friend!.address!.displayName!) + } else { + if call.remoteAddress!.displayName != nil { + completion(call.remoteAddress!.displayName!) + } else if call.remoteAddress!.username != nil { + completion(call.remoteAddress!.username!) } } } @@ -469,16 +467,14 @@ class TelecomManager: ObservableObject { if isRecordingByRemoteTmp && ToastViewModel.shared.toastMessage.isEmpty { var displayName = "" - ContactsManager.shared.getFriendWithAddress(address: call.remoteAddress!) { friendResult in - let friend = friendResult - 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! - } + 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! } } diff --git a/Linphone/UI/Call/Model/ParticipantModel.swift b/Linphone/UI/Call/Model/ParticipantModel.swift index 921fe079e..3a81da4cf 100644 --- a/Linphone/UI/Call/Model/ParticipantModel.swift +++ b/Linphone/UI/Call/Model/ParticipantModel.swift @@ -49,7 +49,7 @@ class ParticipantModel: ObservableObject { self.isAdmin = isAdmin self.isSpeaking = isSpeaking - ContactsManager.shared.getFriendWithAddress(address: self.address) { friendResult in + ContactsManager.shared.getFriendWithAddressInCoreQueue(address: self.address) { friendResult in if let addressFriend = friendResult { self.name = addressFriend.name! } else { diff --git a/Linphone/UI/Call/ViewModel/CallViewModel.swift b/Linphone/UI/Call/ViewModel/CallViewModel.swift index 65834b7f6..275c6fcf5 100644 --- a/Linphone/UI/Call/ViewModel/CallViewModel.swift +++ b/Linphone/UI/Call/ViewModel/CallViewModel.swift @@ -138,21 +138,19 @@ class CallViewModel: ObservableObject { if self.currentCall?.conference != nil { displayNameTmp = self.currentCall?.conference?.subject ?? "" } else if self.currentCall?.remoteAddress != nil { - ContactsManager.shared.getFriendWithAddress(address: self.currentCall!.remoteAddress) { friendResult in - let friend = friendResult - 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 = self.currentCall!.remoteAddress!.username! - } - } - DispatchQueue.main.async { - self.displayName = displayNameTmp + 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 = self.currentCall!.remoteAddress!.username! } } + DispatchQueue.main.async { + self.displayName = displayNameTmp + } ContactAvatarModel.getAvatarModelFromAddress(address: self.currentCall!.remoteAddress!) { avatarResult in DispatchQueue.main.async { @@ -291,16 +289,14 @@ class CallViewModel: ObservableObject { var activeSpeakerNameTmp = "" if activeSpeakerParticipantTmp != nil { - ContactsManager.shared.getFriendWithAddress(address: activeSpeakerParticipantTmp?.address) { friendResult in - let friend = friendResult - 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! - } + 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! } } } @@ -367,48 +363,46 @@ class CallViewModel: ObservableObject { isMuted: cbValue.participantDevice.isMuted ) - ContactsManager.shared.getFriendWithAddress(address: activeSpeakerParticipantTmp.address) { friendResult in - var activeSpeakerNameTmp = "" - let friend = friendResult - 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 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) { - var participantListTmp: [ParticipantModel] = [] - if (activeSpeakerParticipantBis != nil && !activeSpeakerParticipantBis!.address.equal(address2: activeSpeakerParticipantTmp.address)) - || ( activeSpeakerParticipantBis == nil) { - - cbValue.conference.participantDeviceList.forEach({ participantDevice in - if participantDevice.address != nil && !cbValue.conference.isMe(uri: participantDevice.address!.clone()!) { - if !cbValue.conference.isMe(uri: participantDevice.address!.clone()!) { - let isAdmin = cbValue.conference.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 - ) + cbValue.conference.participantDeviceList.forEach({ participantDevice in + if participantDevice.address != nil && !cbValue.conference.isMe(uri: participantDevice.address!.clone()!) { + if !cbValue.conference.isMe(uri: participantDevice.address!.clone()!) { + let isAdmin = cbValue.conference.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.activeSpeakerParticipant = activeSpeakerParticipantTmp - self.activeSpeakerName = activeSpeakerNameTmp - if (activeSpeakerParticipantBis != nil && !activeSpeakerParticipantBis!.address.equal(address2: activeSpeakerParticipantTmp.address)) - || ( activeSpeakerParticipantBis == nil) { - self.participantList = participantListTmp } + }) + } + + DispatchQueue.main.async { + self.activeSpeakerParticipant = activeSpeakerParticipantTmp + self.activeSpeakerName = activeSpeakerNameTmp + if (activeSpeakerParticipantBis != nil && !activeSpeakerParticipantBis!.address.equal(address2: activeSpeakerParticipantTmp.address)) + || ( activeSpeakerParticipantBis == nil) { + self.participantList = participantListTmp } } } @@ -464,21 +458,19 @@ class CallViewModel: ObservableObject { } if activeSpeakerParticipantTmp != nil { - ContactsManager.shared.getFriendWithAddress(address: activeSpeakerParticipantTmp?.address) { friendResult in - let friend = friendResult - 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! - } + 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.activeSpeakerName = activeSpeakerNameTmp } } } diff --git a/Linphone/UI/Call/ViewModel/MeetingWaitingRoomViewModel.swift b/Linphone/UI/Call/ViewModel/MeetingWaitingRoomViewModel.swift index ec0eaf252..72cf45f22 100644 --- a/Linphone/UI/Call/ViewModel/MeetingWaitingRoomViewModel.swift +++ b/Linphone/UI/Call/ViewModel/MeetingWaitingRoomViewModel.swift @@ -64,68 +64,66 @@ class MeetingWaitingRoomViewModel: ObservableObject { let confNameTmp = conf?.subject ?? "Conference" var userNameTmp = "" - ContactsManager.shared.getFriendWithAddress(address: core.defaultAccount?.contactAddress) { friendResult in - let friend = core.defaultAccount != nil && core.defaultAccount!.contactAddress != nil - ? friendResult - : nil - - let addressTmp = friend?.address?.asStringUriOnly() ?? "" - - if friend != nil && friend!.address != nil && friend!.address!.displayName != nil { - userNameTmp = friend!.address!.displayName! - } else { - if core.defaultAccount!.contactAddress!.displayName != nil { - userNameTmp = core.defaultAccount!.contactAddress!.displayName! - } else if core.defaultAccount!.contactAddress!.username != nil { - userNameTmp = core.defaultAccount!.contactAddress!.username! - } + 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!.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 } - 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 - } + self.userName = userNameTmp + self.avatarModel = avatarModelTmp + self.micMutted = micMuttedTmp + self.meetingDate = meetingDateTmp } } } diff --git a/Linphone/UI/Main/Contacts/Model/ContactAvatarModel.swift b/Linphone/UI/Main/Contacts/Model/ContactAvatarModel.swift index 9236d41df..f8383011e 100644 --- a/Linphone/UI/Main/Contacts/Model/ContactAvatarModel.swift +++ b/Linphone/UI/Main/Contacts/Model/ContactAvatarModel.swift @@ -119,7 +119,7 @@ class ContactAvatarModel: ObservableObject { static func getAvatarModelFromAddress(address: Address, completion: @escaping (ContactAvatarModel) -> Void) { - ContactsManager.shared.getFriendWithAddress(address: address) { resultFriend in + ContactsManager.shared.getFriendWithAddressInCoreQueue(address: address) { resultFriend in if let addressFriend = resultFriend { if addressFriend.address != nil { var avatarModel = ContactsManager.shared.avatarListModel.first(where: { diff --git a/Linphone/UI/Main/Conversations/Model/ConversationModel.swift b/Linphone/UI/Main/Conversations/Model/ConversationModel.swift index d98124fb4..8bc21ef81 100644 --- a/Linphone/UI/Main/Conversations/Model/ConversationModel.swift +++ b/Linphone/UI/Main/Conversations/Model/ConversationModel.swift @@ -141,42 +141,40 @@ class ConversationModel: ObservableObject { coreContext.doOnCoreQueue { _ in let lastMessage = self.chatRoom.lastMessageInHistory if lastMessage != nil { - self.contactsManager.getFriendWithAddress(address: lastMessage!.fromAddress) { friendResult in - var fromAddressFriend = lastMessage!.fromAddress != nil - ? friendResult?.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 = "" - } + 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! += ": " + fromAddressFriend = "" } - } else { - fromAddressFriend = nil + fromAddressFriend! += ": " } - let lastMessageTextTmp = (fromAddressFriend ?? "") - + (lastMessage!.contents.first(where: {$0.isText == true})?.utf8Text ?? (lastMessage!.contents.first(where: {$0.isFile == true || $0.isFileTransfer == true})?.name ?? "")) + } 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 - let lastMessageIsOutgoingTmp = lastMessage?.isOutgoing ?? false + self.lastMessageIsOutgoing = lastMessageIsOutgoingTmp - let lastMessageStateTmp = lastMessage?.state.rawValue ?? 0 - - DispatchQueue.main.async { - self.lastMessageText = lastMessageTextTmp - - self.lastMessageIsOutgoing = lastMessageIsOutgoingTmp - - self.lastMessageState = lastMessageStateTmp - } + self.lastMessageState = lastMessageStateTmp } } } @@ -184,51 +182,48 @@ class ConversationModel: ObservableObject { func getChatRoomSubject() { coreContext.doOnCoreQueue { _ in - self.contactsManager.getFriendWithAddress(address: self.chatRoom.participants.first?.address) { friendResult in - let addressFriend = - (self.chatRoom.participants.first != nil && self.chatRoom.participants.first!.address != nil) - ? friendResult - : nil - - if self.isGroup { - self.subject = self.chatRoom.subject! - } else if addressFriend != nil { - self.subject = addressFriend!.name! - } else { - if self.chatRoom.participants.first != nil - && self.chatRoom.participants.first!.address != nil { - - self.subject = 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: self.subject, - address: addressTmp, - withPresence: false - ) - : ContactAvatarModel( - friend: nil, - name: self.subject, - address: addressTmp, - withPresence: false - ) - - DispatchQueue.main.async { - self.avatarModel = avatarModelTmp + let addressFriend = (self.chatRoom.participants.first != nil && self.chatRoom.participants.first!.address != nil) + ? self.contactsManager.getFriendWithAddress(address: self.chatRoom.participants.first?.address) + : nil + + if self.isGroup { + self.subject = self.chatRoom.subject! + } else if addressFriend != nil { + self.subject = addressFriend!.name! + } else { + if self.chatRoom.participants.first != nil + && self.chatRoom.participants.first!.address != nil { + + self.subject = 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: self.subject, + address: addressTmp, + withPresence: false + ) + : ContactAvatarModel( + friend: nil, + name: self.subject, + address: addressTmp, + withPresence: false + ) + + DispatchQueue.main.async { + self.avatarModel = avatarModelTmp + } } } diff --git a/Linphone/UI/Main/Conversations/ViewModel/ConversationsListViewModel.swift b/Linphone/UI/Main/Conversations/ViewModel/ConversationsListViewModel.swift index 838dfa326..9721912c0 100644 --- a/Linphone/UI/Main/Conversations/ViewModel/ConversationsListViewModel.swift +++ b/Linphone/UI/Main/Conversations/ViewModel/ConversationsListViewModel.swift @@ -149,7 +149,7 @@ class ConversationsListViewModel: ObservableObject { } func getContentTextMessage(message: ChatMessage, completion: @escaping (String) -> Void) { - contactsManager.getFriendWithAddress(address: message.fromAddress) { friendResult in + contactsManager.getFriendWithAddressInCoreQueue(address: message.fromAddress) { friendResult in var fromAddressFriend = message.fromAddress != nil ? friendResult?.name ?? nil : nil diff --git a/Linphone/UI/Main/History/Model/HistoryModel.swift b/Linphone/UI/Main/History/Model/HistoryModel.swift index 57e353197..cc356f2f3 100644 --- a/Linphone/UI/Main/History/Model/HistoryModel.swift +++ b/Linphone/UI/Main/History/Model/HistoryModel.swift @@ -71,29 +71,27 @@ class HistoryModel: ObservableObject { func refreshAvatarModel() { coreContext.doOnCoreQueue { _ in - ContactsManager.shared.getFriendWithAddress(address: self.callLog.dir == .Outgoing ? self.callLog.toAddress! : self.callLog.fromAddress!) { friendResult in - let addressFriendTmp = friendResult - if addressFriendTmp != nil { + 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 - - 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) - } + 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) } } } From bd29389a4002e6b064ebd5bce706166bcb7c311c Mon Sep 17 00:00:00 2001 From: Christophe Deschamps Date: Fri, 31 May 2024 12:38:58 +0000 Subject: [PATCH 246/486] URI Handlers --- Linphone.xcodeproj/project.pbxproj | 8 ++ Linphone/Core/CoreContext.swift | 25 +++- Linphone/Info.plist | 83 +++++++++++++ Linphone/LinphoneApp.swift | 17 ++- Linphone/Localizable.xcstrings | 15 +++ Linphone/UI/Main/Fragments/ToastView.swift | 35 ++++++ Linphone/Utils/Extensions/URLExtension.swift | 34 ++++++ Linphone/Utils/LinphoneUtils.swift | 5 + Linphone/Utils/URIHandler.swift | 120 +++++++++++++++++++ 9 files changed, 338 insertions(+), 4 deletions(-) create mode 100644 Linphone/Utils/Extensions/URLExtension.swift create mode 100644 Linphone/Utils/URIHandler.swift diff --git a/Linphone.xcodeproj/project.pbxproj b/Linphone.xcodeproj/project.pbxproj index 24027cc55..eaffc2479 100644 --- a/Linphone.xcodeproj/project.pbxproj +++ b/Linphone.xcodeproj/project.pbxproj @@ -35,6 +35,8 @@ 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 */; }; + C67586AE2C09F23C002E77BF /* URLExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = C67586AD2C09F23C002E77BF /* URLExtension.swift */; }; + C67586B02C09F247002E77BF /* URIHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = C67586AF2C09F247002E77BF /* URIHandler.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 */; }; @@ -192,6 +194,8 @@ 66E56BCB2BA9A1E0006CE56F /* MeetingsListItemModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeetingsListItemModel.swift; sourceTree = ""; }; 66E56BCD2BA9A1F8006CE56F /* MeetingModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeetingModel.swift; sourceTree = ""; }; 66F626B12BCEBB86003E2DEC /* AddParticipantsFragment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddParticipantsFragment.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 = ""; }; 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 = ""; }; @@ -343,6 +347,7 @@ 66C491F72B24D25A00CEA16D /* Extensions */ = { isa = PBXGroup; children = ( + C67586AD2C09F23C002E77BF /* URLExtension.swift */, D717071D2AC5922E0037746F /* ColorExtension.swift */, 66C491F82B24D25A00CEA16D /* ConfigExtension.swift */, D76005F52B0798B00054B79A /* IntExtension.swift */, @@ -426,6 +431,7 @@ D732A9082AFD235500DB42BA /* ShareSheetController.swift */, D7B99E9A2B29F7C200BE7BF2 /* ActivityIndicator.swift */, D7173EBD2B7A5C0A00BCC481 /* LinphoneUtils.swift */, + C67586AF2C09F247002E77BF /* URIHandler.swift */, ); path = Utils; sourceTree = ""; @@ -993,6 +999,7 @@ D717630D2BD7BD0E00464097 /* ParticipantsListFragment.swift in Sources */, D732A90F2B04C3B400DB42BA /* HistoryFragment.swift in Sources */, D79622342B1DFE600037EACD /* DialerBottomSheet.swift in Sources */, + C67586B02C09F247002E77BF /* URIHandler.swift in Sources */, D78290BB2ADD40B2004AA85C /* ContactViewModel.swift in Sources */, D7F4D9CB2B5FD27200CDCD76 /* CallsListFragment.swift in Sources */, D72992392ADD7F68003AF125 /* HistoryContactFragment.swift in Sources */, @@ -1019,6 +1026,7 @@ 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 */, D78E06282BE3811D00CE3783 /* CallStatsModel.swift in Sources */, diff --git a/Linphone/Core/CoreContext.swift b/Linphone/Core/CoreContext.swift index 2bc9c37ca..a5ecf668e 100644 --- a/Linphone/Core/CoreContext.swift +++ b/Linphone/Core/CoreContext.swift @@ -48,7 +48,10 @@ final class CoreContext: ObservableObject { let monitor = NWPathMonitor() private var mCorePushIncomingDelegate: CoreDelegate! - + private var actionsToPerformOnCoreQueueWhenCoreIsStarted : [((Core)->Void)] = [] + private var callStateCallBacks : [((Call.State)->Void)] = [] + private var configuringStateCallBacks : [((ConfiguringState)->Void)] = [] + private init() { do { try initialiseCore() @@ -141,6 +144,8 @@ final class CoreContext: ObservableObject { account.params = newParams } + self.actionsToPerformOnCoreQueueWhenCoreIsStarted.forEach {$0(cbVal.core)} + self.actionsToPerformOnCoreQueueWhenCoreIsStarted.removeAll() } }) @@ -291,6 +296,24 @@ final class CoreContext: ObservableObject { 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 large_tuple diff --git a/Linphone/Info.plist b/Linphone/Info.plist index 1639aabd3..c683a3dcf 100644 --- a/Linphone/Info.plist +++ b/Linphone/Info.plist @@ -2,6 +2,89 @@ + 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 + + + ITSAppUsesNonExemptEncryption ITSEncryptionExportComplianceCode diff --git a/Linphone/LinphoneApp.swift b/Linphone/LinphoneApp.swift index 2082758b3..bd735d195 100644 --- a/Linphone/LinphoneApp.swift +++ b/Linphone/LinphoneApp.swift @@ -86,7 +86,12 @@ struct LinphoneApp: App { WindowGroup { if coreContext.coreIsStarted { if !sharedMainViewModel.welcomeViewDisplayed { - WelcomeView() + ZStack { + WelcomeView() + + ToastView() + .zIndex(3) + } } else if !coreContext.hasDefaultAccount || sharedMainViewModel.displayProfileMode { ZStack { AssistantView() @@ -119,9 +124,13 @@ struct LinphoneApp: App { conversationViewModel: conversationViewModel!, meetingsListViewModel: meetingsListViewModel!, scheduleMeetingViewModel: scheduleMeetingViewModel! - ) + ).onOpenURL { url in + URIHandler.handleURL(url: url) + } } else { - SplashScreen() + SplashScreen().onOpenURL { url in + URIHandler.handleURL(url: url) + } } } else { SplashScreen() @@ -137,6 +146,8 @@ struct LinphoneApp: App { conversationViewModel = ConversationViewModel() meetingsListViewModel = MeetingsListViewModel() scheduleMeetingViewModel = ScheduleMeetingViewModel() + }.onOpenURL { url in + URIHandler.handleURL(url: url) } } }.onChange(of: scenePhase) { newPhase in diff --git a/Linphone/Localizable.xcstrings b/Linphone/Localizable.xcstrings index 129e33904..0890a892c 100644 --- a/Linphone/Localizable.xcstrings +++ b/Linphone/Localizable.xcstrings @@ -242,6 +242,9 @@ }, "Bluetooth" : { + }, + "Call failed" : { + }, "Call has been successfully transferred" : { @@ -284,6 +287,12 @@ }, "Conditions de service" : { + }, + "Configuration failed" : { + + }, + "Configuration successfully applied" : { + }, "Connexion à la réunion" : { @@ -777,6 +786,12 @@ }, "UDP" : { + }, + "Unable to call, invalid address" : { + + }, + "Unable to retrieve configuration, invalid address" : { + }, "Une application de communication **sécurisée**, **open source** et **française**." : { diff --git a/Linphone/UI/Main/Fragments/ToastView.swift b/Linphone/UI/Main/Fragments/ToastView.swift index e42c834c3..b68c15604 100644 --- a/Linphone/UI/Main/Fragments/ToastView.swift +++ b/Linphone/UI/Main/Fragments/ToastView.swift @@ -136,7 +136,42 @@ struct ToastView: View { .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) + default: Text("Error") .multilineTextAlignment(.center) diff --git a/Linphone/Utils/Extensions/URLExtension.swift b/Linphone/Utils/Extensions/URLExtension.swift new file mode 100644 index 000000000..10fb96e93 --- /dev/null +++ b/Linphone/Utils/Extensions/URLExtension.swift @@ -0,0 +1,34 @@ +/* + * 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 { + get { + let nrl : NSURL = self as NSURL + return nrl.resourceSpecifier ?? self.absoluteString + } + } +} diff --git a/Linphone/Utils/LinphoneUtils.swift b/Linphone/Utils/LinphoneUtils.swift index ded14ef87..9b6a4e065 100644 --- a/Linphone/Utils/LinphoneUtils.swift +++ b/Linphone/Utils/LinphoneUtils.swift @@ -59,4 +59,9 @@ class LinphoneUtils: NSObject { 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 + } + } diff --git a/Linphone/Utils/URIHandler.swift b/Linphone/Utils/URIHandler.swift new file mode 100644 index 000000000..8643c7101 --- /dev/null +++ b/Linphone/Utils/URIHandler.swift @@ -0,0 +1,120 @@ +/* + * 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"] + private static let secureCallSchemes = ["sips", "sips-linphone", "linphone-sips"] + private static let configurationSchemes = ["linphone-config"] + + private static var uriHandlerCoreDelegate: CoreDelegateStub? = nil + + static func addCoreDelegate() { + uriHandlerCoreDelegate = CoreDelegateStub( + onCallStateChanged: { (core: Core, call: Call, state: Call.State, message: 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:Core, state:ConfiguringState, status: 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 { + 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 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 + } + } +} From 088f3a7506da2f95a8743582b7a421c5c89abdd1 Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Fri, 31 May 2024 17:21:23 +0200 Subject: [PATCH 247/486] Fix conversation unread counters --- .../Contacts/Fragments/ContactFragment.swift | 71 ++++++++++--------- .../Model/ConversationModel.swift | 8 +-- .../ViewModel/ConversationViewModel.swift | 7 +- 3 files changed, 42 insertions(+), 44 deletions(-) diff --git a/Linphone/UI/Main/Contacts/Fragments/ContactFragment.swift b/Linphone/UI/Main/Contacts/Fragments/ContactFragment.swift index aa805937e..67fefca97 100644 --- a/Linphone/UI/Main/Contacts/Fragments/ContactFragment.swift +++ b/Linphone/UI/Main/Contacts/Fragments/ContactFragment.swift @@ -35,46 +35,47 @@ struct ContactFragment: View { var body: some View { let indexDisplayed = contactViewModel.indexDisplayedFriend != nil ? contactViewModel.indexDisplayedFriend! : 0 - if #available(iOS 16.0, *), idiom != .pad { - ContactInnerFragment( - contactAvatarModel: ContactsManager.shared.avatarListModel[indexDisplayed], - contactViewModel: contactViewModel, - editContactViewModel: editContactViewModel, - cnContact: CNContact(), - isShowDeletePopup: $isShowDeletePopup, - showingSheet: $showingSheet, - showShareSheet: $showShareSheet, - isShowDismissPopup: $isShowDismissPopup - ) - .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, - cnContact: CNContact(), - isShowDeletePopup: $isShowDeletePopup, - showingSheet: $showingSheet, - showShareSheet: $showShareSheet, - isShowDismissPopup: $isShowDismissPopup - ) - .halfSheet(showSheet: $showingSheet) { - ContactListBottomSheet(contactViewModel: contactViewModel, showingSheet: $showingSheet) - } onDismiss: {} + if ContactsManager.shared.avatarListModel.count > indexDisplayed { + if #available(iOS 16.0, *), idiom != .pad { + ContactInnerFragment( + contactAvatarModel: ContactsManager.shared.avatarListModel[indexDisplayed], + contactViewModel: contactViewModel, + editContactViewModel: editContactViewModel, + cnContact: CNContact(), + isShowDeletePopup: $isShowDeletePopup, + showingSheet: $showingSheet, + showShareSheet: $showShareSheet, + isShowDismissPopup: $isShowDismissPopup + ) + .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, + cnContact: CNContact(), + isShowDeletePopup: $isShowDeletePopup, + showingSheet: $showingSheet, + showShareSheet: $showShareSheet, + isShowDismissPopup: $isShowDismissPopup + ) + .halfSheet(showSheet: $showingSheet) { + ContactListBottomSheet(contactViewModel: contactViewModel, showingSheet: $showingSheet) + } onDismiss: {} + .sheet(isPresented: $showShareSheet) { + ShareSheet(friendToShare: ContactsManager.shared.lastSearch[contactViewModel.indexDisplayedFriend!].friend!) + .edgesIgnoringSafeArea(.bottom) + } + } } - } } diff --git a/Linphone/UI/Main/Conversations/Model/ConversationModel.swift b/Linphone/UI/Main/Conversations/Model/ConversationModel.swift index 8bc21ef81..60238fa3f 100644 --- a/Linphone/UI/Main/Conversations/Model/ConversationModel.swift +++ b/Linphone/UI/Main/Conversations/Model/ConversationModel.swift @@ -112,11 +112,9 @@ class ConversationModel: ObservableObject { let unreadMessagesCountTmp = self.chatRoom.unreadMessagesCount if unreadMessagesCountTmp > 0 { self.chatRoom.markAsRead() - } - - DispatchQueue.main.async { - if self.unreadMessagesCount != unreadMessagesCountTmp { - self.unreadMessagesCount = unreadMessagesCountTmp + + DispatchQueue.main.async { + self.unreadMessagesCount = 0 } } } diff --git a/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift b/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift index 43acefef0..5d68dfb26 100644 --- a/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift +++ b/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift @@ -89,10 +89,9 @@ class ConversationViewModel: ObservableObject { if unreadMessagesCount > 0 { self.displayedConversation!.chatRoom.markAsRead() - } - DispatchQueue.main.async { - if self.displayedConversationUnreadMessagesCount != unreadMessagesCount { - self.displayedConversationUnreadMessagesCount = unreadMessagesCount + + DispatchQueue.main.async { + self.displayedConversationUnreadMessagesCount = 0 } } } From 1b879a5c61ee97f1aaf87cffb05580198c5c389b Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Mon, 3 Jun 2024 17:25:46 +0200 Subject: [PATCH 248/486] Fix avatar refresh after image download --- Linphone/Contacts/ContactsManager.swift | 31 ++-- Linphone/Core/CoreContext.swift | 1 + Linphone/LinphoneApp.swift | 1 - Linphone/TelecomManager/TelecomManager.swift | 3 +- .../Fragments/EditContactFragment.swift | 44 ++--- .../Contacts/Model/ContactAvatarModel.swift | 1 - .../Fragments/ConversationsListFragment.swift | 154 +++++++++--------- .../Model/ConversationModel.swift | 15 +- .../ConversationsListViewModel.swift | 2 + Linphone/Utils/EditContactController.swift | 6 +- 10 files changed, 126 insertions(+), 132 deletions(-) diff --git a/Linphone/Contacts/ContactsManager.swift b/Linphone/Contacts/ContactsManager.swift index 2c8246828..c624f58a1 100644 --- a/Linphone/Contacts/ContactsManager.swift +++ b/Linphone/Contacts/ContactsManager.swift @@ -100,6 +100,7 @@ final class ContactsManager: ObservableObject { 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.sync { let newContact = Contact( @@ -109,7 +110,7 @@ final class ContactsManager: ObservableObject { organizationName: contact.organizationName, jobTitle: "", displayName: contact.nickname, - sipAddresses: contact.instantMessageAddresses.map { $0.value.service == "SIP" ? $0.value.username : "" }, + 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: "" ) @@ -125,8 +126,21 @@ final class ContactsManager: ObservableObject { : contact.givenName, lastName: contact.familyName), name: contact.givenName + contact.familyName, prefix: ((imageThumbnail == nil) ? "-default" : ""), - contact: newContact, linphoneFriend: false, existingFriend: nil) + contact: newContact, linphoneFriend: false, existingFriend: nil) { + if (self.friendList?.friends.count ?? 0) + (self.linphoneFriendList?.friends.count ?? 0) == contactCounter { + self.linphoneFriendList?.updateSubscriptions() + self.friendList?.updateSubscriptions() + + MagicSearchSingleton.shared.searchForContacts(sourceFlags: MagicSearch.Source.Friends.rawValue | MagicSearch.Source.LdapServers.rawValue) + + self.friendListSuscription = self.friendList?.publisher?.onPresenceReceived?.postOnMainQueue { (cbValue: (friendList: FriendList, friends: [Friend])) in + MagicSearchSingleton.shared.searchForContacts(sourceFlags: MagicSearch.Source.Friends.rawValue | MagicSearch.Source.LdapServers.rawValue) + self.friendListSuscription = nil + } + } + } } + contactCounter += 1 }) } catch let error { @@ -137,16 +151,6 @@ final class ContactsManager: ObservableObject { print("\(#function) - access denied") } } - - self.linphoneFriendList?.updateSubscriptions() - self.friendList?.updateSubscriptions() - - self.friendListSuscription = self.friendList?.publisher?.onPresenceReceived?.postOnMainQueue { (cbValue: (friendList: FriendList, friends: [Friend])) in - MagicSearchSingleton.shared.searchForContacts(sourceFlags: MagicSearch.Source.Friends.rawValue | MagicSearch.Source.LdapServers.rawValue) - self.friendListSuscription = nil - } - - MagicSearchSingleton.shared.searchForContacts(sourceFlags: MagicSearch.Source.Friends.rawValue | MagicSearch.Source.LdapServers.rawValue) } } @@ -178,7 +182,7 @@ final class ContactsManager: ObservableObject { return IBImgViewUserProfile } - func saveImage(image: UIImage, name: String, prefix: String, contact: Contact, linphoneFriend: Bool, existingFriend: Friend?) { + 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 } @@ -192,6 +196,7 @@ final class ContactsManager: ObservableObject { _ = self.friendList?.addLocalFriend(linphoneFriend: resultFriend!) } } + completion() } } } diff --git a/Linphone/Core/CoreContext.swift b/Linphone/Core/CoreContext.swift index 2bc9c37ca..fb3b23a08 100644 --- a/Linphone/Core/CoreContext.swift +++ b/Linphone/Core/CoreContext.swift @@ -168,6 +168,7 @@ final class CoreContext: ObservableObject { if cbVal.state == .Ok { self.loggingInProgress = false self.loggedIn = true + ContactsManager.shared.fetchContacts() } else if cbVal.state == .Progress { self.loggingInProgress = true } else { diff --git a/Linphone/LinphoneApp.swift b/Linphone/LinphoneApp.swift index 2082758b3..00bef0fa1 100644 --- a/Linphone/LinphoneApp.swift +++ b/Linphone/LinphoneApp.swift @@ -143,7 +143,6 @@ struct LinphoneApp: App { if newPhase == .active { Log.info("Entering foreground") coreContext.onEnterForeground() - ContactsManager.shared.fetchContacts() } else if newPhase == .inactive { } else if newPhase == .background { Log.info("Entering background") diff --git a/Linphone/TelecomManager/TelecomManager.swift b/Linphone/TelecomManager/TelecomManager.swift index 62bbc397d..a8bfc30bd 100644 --- a/Linphone/TelecomManager/TelecomManager.swift +++ b/Linphone/TelecomManager/TelecomManager.swift @@ -443,8 +443,9 @@ class TelecomManager: ObservableObject { } else { DispatchQueue.main.async { self.remoteConfVideo = false + let remoteConfVideoTmp = call.currentParams!.videoEnabled && call.currentParams!.videoDirection == .SendRecv || call.currentParams!.videoDirection == .RecvOnly DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { - self.remoteConfVideo = call.currentParams!.videoEnabled && call.currentParams!.videoDirection == .SendRecv || call.currentParams!.videoDirection == .RecvOnly + self.remoteConfVideo = remoteConfVideoTmp } } } diff --git a/Linphone/UI/Main/Contacts/Fragments/EditContactFragment.swift b/Linphone/UI/Main/Contacts/Fragments/EditContactFragment.swift index 3af2c475b..d6afa15f0 100644 --- a/Linphone/UI/Main/Contacts/Fragments/EditContactFragment.swift +++ b/Linphone/UI/Main/Contacts/Fragments/EditContactFragment.swift @@ -526,29 +526,29 @@ struct EditContactFragment: View { name: editContactViewModel.firstName + editContactViewModel.lastName, prefix: ((selectedImage == nil) ? "-default" : ""), - contact: newContact, linphoneFriend: true, existingFriend: editContactViewModel.selectedEditFriend) + contact: newContact, linphoneFriend: true, existingFriend: editContactViewModel.selectedEditFriend) { + MagicSearchSingleton.shared.searchForContacts(sourceFlags: MagicSearch.Source.Friends.rawValue | MagicSearch.Source.LdapServers.rawValue) + + if editContactViewModel.selectedEditFriend != nil && editContactViewModel.selectedEditFriend!.name != editContactViewModel.firstName + " " + editContactViewModel.lastName { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + let result = ContactsManager.shared.lastSearch.firstIndex(where: { + $0.friend!.name == newContact.firstName + " " + newContact.lastName + }) + contactViewModel.indexDisplayedFriend = result + } + } + + delayColorDismiss() + if editContactViewModel.selectedEditFriend == nil { + withAnimation { + isShowEditContactFragment.toggle() + } + } else { + dismiss() + } + editContactViewModel.resetValues() + } } - - MagicSearchSingleton.shared.searchForContacts(sourceFlags: MagicSearch.Source.Friends.rawValue | MagicSearch.Source.LdapServers.rawValue) - - if editContactViewModel.selectedEditFriend != nil && editContactViewModel.selectedEditFriend!.name != editContactViewModel.firstName + " " + editContactViewModel.lastName { - DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { - let result = ContactsManager.shared.lastSearch.firstIndex(where: { - $0.friend!.name == newContact.firstName + " " + newContact.lastName - }) - contactViewModel.indexDisplayedFriend = result - } - } - - delayColorDismiss() - if editContactViewModel.selectedEditFriend == nil { - withAnimation { - isShowEditContactFragment.toggle() - } - } else { - dismiss() - } - editContactViewModel.resetValues() } } diff --git a/Linphone/UI/Main/Contacts/Model/ContactAvatarModel.swift b/Linphone/UI/Main/Contacts/Model/ContactAvatarModel.swift index f8383011e..59ec6c20c 100644 --- a/Linphone/UI/Main/Contacts/Model/ContactAvatarModel.swift +++ b/Linphone/UI/Main/Contacts/Model/ContactAvatarModel.swift @@ -117,7 +117,6 @@ class ContactAvatarModel: ObservableObject { } } - static func getAvatarModelFromAddress(address: Address, completion: @escaping (ContactAvatarModel) -> Void) { ContactsManager.shared.getFriendWithAddressInCoreQueue(address: address) { resultFriend in if let addressFriend = resultFriend { diff --git a/Linphone/UI/Main/Conversations/Fragments/ConversationsListFragment.swift b/Linphone/UI/Main/Conversations/Fragments/ConversationsListFragment.swift index 5e3fb392c..9ef536ca9 100644 --- a/Linphone/UI/Main/Conversations/Fragments/ConversationsListFragment.swift +++ b/Linphone/UI/Main/Conversations/Fragments/ConversationsListFragment.swift @@ -34,21 +34,21 @@ struct ConversationsListFragment: View { List { ForEach(0.. 0) { view in + view.default_text_style_700(styleSize: 14) + } + .default_text_style(styleSize: 14) + .frame(maxWidth: .infinity, alignment: .leading) + .lineLimit(1) - Text(conversationsListViewModel.conversationsList[index].subject) - .foregroundStyle(Color.grayMain2c800) - .if(conversationsListViewModel.conversationsList[index].unreadMessagesCount > 0) { view in - view.default_text_style_700(styleSize: 14) - } - .default_text_style(styleSize: 14) - .frame(maxWidth: .infinity, alignment: .leading) - .lineLimit(1) - - Text(conversationsListViewModel.conversationsList[index].lastMessageText) + Text(conversationsListViewModel.conversationsList[index].lastMessageText) .foregroundStyle(Color.grayMain2c400) .if(conversationsListViewModel.conversationsList[index].unreadMessagesCount > 0) { view in view.default_text_style_700(styleSize: 14) @@ -56,79 +56,79 @@ struct ConversationsListFragment: View { .default_text_style(styleSize: 14) .frame(maxWidth: .infinity, alignment: .leading) .lineLimit(1) + + Spacer() + } + + Spacer() + + VStack(alignment: .trailing, spacing: 0) { + Spacer() + + HStack { + if !conversationsListViewModel.conversationsList[index].encryptionEnabled { + Image("warning-circle") + .renderingMode(.template) + .resizable() + .foregroundStyle(Color.redDanger500) + .frame(width: 18, height: 18, alignment: .trailing) + } - Spacer() + Text(conversationsListViewModel.getCallTime(startDate: conversationsListViewModel.conversationsList[index].lastUpdateTime)) + .foregroundStyle(Color.grayMain2c400) + .default_text_style(styleSize: 14) + .lineLimit(1) } Spacer() - VStack(alignment: .trailing, spacing: 0) { - Spacer() + HStack { + if conversationsListViewModel.conversationsList[index].isMuted == false + && !(!conversationsListViewModel.conversationsList[index].lastMessageText.isEmpty + && conversationsListViewModel.conversationsList[index].lastMessageIsOutgoing == true) + && conversationsListViewModel.conversationsList[index].unreadMessagesCount == 0 { + Text("") + .frame(width: 18, height: 18, alignment: .trailing) + } - HStack { - if !conversationsListViewModel.conversationsList[index].encryptionEnabled { - Image("warning-circle") - .renderingMode(.template) - .resizable() - .foregroundStyle(Color.redDanger500) - .frame(width: 18, height: 18, alignment: .trailing) - } - - Text(conversationsListViewModel.getCallTime(startDate: conversationsListViewModel.conversationsList[index].lastUpdateTime)) - .foregroundStyle(Color.grayMain2c400) - .default_text_style(styleSize: 14) + if conversationsListViewModel.conversationsList[index].isMuted { + Image("bell-slash") + .renderingMode(.template) + .resizable() + .foregroundStyle(Color.orangeMain500) + .frame(width: 18, height: 18, alignment: .trailing) + } + + if !conversationsListViewModel.conversationsList[index].lastMessageText.isEmpty + && conversationsListViewModel.conversationsList[index].lastMessageIsOutgoing == true { + let imageName = LinphoneUtils.getChatIconState(chatState: conversationsListViewModel.conversationsList[index].lastMessageState) + Image(imageName) + .renderingMode(.template) + .resizable() + .foregroundStyle(Color.orangeMain500) + .frame(width: 18, height: 18, alignment: .trailing) + } + + if conversationsListViewModel.conversationsList[index].unreadMessagesCount > 0 { + HStack { + Text( + conversationsListViewModel.conversationsList[index].unreadMessagesCount < 99 + ? String(conversationsListViewModel.conversationsList[index].unreadMessagesCount) + : "99+" + ) + .foregroundStyle(.white) + .default_text_style(styleSize: 10) .lineLimit(1) + } + .frame(width: 18, height: 18) + .background(Color.redDanger500) + .cornerRadius(50) } - - Spacer() - - HStack { - if conversationsListViewModel.conversationsList[index].isMuted == false - && !(!conversationsListViewModel.conversationsList[index].lastMessageText.isEmpty - && conversationsListViewModel.conversationsList[index].lastMessageIsOutgoing == true) - && conversationsListViewModel.conversationsList[index].unreadMessagesCount == 0 { - Text("") - .frame(width: 18, height: 18, alignment: .trailing) - } - - if conversationsListViewModel.conversationsList[index].isMuted { - Image("bell-slash") - .renderingMode(.template) - .resizable() - .foregroundStyle(Color.orangeMain500) - .frame(width: 18, height: 18, alignment: .trailing) - } - - if !conversationsListViewModel.conversationsList[index].lastMessageText.isEmpty - && conversationsListViewModel.conversationsList[index].lastMessageIsOutgoing == true { - let imageName = LinphoneUtils.getChatIconState(chatState: conversationsListViewModel.conversationsList[index].lastMessageState) - Image(imageName) - .renderingMode(.template) - .resizable() - .foregroundStyle(Color.orangeMain500) - .frame(width: 18, height: 18, alignment: .trailing) - } - - if conversationsListViewModel.conversationsList[index].unreadMessagesCount > 0 { - HStack { - Text( - conversationsListViewModel.conversationsList[index].unreadMessagesCount < 99 - ? String(conversationsListViewModel.conversationsList[index].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) + + Spacer() + } + .padding(.trailing, 10) } .frame(height: 50) .buttonStyle(.borderless) diff --git a/Linphone/UI/Main/Conversations/Model/ConversationModel.swift b/Linphone/UI/Main/Conversations/Model/ConversationModel.swift index 60238fa3f..cb50b7d8c 100644 --- a/Linphone/UI/Main/Conversations/Model/ConversationModel.swift +++ b/Linphone/UI/Main/Conversations/Model/ConversationModel.swift @@ -39,19 +39,14 @@ class ConversationModel: ObservableObject { @Published var subject: String @Published var isComposing: Bool @Published var lastUpdateTime: time_t - //@Published var composingLabel: String @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 dateTime: String @Published var unreadMessagesCount: Int @Published var avatarModel: ContactAvatarModel - //@Published var isBeingDeleted: Bool - - //private let lastMessage: ChatMessage? = nil init(chatRoom: ChatRoom) { self.chatRoom = chatRoom @@ -72,8 +67,6 @@ class ConversationModel: ObservableObject { self.isComposing = chatRoom.isRemoteComposing - //self.composingLabel = chatRoom.compo - self.isMuted = chatRoom.muted self.isEphemeral = chatRoom.ephemeralEnabled @@ -86,15 +79,9 @@ class ConversationModel: ObservableObject { self.lastMessageState = 0 - //self.dateTime = chatRoom.date - self.unreadMessagesCount = 0 - self.avatarModel = ContactAvatarModel(friend: nil, name: "", address: "", withPresence: false) - - //self.isBeingDeleted = MutableLiveData() - - //self.lastMessage: ChatMessage? = null + self.avatarModel = ContactAvatarModel(friend: nil, name: chatRoom.subject ?? "", address: chatRoom.peerAddress?.asStringUriOnly() ?? "", withPresence: false) getContentTextMessage() getChatRoomSubject() diff --git a/Linphone/UI/Main/Conversations/ViewModel/ConversationsListViewModel.swift b/Linphone/UI/Main/Conversations/ViewModel/ConversationsListViewModel.swift index 9721912c0..7ed96c4e2 100644 --- a/Linphone/UI/Main/Conversations/ViewModel/ConversationsListViewModel.swift +++ b/Linphone/UI/Main/Conversations/ViewModel/ConversationsListViewModel.swift @@ -205,5 +205,7 @@ class ConversationsListViewModel: ObservableObject { conversationsList.forEach { conversationModel in conversationModel.refreshAvatarModel() } + + reorderChatRooms() } } diff --git a/Linphone/Utils/EditContactController.swift b/Linphone/Utils/EditContactController.swift index 4734b5562..a7c4c722b 100644 --- a/Linphone/Utils/EditContactController.swift +++ b/Linphone/Utils/EditContactController.swift @@ -54,9 +54,9 @@ struct EditContactView: UIViewControllerRepresentable { 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) + existingFriend: ContactsManager.shared.getFriendWithContact(contact: newContact)) { + MagicSearchSingleton.shared.searchForContacts(sourceFlags: MagicSearch.Source.Friends.rawValue | MagicSearch.Source.LdapServers.rawValue) + } } } viewController.dismiss(animated: true, completion: {}) From b3fa81b5376aeeff52b45af01db78c677ec6bfd1 Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Tue, 4 Jun 2024 14:23:45 +0200 Subject: [PATCH 249/486] Fix bubble display in iOS 15 --- .../ViewModel/ConversationViewModel.swift | 160 ++++++++++++------ 1 file changed, 105 insertions(+), 55 deletions(-) diff --git a/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift b/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift index 5d68dfb26..8a0106a79 100644 --- a/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift +++ b/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift @@ -23,6 +23,7 @@ import Combine import SwiftUI import AVFoundation +// swiftlint:disable type_body_length class ConversationViewModel: ObservableObject { private var coreContext = CoreContext.shared @@ -194,18 +195,20 @@ class ConversationViewModel: ObservableObject { statusTmp = nil } - conversationMessage.append( - Message( - id: UUID().uuidString, - status: statusTmp, - isOutgoing: eventLog.chatMessage?.isOutgoing ?? false, - dateReceived: eventLog.chatMessage?.time ?? 0, - address: addressCleaned?.asStringUriOnly() ?? "", - isFirstMessage: isFirstMessageTmp, - text: contentText, - attachments: attachmentList + if eventLog.chatMessage != nil { + conversationMessage.append( + Message( + id: UUID().uuidString, + status: statusTmp, + isOutgoing: eventLog.chatMessage?.isOutgoing ?? false, + dateReceived: eventLog.chatMessage?.time ?? 0, + address: addressCleaned?.asStringUriOnly() ?? "", + isFirstMessage: isFirstMessageTmp, + text: contentText, + attachments: attachmentList + ) ) - ) + } } DispatchQueue.main.async { @@ -235,9 +238,30 @@ class ConversationViewModel: ObservableObject { if content.filePath == nil || content.filePath!.isEmpty { self.downloadContent(chatMessage: eventLog.chatMessage!, content: content) } else { - if URL(string: self.getNewFilePath(name: content.name ?? "")) != nil { - let attachment = Attachment(id: UUID().uuidString, url: URL(string: self.getNewFilePath(name: content.name ?? ""))!, type: (content.name?.lowercased().hasSuffix("gif"))! ? .gif : .image) - attachmentList.append(attachment) + if content.type != "video" { + let path = URL(string: self.getNewFilePath(name: content.name ?? "")) + if path != nil { + let attachment = + Attachment( + id: UUID().uuidString, + url: path!, + type: (content.name?.lowercased().hasSuffix("gif"))! ? .gif : .image + ) + 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, + thumbnail: pathThumbnail!, + full: path!, + type: .video + ) + attachmentList.append(attachment) + } } } } @@ -272,18 +296,20 @@ class ConversationViewModel: ObservableObject { statusTmp = nil } - conversationMessagesTmp.insert( - Message( - id: UUID().uuidString, - status: statusTmp, - isOutgoing: eventLog.chatMessage?.isOutgoing ?? false, - dateReceived: eventLog.chatMessage?.time ?? 0, - address: addressCleaned?.asStringUriOnly() ?? "", - isFirstMessage: isFirstMessageTmp, - text: contentText, - attachments: attachmentList - ), at: 0 - ) + if eventLog.chatMessage != nil { + conversationMessagesTmp.insert( + Message( + id: UUID().uuidString, + status: statusTmp, + isOutgoing: eventLog.chatMessage?.isOutgoing ?? false, + dateReceived: eventLog.chatMessage?.time ?? 0, + address: addressCleaned?.asStringUriOnly() ?? "", + isFirstMessage: isFirstMessageTmp, + text: contentText, + attachments: attachmentList + ), at: 0 + ) + } } if !conversationMessagesTmp.isEmpty { @@ -311,9 +337,30 @@ class ConversationViewModel: ObservableObject { if content.filePath == nil || content.filePath!.isEmpty { self.downloadContent(chatMessage: eventLog.chatMessage!, content: content) } else { - if URL(string: self.getNewFilePath(name: content.name ?? "")) != nil { - let attachment = Attachment(id: UUID().uuidString, url: URL(string: self.getNewFilePath(name: content.name ?? ""))!, type: (content.name?.lowercased().hasSuffix("gif"))! ? .gif : .image) - attachmentList.append(attachment) + if content.type != "video" { + let path = URL(string: self.getNewFilePath(name: content.name ?? "")) + if path != nil { + let attachment = + Attachment( + id: UUID().uuidString, + url: path!, + type: (content.name?.lowercased().hasSuffix("gif"))! ? .gif : .image + ) + 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, + thumbnail: pathThumbnail!, + full: path!, + type: .video + ) + attachmentList.append(attachment) + } } } } @@ -361,33 +408,35 @@ class ConversationViewModel: ObservableObject { statusTmp = nil } - let message = Message( - id: UUID().uuidString, - status: statusTmp, - isOutgoing: eventLog.chatMessage?.isOutgoing ?? false, - dateReceived: eventLog.chatMessage?.time ?? 0, - address: addressCleaned?.asStringUriOnly() ?? "", - isFirstMessage: isFirstMessageTmp, - text: contentText, - attachments: attachmentList - ) - - DispatchQueue.main.async { - if !self.conversationMessagesSection.isEmpty - && !self.conversationMessagesSection[0].rows.isEmpty - && self.conversationMessagesSection[0].rows[0].isOutgoing - && (self.conversationMessagesSection[0].rows[0].address == message.address) { - self.conversationMessagesSection[0].rows[0].isFirstMessage = false - } + if eventLog.chatMessage != nil { + let message = Message( + id: UUID().uuidString, + status: statusTmp, + isOutgoing: eventLog.chatMessage?.isOutgoing ?? false, + dateReceived: eventLog.chatMessage?.time ?? 0, + address: addressCleaned?.asStringUriOnly() ?? "", + isFirstMessage: isFirstMessageTmp, + text: contentText, + attachments: attachmentList + ) - if self.conversationMessagesSection.isEmpty { - self.conversationMessagesSection.append(MessagesSection(date: Date(), rows: [message])) - } else { - self.conversationMessagesSection[0].rows.insert(message, at: 0) - } - - if !message.isOutgoing { - self.displayedConversationUnreadMessagesCount += 1 + DispatchQueue.main.async { + if !self.conversationMessagesSection.isEmpty + && !self.conversationMessagesSection[0].rows.isEmpty + && self.conversationMessagesSection[0].rows[0].isOutgoing + && (self.conversationMessagesSection[0].rows[0].address == message.address) { + self.conversationMessagesSection[0].rows[0].isFirstMessage = false + } + + if self.conversationMessagesSection.isEmpty { + self.conversationMessagesSection.append(MessagesSection(date: Date(), rows: [message])) + } else { + self.conversationMessagesSection[0].rows.insert(message, at: 0) + } + + if !message.isOutgoing { + self.displayedConversationUnreadMessagesCount += 1 + } } } @@ -627,3 +676,4 @@ extension LinphoneCustomEventLog { return lhs.id == rhs.id } } +// swiftlint:enable type_body_length From bd9e4a000fd37d1b59c2bec0b0a17da4e8bc0a20 Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Tue, 4 Jun 2024 14:25:30 +0200 Subject: [PATCH 250/486] Fix remote Conf Video crash --- Linphone/TelecomManager/TelecomManager.swift | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/Linphone/TelecomManager/TelecomManager.swift b/Linphone/TelecomManager/TelecomManager.swift index a8bfc30bd..bd5cdf888 100644 --- a/Linphone/TelecomManager/TelecomManager.swift +++ b/Linphone/TelecomManager/TelecomManager.swift @@ -443,9 +443,11 @@ class TelecomManager: ObservableObject { } else { DispatchQueue.main.async { self.remoteConfVideo = false - let remoteConfVideoTmp = call.currentParams!.videoEnabled && call.currentParams!.videoDirection == .SendRecv || call.currentParams!.videoDirection == .RecvOnly - DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { - self.remoteConfVideo = remoteConfVideoTmp + if call.currentParams != nil { + let remoteConfVideoTmp = call.currentParams!.videoEnabled && call.currentParams!.videoDirection == .SendRecv || call.currentParams!.videoDirection == .RecvOnly + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + self.remoteConfVideo = remoteConfVideoTmp + } } } } From 5215256f725a2165c7763538db936cb252c56082 Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Tue, 4 Jun 2024 15:05:25 +0200 Subject: [PATCH 251/486] Fix getFriendWithAddress crash (check if friend lists are empty) --- Linphone/Contacts/ContactsManager.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Linphone/Contacts/ContactsManager.swift b/Linphone/Contacts/ContactsManager.swift index c624f58a1..5e8efdf50 100644 --- a/Linphone/Contacts/ContactsManager.swift +++ b/Linphone/Contacts/ContactsManager.swift @@ -312,10 +312,10 @@ final class ContactsManager: ObservableObject { clonedAddress!.clean() let sipUri = clonedAddress!.asStringUriOnly() - if self.friendList != nil { + 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 { + if friend == nil && self.linphoneFriendList != nil && !self.linphoneFriendList!.friends.isEmpty { friend = self.linphoneFriendList!.friends.first(where: {$0.addresses.contains(where: {$0.asStringUriOnly() == sipUri})}) } From b885963d7a6957c8a759279f1559c51e6350e93d Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Tue, 4 Jun 2024 15:42:19 +0200 Subject: [PATCH 252/486] Fix media download in ConversationViewModel --- .../Conversations/ViewModel/ConversationViewModel.swift | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift b/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift index 8a0106a79..ffeb7deef 100644 --- a/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift +++ b/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift @@ -133,7 +133,7 @@ class ConversationViewModel: ObservableObject { eventLog.chatMessage!.contents.forEach { content in if content.isText { contentText = content.utf8Text ?? "" - } else { + } else if content.name != nil && !content.name!.isEmpty { if content.filePath == nil || content.filePath!.isEmpty { self.downloadContent(chatMessage: eventLog.chatMessage!, content: content) } else { @@ -234,7 +234,7 @@ class ConversationViewModel: ObservableObject { eventLog.chatMessage!.contents.forEach { content in if content.isText { contentText = content.utf8Text ?? "" - } else { + } else if content.name != nil && !content.name!.isEmpty { if content.filePath == nil || content.filePath!.isEmpty { self.downloadContent(chatMessage: eventLog.chatMessage!, content: content) } else { @@ -336,7 +336,7 @@ class ConversationViewModel: ObservableObject { } else { if content.filePath == nil || content.filePath!.isEmpty { self.downloadContent(chatMessage: eventLog.chatMessage!, content: content) - } else { + } else if content.name != nil && !content.name!.isEmpty { if content.type != "video" { let path = URL(string: self.getNewFilePath(name: content.name ?? "")) if path != nil { @@ -580,7 +580,7 @@ class ConversationViewModel: ObservableObject { let contentName = content.name if contentName != nil { let isImage = FileUtil.isExtensionImage(path: contentName!) - let file = FileUtil.getFileStoragePath(fileName: contentName!.addingPercentEncoding(withAllowedCharacters: .urlHostAllowed) ?? "", isImage: isImage) + let file = FileUtil.getFileStoragePath(fileName: contentName ?? "", isImage: isImage) content.filePath = file Log.info( "[ConversationViewModel] File \(contentName) will be downloaded at \(content.filePath)" From 0cf8346c89e221a6bbe8ed5b8864b15d29b983e9 Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Wed, 5 Jun 2024 15:03:02 +0200 Subject: [PATCH 253/486] Fix phone numbers --- Linphone/Contacts/ContactsManager.swift | 40 +++++++++++++++---- Linphone/Core/CoreContext.swift | 8 ++++ .../Fragments/ContactsInnerFragment.swift | 4 +- .../Fragments/ContactsListFragment.swift | 10 ++--- 4 files changed, 47 insertions(+), 15 deletions(-) diff --git a/Linphone/Contacts/ContactsManager.swift b/Linphone/Contacts/ContactsManager.swift index 5e8efdf50..cc3c30db1 100644 --- a/Linphone/Contacts/ContactsManager.swift +++ b/Linphone/Contacts/ContactsManager.swift @@ -133,9 +133,31 @@ final class ContactsManager: ObservableObject { MagicSearchSingleton.shared.searchForContacts(sourceFlags: MagicSearch.Source.Friends.rawValue | MagicSearch.Source.LdapServers.rawValue) - self.friendListSuscription = self.friendList?.publisher?.onPresenceReceived?.postOnMainQueue { (cbValue: (friendList: FriendList, friends: [Friend])) in - MagicSearchSingleton.shared.searchForContacts(sourceFlags: MagicSearch.Source.Friends.rawValue | MagicSearch.Source.LdapServers.rawValue) - self.friendListSuscription = nil + self.friendListSuscription = self.friendList?.publisher?.onNewSipAddressDiscovered?.postOnMainQueue { (cbValue: (friendList: FriendList, linphoneFriend: Friend, sipUri: String)) in + + cbValue.linphoneFriend.phoneNumbers.forEach { phone in + do { + let address = core.interpretUrl(url: phone, applyInternationalPrefix: true) + + let presence = cbValue.linphoneFriend.getPresenceModelForUriOrTel(uriOrTel: address?.asStringUriOnly() ?? "") + if address != nil && presence != nil { + cbValue.linphoneFriend.edit() + cbValue.linphoneFriend.addAddress(address: address!) + cbValue.linphoneFriend.done() + + self.avatarListModel.append( + ContactAvatarModel( + friend: cbValue.linphoneFriend, + name: cbValue.linphoneFriend.name ?? "", + address: cbValue.linphoneFriend.address?.clone()?.asStringUriOnly() ?? "", + withPresence: true + ) + ) + } + } catch let error { + print("\(#function) - Failed to create friend phone number for \(phone):", error) + } + } } } } @@ -224,11 +246,13 @@ final class ContactsManager: ObservableObject { friend.removeAddress(address: address) }) contact.sipAddresses.forEach { sipAddress in - 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!) + 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!) + } } } diff --git a/Linphone/Core/CoreContext.swift b/Linphone/Core/CoreContext.swift index c8cab6475..c3608d72f 100644 --- a/Linphone/Core/CoreContext.swift +++ b/Linphone/Core/CoreContext.swift @@ -173,6 +173,14 @@ final class CoreContext: ObservableObject { if cbVal.state == .Ok { self.loggingInProgress = false self.loggedIn = true + + let newParams = cbVal.account.params?.clone() + newParams?.internationalPrefix = "33" + newParams?.internationalPrefixIsoCountryCode = "FRA" + newParams?.useInternationalPrefixForCallsAndChats = true + + cbVal.account.params = newParams + ContactsManager.shared.fetchContacts() } else if cbVal.state == .Progress { self.loggingInProgress = true diff --git a/Linphone/UI/Main/Contacts/Fragments/ContactsInnerFragment.swift b/Linphone/UI/Main/Contacts/Fragments/ContactsInnerFragment.swift index 5342c92f5..a2cc390f9 100644 --- a/Linphone/UI/Main/Contacts/Fragments/ContactsInnerFragment.swift +++ b/Linphone/UI/Main/Contacts/Fragments/ContactsInnerFragment.swift @@ -32,7 +32,7 @@ struct ContactsInnerFragment: View { var body: some View { VStack(alignment: .leading) { - if !contactsManager.lastSearch.filter({ $0.friend?.starred == true }).isEmpty { + if !contactsManager.avatarListModel.filter({ $0.friend?.starred == true }).isEmpty { HStack(alignment: .center) { Text("Favourites") .default_text_style_800(styleSize: 16) @@ -80,7 +80,7 @@ struct ContactsInnerFragment: View { .listStyle(.plain) .overlay( VStack { - if contactsManager.lastSearch.isEmpty { + if contactsManager.avatarListModel.isEmpty { Spacer() Image("illus-belledonne") .resizable() diff --git a/Linphone/UI/Main/Contacts/Fragments/ContactsListFragment.swift b/Linphone/UI/Main/Contacts/Fragments/ContactsListFragment.swift index 45045959e..c8db8a7c6 100644 --- a/Linphone/UI/Main/Contacts/Fragments/ContactsListFragment.swift +++ b/Linphone/UI/Main/Contacts/Fragments/ContactsListFragment.swift @@ -32,21 +32,21 @@ struct ContactsListFragment: View { var startCallFunc: (_ addr: Address) -> Void var body: some View { - ForEach(0.. Date: Fri, 31 May 2024 13:53:11 +0200 Subject: [PATCH 254/486] Single Sign On --- Linphone.xcodeproj/project.pbxproj | 32 ++++ Linphone/Core/CoreContext.swift | 15 ++ Linphone/Info.plist | 10 + .../Utils/Extensions/DecodableExtension.swift | 28 +++ .../Utils/Extensions/EncodableExtension.swift | 27 +++ .../Extensions/UIApplicationExtension.swift | 38 ++++ Linphone/Utils/SingleSignOn/AuthState.swift | 44 +++++ .../SingleSignOn/OIDAuthStateExtension.swift | 36 ++++ .../SingleSignOn/SingleSignOnManager.swift | 178 ++++++++++++++++++ Linphone/Utils/URIHandler.swift | 20 +- Podfile | 1 + 11 files changed, 423 insertions(+), 6 deletions(-) create mode 100644 Linphone/Utils/Extensions/DecodableExtension.swift create mode 100644 Linphone/Utils/Extensions/EncodableExtension.swift create mode 100644 Linphone/Utils/Extensions/UIApplicationExtension.swift create mode 100644 Linphone/Utils/SingleSignOn/AuthState.swift create mode 100644 Linphone/Utils/SingleSignOn/OIDAuthStateExtension.swift create mode 100644 Linphone/Utils/SingleSignOn/SingleSignOnManager.swift diff --git a/Linphone.xcodeproj/project.pbxproj b/Linphone.xcodeproj/project.pbxproj index eaffc2479..560c3787a 100644 --- a/Linphone.xcodeproj/project.pbxproj +++ b/Linphone.xcodeproj/project.pbxproj @@ -35,8 +35,14 @@ 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 */; }; + C60E8F192C0F649200A06DB8 /* UIApplicationExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = C60E8F182C0F649200A06DB8 /* UIApplicationExtension.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 */; }; 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 */; }; @@ -194,8 +200,14 @@ 66E56BCB2BA9A1E0006CE56F /* MeetingsListItemModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeetingsListItemModel.swift; sourceTree = ""; }; 66E56BCD2BA9A1F8006CE56F /* MeetingModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeetingModel.swift; sourceTree = ""; }; 66F626B12BCEBB86003E2DEC /* AddParticipantsFragment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddParticipantsFragment.swift; sourceTree = ""; }; + C60E8F182C0F649200A06DB8 /* UIApplicationExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIApplicationExtension.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 = ""; }; 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 = ""; }; @@ -353,6 +365,9 @@ D76005F52B0798B00054B79A /* IntExtension.swift */, D717071F2AC5989C0037746F /* TextExtension.swift */, D71A0E182B485ADF0002C6CD /* ViewExtension.swift */, + C60E8F182C0F649200A06DB8 /* UIApplicationExtension.swift */, + C6A5A9402C10B5D50070FEA4 /* EncodableExtension.swift */, + C6A5A9422C10B5ED0070FEA4 /* DecodableExtension.swift */, ); path = Extensions; sourceTree = ""; @@ -406,6 +421,16 @@ 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 = ( @@ -432,6 +457,7 @@ D7B99E9A2B29F7C200BE7BF2 /* ActivityIndicator.swift */, D7173EBD2B7A5C0A00BCC481 /* LinphoneUtils.swift */, C67586AF2C09F247002E77BF /* URIHandler.swift */, + C6A5A9462C10B64A0070FEA4 /* SingleSignOn */, ); path = Utils; sourceTree = ""; @@ -997,11 +1023,15 @@ 66E50A492BD12B2300AD61CA /* MeetingsView.swift in Sources */, D719ABCF2ABC779A00B41C10 /* AccountLoginViewModel.swift in Sources */, D717630D2BD7BD0E00464097 /* ParticipantsListFragment.swift in Sources */, + C6A5A9452C10B6270070FEA4 /* OIDAuthStateExtension.swift in Sources */, D732A90F2B04C3B400DB42BA /* HistoryFragment.swift in Sources */, D79622342B1DFE600037EACD /* DialerBottomSheet.swift in Sources */, C67586B02C09F247002E77BF /* URIHandler.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 */, @@ -1041,6 +1071,7 @@ 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 */, 6613A0B62BAEBE5C008923A4 /* ScheduleMeetingViewModel.swift in Sources */, D748BF2C2ACD82D2004844EB /* ThirdPartySipAccountLoginFragment.swift in Sources */, @@ -1075,6 +1106,7 @@ D7DA67642ACCB31700E95002 /* ProfileModeFragment.swift in Sources */, D7CEE03D2B7A23B200FD79B7 /* ConversationsListFragment.swift in Sources */, D74C9CFC2ACACF370021626A /* WelcomePage3Fragment.swift in Sources */, + C6A5A9412C10B5D50070FEA4 /* EncodableExtension.swift in Sources */, D719ABCC2ABC769C00B41C10 /* AssistantView.swift in Sources */, D78E062C2BEA69BC00CE3783 /* CallStatisticsSheetBottomSheet.swift in Sources */, D7C365082AEFAB7F00FE6142 /* ContactListBottomSheet.swift in Sources */, diff --git a/Linphone/Core/CoreContext.swift b/Linphone/Core/CoreContext.swift index a5ecf668e..92b801974 100644 --- a/Linphone/Core/CoreContext.swift +++ b/Linphone/Core/CoreContext.swift @@ -45,6 +45,8 @@ final class CoreContext: ObservableObject { private var mIterateSuscription: AnyCancellable? private var mCoreSuscriptions = Set() + var bearerAuthInfoPendingPasswordUpdate: AuthInfo? = nil + let monitor = NWPathMonitor() private var mCorePushIncomingDelegate: CoreDelegate! @@ -250,6 +252,19 @@ final class CoreContext: ObservableObject { } }) + self.mCoreSuscriptions.insert(self.mCore.publisher?.onAuthenticationRequested?.postOnCoreQueue { (cbValue: (_: Core, authInfo: AuthInfo, method: AuthMethod)) in + let authInfo = cbValue.authInfo + 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 cbValue.method == .Bearer { + Log.info("Authentication requested method is Bearer, starting Single Sign On activity with server URL \(server) and username \(username)") + self.bearerAuthInfoPendingPasswordUpdate = cbValue.authInfo + SingleSignOnManager.shared.setUp(ssoUrl: server, user: username) + } + }) + self.mIterateSuscription = Timer.publish(every: 0.02, on: .main, in: .common) .autoconnect() .receive(on: coreQueue) diff --git a/Linphone/Info.plist b/Linphone/Info.plist index c683a3dcf..e32fc036e 100644 --- a/Linphone/Info.plist +++ b/Linphone/Info.plist @@ -84,6 +84,16 @@ tel + + CFBundleTypeRole + Editor + CFBundleURLName + org.linphone.phone + CFBundleURLSchemes + + org.linphone + + ITSAppUsesNonExemptEncryption diff --git a/Linphone/Utils/Extensions/DecodableExtension.swift b/Linphone/Utils/Extensions/DecodableExtension.swift new file mode 100644 index 000000000..71ccb81c5 --- /dev/null +++ b/Linphone/Utils/Extensions/DecodableExtension.swift @@ -0,0 +1,28 @@ +/* +* Copyright (c) 2010-2020 Belledonne Communications SARL. +* +* This file is part of linhome +* +* This program is free software: you can redistribute it and/or modify +* it under the terms of the GNU General Public License as published by +* the Free Software Foundation, either version 3 of the License, or +* (at your option) any later version. +* +* This program is distributed in the hope that it will be useful, +* but WITHOUT ANY WARRANTY; without even the implied warranty of +* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +* GNU General 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..8c0e75710 --- /dev/null +++ b/Linphone/Utils/Extensions/EncodableExtension.swift @@ -0,0 +1,27 @@ +/* +* Copyright (c) 2010-2020 Belledonne Communications SARL. +* +* This file is part of linhome +* +* This program is free software: you can redistribute it and/or modify +* it under the terms of the GNU General Public License as published by +* the Free Software Foundation, either version 3 of the License, or +* (at your option) any later version. +* +* This program is distributed in the hope that it will be useful, +* but WITHOUT ANY WARRANTY; without even the implied warranty of +* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +* GNU General 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/UIApplicationExtension.swift b/Linphone/Utils/Extensions/UIApplicationExtension.swift new file mode 100644 index 000000000..09f1d5d54 --- /dev/null +++ b/Linphone/Utils/Extensions/UIApplicationExtension.swift @@ -0,0 +1,38 @@ +/* +* Copyright (c) 2010-2020 Belledonne Communications SARL. +* +* This file is part of linhome +* +* This program is free software: you can redistribute it and/or modify +* it under the terms of the GNU General Public License as published by +* the Free Software Foundation, either version 3 of the License, or +* (at your option) any later version. +* +* This program is distributed in the hope that it will be useful, +* but WITHOUT ANY WARRANTY; without even the implied warranty of +* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +* GNU General 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 + } +} 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..f9f541529 --- /dev/null +++ b/Linphone/Utils/SingleSignOn/OIDAuthStateExtension.swift @@ -0,0 +1,36 @@ +/* +* Copyright (c) 2010-2020 Belledonne Communications SARL. +* +* This file is part of linhome +* +* This program is free software: you can redistribute it and/or modify +* it under the terms of the GNU General Public License as published by +* the Free Software Foundation, either version 3 of the License, or +* (at your option) any later version. +* +* This program is distributed in the hope that it will be useful, +* but WITHOUT ANY WARRANTY; without even the implied warranty of +* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +* GNU General 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/URIHandler.swift b/Linphone/Utils/URIHandler.swift index 8643c7101..54dfeab49 100644 --- a/Linphone/Utils/URIHandler.swift +++ b/Linphone/Utils/URIHandler.swift @@ -27,12 +27,12 @@ class URIHandler { private static let callSchemes = ["sip", "sip-linphone", "linphone-sip", "tel"] private static let secureCallSchemes = ["sips", "sips-linphone", "linphone-sips"] private static let configurationSchemes = ["linphone-config"] - - private static var uriHandlerCoreDelegate: CoreDelegateStub? = nil + + private static var uriHandlerCoreDelegate: CoreDelegateStub? static func addCoreDelegate() { uriHandlerCoreDelegate = CoreDelegateStub( - onCallStateChanged: { (core: Core, call: Call, state: Call.State, message: String) in + onCallStateChanged: { (_: Core, _: Call, state: Call.State, _: String) in if state == .Error { toast("Failed_uri_handler_call_failed") CoreContext.shared.removeCoreDelegateStub(delegate: uriHandlerCoreDelegate!) @@ -41,7 +41,7 @@ class URIHandler { CoreContext.shared.removeCoreDelegateStub(delegate: uriHandlerCoreDelegate!) } }, - onConfiguringStatus: { (core:Core, state:ConfiguringState, status: String) in + onConfiguringStatus: { (_: Core, state: ConfiguringState, _: String) in if state == .Failed { toast("Failed_uri_handler_config_failed") CoreContext.shared.removeCoreDelegateStub(delegate: uriHandlerCoreDelegate!) @@ -54,7 +54,6 @@ class URIHandler { CoreContext.shared.addCoreDelegateStub(delegate: uriHandlerCoreDelegate!) } - static func handleURL(url: URL) { Log.info("[URIHandler] handleURL: \(url)") if let scheme = url.scheme { @@ -64,6 +63,8 @@ class URIHandler { 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)") } @@ -107,10 +108,17 @@ class URIHandler { } } + 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 diff --git a/Podfile b/Podfile index cc53ba162..196e3f81f 100644 --- a/Podfile +++ b/Podfile @@ -26,6 +26,7 @@ target 'Linphone' do # Pods for Linphone pod 'SwiftLint' + pod 'AppAuth' basic_pods end From 0e00819a67f4cd057016258ed4e65b3ee545adc9 Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Thu, 6 Jun 2024 09:28:09 +0200 Subject: [PATCH 255/486] Fix image in chat --- .../Fragments/ChatBubbleView.swift | 136 +++++++++++++++++- 1 file changed, 135 insertions(+), 1 deletion(-) diff --git a/Linphone/UI/Main/Conversations/Fragments/ChatBubbleView.swift b/Linphone/UI/Main/Conversations/Fragments/ChatBubbleView.swift index 9767bccfb..1a6ea6000 100644 --- a/Linphone/UI/Main/Conversations/Fragments/ChatBubbleView.swift +++ b/Linphone/UI/Main/Conversations/Fragments/ChatBubbleView.swift @@ -161,6 +161,7 @@ struct ChatBubbleView: View { func messageAttachments() -> some View { if message.attachments.count == 1 { if message.attachments.first!.type == .image || message.attachments.first!.type == .gif || message.attachments.first!.type == .video { + /* let result = imageDimensions(url: message.attachments.first!.thumbnail.absoluteString) ZStack { Rectangle() @@ -184,7 +185,6 @@ struct ChatBubbleView: View { maxHeight: UIScreen.main.bounds.height/2.5 ) } - if message.attachments.first!.type == .image || message.attachments.first!.type == .video { if #available(iOS 16.0, *) { AsyncImage(url: message.attachments.first!.thumbnail) { image in @@ -243,6 +243,140 @@ struct ChatBubbleView: View { } .clipShape(RoundedRectangle(cornerRadius: 4)) .clipped() + */ + + if message.attachments.first!.type == .image || message.attachments.first!.type == .video { + if #available(iOS 16.0, *) { + /* + AsyncImage(url: message.attachments.first?.thumbnail) { phase in + switch phase { + case .empty: + ProgressView() + case .success(let image): + ZStack { + image + .resizable() + .interpolation(.medium) + .scaledToFit() + .frame(maxWidth: geometryProxy.size.width - 110, maxHeight: UIScreen.main.bounds.height/2.5) + //.aspectRatio(contentMode: .fit) + //.frame(maxWidth: geometryProxy.size.width - 110, maxHeight: UIScreen.main.bounds.height/2.5) + + if message.attachments.first!.type == .video { + Image("play-fill") + .renderingMode(.template) + .resizable() + .foregroundStyle(.white) + .frame(width: 40, height: 40, alignment: .leading) + } + } + case .failure: + Image(systemName: "photo") + @unknown default: + EmptyView() + } + } + */ + + AsyncImage(url: message.attachments.first?.thumbnail) { phase in + switch phase { + case .success(let image): + image + .resizable() + .scaledToFit() + .frame(maxHeight: geometryProxy.size.width * 0.36) + + case .failure: + Image(systemName: "ant.circle.fill") + .resizable() + .scaledToFit() + .frame(maxWidth: geometryProxy.size.width * 0.36, maxHeight: geometryProxy.size.width * 0.36) + .foregroundColor(.teal) + .opacity(0.6) + + case .empty: + Image(systemName: "photo.circle.fill") + .resizable() + .scaledToFit() + .frame(maxWidth: geometryProxy.size.width * 0.36, maxHeight: geometryProxy.size.width * 0.36) + .foregroundColor(.teal) + .opacity(0.6) + + @unknown default: + ProgressView() + } + } + .clipShape(RoundedRectangle(cornerRadius: 4)) + .onAppear { + print("AsyncImageAsyncImage \(geometryProxy.size.width) \(geometryProxy.size.width - 264) \(geometryProxy.size.width * 0.36)") + } + } else { + /* + AsyncImage(url: message.attachments.first!.thumbnail) { image in + ZStack { + image + .resizable() + .interpolation(.medium) + .aspectRatio(contentMode: .fit) + + if message.attachments.first!.type == .video { + Image("play-fill") + .renderingMode(.template) + .resizable() + .foregroundStyle(.white) + .frame(width: 40, height: 40, alignment: .leading) + } + } + } placeholder: { + ProgressView() + } + .id(UUID()) + */ + + AsyncImage(url: message.attachments.first?.thumbnail) { phase in + switch phase { + case .success(let image): + image + .resizable() + .scaledToFit() + .frame(maxHeight: geometryProxy.size.width * 0.36) + + case .failure: + Image(systemName: "ant.circle.fill") + .resizable() + .scaledToFit() + .frame(maxWidth: geometryProxy.size.width * 0.36, maxHeight: geometryProxy.size.width * 0.36) + .foregroundColor(.teal) + .opacity(0.6) + + case .empty: + Image(systemName: "photo.circle.fill") + .resizable() + .scaledToFit() + .frame(maxWidth: geometryProxy.size.width * 0.36, maxHeight: geometryProxy.size.width * 0.36) + .foregroundColor(.teal) + .opacity(0.6) + + @unknown default: + ProgressView() + } + } + .clipShape(RoundedRectangle(cornerRadius: 4)) + .onAppear { + print("AsyncImageAsyncImage \(geometryProxy.size.width) \(geometryProxy.size.width - 264) \(geometryProxy.size.width * 0.36)") + } + .id(UUID()) + } + } else if message.attachments.first!.type == .gif { + if #available(iOS 16.0, *) { + GifImageView(message.attachments.first!.thumbnail) + .clipShape(RoundedRectangle(cornerRadius: 4)) + } else { + GifImageView(message.attachments.first!.thumbnail) + .id(UUID()) + .clipShape(RoundedRectangle(cornerRadius: 4)) + } + } } } else if message.attachments.count > 1 { let isGroup = conversationViewModel.displayedConversation != nil && conversationViewModel.displayedConversation!.isGroup From 290d842843435e6217aeb9d14fdb33d0f0a8d76e Mon Sep 17 00:00:00 2001 From: QuentinArguillere Date: Thu, 6 Jun 2024 15:11:49 +0200 Subject: [PATCH 256/486] Replace all postOnMainQueue by postOnCoreQueue --- Linphone/Contacts/ContactsManager.swift | 9 +- Linphone/Core/CoreContext.swift | 100 +++++++++--------- .../UI/Call/ViewModel/CallViewModel.swift | 56 +++++----- .../Contacts/Model/ContactAvatarModel.swift | 20 ++-- .../ViewModel/ConversationViewModel.swift | 2 +- .../ConversationsListViewModel.swift | 6 +- Linphone/Utils/MagicSearchSingleton.swift | 60 ++++++----- 7 files changed, 132 insertions(+), 121 deletions(-) diff --git a/Linphone/Contacts/ContactsManager.swift b/Linphone/Contacts/ContactsManager.swift index cc3c30db1..454090020 100644 --- a/Linphone/Contacts/ContactsManager.swift +++ b/Linphone/Contacts/ContactsManager.swift @@ -133,8 +133,9 @@ final class ContactsManager: ObservableObject { MagicSearchSingleton.shared.searchForContacts(sourceFlags: MagicSearch.Source.Friends.rawValue | MagicSearch.Source.LdapServers.rawValue) - self.friendListSuscription = self.friendList?.publisher?.onNewSipAddressDiscovered?.postOnMainQueue { (cbValue: (friendList: FriendList, linphoneFriend: Friend, sipUri: String)) in + self.friendListSuscription = self.friendList?.publisher?.onNewSipAddressDiscovered?.postOnCoreQueue { (cbValue: (friendList: FriendList, linphoneFriend: Friend, sipUri: String)) in + var addedAvatarListModel : [ContactAvatarModel] = [] cbValue.linphoneFriend.phoneNumbers.forEach { phone in do { let address = core.interpretUrl(url: phone, applyInternationalPrefix: true) @@ -145,7 +146,7 @@ final class ContactsManager: ObservableObject { cbValue.linphoneFriend.addAddress(address: address!) cbValue.linphoneFriend.done() - self.avatarListModel.append( + addedAvatarListModel.append( ContactAvatarModel( friend: cbValue.linphoneFriend, name: cbValue.linphoneFriend.name ?? "", @@ -158,6 +159,10 @@ final class ContactsManager: ObservableObject { print("\(#function) - Failed to create friend phone number for \(phone):", error) } } + + DispatchQueue.main.async { + self.avatarListModel += addedAvatarListModel + } } } } diff --git a/Linphone/Core/CoreContext.swift b/Linphone/Core/CoreContext.swift index c3608d72f..b3bc8cc9b 100644 --- a/Linphone/Core/CoreContext.swift +++ b/Linphone/Core/CoreContext.swift @@ -117,19 +117,8 @@ final class CoreContext: ObservableObject { self.mCore.videoCaptureEnabled = true self.mCore.videoDisplayEnabled = true self.mCore.videoPreviewEnabled = false - self.mCore.fecEnabled = true - self.mCoreSuscriptions.insert(self.mCore.publisher?.onGlobalStateChanged?.postOnMainQueue { (cbVal: (core: Core, state: GlobalState, message: String)) in - if cbVal.state == GlobalState.On { - self.hasDefaultAccount = self.mCore.defaultAccount != nil ? true : false - self.coreIsStarted = true - } else if cbVal.state == GlobalState.Off { - self.hasDefaultAccount = false - self.coreIsStarted = false - } - }) - self.mCoreSuscriptions.insert(self.mCore.publisher?.onGlobalStateChanged?.postOnCoreQueue { (cbVal: (core: Core, state: GlobalState, message: String)) in if cbVal.state == GlobalState.On { #if DEBUG @@ -146,16 +135,28 @@ final class CoreContext: ObservableObject { self.actionsToPerformOnCoreQueueWhenCoreIsStarted.forEach {$0(cbVal.core)} self.actionsToPerformOnCoreQueueWhenCoreIsStarted.removeAll() + + DispatchQueue.main.async { + if cbVal.state == GlobalState.On { + self.hasDefaultAccount = self.mCore.defaultAccount != nil ? true : false + self.coreIsStarted = true + } else if cbVal.state == GlobalState.Off { + self.hasDefaultAccount = false + self.coreIsStarted = false + } + } } }) // Create a Core listener to listen for the callback we need // In this case, we want to know about the account registration status - self.mCoreSuscriptions.insert(self.mCore.publisher?.onConfiguringStatus?.postOnMainQueue { (cbVal: (core: Core, status: ConfiguringState, message: String)) in + self.mCoreSuscriptions.insert(self.mCore.publisher?.onConfiguringStatus?.postOnCoreQueue { (cbVal: (core: Core, status: ConfiguringState, message: String)) in Log.info("New configuration state is \(cbVal.status) = \(cbVal.message)\n") - if cbVal.status == ConfiguringState.Successful { - ToastViewModel.shared.toastMessage = "Successful" - ToastViewModel.shared.displayToast = true + DispatchQueue.main.async { + if cbVal.status == ConfiguringState.Successful { + ToastViewModel.shared.toastMessage = "Successful" + ToastViewModel.shared.displayToast = true + } } /* else { @@ -165,35 +166,21 @@ final class CoreContext: ObservableObject { */ }) - self.mCoreSuscriptions.insert(self.mCore.publisher?.onAccountRegistrationStateChanged?.postOnMainQueue { (cbVal: (core: Core, account: Account, state: RegistrationState, message: String)) in + self.mCoreSuscriptions.insert(self.mCore.publisher?.onAccountRegistrationStateChanged?.postOnCoreQueue { (cbVal: (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 \(cbVal.state) for user id " + "\( String(describing: cbVal.account.params?.identityAddress?.asString())) = \(cbVal.message)\n") + if cbVal.state == .Ok { - self.loggingInProgress = false - self.loggedIn = true - let newParams = cbVal.account.params?.clone() newParams?.internationalPrefix = "33" newParams?.internationalPrefixIsoCountryCode = "FRA" newParams?.useInternationalPrefixForCallsAndChats = true - cbVal.account.params = newParams ContactsManager.shared.fetchContacts() - } else if cbVal.state == .Progress { - self.loggingInProgress = true - } else { - self.loggingInProgress = false - self.loggedIn = false - ToastViewModel.shared.toastMessage = "Registration failed" - ToastViewModel.shared.displayToast = true - } - }) - - self.mCoreSuscriptions.insert(self.mCore.publisher?.onAccountRegistrationStateChanged?.postOnCoreQueue { (cbVal: (core: Core, account: Account, state: RegistrationState, message: String)) in - if cbVal.state == .Ok { if self.mCore.consolidatedPresence != ConsolidatedPresence.Online { self.updatePresence(core: self.mCore, presence: ConsolidatedPresence.Online) } @@ -213,6 +200,20 @@ final class CoreContext: ObservableObject { } } TelecomManager.shared.onAccountRegistrationStateChanged(core: cbVal.core, account: cbVal.account, state: cbVal.state, message: cbVal.message) + + DispatchQueue.main.async { + if cbVal.state == .Ok { + self.loggingInProgress = false + self.loggedIn = true + } else if cbVal.state == .Progress { + self.loggingInProgress = true + } else { + self.loggingInProgress = false + self.loggedIn = false + ToastViewModel.shared.toastMessage = "Registration failed" + ToastViewModel.shared.displayToast = true + } + } }) self.mCoreSuscriptions.insert(self.mCore.publisher?.onCallStateChanged?.postOnCoreQueue { (cbVal: (core: Core, call: Call, state: Call.State, message: String)) in @@ -229,33 +230,36 @@ final class CoreContext: ObservableObject { }) self.mCore.addDelegate(delegate: self.mCorePushIncomingDelegate) - self.mCoreSuscriptions.insert(self.mCore.publisher?.onLogCollectionUploadStateChanged?.postOnMainQueue { (cbValue: (_: Core, _: Core.LogCollectionUploadState, info: String)) in + self.mCoreSuscriptions.insert(self.mCore.publisher?.onLogCollectionUploadStateChanged?.postOnCoreQueue { (cbValue: (_: Core, _: Core.LogCollectionUploadState, info: String)) in + if cbValue.info.starts(with: "https") { - UIPasteboard.general.setValue( - cbValue.info, - forPasteboardType: UTType.plainText.identifier - ) - DispatchQueue.main.async { + UIPasteboard.general.setValue( + cbValue.info, + forPasteboardType: UTType.plainText.identifier + ) ToastViewModel.shared.toastMessage = "Success_send_logs" ToastViewModel.shared.displayToast = true } } }) - self.mCoreSuscriptions.insert(self.mCore.publisher?.onTransferStateChanged?.postOnMainQueue { (cbValue: (_: Core, transfered: Call, callState: Call.State)) in + self.mCoreSuscriptions.insert(self.mCore.publisher?.onTransferStateChanged?.postOnCoreQueue { (cbValue: (_: Core, transfered: Call, callState: Call.State)) in Log.info( "[CoreContext] Transferred call \(cbValue.transfered.remoteAddress!.asStringUriOnly()) state changed \(cbValue.callState)" ) - if cbValue.callState == Call.State.Connected { - ToastViewModel.shared.toastMessage = "Success_toast_call_transfer_successful" - ToastViewModel.shared.displayToast = true - } else if cbValue.callState == Call.State.OutgoingProgress { - ToastViewModel.shared.toastMessage = "Success_toast_call_transfer_in_progress" - ToastViewModel.shared.displayToast = true - } else if cbValue.callState == Call.State.End || cbValue.callState == Call.State.Error { - ToastViewModel.shared.toastMessage = "Failed_toast_call_transfer_failed" - ToastViewModel.shared.displayToast = true + + DispatchQueue.main.async { + if cbValue.callState == Call.State.Connected { + ToastViewModel.shared.toastMessage = "Success_toast_call_transfer_successful" + ToastViewModel.shared.displayToast = true + } else if cbValue.callState == Call.State.OutgoingProgress { + ToastViewModel.shared.toastMessage = "Success_toast_call_transfer_in_progress" + ToastViewModel.shared.displayToast = true + } else if cbValue.callState == Call.State.End || cbValue.callState == Call.State.Error { + ToastViewModel.shared.toastMessage = "Failed_toast_call_transfer_failed" + ToastViewModel.shared.displayToast = true + } } }) diff --git a/Linphone/UI/Call/ViewModel/CallViewModel.swift b/Linphone/UI/Call/ViewModel/CallViewModel.swift index 275c6fcf5..b799b58d0 100644 --- a/Linphone/UI/Call/ViewModel/CallViewModel.swift +++ b/Linphone/UI/Call/ViewModel/CallViewModel.swift @@ -214,16 +214,20 @@ class CallViewModel: ObservableObject { } } - self.callSuscriptions.insert(self.currentCall!.publisher?.onEncryptionChanged?.postOnMainQueue {(cbVal: (call: Call, on: Bool, authenticationToken: String?)) in - _ = self.updateEncryption() - if self.currentCall != nil { - self.callMediaEncryptionModel.update(call: self.currentCall!) + self.callSuscriptions.insert(self.currentCall!.publisher?.onEncryptionChanged?.postOnCoreQueue {(cbVal: (call: Call, on: Bool, authenticationToken: String?)) in + DispatchQueue.main.async { + _ = self.updateEncryption() + if self.currentCall != nil { + self.callMediaEncryptionModel.update(call: self.currentCall!) + } } }) - self.callSuscriptions.insert(self.currentCall!.publisher?.onStatsUpdated?.postOnMainQueue {(cbVal: (call: Call, stats: CallStats)) in + self.callSuscriptions.insert(self.currentCall!.publisher?.onStatsUpdated?.postOnCoreQueue {(cbVal: (call: Call, stats: CallStats)) in if self.currentCall != nil { - self.callStatsModel.update(call: self.currentCall!, stats: cbVal.stats) + DispatchQueue.main.async { + self.callStatsModel.update(call: self.currentCall!, stats: cbVal.stats) + } } }) @@ -340,9 +344,11 @@ class CallViewModel: ObservableObject { func waitingForCreatedStateConference() { self.mConferenceSuscriptions.insert( - self.currentCall?.conference?.publisher?.onStateChanged?.postOnMainQueue {(cbValue: (conference: Conference, state: Conference.State)) in + self.currentCall?.conference?.publisher?.onStateChanged?.postOnCoreQueue {(cbValue: (conference: Conference, state: Conference.State)) in if cbValue.state == .Created { - self.getConference() + DispatchQueue.main.async { + self.getConference() + } } } ) @@ -352,7 +358,7 @@ class CallViewModel: ObservableObject { func addConferenceCallBacks() { coreContext.doOnCoreQueue { core in self.mConferenceSuscriptions.insert( - self.currentCall?.conference?.publisher?.onActiveSpeakerParticipantDevice?.postOnMainQueue {(cbValue: (conference: Conference, participantDevice: ParticipantDevice)) in + self.currentCall?.conference?.publisher?.onActiveSpeakerParticipantDevice?.postOnCoreQueue {(cbValue: (conference: Conference, participantDevice: ParticipantDevice)) in if cbValue.participantDevice.address != nil { let activeSpeakerParticipantBis = self.activeSpeakerParticipant @@ -410,7 +416,7 @@ class CallViewModel: ObservableObject { ) self.mConferenceSuscriptions.insert( - self.currentCall?.conference?.publisher?.onParticipantDeviceAdded?.postOnMainQueue {(cbValue: (conference: Conference, participantDevice: ParticipantDevice)) in + self.currentCall?.conference?.publisher?.onParticipantDeviceAdded?.postOnCoreQueue {(cbValue: (conference: Conference, participantDevice: ParticipantDevice)) in if cbValue.participantDevice.address != nil { var participantListTmp: [ParticipantModel] = [] cbValue.conference.participantDeviceList.forEach({ participantDevice in @@ -488,7 +494,7 @@ class CallViewModel: ObservableObject { ) self.mConferenceSuscriptions.insert( - self.currentCall?.conference?.publisher?.onParticipantDeviceRemoved?.postOnMainQueue {(cbValue: (conference: Conference, participantDevice: ParticipantDevice)) in + self.currentCall?.conference?.publisher?.onParticipantDeviceRemoved?.postOnCoreQueue {(cbValue: (conference: Conference, participantDevice: ParticipantDevice)) in if cbValue.participantDevice.address != nil { var participantListTmp: [ParticipantModel] = [] cbValue.conference.participantDeviceList.forEach({ participantDevice in @@ -522,20 +528,16 @@ class CallViewModel: ObservableObject { ) self.mConferenceSuscriptions.insert( - self.currentCall?.conference?.publisher?.onParticipantDeviceIsMuted?.postOnMainQueue {(cbValue: (conference: Conference, participantDevice: ParticipantDevice, isMuted: Bool)) in + self.currentCall?.conference?.publisher?.onParticipantDeviceIsMuted?.postOnCoreQueue {(cbValue: (conference: Conference, participantDevice: ParticipantDevice, isMuted: Bool)) in if self.activeSpeakerParticipant != nil && self.activeSpeakerParticipant!.address.equal(address2: cbValue.participantDevice.address!) { - let isMutedTmp = cbValue.isMuted - DispatchQueue.main.async { - self.activeSpeakerParticipant!.isMuted = isMutedTmp + self.activeSpeakerParticipant!.isMuted = cbValue.isMuted } } self.participantList.forEach({ participantDevice in if participantDevice.address.equal(address2: cbValue.participantDevice.address!) { - let isMutedTmp = cbValue.isMuted - DispatchQueue.main.async { - participantDevice.isMuted = isMutedTmp + participantDevice.isMuted = cbValue.isMuted } } }) @@ -543,25 +545,21 @@ class CallViewModel: ObservableObject { ) self.mConferenceSuscriptions.insert( - self.currentCall?.conference?.publisher?.onParticipantDeviceStateChanged?.postOnMainQueue {(cbValue: (conference: Conference, device: ParticipantDevice, state: ParticipantDevice.State)) in + self.currentCall?.conference?.publisher?.onParticipantDeviceStateChanged?.postOnCoreQueue {(cbValue: (conference: Conference, device: ParticipantDevice, state: ParticipantDevice.State)) in Log.info( "[CallViewModel] Participant device \(cbValue.device.address!.asStringUriOnly()) state changed \(cbValue.state)" ) if self.activeSpeakerParticipant != nil && self.activeSpeakerParticipant!.address.equal(address2: cbValue.device.address!) { - let activeSpeakerParticipantOnPauseTmp = cbValue.state == .OnHold - let activeSpeakerParticipantIsJoiningTmp = cbValue.state == .Joining || cbValue.state == .Alerting DispatchQueue.main.async { - self.activeSpeakerParticipant!.onPause = activeSpeakerParticipantOnPauseTmp - self.activeSpeakerParticipant!.isJoining = activeSpeakerParticipantIsJoiningTmp + self.activeSpeakerParticipant!.onPause = cbValue.state == .OnHold + self.activeSpeakerParticipant!.isJoining = cbValue.state == .Joining || cbValue.state == .Alerting } } self.participantList.forEach({ participantDevice in if participantDevice.address.equal(address2: cbValue.device.address!) { - let participantDeviceOnPauseTmp = cbValue.state == .OnHold - let participantDeviceIsJoiningTmp = cbValue.state == .Joining || cbValue.state == .Alerting DispatchQueue.main.async { - participantDevice.onPause = participantDeviceOnPauseTmp - participantDevice.isJoining = participantDeviceIsJoiningTmp + participantDevice.onPause = cbValue.state == .OnHold + participantDevice.isJoining = cbValue.state == .Joining || cbValue.state == .Alerting } } }) @@ -569,7 +567,7 @@ class CallViewModel: ObservableObject { ) self.mConferenceSuscriptions.insert( - self.currentCall?.conference?.publisher?.onParticipantAdminStatusChanged?.postOnMainQueue {(cbValue: (conference: Conference, participant: Participant)) in + self.currentCall?.conference?.publisher?.onParticipantAdminStatusChanged?.postOnCoreQueue {(cbValue: (conference: Conference, participant: Participant)) in let isAdmin = cbValue.participant.isAdmin if self.myParticipantModel != nil && self.myParticipantModel!.address.clone()!.equal(address2: cbValue.participant.address!) { DispatchQueue.main.async { @@ -587,7 +585,7 @@ class CallViewModel: ObservableObject { ) self.mConferenceSuscriptions.insert( - self.currentCall?.conference?.publisher?.onParticipantDeviceIsSpeakingChanged?.postOnMainQueue {(cbValue: (conference: Conference, participantDevice: ParticipantDevice, isSpeaking: Bool)) in + self.currentCall?.conference?.publisher?.onParticipantDeviceIsSpeakingChanged?.postOnCoreQueue {(cbValue: (conference: Conference, participantDevice: ParticipantDevice, isSpeaking: Bool)) in let isSpeaking = cbValue.participantDevice.isSpeaking if self.myParticipantModel != nil && self.myParticipantModel!.address.clone()!.equal(address2: cbValue.participantDevice.address!) { DispatchQueue.main.async { diff --git a/Linphone/UI/Main/Contacts/Model/ContactAvatarModel.swift b/Linphone/UI/Main/Contacts/Model/ContactAvatarModel.swift index 59ec6c20c..e59eee351 100644 --- a/Linphone/UI/Main/Contacts/Model/ContactAvatarModel.swift +++ b/Linphone/UI/Main/Contacts/Model/ContactAvatarModel.swift @@ -71,17 +71,19 @@ class ContactAvatarModel: ObservableObject { } func addSubscription() { - friendSuscription = self.friend?.publisher?.onPresenceReceived?.postOnMainQueue { (cbValue: (Friend)) in - self.presenceStatus = cbValue.consolidatedPresence - if cbValue.consolidatedPresence == .Online || cbValue.consolidatedPresence == .Busy { - if cbValue.consolidatedPresence == .Online || cbValue.presenceModel!.latestActivityTimestamp != -1 { - self.lastPresenceInfo = cbValue.consolidatedPresence == .Online ? - "Online" : self.getCallTime(startDate: cbValue.presenceModel!.latestActivityTimestamp) + friendSuscription = self.friend?.publisher?.onPresenceReceived?.postOnCoreQueue { (cbValue: (Friend)) in + DispatchQueue.main.async { + self.presenceStatus = cbValue.consolidatedPresence + if cbValue.consolidatedPresence == .Online || cbValue.consolidatedPresence == .Busy { + if cbValue.consolidatedPresence == .Online || cbValue.presenceModel!.latestActivityTimestamp != -1 { + self.lastPresenceInfo = cbValue.consolidatedPresence == .Online ? + "Online" : self.getCallTime(startDate: cbValue.presenceModel!.latestActivityTimestamp) + } else { + self.lastPresenceInfo = "Away" + } } else { - self.lastPresenceInfo = "Away" + self.lastPresenceInfo = "" } - } else { - self.lastPresenceInfo = "" } } } diff --git a/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift b/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift index ffeb7deef..fc797a25f 100644 --- a/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift +++ b/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift @@ -51,7 +51,7 @@ class ConversationViewModel: ObservableObject { self.getNewMessages(eventLogs: [cbValue.eventLog]) }) - self.chatRoomSuscriptions.insert(self.displayedConversation?.chatRoom.publisher?.onChatMessagesReceived?.postOnMainQueue { (cbValue: (chatRoom: ChatRoom, eventLogs: [EventLog])) in + self.chatRoomSuscriptions.insert(self.displayedConversation?.chatRoom.publisher?.onChatMessagesReceived?.postOnCoreQueue { (cbValue: (chatRoom: ChatRoom, eventLogs: [EventLog])) in self.getNewMessages(eventLogs: cbValue.eventLogs) }) } diff --git a/Linphone/UI/Main/Conversations/ViewModel/ConversationsListViewModel.swift b/Linphone/UI/Main/Conversations/ViewModel/ConversationsListViewModel.swift index 7ed96c4e2..2b9b18d07 100644 --- a/Linphone/UI/Main/Conversations/ViewModel/ConversationsListViewModel.swift +++ b/Linphone/UI/Main/Conversations/ViewModel/ConversationsListViewModel.swift @@ -81,7 +81,7 @@ class ConversationsListViewModel: ObservableObject { func addConversationDelegate() { coreContext.doOnCoreQueue { core in - self.mCoreSuscriptions.insert(core.publisher?.onChatRoomStateChanged?.postOnMainQueue { (cbValue: (core: Core, chatRoom: ChatRoom, state: ChatRoom.State)) in + self.mCoreSuscriptions.insert(core.publisher?.onChatRoomStateChanged?.postOnCoreQueue { (cbValue: (core: Core, chatRoom: ChatRoom, state: ChatRoom.State)) in //Log.info("[ConversationsListViewModel] Conversation [${LinphoneUtils.getChatRoomId(chatRoom)}] state changed [$state]") switch cbValue.state { case ChatRoom.State.Created: @@ -96,11 +96,11 @@ class ConversationsListViewModel: ObservableObject { } }) - self.mCoreSuscriptions.insert(core.publisher?.onMessageSent?.postOnMainQueue { _ in + self.mCoreSuscriptions.insert(core.publisher?.onMessageSent?.postOnCoreQueue { _ in self.computeChatRoomsList(filter: "") }) - self.mCoreSuscriptions.insert(core.publisher?.onMessagesReceived?.postOnMainQueue { _ in + self.mCoreSuscriptions.insert(core.publisher?.onMessagesReceived?.postOnCoreQueue { _ in self.computeChatRoomsList(filter: "") }) } diff --git a/Linphone/Utils/MagicSearchSingleton.swift b/Linphone/Utils/MagicSearchSingleton.swift index cf23e5905..03b3e4c7b 100644 --- a/Linphone/Utils/MagicSearchSingleton.swift +++ b/Linphone/Utils/MagicSearchSingleton.swift @@ -54,7 +54,7 @@ final class MagicSearchSingleton: ObservableObject { self.magicSearch = try? core.createMagicSearch() self.magicSearch.limitedSearch = false - self.searchSubscription = self.magicSearch.publisher?.onSearchResultsReceived?.postOnMainQueue { (magicSearch: MagicSearch) in + self.searchSubscription = self.magicSearch.publisher?.onSearchResultsReceived?.postOnCoreQueue { (magicSearch: MagicSearch) in self.needUpdateLastSearchContacts = true var lastSearchFriend: [SearchResult] = [] @@ -68,36 +68,38 @@ final class MagicSearchSingleton: ObservableObject { } } - self.contactsManager.lastSearch = lastSearchFriend.sorted(by: { - $0.friend!.name!.lowercased().folding(options: .diacriticInsensitive, locale: .current) - < - $1.friend!.name!.lowercased().folding(options: .diacriticInsensitive, locale: .current) - }) - - self.contactsManager.lastSearchSuggestions = lastSearchSuggestions.sorted(by: { - $0.address!.asStringUriOnly() < $1.address!.asStringUriOnly() - }) - - self.contactsManager.avatarListModel.forEach { contactAvatarModel in - contactAvatarModel.removeAllSuscription() - } - - self.contactsManager.avatarListModel.removeAll() - - self.contactsManager.lastSearch.forEach { searchResult in - if searchResult.friend != nil { - self.contactsManager.avatarListModel.append( - ContactAvatarModel( - friend: searchResult.friend!, - name: searchResult.friend?.name ?? "", - address: searchResult.friend?.address?.clone()?.asStringUriOnly() ?? "", - withPresence: true - ) - ) + DispatchQueue.main.async { + self.contactsManager.lastSearch = lastSearchFriend.sorted(by: { + $0.friend!.name!.lowercased().folding(options: .diacriticInsensitive, locale: .current) + < + $1.friend!.name!.lowercased().folding(options: .diacriticInsensitive, locale: .current) + }) + + self.contactsManager.lastSearchSuggestions = lastSearchSuggestions.sorted(by: { + $0.address!.asStringUriOnly() < $1.address!.asStringUriOnly() + }) + + self.contactsManager.avatarListModel.forEach { contactAvatarModel in + contactAvatarModel.removeAllSuscription() } + + self.contactsManager.avatarListModel.removeAll() + + self.contactsManager.lastSearch.forEach { searchResult in + if searchResult.friend != nil { + self.contactsManager.avatarListModel.append( + ContactAvatarModel( + friend: searchResult.friend!, + name: searchResult.friend?.name ?? "", + address: searchResult.friend?.address?.clone()?.asStringUriOnly() ?? "", + withPresence: true + ) + ) + } + } + + NotificationCenter.default.post(name: NSNotification.Name("ContactLoaded"), object: nil) } - - NotificationCenter.default.post(name: NSNotification.Name("ContactLoaded"), object: nil) } } } From dbb667fd9e0e34b922dfc3bb101c52013cc73ccb Mon Sep 17 00:00:00 2001 From: QuentinArguillere Date: Thu, 6 Jun 2024 15:36:41 +0200 Subject: [PATCH 257/486] Extract forgotten Linphone objects that were being used in the main queue --- .../Contacts/Model/ContactAvatarModel.swift | 7 ++- Linphone/Utils/MagicSearchSingleton.swift | 48 ++++++++++--------- 2 files changed, 30 insertions(+), 25 deletions(-) diff --git a/Linphone/UI/Main/Contacts/Model/ContactAvatarModel.swift b/Linphone/UI/Main/Contacts/Model/ContactAvatarModel.swift index e59eee351..8caf5b8eb 100644 --- a/Linphone/UI/Main/Contacts/Model/ContactAvatarModel.swift +++ b/Linphone/UI/Main/Contacts/Model/ContactAvatarModel.swift @@ -72,12 +72,15 @@ class ContactAvatarModel: ObservableObject { func addSubscription() { friendSuscription = self.friend?.publisher?.onPresenceReceived?.postOnCoreQueue { (cbValue: (Friend)) in + + let latestActivityTimestamp = cbValue.presenceModel?.latestActivityTimestamp ?? -1 + DispatchQueue.main.async { self.presenceStatus = cbValue.consolidatedPresence if cbValue.consolidatedPresence == .Online || cbValue.consolidatedPresence == .Busy { - if cbValue.consolidatedPresence == .Online || cbValue.presenceModel!.latestActivityTimestamp != -1 { + if cbValue.consolidatedPresence == .Online || latestActivityTimestamp != -1 { self.lastPresenceInfo = cbValue.consolidatedPresence == .Online ? - "Online" : self.getCallTime(startDate: cbValue.presenceModel!.latestActivityTimestamp) + "Online" : self.getCallTime(startDate: latestActivityTimestamp) } else { self.lastPresenceInfo = "Away" } diff --git a/Linphone/Utils/MagicSearchSingleton.swift b/Linphone/Utils/MagicSearchSingleton.swift index 03b3e4c7b..0307c5bc9 100644 --- a/Linphone/Utils/MagicSearchSingleton.swift +++ b/Linphone/Utils/MagicSearchSingleton.swift @@ -67,36 +67,38 @@ final class MagicSearchSingleton: ObservableObject { 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 = lastSearchFriend.sorted(by: { - $0.friend!.name!.lowercased().folding(options: .diacriticInsensitive, locale: .current) - < - $1.friend!.name!.lowercased().folding(options: .diacriticInsensitive, locale: .current) - }) - - self.contactsManager.lastSearchSuggestions = lastSearchSuggestions.sorted(by: { - $0.address!.asStringUriOnly() < $1.address!.asStringUriOnly() - }) + self.contactsManager.lastSearch = sortedLastSearch + self.contactsManager.lastSearchSuggestions = lastSearchSuggestions self.contactsManager.avatarListModel.forEach { contactAvatarModel in contactAvatarModel.removeAllSuscription() } - self.contactsManager.avatarListModel.removeAll() - - self.contactsManager.lastSearch.forEach { searchResult in - if searchResult.friend != nil { - self.contactsManager.avatarListModel.append( - ContactAvatarModel( - friend: searchResult.friend!, - name: searchResult.friend?.name ?? "", - address: searchResult.friend?.address?.clone()?.asStringUriOnly() ?? "", - withPresence: true - ) - ) - } - } + self.contactsManager.avatarListModel += addedAvatarListModel NotificationCenter.default.post(name: NSNotification.Name("ContactLoaded"), object: nil) } From fafedeef428d44abd41c83d669172d652b0497d8 Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Thu, 6 Jun 2024 16:03:16 +0200 Subject: [PATCH 258/486] Fix display name for incoming calls --- Linphone/TelecomManager/TelecomManager.swift | 23 +++++++++++--------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/Linphone/TelecomManager/TelecomManager.swift b/Linphone/TelecomManager/TelecomManager.swift index bd5cdf888..1cb34c1dc 100644 --- a/Linphone/TelecomManager/TelecomManager.swift +++ b/Linphone/TelecomManager/TelecomManager.swift @@ -343,19 +343,22 @@ class TelecomManager: ObservableObject { func incomingDisplayName(call: Call, completion: @escaping (String) -> Void) { CoreContext.shared.doOnCoreQueue { core in - if call.remoteAddress != nil { - let friend = ContactsManager.shared.getFriendWithAddress(address: call.remoteAddress!) - if friend != nil && friend!.address != nil && friend!.address!.displayName != nil { - completion(friend!.address!.displayName!) - } else { - if call.remoteAddress!.displayName != nil { - completion(call.remoteAddress!.displayName!) - } else if call.remoteAddress!.username != nil { - completion(call.remoteAddress!.username!) + 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") } } - completion("IncomingDisplayName") } } From e91e722587ee973df46d4f22939519a92566c52f Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Fri, 7 Jun 2024 09:44:31 +0200 Subject: [PATCH 259/486] Revert "Fix image in chat" This reverts commit 0e00819a67f4cd057016258ed4e65b3ee545adc9. --- .../Fragments/ChatBubbleView.swift | 136 +----------------- 1 file changed, 1 insertion(+), 135 deletions(-) diff --git a/Linphone/UI/Main/Conversations/Fragments/ChatBubbleView.swift b/Linphone/UI/Main/Conversations/Fragments/ChatBubbleView.swift index 1a6ea6000..9767bccfb 100644 --- a/Linphone/UI/Main/Conversations/Fragments/ChatBubbleView.swift +++ b/Linphone/UI/Main/Conversations/Fragments/ChatBubbleView.swift @@ -161,7 +161,6 @@ struct ChatBubbleView: View { func messageAttachments() -> some View { if message.attachments.count == 1 { if message.attachments.first!.type == .image || message.attachments.first!.type == .gif || message.attachments.first!.type == .video { - /* let result = imageDimensions(url: message.attachments.first!.thumbnail.absoluteString) ZStack { Rectangle() @@ -185,6 +184,7 @@ struct ChatBubbleView: View { maxHeight: UIScreen.main.bounds.height/2.5 ) } + if message.attachments.first!.type == .image || message.attachments.first!.type == .video { if #available(iOS 16.0, *) { AsyncImage(url: message.attachments.first!.thumbnail) { image in @@ -243,140 +243,6 @@ struct ChatBubbleView: View { } .clipShape(RoundedRectangle(cornerRadius: 4)) .clipped() - */ - - if message.attachments.first!.type == .image || message.attachments.first!.type == .video { - if #available(iOS 16.0, *) { - /* - AsyncImage(url: message.attachments.first?.thumbnail) { phase in - switch phase { - case .empty: - ProgressView() - case .success(let image): - ZStack { - image - .resizable() - .interpolation(.medium) - .scaledToFit() - .frame(maxWidth: geometryProxy.size.width - 110, maxHeight: UIScreen.main.bounds.height/2.5) - //.aspectRatio(contentMode: .fit) - //.frame(maxWidth: geometryProxy.size.width - 110, maxHeight: UIScreen.main.bounds.height/2.5) - - if message.attachments.first!.type == .video { - Image("play-fill") - .renderingMode(.template) - .resizable() - .foregroundStyle(.white) - .frame(width: 40, height: 40, alignment: .leading) - } - } - case .failure: - Image(systemName: "photo") - @unknown default: - EmptyView() - } - } - */ - - AsyncImage(url: message.attachments.first?.thumbnail) { phase in - switch phase { - case .success(let image): - image - .resizable() - .scaledToFit() - .frame(maxHeight: geometryProxy.size.width * 0.36) - - case .failure: - Image(systemName: "ant.circle.fill") - .resizable() - .scaledToFit() - .frame(maxWidth: geometryProxy.size.width * 0.36, maxHeight: geometryProxy.size.width * 0.36) - .foregroundColor(.teal) - .opacity(0.6) - - case .empty: - Image(systemName: "photo.circle.fill") - .resizable() - .scaledToFit() - .frame(maxWidth: geometryProxy.size.width * 0.36, maxHeight: geometryProxy.size.width * 0.36) - .foregroundColor(.teal) - .opacity(0.6) - - @unknown default: - ProgressView() - } - } - .clipShape(RoundedRectangle(cornerRadius: 4)) - .onAppear { - print("AsyncImageAsyncImage \(geometryProxy.size.width) \(geometryProxy.size.width - 264) \(geometryProxy.size.width * 0.36)") - } - } else { - /* - AsyncImage(url: message.attachments.first!.thumbnail) { image in - ZStack { - image - .resizable() - .interpolation(.medium) - .aspectRatio(contentMode: .fit) - - if message.attachments.first!.type == .video { - Image("play-fill") - .renderingMode(.template) - .resizable() - .foregroundStyle(.white) - .frame(width: 40, height: 40, alignment: .leading) - } - } - } placeholder: { - ProgressView() - } - .id(UUID()) - */ - - AsyncImage(url: message.attachments.first?.thumbnail) { phase in - switch phase { - case .success(let image): - image - .resizable() - .scaledToFit() - .frame(maxHeight: geometryProxy.size.width * 0.36) - - case .failure: - Image(systemName: "ant.circle.fill") - .resizable() - .scaledToFit() - .frame(maxWidth: geometryProxy.size.width * 0.36, maxHeight: geometryProxy.size.width * 0.36) - .foregroundColor(.teal) - .opacity(0.6) - - case .empty: - Image(systemName: "photo.circle.fill") - .resizable() - .scaledToFit() - .frame(maxWidth: geometryProxy.size.width * 0.36, maxHeight: geometryProxy.size.width * 0.36) - .foregroundColor(.teal) - .opacity(0.6) - - @unknown default: - ProgressView() - } - } - .clipShape(RoundedRectangle(cornerRadius: 4)) - .onAppear { - print("AsyncImageAsyncImage \(geometryProxy.size.width) \(geometryProxy.size.width - 264) \(geometryProxy.size.width * 0.36)") - } - .id(UUID()) - } - } else if message.attachments.first!.type == .gif { - if #available(iOS 16.0, *) { - GifImageView(message.attachments.first!.thumbnail) - .clipShape(RoundedRectangle(cornerRadius: 4)) - } else { - GifImageView(message.attachments.first!.thumbnail) - .id(UUID()) - .clipShape(RoundedRectangle(cornerRadius: 4)) - } - } } } else if message.attachments.count > 1 { let isGroup = conversationViewModel.displayedConversation != nil && conversationViewModel.displayedConversation!.isGroup From 0fff983b0ad844c7fcf7dceb8deefc3f76241975 Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Fri, 7 Jun 2024 15:03:57 +0200 Subject: [PATCH 260/486] Change download directory --- .../image-broken.imageset/Contents.json | 21 +++++ .../image-broken.imageset/image-broken.svg | 1 + .../Fragments/ContactsListFragment.swift | 2 +- .../Fragments/ChatBubbleView.swift | 76 +++++++++++-------- .../ViewModel/ConversationViewModel.swift | 23 ++++-- 5 files changed, 87 insertions(+), 36 deletions(-) create mode 100644 Linphone/Assets.xcassets/image-broken.imageset/Contents.json create mode 100644 Linphone/Assets.xcassets/image-broken.imageset/image-broken.svg 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/UI/Main/Contacts/Fragments/ContactsListFragment.swift b/Linphone/UI/Main/Contacts/Fragments/ContactsListFragment.swift index c8db8a7c6..9c57fb39d 100644 --- a/Linphone/UI/Main/Contacts/Fragments/ContactsListFragment.swift +++ b/Linphone/UI/Main/Contacts/Fragments/ContactsListFragment.swift @@ -87,7 +87,7 @@ struct ContactsListFragment: View { withAnimation { contactViewModel.indexDisplayedFriend = index } - if contactsManager.lastSearch[index].friend != nil && contactsManager.lastSearch[index].friend!.address != nil { + if index < contactsManager.lastSearch.count && contactsManager.lastSearch[index].friend != nil && contactsManager.lastSearch[index].friend!.address != nil { startCallFunc(contactsManager.lastSearch[index].friend!.address!) } } diff --git a/Linphone/UI/Main/Conversations/Fragments/ChatBubbleView.swift b/Linphone/UI/Main/Conversations/Fragments/ChatBubbleView.swift index 9767bccfb..38d796517 100644 --- a/Linphone/UI/Main/Conversations/Fragments/ChatBubbleView.swift +++ b/Linphone/UI/Main/Conversations/Fragments/ChatBubbleView.swift @@ -187,46 +187,62 @@ struct ChatBubbleView: View { if message.attachments.first!.type == .image || message.attachments.first!.type == .video { if #available(iOS 16.0, *) { - AsyncImage(url: message.attachments.first!.thumbnail) { image in - ZStack { - image - .resizable() - .interpolation(.medium) - .aspectRatio(contentMode: .fill) - - if message.attachments.first!.type == .video { - Image("play-fill") - .renderingMode(.template) + AsyncImage(url: message.attachments.first!.thumbnail) { phase in + switch phase { + case .empty: + ProgressView() + case .success(let image): + ZStack { + image .resizable() - .foregroundStyle(.white) - .frame(width: 40, height: 40, alignment: .leading) + .interpolation(.medium) + .aspectRatio(contentMode: .fill) + + if 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() } - } placeholder: { - ProgressView() } .layoutPriority(-1) + .clipShape(RoundedRectangle(cornerRadius: 4)) } else { - AsyncImage(url: message.attachments.first!.thumbnail) { image in - ZStack { - image - .resizable() - .interpolation(.medium) - .aspectRatio(contentMode: .fill) - - if message.attachments.first!.type == .video { - Image("play-fill") - .renderingMode(.template) + AsyncImage(url: message.attachments.first!.thumbnail) { phase in + switch phase { + case .empty: + ProgressView() + case .success(let image): + ZStack { + image .resizable() - .foregroundStyle(.white) - .frame(width: 40, height: 40, alignment: .leading) + .interpolation(.medium) + .aspectRatio(contentMode: .fill) + + if 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() } - } placeholder: { - ProgressView() } - .id(UUID()) .layoutPriority(-1) + .clipShape(RoundedRectangle(cornerRadius: 4)) + .id(UUID()) } } else if message.attachments.first!.type == .gif { if #available(iOS 16.0, *) { @@ -319,7 +335,7 @@ struct ChatBubbleView: View { return orientation != nil && orientation == 6 ? (pixelHeight ?? 0, pixelWidth ?? 0) : (pixelWidth ?? 0, pixelHeight ?? 0) } } - return (0, 0) + return (100, 100) } } diff --git a/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift b/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift index fc797a25f..50a952660 100644 --- a/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift +++ b/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift @@ -139,6 +139,7 @@ class ConversationViewModel: ObservableObject { } else { if content.type != "video" { let path = URL(string: self.getNewFilePath(name: content.name ?? "")) + if path != nil { let attachment = Attachment( @@ -151,6 +152,7 @@ class ConversationViewModel: ObservableObject { } 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( @@ -240,6 +242,7 @@ class ConversationViewModel: ObservableObject { } else { if content.type != "video" { let path = URL(string: self.getNewFilePath(name: content.name ?? "")) + if path != nil { let attachment = Attachment( @@ -252,6 +255,7 @@ class ConversationViewModel: ObservableObject { } 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( @@ -339,6 +343,7 @@ class ConversationViewModel: ObservableObject { } else if content.name != nil && !content.name!.isEmpty { if content.type != "video" { let path = URL(string: self.getNewFilePath(name: content.name ?? "")) + if path != nil { let attachment = Attachment( @@ -350,6 +355,7 @@ class ConversationViewModel: ObservableObject { } } 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 = @@ -512,7 +518,8 @@ class ConversationViewModel: ObservableObject { if message != nil { let path = FileManager.default.temporaryDirectory.appendingPathComponent((attachment.full.lastPathComponent.addingPercentEncoding(withAllowedCharacters: .urlHostAllowed) ?? "")) - let newPath = URL(string: "file://" + Factory.Instance.getDownloadDir(context: nil) + (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) @@ -581,7 +588,7 @@ class ConversationViewModel: ObservableObject { if contentName != nil { let isImage = FileUtil.isExtensionImage(path: contentName!) let file = FileUtil.getFileStoragePath(fileName: contentName ?? "", isImage: isImage) - content.filePath = file + content.filePath = String(file.dropFirst(7)) Log.info( "[ConversationViewModel] File \(contentName) will be downloaded at \(content.filePath)" ) @@ -593,13 +600,14 @@ class ConversationViewModel: ObservableObject { } func getNewFilePath(name: String) -> String { - return "file://" + Factory.Instance.getDownloadDir(context: nil) + (name.addingPercentEncoding(withAllowedCharacters: .urlHostAllowed) ?? "") + let groupName = "group.\(Bundle.main.bundleIdentifier ?? "").linphoneExtension" + 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://" + Factory.Instance.getDownloadDir(context: nil) + (name.addingPercentEncoding(withAllowedCharacters: .urlHostAllowed) ?? "")) + ? 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) @@ -612,7 +620,12 @@ class ConversationViewModel: ObservableObject { } let urlName = pathThumbnail == nil - ? URL(string: "file://" + Factory.Instance.getDownloadDir(context: nil) + "preview_" + (name.addingPercentEncoding(withAllowedCharacters: .urlHostAllowed) ?? "") + ".png") + ? 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 { From ef471b2e1dd8f4d35c4da9f77231408f4636af4c Mon Sep 17 00:00:00 2001 From: Christophe Deschamps Date: Tue, 11 Jun 2024 11:40:56 +0200 Subject: [PATCH 261/486] Added callto scheme URI handler --- Linphone/Utils/URIHandler.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Linphone/Utils/URIHandler.swift b/Linphone/Utils/URIHandler.swift index 54dfeab49..7706dc530 100644 --- a/Linphone/Utils/URIHandler.swift +++ b/Linphone/Utils/URIHandler.swift @@ -24,7 +24,7 @@ import Combine class URIHandler { // Need to cover all Info.plist URL schemes. - private static let callSchemes = ["sip", "sip-linphone", "linphone-sip", "tel"] + 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"] From e0374d458d1e5ddb827366f994e71da877bea25b Mon Sep 17 00:00:00 2001 From: Christophe Deschamps Date: Tue, 11 Jun 2024 11:49:28 +0200 Subject: [PATCH 262/486] Fix callto uri handler --- Linphone/Info.plist | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/Linphone/Info.plist b/Linphone/Info.plist index e32fc036e..c374ee2fe 100644 --- a/Linphone/Info.plist +++ b/Linphone/Info.plist @@ -94,6 +94,16 @@ org.linphone + + CFBundleTypeRole + Editor + CFBundleURLName + org.linphone.phone + CFBundleURLSchemes + + callto + + ITSAppUsesNonExemptEncryption From eca85b80ad9ba437d1085b5c5f7438c241018665 Mon Sep 17 00:00:00 2001 From: QuentinArguillere Date: Mon, 10 Jun 2024 15:00:44 +0200 Subject: [PATCH 263/486] Need to start core in the core initialization in order to instanciate the PushRegistry and be able to process the voip push --- Linphone/Core/CoreContext.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Linphone/Core/CoreContext.swift b/Linphone/Core/CoreContext.swift index c3e461383..7344dc17b 100644 --- a/Linphone/Core/CoreContext.swift +++ b/Linphone/Core/CoreContext.swift @@ -284,6 +284,7 @@ final class CoreContext: ObservableObject { .sink { _ in self.mCore.iterate() } + try? self.mCore.start() } } From 7fb63c19ddf39fd1a4ff6e10ef830e40a7873217 Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Mon, 10 Jun 2024 14:55:29 +0200 Subject: [PATCH 264/486] ZRTP Changes --- Linphone/UI/Call/CallView.swift | 12 -- Linphone/UI/Call/Fragments/ZRTPPopup.swift | 45 ++--- .../UI/Call/ViewModel/CallViewModel.swift | 177 +++++++++++------- 3 files changed, 124 insertions(+), 110 deletions(-) diff --git a/Linphone/UI/Call/CallView.swift b/Linphone/UI/Call/CallView.swift index 82cdad0fe..2d74a788b 100644 --- a/Linphone/UI/Call/CallView.swift +++ b/Linphone/UI/Call/CallView.swift @@ -210,18 +210,6 @@ struct CallView: View { ZStack { VStack { if !fullscreenVideo || (fullscreenVideo && telecomManager.isPausedByRemote) { - 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) - } ZStack { HStack { Button { diff --git a/Linphone/UI/Call/Fragments/ZRTPPopup.swift b/Linphone/UI/Call/Fragments/ZRTPPopup.swift index 8e1a1b448..332803d22 100644 --- a/Linphone/UI/Call/Fragments/ZRTPPopup.swift +++ b/Linphone/UI/Call/Fragments/ZRTPPopup.swift @@ -27,11 +27,6 @@ struct ZRTPPopup: View { @ObservedObject var callViewModel: CallViewModel - @State private var letters1: String = "AA" - @State private var letters2: String = "BB" - @State private var letters3: String = "CC" - @State private var letters4: String = "DD" - var body: some View { GeometryReader { geometry in VStack(alignment: .leading) { @@ -46,7 +41,7 @@ struct ZRTPPopup: View { Spacer() HStack(alignment: .center) { - Text(letters1) + Text(callViewModel.letters1) .default_text_style(styleSize: 30) .frame(width: 60, height: 60) } @@ -54,12 +49,12 @@ struct ZRTPPopup: View { .background(Color.grayMain2c200) .cornerRadius(40) .onTapGesture { - callViewModel.lettersClicked(letters: letters1) + callViewModel.updateZrtpSas(authTokenClicked: callViewModel.letters1) callViewModel.zrtpPopupDisplayed = false } HStack(alignment: .center) { - Text(letters2) + Text(callViewModel.letters2) .default_text_style(styleSize: 30) .frame(width: 60, height: 60) } @@ -67,7 +62,7 @@ struct ZRTPPopup: View { .background(Color.grayMain2c200) .cornerRadius(40) .onTapGesture { - callViewModel.lettersClicked(letters: letters2) + callViewModel.updateZrtpSas(authTokenClicked: callViewModel.letters2) callViewModel.zrtpPopupDisplayed = false } @@ -79,7 +74,7 @@ struct ZRTPPopup: View { Spacer() HStack(alignment: .center) { - Text(letters3) + Text(callViewModel.letters3) .default_text_style(styleSize: 30) .frame(width: 60, height: 60) } @@ -87,12 +82,12 @@ struct ZRTPPopup: View { .background(Color.grayMain2c200) .cornerRadius(40) .onTapGesture { - callViewModel.lettersClicked(letters: letters3) + callViewModel.updateZrtpSas(authTokenClicked: callViewModel.letters3) callViewModel.zrtpPopupDisplayed = false } HStack(alignment: .center) { - Text(letters4) + Text(callViewModel.letters4) .default_text_style(styleSize: 30) .frame(width: 60, height: 60) } @@ -100,7 +95,7 @@ struct ZRTPPopup: View { .background(Color.grayMain2c200) .cornerRadius(40) .onTapGesture { - callViewModel.lettersClicked(letters: letters4) + callViewModel.updateZrtpSas(authTokenClicked: callViewModel.letters4) callViewModel.zrtpPopupDisplayed = false } @@ -118,10 +113,12 @@ struct ZRTPPopup: View { .frame(maxWidth: .infinity) .padding(.bottom, 30) .onTapGesture { + callViewModel.skipZrtpAuthentication() callViewModel.zrtpPopupDisplayed = false } Button(action: { + callViewModel.updateZrtpSas(authTokenClicked: "") callViewModel.zrtpPopupDisplayed = false }, label: { Text("Letters don't match!") @@ -149,30 +146,10 @@ struct ZRTPPopup: View { .frame(maxWidth: sharedMainViewModel.maxWidth) .position(x: geometry.size.width / 2, y: geometry.size.height / 2) .onAppear { - - var random = SystemRandomNumberGenerator() - let correctLetters = Int(random.next(upperBound: UInt32(4))) - - letters1 = (correctLetters == 0) ? callViewModel.upperCaseAuthTokenToListen : self.randomAlphanumericString(2) - letters2 = (correctLetters == 1) ? callViewModel.upperCaseAuthTokenToListen : self.randomAlphanumericString(2) - letters3 = (correctLetters == 2) ? callViewModel.upperCaseAuthTokenToListen : self.randomAlphanumericString(2) - letters4 = (correctLetters == 3) ? callViewModel.upperCaseAuthTokenToListen : self.randomAlphanumericString(2) + callViewModel.remoteAuthenticationTokens() } } } - - func randomAlphanumericString(_ length: Int) -> String { - let letters = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" - let len = UInt32(letters.count) - var random = SystemRandomNumberGenerator() - var randomString = "" - for _ in 0..() + @Published var letters1: String = "AA" + @Published var letters2: String = "BB" + @Published var letters3: String = "CC" + @Published var letters4: String = "DD" + init() { do { try AVAudioSession.sharedInstance().setCategory(.playAndRecord, mode: .voiceChat, options: .allowBluetooth) @@ -164,8 +169,9 @@ class CallViewModel: ObservableObject { let isPausedTmp = self.isCallPaused() let timeElapsedTmp = self.currentCall?.duration ?? 0 - let authToken = self.currentCall!.authenticationToken - let isDeviceTrusted = self.currentCall!.authenticationTokenVerified && authToken != nil + 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 { @@ -215,11 +221,9 @@ class CallViewModel: ObservableObject { } self.callSuscriptions.insert(self.currentCall!.publisher?.onEncryptionChanged?.postOnCoreQueue {(cbVal: (call: Call, on: Bool, authenticationToken: String?)) in - DispatchQueue.main.async { - _ = self.updateEncryption() - if self.currentCall != nil { - self.callMediaEncryptionModel.update(call: self.currentCall!) - } + self.updateEncryption() + if self.currentCall != nil { + self.callMediaEncryptionModel.update(call: self.currentCall!) } }) @@ -231,6 +235,18 @@ class CallViewModel: ObservableObject { } }) + + self.callSuscriptions.insert( + self.currentCall!.publisher?.onAuthenticationTokenVerified?.postOnCoreQueue {(call: Call, verified: Bool) in + Log.warn("[CallViewModel][ZRTPPopup] Notified that authentication token is \(verified ? "verified" : "not verified!")") + + self.updateEncryption() + if self.currentCall != nil { + self.callMediaEncryptionModel.update(call: self.currentCall!) + } + } + ) + self.updateCallQualityIcon() } } @@ -832,81 +848,114 @@ class CallViewModel: ObservableObject { } } - func lettersClicked(letters: String) { - let verified = letters == self.upperCaseAuthTokenToListen + func skipZrtpAuthentication() { Log.info( - "[ZRTPPopup] User clicked on \(verified ? "right" : "wrong") letters" + "[ZRTPPopup] User skipped SAS validation in ZRTP call" ) - if verified { - coreContext.doOnCoreQueue { core in - if core.currentCall != nil { - core.currentCall!.authenticationTokenVerified = verified - } + coreContext.doOnCoreQueue { core in + if core.currentCall != nil { + core.currentCall!.skipZrtpAuthentication() } } } - private func updateEncryption() -> Bool { - if currentCall != nil && currentCall!.currentParams != nil { - switch currentCall!.currentParams!.mediaEncryption { - case MediaEncryption.ZRTP: - let authToken = currentCall!.authenticationToken - let isDeviceTrusted = currentCall!.authenticationTokenVerified && authToken != nil - - Log.info( - "[CallViewModel] Current call media encryption is ZRTP, auth token is \(isDeviceTrusted ? "trusted" : "not trusted yet")" - ) - - isRemoteDeviceTrusted = isDeviceTrusted - - if isDeviceTrusted { - ToastViewModel.shared.toastMessage = "Info_call_securised" - ToastViewModel.shared.displayToast = true + 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 + self.letters1 = tokens[0] + self.letters2 = tokens[1] + self.letters3 = tokens[2] + self.letters4 = tokens[3] + } + } + } + + private func updateEncryption() { + coreContext.doOnCoreQueue { core 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!") + } + */ + + // When Post Quantum is available, ZRTP is Post Quantum + let isZrtpPqTmp = Core.getPostQuantumAvailable + + DispatchQueue.main.async { + self.isRemoteDeviceTrusted = isRemoteDeviceTrustedTmp + self.isMediaEncrypted = true + self.isZrtpPq = isZrtpPqTmp + + if isDeviceTrusted { + 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.isZrtpPq = false + } + default: + DispatchQueue.main.async { + self.isMediaEncrypted = false + self.isZrtpPq = false + } } - - /* - 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!") - } - */ - - isMediaEncrypted = true - // When Post Quantum is available, ZRTP is Post Quantum - isZrtpPq = Core.getPostQuantumAvailable - - if !isDeviceTrusted && authToken != nil && !authToken!.isEmpty { - Log.info("[CallViewModel] Showing ZRTP SAS confirmation dialog") - showZrtpSasDialog(authToken: authToken!) - } - - return isDeviceTrusted - case MediaEncryption.SRTP, MediaEncryption.DTLS: - isMediaEncrypted = true - isZrtpPq = false - return false - default: - isMediaEncrypted = false - isZrtpPq = false - return false } } - return false } func showZrtpSasDialogIfPossible() { if currentCall != nil && currentCall!.currentParams != nil && currentCall!.currentParams!.mediaEncryption == MediaEncryption.ZRTP { - let authToken = currentCall!.authenticationToken + 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) { + if authToken != nil && !authToken!.isEmpty { showZrtpSasDialog(authToken: authToken!) } } From a9eb5caad40c1d134a7b4b17da3cae8d0517af4a Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Tue, 11 Jun 2024 14:09:58 +0200 Subject: [PATCH 265/486] Fix onChatRoomStateChanged callback when app moves in background --- .../ConversationsListViewModel.swift | 41 +++---------------- 1 file changed, 5 insertions(+), 36 deletions(-) diff --git a/Linphone/UI/Main/Conversations/ViewModel/ConversationsListViewModel.swift b/Linphone/UI/Main/Conversations/ViewModel/ConversationsListViewModel.swift index 2b9b18d07..573a11bc3 100644 --- a/Linphone/UI/Main/Conversations/ViewModel/ConversationsListViewModel.swift +++ b/Linphone/UI/Main/Conversations/ViewModel/ConversationsListViewModel.swift @@ -46,33 +46,14 @@ class ConversationsListViewModel: ObservableObject { var conversationsListTmp: [ConversationModel] = [] chatRooms.forEach { chatRoom in - if chatRoom.hasCapability(mask: ChatRoom.Capabilities.OneToOne.rawValue) { - } - if filter.isEmpty { let model = ConversationModel(chatRoom: chatRoom) conversationsListTmp.append(model) } } - if !self.conversationsList.isEmpty { - for (index, element) in conversationsListTmp.enumerated() { - if index > 0 && index < self.conversationsList.count && element.id != self.conversationsList[index].id { - DispatchQueue.main.async { - self.conversationsList[index] = element - } - } - } - - DispatchQueue.main.async { - if conversationsListTmp.first != nil { - self.conversationsList[0] = conversationsListTmp.first! - } - } - } else { - DispatchQueue.main.async { - self.conversationsList = conversationsListTmp - } + DispatchQueue.main.async { + self.conversationsList = conversationsListTmp } self.updateUnreadMessagesCount() @@ -85,8 +66,7 @@ class ConversationsListViewModel: ObservableObject { //Log.info("[ConversationsListViewModel] Conversation [${LinphoneUtils.getChatRoomId(chatRoom)}] state changed [$state]") switch cbValue.state { case ChatRoom.State.Created: - let model = ConversationModel(chatRoom: cbValue.chatRoom) - self.addChatRoom(cbChatRoom: model) + self.computeChatRoomsList(filter: "") case ChatRoom.State.Deleted: self.computeChatRoomsList(filter: "") //ToastViewModel.shared.toastMessage = "toast_conversation_deleted" @@ -106,19 +86,6 @@ class ConversationsListViewModel: ObservableObject { } } - func addChatRoom(cbChatRoom: ConversationModel) { - Log.info("[ConversationsListViewModel] Re-ordering conversations") - var sortedList: [ConversationModel] = [] - sortedList.append(cbChatRoom) - sortedList.append(contentsOf: self.conversationsList) - - DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { - self.conversationsList = sortedList.sorted { $0.lastUpdateTime > $1.lastUpdateTime } - } - - updateUnreadMessagesCount() - } - func reorderChatRooms() { Log.info("[ConversationsListViewModel] Re-ordering conversations") var sortedList: [ConversationModel] = [] @@ -139,10 +106,12 @@ class ConversationsListViewModel: ObservableObject { DispatchQueue.main.async { self.unreadMessages = count + UIApplication.shared.applicationIconBadgeNumber = count } } else { DispatchQueue.main.async { self.unreadMessages = 0 + UIApplication.shared.applicationIconBadgeNumber = 0 } } } From c441e2cb435debcefb5d9ad469b126fc244ed3be Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Thu, 13 Jun 2024 10:23:36 +0200 Subject: [PATCH 266/486] Fix chatRoom refresh in background --- Linphone/Core/CoreContext.swift | 1 + Linphone/LinphoneApp.swift | 6 + .../UI/Call/ViewModel/CallViewModel.swift | 10 +- .../Fragments/ConversationFragment.swift | 1 + .../ConversationsListBottomSheet.swift | 2 +- .../Fragments/ConversationsListFragment.swift | 36 +-- .../Main/Conversations/Fragments/UIList.swift | 260 +++++++----------- .../Model/ConversationModel.swift | 13 - .../ViewModel/ConversationViewModel.swift | 32 ++- .../ConversationsListViewModel.swift | 23 +- 10 files changed, 169 insertions(+), 215 deletions(-) diff --git a/Linphone/Core/CoreContext.swift b/Linphone/Core/CoreContext.swift index 7344dc17b..1617df7f9 100644 --- a/Linphone/Core/CoreContext.swift +++ b/Linphone/Core/CoreContext.swift @@ -120,6 +120,7 @@ final class CoreContext: ObservableObject { self.mCore.videoDisplayEnabled = true self.mCore.videoPreviewEnabled = false self.mCore.fecEnabled = true + self.mCore.friendListSubscriptionEnabled = true self.mCoreSuscriptions.insert(self.mCore.publisher?.onGlobalStateChanged?.postOnCoreQueue { (cbVal: (core: Core, state: GlobalState, message: String)) in if cbVal.state == GlobalState.On { diff --git a/Linphone/LinphoneApp.swift b/Linphone/LinphoneApp.swift index 0f8a322ee..95cb4d7f8 100644 --- a/Linphone/LinphoneApp.swift +++ b/Linphone/LinphoneApp.swift @@ -154,6 +154,12 @@ struct LinphoneApp: App { if newPhase == .active { Log.info("Entering foreground") coreContext.onEnterForeground() + + DispatchQueue.main.asyncAfter(deadline: .now() + 1) { + if conversationViewModel != nil && conversationViewModel!.displayedConversation != nil && conversationsListViewModel != nil { + conversationViewModel!.resetDisplayedChatRoom(conversationsList: conversationsListViewModel!.conversationsList) + } + } } else if newPhase == .inactive { } else if newPhase == .background { Log.info("Entering background") diff --git a/Linphone/UI/Call/ViewModel/CallViewModel.swift b/Linphone/UI/Call/ViewModel/CallViewModel.swift index 04c598d17..741d58cf4 100644 --- a/Linphone/UI/Call/ViewModel/CallViewModel.swift +++ b/Linphone/UI/Call/ViewModel/CallViewModel.swift @@ -881,10 +881,12 @@ class CallViewModel: ObservableObject { coreContext.doOnCoreQueue { core in if core.currentCall != nil { let tokens = core.currentCall!.remoteAuthenticationTokens - self.letters1 = tokens[0] - self.letters2 = tokens[1] - self.letters3 = tokens[2] - self.letters4 = tokens[3] + DispatchQueue.main.async { + self.letters1 = tokens[0] + self.letters2 = tokens[1] + self.letters3 = tokens[2] + self.letters4 = tokens[3] + } } } } diff --git a/Linphone/UI/Main/Conversations/Fragments/ConversationFragment.swift b/Linphone/UI/Main/Conversations/Fragments/ConversationFragment.swift index 265637b1d..ec8fe4443 100644 --- a/Linphone/UI/Main/Conversations/Fragments/ConversationFragment.swift +++ b/Linphone/UI/Main/Conversations/Fragments/ConversationFragment.swift @@ -162,6 +162,7 @@ struct ConversationFragment: View { UIList(viewModel: viewModel, paginationState: paginationState, conversationViewModel: conversationViewModel, + conversationsListViewModel: conversationsListViewModel, isScrolledToBottom: $isScrolledToBottom, showMessageMenuOnLongPress: showMessageMenuOnLongPress, geometryProxy: geometry, diff --git a/Linphone/UI/Main/Conversations/Fragments/ConversationsListBottomSheet.swift b/Linphone/UI/Main/Conversations/Fragments/ConversationsListBottomSheet.swift index 7e4a53022..384293762 100644 --- a/Linphone/UI/Main/Conversations/Fragments/ConversationsListBottomSheet.swift +++ b/Linphone/UI/Main/Conversations/Fragments/ConversationsListBottomSheet.swift @@ -57,7 +57,7 @@ struct ConversationsListBottomSheet: View { Button { if conversationsListViewModel.selectedConversation != nil { conversationsListViewModel.objectWillChange.send() - conversationsListViewModel.selectedConversation!.markAsRead() + conversationsListViewModel.markAsReadSelectedConversation() conversationsListViewModel.updateUnreadMessagesCount() } diff --git a/Linphone/UI/Main/Conversations/Fragments/ConversationsListFragment.swift b/Linphone/UI/Main/Conversations/Fragments/ConversationsListFragment.swift index 9ef536ca9..e7c33020b 100644 --- a/Linphone/UI/Main/Conversations/Fragments/ConversationsListFragment.swift +++ b/Linphone/UI/Main/Conversations/Fragments/ConversationsListFragment.swift @@ -27,8 +27,6 @@ struct ConversationsListFragment: View { @Binding var showingSheet: Bool - @State var canChangeChatRoom: Bool = true - var body: some View { VStack { List { @@ -136,34 +134,16 @@ struct ConversationsListFragment: View { .listRowSeparator(.hidden) .background(.white) .onTapGesture { - if canChangeChatRoom { - if conversationViewModel.displayedConversation != nil { - conversationViewModel.displayedConversation = nil - conversationViewModel.resetMessage() + if conversationViewModel.displayedConversation != nil { + conversationViewModel.displayedConversation = nil + conversationViewModel.resetMessage() + conversationViewModel.changeDisplayedChatRoom(conversationModel: conversationsListViewModel.conversationsList[index]) + + conversationViewModel.getMessages() + } else { + withAnimation { conversationViewModel.changeDisplayedChatRoom(conversationModel: conversationsListViewModel.conversationsList[index]) - - let firstScene = UIApplication.shared.connectedScenes.first as? UIWindowScene - if firstScene != nil { - let firstWindow = firstScene!.windows.first - if firstWindow != nil { - firstWindow!.layer.speed = 20.0 - canChangeChatRoom = false - - DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { - firstWindow!.layer.speed = 1.0 - canChangeChatRoom = true - } - } - } - - conversationViewModel.getMessages() - } else { - withAnimation { - conversationViewModel.changeDisplayedChatRoom(conversationModel: conversationsListViewModel.conversationsList[index]) - } } - conversationsListViewModel.conversationsList[index].markAsRead() - conversationsListViewModel.updateUnreadMessagesCount() } } .onLongPressGesture(minimumDuration: 0.2) { diff --git a/Linphone/UI/Main/Conversations/Fragments/UIList.swift b/Linphone/UI/Main/Conversations/Fragments/UIList.swift index 6bae3da69..79d4451b7 100644 --- a/Linphone/UI/Main/Conversations/Fragments/UIList.swift +++ b/Linphone/UI/Main/Conversations/Fragments/UIList.swift @@ -29,19 +29,16 @@ struct UIList: UIViewRepresentable { @ObservedObject var viewModel: ChatViewModel @ObservedObject var paginationState: PaginationState @ObservedObject var conversationViewModel: ConversationViewModel - + @ObservedObject var conversationsListViewModel: ConversationsListViewModel + @Binding var isScrolledToBottom: Bool let showMessageMenuOnLongPress: Bool let geometryProxy: GeometryProxy let sections: [MessagesSection] - + @State private var isScrolledToTop = false - - private let updatesQueue = DispatchQueue(label: "updatesQueue", qos: .utility) - @State private var updateSemaphore = DispatchSemaphore(value: 1) - @State private var tableSemaphore = DispatchSemaphore(value: 0) - + func makeUIView(context: Context) -> UITableView { let tableView = UITableView(frame: .zero, style: .grouped) tableView.contentInset = UIEdgeInsets(top: -10, left: 0, bottom: -20, right: 0) @@ -57,7 +54,7 @@ struct UIList: UIViewRepresentable { tableView.estimatedSectionFooterHeight = UITableView.automaticDimension tableView.backgroundColor = UIColor(.white) tableView.scrollsToTop = true - + NotificationCenter.default.addObserver(forName: .onScrollToBottom, object: nil, queue: nil) { _ in DispatchQueue.main.async { if !context.coordinator.sections.isEmpty { @@ -65,127 +62,77 @@ struct UIList: UIViewRepresentable { } } } - + return tableView } - + func updateUIView(_ tableView: UITableView, context: Context) { if context.coordinator.sections == sections { return } - updatesQueue.async { - updateSemaphore.wait() - - if context.coordinator.sections == sections { - updateSemaphore.signal() - return - } - - let prevSections = context.coordinator.sections - let (appliedDeletes, appliedDeletesSwapsAndEdits, deleteOperations, swapOperations, editOperations, insertOperations) = operationsSplit(oldSections: prevSections, newSections: sections) - - // step 1 - // preapare intermediate sections and operations - //print("1 updateUIView sections:", "\n") - //print("whole previous:\n", formatSections(prevSections), "\n") - //print("whole appliedDeletes:\n", formatSections(appliedDeletes), "\n") - //print("whole appliedDeletesSwapsAndEdits:\n", formatSections(appliedDeletesSwapsAndEdits), "\n") - //print("whole final sections:\n", formatSections(sections), "\n") - - //print("operations delete:\n", deleteOperations) - //print("operations swap:\n", swapOperations) - //print("operations edit:\n", editOperations) - //print("operations insert:\n", insertOperations) - - DispatchQueue.main.async { - tableView.performBatchUpdates { - // step 2 - // delete sections and rows if necessary - //print("2 apply delete") - context.coordinator.sections = appliedDeletes - for operation in deleteOperations { - applyOperation(operation, tableView: tableView) - } - } completion: { _ in - tableSemaphore.signal() - //print("2 finished delete") - } - } - tableSemaphore.wait() - - DispatchQueue.main.async { - tableView.performBatchUpdates { - // step 3 - // swap places for rows that moved inside the table - // (example of how this happens. send two messages: first m1, then m2. if m2 is delivered to server faster, then it should jump above m1 even though it was sent later) - //print("3 apply swaps") - 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) - } - } completion: { _ in - tableSemaphore.signal() - //print("3 finished swaps") - } - } - tableSemaphore.wait() - - DispatchQueue.main.async { - tableView.performBatchUpdates { - // step 4 - // check only sections that are already in the table for existing rows that changed and apply only them to table's dataSource without animation - //print("4 apply edits") - context.coordinator.sections = appliedDeletesSwapsAndEdits - for operation in editOperations { - applyOperation(operation, tableView: tableView) - } - } completion: { _ in - tableSemaphore.signal() - //print("4 finished edits") - } - } - tableSemaphore.wait() - - if isScrolledToBottom || isScrolledToTop { - DispatchQueue.main.sync { - // step 5 - // apply the rest of the changes to table's dataSource, i.e. inserts - //print("5 apply inserts") - context.coordinator.sections = sections - - tableView.beginUpdates() - for operation in insertOperations { - applyOperation(operation, tableView: tableView) - } - tableView.endUpdates() - - updateSemaphore.signal() - } - } else { - updateSemaphore.signal() + 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) + } + } + + if isScrolledToBottom || isScrolledToTop { + context.coordinator.sections = sections + + tableView.beginUpdates() + for operation in insertOperations { + applyOperation(operation, tableView: tableView) + } + tableView.endUpdates() + } + + if isScrolledToBottom { + 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: .top) case .insert(let section, let row): @@ -197,19 +144,16 @@ struct UIList: UIViewRepresentable { 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 // start with old sections, remove rows that need to be deleted - var appliedDeletesSwapsAndEdits = newSections // take new sections and remove rows that need to be inserted for now, then we'll get array with all the changes except for inserts - // appliedDeletesSwapsEditsAndInserts == newSection - + var appliedDeletes = oldSections + var appliedDeletesSwapsAndEdits = newSections + var deleteOperations = [Operation]() var swapOperations = [Operation]() var editOperations = [Operation]() var insertOperations = [Operation]() - - // 1 compare sections - + let oldDates = oldSections.map { $0.date } let newDates = newSections.map { $0.date } let commonDates = Array(Set(oldDates + newDates)).sorted(by: >) @@ -217,7 +161,6 @@ struct UIList: UIViewRepresentable { let oldIndex = appliedDeletes.firstIndex(where: { $0.date == date } ) let newIndex = appliedDeletesSwapsAndEdits.firstIndex(where: { $0.date == date } ) if oldIndex == nil, let newIndex { - // operationIndex is not the same as newIndex because appliedDeletesSwapsAndEdits is being changed as we go, but to apply changes to UITableView we should have initial index if let operationIndex = newSections.firstIndex(where: { $0.date == date } ) { appliedDeletesSwapsAndEdits.remove(at: newIndex) insertOperations.append(.insertSection(operationIndex)) @@ -232,63 +175,53 @@ struct UIList: UIViewRepresentable { continue } guard let newIndex, let oldIndex else { continue } - - // 2 compare section rows - // isolate deletes and inserts, and remove them from row arrays, leaving only rows that are in both arrays: 'duplicates' - // this will allow to compare relative position changes of rows - swaps - + var oldRows = appliedDeletes[oldIndex].rows var newRows = appliedDeletesSwapsAndEdits[newIndex].rows let oldRowIDs = Set(oldRows.map { $0.id }) let newRowIDs = Set(newRows.map { $0.id }) let rowIDsToDelete = oldRowIDs.subtracting(newRowIDs) - let rowIDsToInsert = newRowIDs.subtracting(oldRowIDs) // TODO is order important? + let rowIDsToInsert = newRowIDs.subtracting(oldRowIDs) for rowId in rowIDsToDelete { if let index = oldRows.firstIndex(where: { $0.id == rowId }) { oldRows.remove(at: index) - deleteOperations.append(.delete(oldIndex, index)) // this row was in old section, should not be in final result + deleteOperations.append(.delete(oldIndex, index)) } } for rowId in rowIDsToInsert { if let index = newRows.firstIndex(where: { $0.id == rowId }) { - // this row was not in old section, should add it to final result insertOperations.append(.insert(newIndex, index)) } } - + for rowId in rowIDsToInsert { if let index = newRows.firstIndex(where: { $0.id == rowId }) { - // remove for now, leaving only 'duplicates' newRows.remove(at: index) } } - - // 3 isolate swaps and edits - + for row in 0.. Bool { !swaps.filter { if case let .swap(section, rowFrom, rowTo) = $0 { @@ -297,12 +230,13 @@ struct UIList: UIViewRepresentable { return false }.isEmpty } - + // MARK: - Coordinator - + func makeCoordinator() -> Coordinator { Coordinator( conversationViewModel: conversationViewModel, + conversationsListViewModel: conversationsListViewModel, viewModel: viewModel, paginationState: paginationState, isScrolledToBottom: $isScrolledToBottom, @@ -314,20 +248,22 @@ struct UIList: UIViewRepresentable { } class Coordinator: NSObject, UITableViewDataSource, UITableViewDelegate { - + @ObservedObject var viewModel: ChatViewModel @ObservedObject var paginationState: PaginationState @ObservedObject var conversationViewModel: ConversationViewModel - + @ObservedObject var conversationsListViewModel: ConversationsListViewModel + @Binding var isScrolledToBottom: Bool @Binding var isScrolledToTop: Bool let showMessageMenuOnLongPress: Bool let geometryProxy: GeometryProxy var sections: [MessagesSection] - - init(conversationViewModel: ConversationViewModel, viewModel: ChatViewModel, paginationState: PaginationState, isScrolledToBottom: Binding, isScrolledToTop: Binding, showMessageMenuOnLongPress: Bool, geometryProxy: GeometryProxy, sections: [MessagesSection]) { + + init(conversationViewModel: ConversationViewModel, conversationsListViewModel: ConversationsListViewModel, viewModel: ChatViewModel, paginationState: PaginationState, isScrolledToBottom: Binding, isScrolledToTop: Binding, showMessageMenuOnLongPress: Bool, geometryProxy: GeometryProxy, sections: [MessagesSection]) { self.conversationViewModel = conversationViewModel + self.conversationsListViewModel = conversationsListViewModel self.viewModel = viewModel self.paginationState = paginationState self._isScrolledToBottom = isScrolledToBottom @@ -336,15 +272,15 @@ struct UIList: UIViewRepresentable { self.geometryProxy = geometryProxy self.sections = sections } - + 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) } @@ -361,13 +297,13 @@ struct UIList: UIViewRepresentable { } 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 { @@ -381,20 +317,20 @@ struct UIList: UIViewRepresentable { } 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] paginationState.handle(row) } - + func scrollViewDidScroll(_ scrollView: UIScrollView) { isScrolledToBottom = scrollView.contentOffset.y <= 10 - if isScrolledToBottom && conversationViewModel.displayedConversationUnreadMessagesCount > 0 { conversationViewModel.markAsRead() + conversationsListViewModel.computeChatRoomsList(filter: "") } if !isScrolledToTop && scrollView.contentOffset.y >= scrollView.contentSize.height - scrollView.frame.height - 200 { @@ -406,44 +342,44 @@ struct UIList: UIViewRepresentable { } struct MessagesSection: Equatable { - + let date: Date var rows: [Message] - + static var formatter = { let formatter = DateFormatter() formatter.dateFormat = "EEEE, MMMM d" return formatter }() - + init(date: Date, rows: [Message]) { self.date = date 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 } - + } 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 @@ -457,9 +393,9 @@ final class ChatViewModel: ObservableObject { @Published private(set) var fullscreenAttachmentItem: Optional = nil @Published var fullscreenAttachmentPresented = false - + @Published var messageMenuRow: Message? - + public var didSendMessage: (DraftMessage) -> Void = {_ in} func presentAttachmentFullScreen(_ attachment: Attachment) { @@ -471,7 +407,7 @@ final class ChatViewModel: ObservableObject { fullscreenAttachmentPresented = false fullscreenAttachmentItem = nil } - + func sendMessage(_ message: DraftMessage) { didSendMessage(message) } diff --git a/Linphone/UI/Main/Conversations/Model/ConversationModel.swift b/Linphone/UI/Main/Conversations/Model/ConversationModel.swift index cb50b7d8c..f2f3e27eb 100644 --- a/Linphone/UI/Main/Conversations/Model/ConversationModel.swift +++ b/Linphone/UI/Main/Conversations/Model/ConversationModel.swift @@ -94,19 +94,6 @@ class ConversationModel: ObservableObject { } } - func markAsRead() { - coreContext.doOnCoreQueue { _ in - let unreadMessagesCountTmp = self.chatRoom.unreadMessagesCount - if unreadMessagesCountTmp > 0 { - self.chatRoom.markAsRead() - - DispatchQueue.main.async { - self.unreadMessagesCount = 0 - } - } - } - } - func toggleMute() { coreContext.doOnCoreQueue { _ in self.chatRoom.muted.toggle() diff --git a/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift b/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift index 50a952660..3cc07eec6 100644 --- a/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift +++ b/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift @@ -400,6 +400,8 @@ class ConversationViewModel: ObservableObject { let isFirstMessageTmp = (eventLog.chatMessage?.isOutgoing ?? false) ? isFirstMessageOutgoingTmp : isFirstMessageIncomingTmp + let unreadMessagesCount = self.displayedConversation!.chatRoom.unreadMessagesCount + var statusTmp: Message.Status? = .sending switch eventLog.chatMessage?.state { case .InProgress: @@ -441,14 +443,10 @@ class ConversationViewModel: ObservableObject { } if !message.isOutgoing { - self.displayedConversationUnreadMessagesCount += 1 + self.displayedConversationUnreadMessagesCount = unreadMessagesCount } } } - - if self.displayedConversation != nil { - self.displayedConversation!.markAsRead() - } } } @@ -581,13 +579,35 @@ class ConversationViewModel: ObservableObject { self.displayedConversation = conversationModel } + func resetDisplayedChatRoom(conversationsList: [ConversationModel]) { + removeConversationDelegate() + + if self.displayedConversation != nil { + conversationsList.forEach { conversation in + if conversation.id == self.displayedConversation!.id { + self.displayedConversation = conversation + + self.chatRoomSuscriptions.insert(conversation.chatRoom.publisher?.onChatMessageSending?.postOnCoreQueue { (cbValue: (chatRoom: ChatRoom, eventLog: EventLog)) in + self.getNewMessages(eventLogs: [cbValue.eventLog]) + }) + + self.chatRoomSuscriptions.insert(conversation.chatRoom.publisher?.onChatMessagesReceived?.postOnCoreQueue { (cbValue: (chatRoom: ChatRoom, eventLogs: [EventLog])) in + self.getNewMessages(eventLogs: cbValue.eventLogs) + }) + } + } + } + } + func downloadContent(chatMessage: ChatMessage, content: Content) { //Log.debug("[ConversationViewModel] Starting downloading content for file \(model.fileName)") if content.filePath == nil || content.filePath!.isEmpty { let contentName = content.name if contentName != nil { let isImage = FileUtil.isExtensionImage(path: contentName!) - let file = FileUtil.getFileStoragePath(fileName: contentName ?? "", isImage: isImage) + let groupName = "group.\(Bundle.main.bundleIdentifier ?? "").linphoneExtension" + 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)" diff --git a/Linphone/UI/Main/Conversations/ViewModel/ConversationsListViewModel.swift b/Linphone/UI/Main/Conversations/ViewModel/ConversationsListViewModel.swift index 573a11bc3..71d92dac4 100644 --- a/Linphone/UI/Main/Conversations/ViewModel/ConversationsListViewModel.swift +++ b/Linphone/UI/Main/Conversations/ViewModel/ConversationsListViewModel.swift @@ -62,11 +62,21 @@ class ConversationsListViewModel: ObservableObject { 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.mCoreSuscriptions.insert(core.publisher?.onChatRoomStateChanged?.postOnCoreQueue { (cbValue: (core: Core, chatRoom: ChatRoom, state: ChatRoom.State)) in //Log.info("[ConversationsListViewModel] Conversation [${LinphoneUtils.getChatRoomId(chatRoom)}] state changed [$state]") switch cbValue.state { case ChatRoom.State.Created: - self.computeChatRoomsList(filter: "") + if !(cbValue.chatRoom.isEmpty && cbValue.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" @@ -177,4 +187,15 @@ class ConversationsListViewModel: ObservableObject { reorderChatRooms() } + + func markAsReadSelectedConversation() { + coreContext.doOnCoreQueue { _ in + let unreadMessagesCount = self.selectedConversation!.chatRoom.unreadMessagesCount + + if unreadMessagesCount > 0 { + self.selectedConversation!.chatRoom.markAsRead() + self.selectedConversation!.unreadMessagesCount = 0 + } + } + } } From 9e314aa205677855d92eadbcd73207614d89f08c Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Thu, 13 Jun 2024 14:20:12 +0200 Subject: [PATCH 267/486] Fix friends presence --- Linphone/Contacts/ContactsManager.swift | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/Linphone/Contacts/ContactsManager.swift b/Linphone/Contacts/ContactsManager.swift index 454090020..62090bfd5 100644 --- a/Linphone/Contacts/ContactsManager.swift +++ b/Linphone/Contacts/ContactsManager.swift @@ -131,11 +131,9 @@ final class ContactsManager: ObservableObject { self.linphoneFriendList?.updateSubscriptions() self.friendList?.updateSubscriptions() - MagicSearchSingleton.shared.searchForContacts(sourceFlags: MagicSearch.Source.Friends.rawValue | MagicSearch.Source.LdapServers.rawValue) - self.friendListSuscription = self.friendList?.publisher?.onNewSipAddressDiscovered?.postOnCoreQueue { (cbValue: (friendList: FriendList, linphoneFriend: Friend, sipUri: String)) in - var addedAvatarListModel : [ContactAvatarModel] = [] + var addedAvatarListModel: [ContactAvatarModel] = [] cbValue.linphoneFriend.phoneNumbers.forEach { phone in do { let address = core.interpretUrl(url: phone, applyInternationalPrefix: true) @@ -163,11 +161,16 @@ final class ContactsManager: ObservableObject { DispatchQueue.main.async { self.avatarListModel += addedAvatarListModel } + + MagicSearchSingleton.shared.searchForContacts(sourceFlags: MagicSearch.Source.Friends.rawValue | MagicSearch.Source.LdapServers.rawValue) } } } } - contactCounter += 1 + + if !(contact.givenName.isEmpty && contact.familyName.isEmpty) { + contactCounter += 1 + } }) } catch let error { From dfcd501dc34fc5a9e02798b84a4474d583c1e048 Mon Sep 17 00:00:00 2001 From: Christophe Deschamps Date: Mon, 17 Jun 2024 10:28:39 +0000 Subject: [PATCH 268/486] Side menu --- Linphone.xcodeproj/project.pbxproj | 28 ++ Linphone/.DS_Store | Bin 8196 -> 0 bytes Linphone/Core/CoreContext.swift | 19 +- Linphone/Localizable.xcstrings | 182 +++++++++++- .../Assistant/Fragments/LoginFragment.swift | 29 ++ Linphone/UI/Main/ContentView.swift | 20 +- Linphone/UI/Main/Fragments/HelpView.swift | 62 +++++ Linphone/UI/Main/Fragments/SideMenu.swift | 259 +++++++++++------- .../Main/Fragments/SideMenuAccountRow.swift | 87 ++++++ .../UI/Main/Fragments/SideMenuEntry.swift | 53 ++++ Linphone/UI/Main/Viewmodel/AccountModel.swift | 81 ++++++ .../Utils/Extensions/AccountExtension.swift | 36 +++ .../Utils/Extensions/BundleExtenion.swift | 26 ++ .../Utils/Extensions/StringExtension.swift | 26 ++ Linphone/Utils/Extensions/TextExtension.swift | 26 ++ 15 files changed, 825 insertions(+), 109 deletions(-) delete mode 100644 Linphone/.DS_Store create mode 100644 Linphone/UI/Main/Fragments/HelpView.swift create mode 100644 Linphone/UI/Main/Fragments/SideMenuAccountRow.swift create mode 100644 Linphone/UI/Main/Fragments/SideMenuEntry.swift create mode 100644 Linphone/UI/Main/Viewmodel/AccountModel.swift create mode 100644 Linphone/Utils/Extensions/AccountExtension.swift create mode 100644 Linphone/Utils/Extensions/BundleExtenion.swift create mode 100644 Linphone/Utils/Extensions/StringExtension.swift diff --git a/Linphone.xcodeproj/project.pbxproj b/Linphone.xcodeproj/project.pbxproj index 560c3787a..137de0f75 100644 --- a/Linphone.xcodeproj/project.pbxproj +++ b/Linphone.xcodeproj/project.pbxproj @@ -36,6 +36,11 @@ 66FBFC4A2B83BD3300BC6AB1 /* FileUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = 66C491FE2B24D4AC00CEA16D /* FileUtils.swift */; }; 66FBFC4B2B83BD7B00BC6AB1 /* CoreExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 66C491FA2B24D32600CEA16D /* CoreExtension.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 */; }; @@ -43,6 +48,8 @@ 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 */; }; @@ -201,6 +208,11 @@ 66E56BCD2BA9A1F8006CE56F /* MeetingModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeetingModel.swift; sourceTree = ""; }; 66F626B12BCEBB86003E2DEC /* AddParticipantsFragment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddParticipantsFragment.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 = ""; }; @@ -208,6 +220,8 @@ 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 = ""; }; @@ -368,6 +382,9 @@ C60E8F182C0F649200A06DB8 /* UIApplicationExtension.swift */, C6A5A9402C10B5D50070FEA4 /* EncodableExtension.swift */, C6A5A9422C10B5ED0070FEA4 /* DecodableExtension.swift */, + C6DC4E3C2C199C4E009096FD /* BundleExtenion.swift */, + C628172D2C1C3A3600DBA646 /* AccountExtension.swift */, + C62817312C1C400A00DBA646 /* StringExtension.swift */, ); path = Extensions; sourceTree = ""; @@ -624,6 +641,9 @@ D750D3382AD3E6EE00EC99C5 /* PopupLoadingView.swift */, D706BA812ADD72D100278F45 /* DeviceRotationViewModifier.swift */, D72250682ADFBF2D008FB426 /* SideMenu.swift */, + C62817332C1C7C7400DBA646 /* HelpView.swift */, + C6DC4E3E2C19C289009096FD /* SideMenuEntry.swift */, + C62817272C1B389700DBA646 /* SideMenuAccountRow.swift */, D7E6D04C2AEBD77600A57AAF /* CustomBottomSheet.swift */, ); path = Fragments; @@ -724,6 +744,7 @@ 66162A1F2BDFC2F900DCE913 /* AddParticipantsViewModel.swift */, D7A2EDD52AC18115005D90FC /* SharedMainViewModel.swift */, D796F1FF2B0BB61A0041115F /* ToastViewModel.swift */, + C628172F2C1C3DCC00DBA646 /* AccountModel.swift */, ); path = Viewmodel; sourceTree = ""; @@ -1014,6 +1035,7 @@ 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 */, D777DBB32AE12C5900565A99 /* ContactsManager.swift in Sources */, D720E6AD2BAD822000DDFD87 /* ParticipantModel.swift in Sources */, @@ -1027,6 +1049,7 @@ 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 */, @@ -1037,9 +1060,12 @@ 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 */, @@ -1099,6 +1125,7 @@ D7D1698C2AE66FA500109A5C /* MagicSearchSingleton.swift in Sources */, D74DA0122C047F0700A8561D /* HistoryModel.swift in Sources */, D72250692ADFBF2D008FB426 /* SideMenu.swift in Sources */, + C6DC4E3F2C19C289009096FD /* SideMenuEntry.swift in Sources */, D7CEE0352B7A210300FD79B7 /* ConversationsView.swift in Sources */, D717071E2AC5922E0037746F /* ColorExtension.swift in Sources */, D71968922B86369D00DF4459 /* ChatBubbleView.swift in Sources */, @@ -1108,6 +1135,7 @@ D74C9CFC2ACACF370021626A /* WelcomePage3Fragment.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 */, diff --git a/Linphone/.DS_Store b/Linphone/.DS_Store deleted file mode 100644 index 06ce575e56dfde609b73451038cc47f059aa7fea..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 8196 zcmeHM&2AGh5FV!~-L^t4h)TU6t;977{h?JWE@@hjDsey!KLpqPQe+ zKj)!TFf9v^3i?DTB~+&O)F5vNZ5u2DmI2FvWxz6E8Tc0%z&D$dbH#UG_uA4jU>W!? z8Q}B5MP^wsvZtjS9VjFOfGna}66%NpM8`FqcH|k+r2a;c| zE#&gKB-Hq7V4Gr!pz9&-#|^+BzVJPVFABa*{veLNUWKc7la{gnt=7`27*66(UnL4GcZ zSDl?d&n1?-A)fmO_)A@8pl^(@7HZ2}5A!}GzL8zk96bfrrxW0PSar2jevODW_A0&KJ$&`8n#B>WtkpH`p_m*RJWAm#!`6rMIajKIeh8Bh(wh zKePSk{m<@Nfu7UuAkPJU3$i{Un6dNvciwneOOD4$aFD`l!y%7jFU_)*L5`b`)#Z(n zyRvcJ6EL%)94lD5O(SPuLR(zq`2W$>_x~fGmo;b^undfj0ae_o?Nnj!;^%ALG{@R5 z@>^t1jO%GBDkvlzhm>#}vj2x6>Mo$HQ!%oqC0dY1ei1ObU;Os_FFPy+#{%~I|C^7o G*M0%tT_u CoreDelegatePublisher? { + return mCore.publisher + } + } // swiftlint:enable large_tuple diff --git a/Linphone/Localizable.xcstrings b/Linphone/Localizable.xcstrings index 0890a892c..77a3aaae4 100644 --- a/Linphone/Localizable.xcstrings +++ b/Linphone/Localizable.xcstrings @@ -276,7 +276,7 @@ "Chiffrement du média" : { }, - "Clear logs" : { + "Clear Logs" : { }, "Close" : { @@ -365,6 +365,124 @@ }, "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" : { @@ -433,6 +551,23 @@ }, "Headphones" : { + }, + "help_title" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Help" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aide" + } + } + } }, "History has been deleted" : { @@ -490,6 +625,12 @@ "Key" : { "extractionState" : "manual" }, + "Key 1" : { + "extractionState" : "manual" + }, + "Key 2" : { + "extractionState" : "manual" + }, "Last name" : { }, @@ -647,6 +788,23 @@ }, "Record" : { + }, + "recordings_title" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Recordings" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Enregistrements" + } + } + } }, "Register" : { @@ -706,8 +864,25 @@ "Send invitations to participants" : { }, - "Send logs" : { + "Send Logs" : { + }, + "settings_title" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Settings" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Paramètres" + } + } + } }, "Share" : { @@ -747,6 +922,9 @@ }, "TCP" : { + }, + "Temp Help" : { + }, "The user name or password is incorrects" : { diff --git a/Linphone/UI/Assistant/Fragments/LoginFragment.swift b/Linphone/UI/Assistant/Fragments/LoginFragment.swift index c7e1da5e2..35c629ede 100644 --- a/Linphone/UI/Assistant/Fragments/LoginFragment.swift +++ b/Linphone/UI/Assistant/Fragments/LoginFragment.swift @@ -37,6 +37,10 @@ struct LoginFragment: View { @State private var isLinkSIPActive = false @State private var isLinkREGActive = false + var isShowBack = false + + var onBackPressed: (() -> Void)? + var body: some View { NavigationView { ZStack { @@ -49,6 +53,31 @@ struct LoginFragment: View { .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) diff --git a/Linphone/UI/Main/ContentView.swift b/Linphone/UI/Main/ContentView.swift index f71015354..5e76f7dcc 100644 --- a/Linphone/UI/Main/ContentView.swift +++ b/Linphone/UI/Main/ContentView.swift @@ -64,6 +64,7 @@ struct ContentView: View { @State var fullscreenVideo = false @State var isShowScheduleMeetingFragment = false + @State private var isShowLoginFragment: Bool = false var body: some View { let pub = NotificationCenter.default @@ -765,15 +766,30 @@ struct ContentView: View { } SideMenu( - callViewModel: callViewModel, width: geometry.size.width / 5 * 4, isOpen: self.sideMenuIsOpen, menuClose: self.openMenu, - safeAreaInsets: geometry.safeAreaInsets + 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, diff --git a/Linphone/UI/Main/Fragments/HelpView.swift b/Linphone/UI/Main/Fragments/HelpView.swift new file mode 100644 index 000000000..af227f297 --- /dev/null +++ b/Linphone/UI/Main/Fragments/HelpView.swift @@ -0,0 +1,62 @@ +/* + * 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 core.defaultAccount != nil { + let authInfo = core.defaultAccount!.findAuthInfo() + if authInfo != nil { + Log.info("$TAG Found auth info for account, removing it") + core.removeAuthInfo(info: authInfo!) + } else { + Log.warn("$TAG Failed to find matching auth info for account") + } + + core.removeAccount(account: core.defaultAccount!) + Log.info("$TAG Account has been removed") + + DispatchQueue.main.async { + CoreContext.shared.hasDefaultAccount = false + CoreContext.shared.loggedIn = false + } + } + } + } +} diff --git a/Linphone/UI/Main/Fragments/SideMenu.swift b/Linphone/UI/Main/Fragments/SideMenu.swift index 200619300..0df717379 100644 --- a/Linphone/UI/Main/Fragments/SideMenu.swift +++ b/Linphone/UI/Main/Fragments/SideMenu.swift @@ -1,5 +1,5 @@ /* - * Copyright (c) 2010-2023 Belledonne Communications SARL. + * Copyright (c) 2010-2024 Belledonne Communications SARL. * * This file is part of linphone-iphone * @@ -23,114 +23,165 @@ import UniformTypeIdentifiers struct SideMenu: View { - @ObservedObject private var coreContext = CoreContext.shared + let width: CGFloat + let isOpen: Bool + let menuClose: () -> Void + let safeAreaInsets: EdgeInsets + @Binding var isShowLoginFragment: Bool + @State private var showHelp = false + @State private var selectedAccountIndex: Int = 0 - @ObservedObject var callViewModel: CallViewModel - - let width: CGFloat - let isOpen: Bool - let menuClose: () -> Void - let safeAreaInsets: EdgeInsets - - var body: some View { - ZStack { - GeometryReader { _ in - EmptyView() - } - .background(Color.gray.opacity(0.3)) - .opacity(self.isOpen ? 1.0 : 0.0) - .onTapGesture { - self.menuClose() - } - - HStack { - List { - /* - Text("My Profile") - .frame(height: 40) - .frame(maxWidth: .infinity, alignment: .leading) - .background(Color.white) - .onTapGesture { - print("My Profile") - self.menuClose() - } - */ - Text("Send logs") - .frame(height: 40) - .frame(maxWidth: .infinity, alignment: .leading) - .background(Color.white) - .onTapGesture { - print("Send logs") - sendLogs() - self.menuClose() - } - Text("Clear logs") - .frame(height: 40) - .frame(maxWidth: .infinity, alignment: .leading) - .background(Color.white) - .onTapGesture { - print("Clear logs") - clearLogs() - self.menuClose() - } - Text("Logout") - .frame(height: 40) - .frame(maxWidth: .infinity, alignment: .leading) - .background(Color.white) - .onTapGesture { - print("Logout") - logout() - self.menuClose() - } - } - .frame(width: self.width - safeAreaInsets.leading) - .background(Color.white) - .offset(x: self.isOpen ? 0 : -self.width) - - Spacer() - } - .padding(.leading, safeAreaInsets.leading) - .padding(.top, TelecomManager.shared.callInProgress ? 0 : safeAreaInsets.top) - .padding(.bottom, safeAreaInsets.bottom) - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - } - - func sendLogs() { - coreContext.doOnCoreQueue { core in - core.uploadLogCollection() - } - } - - func clearLogs() { - coreContext.doOnCoreQueue { core in - Core.resetLogCollection() - DispatchQueue.main.async { - ToastViewModel.shared.toastMessage = "Success_clear_logs" - ToastViewModel.shared.displayToast = true + var body: some View { + ZStack { + GeometryReader { _ in + EmptyView() } - } - } - - func logout() { - coreContext.doOnCoreQueue { core in - if core.defaultAccount != nil { - let authInfo = core.defaultAccount!.findAuthInfo() - if authInfo != nil { - Log.info("$TAG Found auth info for account, removing it") - core.removeAuthInfo(info: authInfo!) - } else { - Log.warn("$TAG Failed to find matching auth info for account") + .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 backgroundColor: Color + var body: some View { + HStack { + + Avatar(contactAvatarModel: + ContactAvatarModel(friend: nil, + name: model.account.displayName(), + address: model.account.params!.identityAddress!.asString(), + withPresence: true), + avatarSize: 45) + .padding(.leading, 6) + + VStack { + Text(model.account.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(backgroundColor) + } +} 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/Viewmodel/AccountModel.swift b/Linphone/UI/Main/Viewmodel/AccountModel.swift new file mode 100644 index 000000000..2953e84a2 --- /dev/null +++ b/Linphone/UI/Main/Viewmodel/AccountModel.swift @@ -0,0 +1,81 @@ +/* + * 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 + +class AccountModel: ObservableObject { + let account: Account + @Published var humanReadableRegistrationState: String = "" + @Published var registrationStateAssociatedUIColor: Color = .clear + @Published var notificationsCount: Int = 0 + + init(account: Account, corePublisher: CoreDelegatePublisher?) { + self.account = account + update() + account.publisher?.onRegistrationStateChanged? + .postOnMainQueue { _ in + self.update() + } + corePublisher?.onChatRoomRead?.postOnMainQueue( + receiveValue: { _ in + self.computeNotificationsCount() + }) + corePublisher?.onMessagesReceived?.postOnMainQueue( + receiveValue: { _ in + self.computeNotificationsCount() + }) + corePublisher?.onCallStateChanged?.postOnMainQueue( + receiveValue: { _ in + self.computeNotificationsCount() + }) + } + + func update() { + switch account.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 + } + computeNotificationsCount() + } + + func computeNotificationsCount() { + notificationsCount = account.unreadChatMessageCount + account.missedCallsCount + } + + func refreshRegiter() { + CoreContext.shared.doOnCoreQueue { _ in + self.account.refreshRegister() + } + } +} diff --git a/Linphone/Utils/Extensions/AccountExtension.swift b/Linphone/Utils/Extensions/AccountExtension.swift new file mode 100644 index 000000000..cdfde1181 --- /dev/null +++ b/Linphone/Utils/Extensions/AccountExtension.swift @@ -0,0 +1,36 @@ +/* + * 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() + } +} 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/StringExtension.swift b/Linphone/Utils/Extensions/StringExtension.swift new file mode 100644 index 000000000..47c681f12 --- /dev/null +++ b/Linphone/Utils/Extensions/StringExtension.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 String { + func localized(comment: String? = nil) -> String { + return NSLocalizedString(self, comment: comment != nil ? comment! : self) + } +} diff --git a/Linphone/Utils/Extensions/TextExtension.swift b/Linphone/Utils/Extensions/TextExtension.swift index 8465dfbff..e48d97e57 100644 --- a/Linphone/Utils/Extensions/TextExtension.swift +++ b/Linphone/Utils/Extensions/TextExtension.swift @@ -20,6 +20,18 @@ 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 { @@ -141,4 +153,18 @@ extension 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) + } } From 02e6baf9bad51e62196670d44f9c9076ca314f5f Mon Sep 17 00:00:00 2001 From: Christophe Deschamps Date: Mon, 17 Jun 2024 13:14:47 +0200 Subject: [PATCH 269/486] Highlihght side menu default account --- Linphone/UI/Main/Fragments/SideMenu.swift | 7 +- .../Main/Fragments/SideMenuAccountRow.swift | 3 +- Linphone/UI/Main/Viewmodel/AccountModel.swift | 90 +++++++++++-------- .../Utils/Extensions/AccountExtension.swift | 4 + 4 files changed, 57 insertions(+), 47 deletions(-) diff --git a/Linphone/UI/Main/Fragments/SideMenu.swift b/Linphone/UI/Main/Fragments/SideMenu.swift index 0df717379..c3ab8b756 100644 --- a/Linphone/UI/Main/Fragments/SideMenu.swift +++ b/Linphone/UI/Main/Fragments/SideMenu.swift @@ -29,7 +29,6 @@ struct SideMenu: View { let safeAreaInsets: EdgeInsets @Binding var isShowLoginFragment: Bool @State private var showHelp = false - @State private var selectedAccountIndex: Int = 0 var body: some View { ZStack { @@ -69,13 +68,9 @@ struct SideMenu: View { ForEach(0.. Bool { + return lhs.params?.identityAddress?.asString() == rhs.params?.identityAddress?.asString() + } } From 2c28474ca5ec26750888cb8d98ae619a0bd9ed84 Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Thu, 13 Jun 2024 17:53:31 +0200 Subject: [PATCH 270/486] Add register view --- Linphone.xcodeproj/project.pbxproj | 28 +- .../Contents.json | 21 ++ .../confirm_sms_code_illu.png | Bin 0 -> 18364 bytes Linphone/Localizable.xcstrings | 174 ++++++++++- .../assistant_linphone_default_values | 2 +- .../assistant_third_party_default_values | 8 + Linphone/Ressources/linphonerc-default | 6 +- Linphone/Ressources/linphonerc-factory | 14 +- .../Assistant/Fragments/LoginFragment.swift | 10 +- .../RegisterCodeConfirmationFragment.swift | 228 ++++++++++++++ .../Fragments/RegisterFragment.swift | 285 ++++++++++++++++-- .../Viewmodel/RegisterViewModel.swift | 57 ++++ 12 files changed, 772 insertions(+), 61 deletions(-) create mode 100644 Linphone/Assets.xcassets/confirm_sms_code_illu.imageset/Contents.json create mode 100644 Linphone/Assets.xcassets/confirm_sms_code_illu.imageset/confirm_sms_code_illu.png create mode 100644 Linphone/UI/Assistant/Fragments/RegisterCodeConfirmationFragment.swift create mode 100644 Linphone/UI/Assistant/Viewmodel/RegisterViewModel.swift diff --git a/Linphone.xcodeproj/project.pbxproj b/Linphone.xcodeproj/project.pbxproj index 137de0f75..2fc9d6d40 100644 --- a/Linphone.xcodeproj/project.pbxproj +++ b/Linphone.xcodeproj/project.pbxproj @@ -57,6 +57,8 @@ D70A26F22B7F5D95006CC8FC /* ConversationFragment.swift in Sources */ = {isa = PBXBuildFile; fileRef = D70A26F12B7F5D95006CC8FC /* ConversationFragment.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 */; }; 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 */; }; @@ -229,6 +231,8 @@ D70A26F12B7F5D95006CC8FC /* ConversationFragment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConversationFragment.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 = ""; }; 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 = ""; }; @@ -578,6 +582,7 @@ D719ABCE2ABC779A00B41C10 /* AccountLoginViewModel.swift */, D72343332ACEFFC3009AA24E /* QRScanner.swift */, D72343312ACEFF58009AA24E /* QRScannerController.swift */, + D714DE5F2C1B3B34006C1F1D /* RegisterViewModel.swift */, ); path = Viewmodel; sourceTree = ""; @@ -837,6 +842,7 @@ D7FB55102AD447FD00A5AB15 /* RegisterFragment.swift */, D748BF2B2ACD82D2004844EB /* ThirdPartySipAccountLoginFragment.swift */, D748BF2D2ACD82E7004844EB /* ThirdPartySipAccountWarningFragment.swift */, + D714DE612C1C4636006C1F1D /* RegisterCodeConfirmationFragment.swift */, ); path = Fragments; sourceTree = ""; @@ -1123,9 +1129,11 @@ D726E43F2B19E56F0083C415 /* StartCallViewModel.swift in Sources */, D70A26EE2B7CF60B006CC8FC /* ConversationsListBottomSheet.swift in Sources */, D7D1698C2AE66FA500109A5C /* MagicSearchSingleton.swift in Sources */, + D714DE602C1B3B34006C1F1D /* RegisterViewModel.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 */, @@ -1171,6 +1179,7 @@ GCC_PREPROCESSOR_DEFINITIONS = ( "$(inherited)", "DEBUG=1", + "USE_CRASHLYTICS=1", ); GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = msgNotificationService/Info.plist; @@ -1184,7 +1193,7 @@ ); LOCALIZATION_PREFERS_STRING_CATALOGS = YES; MARKETING_VERSION = 1.0; - OTHER_SWIFT_FLAGS = "$(inherited)"; + OTHER_SWIFT_FLAGS = "$(inherited) -DUSE_CRASHLYTICS"; PRODUCT_BUNDLE_IDENTIFIER = org.linphone.phone.msgNotificationService; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -1210,7 +1219,10 @@ DEVELOPMENT_TEAM = Z2V957B3D6; ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; - GCC_PREPROCESSOR_DEFINITIONS = "$(inherited)"; + GCC_PREPROCESSOR_DEFINITIONS = ( + "$(inherited)", + "USE_CRASHLYTICS=1", + ); GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = msgNotificationService/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = msgNotificationService; @@ -1223,7 +1235,7 @@ ); LOCALIZATION_PREFERS_STRING_CATALOGS = YES; MARKETING_VERSION = 1.0; - OTHER_SWIFT_FLAGS = "$(inherited)"; + OTHER_SWIFT_FLAGS = "$(inherited) -DUSE_CRASHLYTICS"; PRODUCT_BUNDLE_IDENTIFIER = org.linphone.phone.msgNotificationService; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -1370,6 +1382,7 @@ GCC_PREPROCESSOR_DEFINITIONS = ( "$(inherited)", "DEBUG=1", + "USE_CRASHLYTICS=1", ); GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = Linphone/Info.plist; @@ -1396,7 +1409,7 @@ "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; MACOSX_DEPLOYMENT_TARGET = 13.3; MARKETING_VERSION = 6.0; - OTHER_SWIFT_FLAGS = "$(inherited)"; + OTHER_SWIFT_FLAGS = "$(inherited) -DUSE_CRASHLYTICS"; PRODUCT_BUNDLE_IDENTIFIER = org.linphone.phone; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; @@ -1422,7 +1435,10 @@ DEVELOPMENT_TEAM = Z2V957B3D6; ENABLE_HARDENED_RUNTIME = YES; ENABLE_PREVIEWS = YES; - GCC_PREPROCESSOR_DEFINITIONS = "$(inherited)"; + 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"; @@ -1448,7 +1464,7 @@ "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; MACOSX_DEPLOYMENT_TARGET = 13.3; MARKETING_VERSION = 6.0; - OTHER_SWIFT_FLAGS = "$(inherited)"; + OTHER_SWIFT_FLAGS = "$(inherited) -DUSE_CRASHLYTICS"; PRODUCT_BUNDLE_IDENTIFIER = org.linphone.phone; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; 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 0000000000000000000000000000000000000000..e25974c02e227acb5c0c169c8ae0250cec0c6837 GIT binary patch literal 18364 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 XI4yEpN4jd)00000NkvXXu0mjf535Sb literal 0 HcmV?d00001 diff --git a/Linphone/Localizable.xcstrings b/Linphone/Localizable.xcstrings index 77a3aaae4..827ba45ed 100644 --- a/Linphone/Localizable.xcstrings +++ b/Linphone/Localizable.xcstrings @@ -207,6 +207,54 @@ }, "Appel chiffré de bout en bout" : { + }, + "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", @@ -225,6 +273,87 @@ } } }, + "assistant_account_register" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Register" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "S’inscrire" + } + } + } + }, + "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" : { }, @@ -265,7 +394,20 @@ }, "Cancel" : { - + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cancel" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Annuler" + } + } + } }, "Ce mode vous permet d’être interopérable avec d’autres services SIP.\nVos communications seront chiffrées de point à point. " : { @@ -304,7 +446,20 @@ }, "Continue" : { - + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Continue" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Continuer" + } + } + } }, "Conversations" : { @@ -642,6 +797,9 @@ }, "Log out" : { + }, + "Login" : { + }, "Logout" : { @@ -807,7 +965,14 @@ } }, "Register" : { - + "localizations" : { + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "S’inscrire" + } + } + } }, "Rejoindre" : { @@ -910,6 +1075,9 @@ }, "Subject" : { + }, + "subscribe.linphone.org" : { + }, "Suggestions" : { diff --git a/Linphone/Ressources/assistant_linphone_default_values b/Linphone/Ressources/assistant_linphone_default_values index c1e26073c..54453d542 100644 --- a/Linphone/Ressources/assistant_linphone_default_values +++ b/Linphone/Ressources/assistant_linphone_default_values @@ -29,7 +29,7 @@
zrtp - 1 + 1
1 diff --git a/Linphone/Ressources/assistant_third_party_default_values b/Linphone/Ressources/assistant_third_party_default_values index 78927cf4e..bd9c9b79f 100644 --- a/Linphone/Ressources/assistant_third_party_default_values +++ b/Linphone/Ressources/assistant_third_party_default_values @@ -22,4 +22,12 @@ 0
+
+ stun.linphone.org + stun,ice +
+
+ srtp + 0 +
diff --git a/Linphone/Ressources/linphonerc-default b/Linphone/Ressources/linphonerc-default index 8126cf1cc..80f2f4736 100644 --- a/Linphone/Ressources/linphonerc-default +++ b/Linphone/Ressources/linphonerc-default @@ -25,9 +25,9 @@ automatically_accept_direction=2 #receive only [app] tunnel=disabled -auto_start=1 -record_aware=1 -disable_chat_feature=0 +auto_download_incoming_voice_recordings=1 +auto_download_incoming_icalendars=1 + [tunnel] host= diff --git a/Linphone/Ressources/linphonerc-factory b/Linphone/Ressources/linphonerc-factory index eb16b25f2..eb87047ef 100644 --- a/Linphone/Ressources/linphonerc-factory +++ b/Linphone/Ressources/linphonerc-factory @@ -42,16 +42,11 @@ notify_each_friend_individually_when_presence_received=0 store_friends=0 [app] -activation_code_length=4 -prefer_basic_chat_room=1 record_aware=1 -disable_chat_feature=0 [account_creator] -backend=1 -# 1 means FlexiAPI, 0 is XMLRPC -url=https://subscribe.linphone.org/api/ -# replace above URL by https://staging-subscribe.linphone.org/api/ for testing +url=https://flexisip-staging-master.linphone.org/login # For testing +# url=https://subscribe.linphone.org/api/ [lime] lime_update_threshold=86400 @@ -59,9 +54,4 @@ lime_update_threshold=86400 [alerts] alerts_enabled=1 -[assistant] -algorithm=SHA-256 -password_min_length=6 -username_regex=^[a-z0-9+_.\-]*$ - ## End of factory rc diff --git a/Linphone/UI/Assistant/Fragments/LoginFragment.swift b/Linphone/UI/Assistant/Fragments/LoginFragment.swift index 35c629ede..a53421ab8 100644 --- a/Linphone/UI/Assistant/Fragments/LoginFragment.swift +++ b/Linphone/UI/Assistant/Fragments/LoginFragment.swift @@ -259,19 +259,15 @@ struct LoginFragment: View { .foregroundStyle(Color.grayMain2c700) .padding(.horizontal, 10) - NavigationLink(destination: RegisterFragment(), isActive: $isLinkREGActive, label: {Text("Register") - .default_text_style_orange_600(styleSize: 20) + 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) - .overlay( - RoundedRectangle(cornerRadius: 60) - .inset(by: 0.5) - .stroke(Color.orangeMain500, lineWidth: 1) - ) .padding(.horizontal, 10) .simultaneousGesture( TapGesture().onEnded { diff --git a/Linphone/UI/Assistant/Fragments/RegisterCodeConfirmationFragment.swift b/Linphone/UI/Assistant/Fragments/RegisterCodeConfirmationFragment.swift new file mode 100644 index 000000000..2a0107edc --- /dev/null +++ b/Linphone/UI/Assistant/Fragments/RegisterCodeConfirmationFragment.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 SwiftUI + +struct RegisterCodeConfirmationFragment: View { + @ObservedObject private var sharedMainViewModel = SharedMainViewModel.shared + @ObservedObject var registerViewModel: RegisterViewModel + + @Environment(\.dismiss) var dismiss + + @StateObject var viewModel = ViewModel() + @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 + 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: viewModel.otp1, focused: viewModel.otpField.isEmpty) + otpText(text: viewModel.otp2, focused: viewModel.otpField.count == 1) + otpText(text: viewModel.otp3, focused: viewModel.otpField.count == 2) + otpText(text: viewModel.otp4, focused: viewModel.otpField.count == 3) + } + + TextField("", text: $viewModel.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: viewModel.otpField) { _ in + limitText(textLimit) + } + } + } + .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) + } + } + .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 viewModel.otpField.count > upper { + viewModel.otpField = String(viewModel.otpField.prefix(upper)) + } + } +} + +class ViewModel: ObservableObject { + + @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]) + } + + @Published var borderColor: Color = .black + var successCompletionHandler: (() -> Void)? + @Published var showResendText = false + +} + +#Preview { + RegisterCodeConfirmationFragment(registerViewModel: RegisterViewModel()) +} diff --git a/Linphone/UI/Assistant/Fragments/RegisterFragment.swift b/Linphone/UI/Assistant/Fragments/RegisterFragment.swift index 5b8178f30..2a9510835 100644 --- a/Linphone/UI/Assistant/Fragments/RegisterFragment.swift +++ b/Linphone/UI/Assistant/Fragments/RegisterFragment.swift @@ -20,51 +20,278 @@ 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 isLinkActive = false + @State private var isShowPopup = false + var body: some View { NavigationView { GeometryReader { geometry in - ScrollView(.vertical) { - VStack { - ZStack { - Image("mountain") - .resizable() - .scaledToFill() - .frame(width: geometry.size.width, height: 100) - .clipped() + 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 : Color.gray200, lineWidth: 1) + ) + .padding(.bottom) + .focused($isNameFocused) + + Text(String(localized: "Phone number")+"*") + .default_text_style_700(styleSize: 15) + .padding(.bottom, -5) + 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() + Menu { + Picker("", selection: $registerViewModel.dialPlanSelected) { + ForEach(Array(registerViewModel.dialPlansLabelList.enumerated()), id: \.offset) { index, dialPlan in + Text(dialPlan).tag(registerViewModel.dialPlansShortLabelList[index]) } } + } label: { + HStack { + Text(registerViewModel.dialPlanSelected) + + 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) + } + .frame(height: 25) + .padding(.horizontal, 20) + .padding(.vertical, 15) + .cornerRadius(60) + .overlay( + RoundedRectangle(cornerRadius: 60) + .inset(by: 0.5) + .stroke(isPhoneNumberFocused ? Color.orangeMain500 : Color.gray200, lineWidth: 1) + ) + .padding(.bottom) + .focused($isPhoneNumberFocused) + + 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) + } else { + TextField("password", text: $registerViewModel.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) + }) + } + .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) + + NavigationLink(isActive: $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(!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(.leading) + .padding(.bottom) } - .frame(width: geometry.size.width) - - Text("Register") - .default_text_style_white_800(styleSize: 20) - .padding(.top, 20) + .frame(maxWidth: sharedMainViewModel.maxWidth) + .padding(.horizontal, 20) } - .padding(.top, 35) - .padding(.bottom, 10) - + .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 + self.isLinkActive = true + } + ) + .background(.black.opacity(0.65)) + .onTapGesture { + self.isShowPopup = false + } + } + } } .navigationTitle("") @@ -77,5 +304,5 @@ struct RegisterFragment: View { } #Preview { - RegisterFragment() + RegisterFragment(registerViewModel: RegisterViewModel()) } diff --git a/Linphone/UI/Assistant/Viewmodel/RegisterViewModel.swift b/Linphone/UI/Assistant/Viewmodel/RegisterViewModel.swift new file mode 100644 index 000000000..bebc94fa4 --- /dev/null +++ b/Linphone/UI/Assistant/Viewmodel/RegisterViewModel.swift @@ -0,0 +1,57 @@ +/* + * 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 RegisterViewModel: ObservableObject { + + private var coreContext = CoreContext.shared + + @Published var username: String = "" + @Published var phoneNumber: String = "" + @Published var passwd: String = "" + @Published var domain: String = "sip.linphone.org" + @Published var displayName: String = "" + @Published var transportType: String = "TLS" + + @Published var dialPlanSelected: String = "🇫🇷 +33" + @Published var dialPlansList: [DialPlan] = [] + @Published var dialPlansLabelList: [String] = [] + @Published var dialPlansShortLabelList: [String] = [] + + init() { + getDialPlansList() + } + + func getDialPlansList() { + coreContext.doOnCoreQueue { core 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)" + ) + } + } + } +} From 02a89a08c39c91b0b97c875ae3a2179ed82f94f0 Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Thu, 20 Jun 2024 16:39:49 +0200 Subject: [PATCH 271/486] Add Account creation feature (Register) Fix unreadMessagesCount when displayedConversation is null Change user agent --- Linphone.xcodeproj/project.pbxproj | 24 +- Linphone/Core/CoreContext.swift | 7 +- Linphone/Localizable.xcstrings | 32 ++ Linphone/Ressources/linphonerc-factory | 3 +- .../RegisterCodeConfirmationFragment.swift | 260 ++++++------ .../Fragments/RegisterFragment.swift | 17 +- .../Viewmodel/RegisterViewModel.swift | 377 +++++++++++++++++- .../ViewModel/ConversationViewModel.swift | 2 +- Linphone/UI/Main/Fragments/ToastView.swift | 21 + 9 files changed, 569 insertions(+), 174 deletions(-) diff --git a/Linphone.xcodeproj/project.pbxproj b/Linphone.xcodeproj/project.pbxproj index 2fc9d6d40..59c53d4f0 100644 --- a/Linphone.xcodeproj/project.pbxproj +++ b/Linphone.xcodeproj/project.pbxproj @@ -1179,7 +1179,6 @@ GCC_PREPROCESSOR_DEFINITIONS = ( "$(inherited)", "DEBUG=1", - "USE_CRASHLYTICS=1", ); GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = msgNotificationService/Info.plist; @@ -1193,7 +1192,7 @@ ); LOCALIZATION_PREFERS_STRING_CATALOGS = YES; MARKETING_VERSION = 1.0; - OTHER_SWIFT_FLAGS = "$(inherited) -DUSE_CRASHLYTICS"; + OTHER_SWIFT_FLAGS = "$(inherited)"; PRODUCT_BUNDLE_IDENTIFIER = org.linphone.phone.msgNotificationService; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -1219,10 +1218,7 @@ DEVELOPMENT_TEAM = Z2V957B3D6; ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; - GCC_PREPROCESSOR_DEFINITIONS = ( - "$(inherited)", - "USE_CRASHLYTICS=1", - ); + GCC_PREPROCESSOR_DEFINITIONS = "$(inherited)"; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = msgNotificationService/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = msgNotificationService; @@ -1235,7 +1231,7 @@ ); LOCALIZATION_PREFERS_STRING_CATALOGS = YES; MARKETING_VERSION = 1.0; - OTHER_SWIFT_FLAGS = "$(inherited) -DUSE_CRASHLYTICS"; + OTHER_SWIFT_FLAGS = "$(inherited)"; PRODUCT_BUNDLE_IDENTIFIER = org.linphone.phone.msgNotificationService; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -1382,7 +1378,6 @@ GCC_PREPROCESSOR_DEFINITIONS = ( "$(inherited)", "DEBUG=1", - "USE_CRASHLYTICS=1", ); GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = Linphone/Info.plist; @@ -1408,8 +1403,8 @@ 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; - OTHER_SWIFT_FLAGS = "$(inherited) -DUSE_CRASHLYTICS"; + MARKETING_VERSION = 6.0.0; + OTHER_SWIFT_FLAGS = "$(inherited)"; PRODUCT_BUNDLE_IDENTIFIER = org.linphone.phone; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; @@ -1435,10 +1430,7 @@ DEVELOPMENT_TEAM = Z2V957B3D6; ENABLE_HARDENED_RUNTIME = YES; ENABLE_PREVIEWS = YES; - GCC_PREPROCESSOR_DEFINITIONS = ( - "$(inherited)", - "USE_CRASHLYTICS=1", - ); + GCC_PREPROCESSOR_DEFINITIONS = "$(inherited)"; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = Linphone/Info.plist; INFOPLIST_KEY_NSCameraUsageDescription = "Camera usage is required for video VOIP calls"; @@ -1463,8 +1455,8 @@ 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; - OTHER_SWIFT_FLAGS = "$(inherited) -DUSE_CRASHLYTICS"; + MARKETING_VERSION = 6.0.0; + OTHER_SWIFT_FLAGS = "$(inherited)"; PRODUCT_BUNDLE_IDENTIFIER = org.linphone.phone; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; diff --git a/Linphone/Core/CoreContext.swift b/Linphone/Core/CoreContext.swift index c413881fb..7ae9798a6 100644 --- a/Linphone/Core/CoreContext.swift +++ b/Linphone/Core/CoreContext.swift @@ -114,8 +114,11 @@ final class CoreContext: ObservableObject { self.mCore.autoIterateEnabled = false self.mCore.callkitEnabled = true self.mCore.pushNotificationEnabled = true - - self.mCore.setUserAgent(name: "Linphone iOS 6.0 Beta (\(UIDevice.current.localizedModel)) - Linphone SDK : \(self.coreVersion)", version: "6.0") + + 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 diff --git a/Linphone/Localizable.xcstrings b/Linphone/Localizable.xcstrings index 827ba45ed..90003759d 100644 --- a/Linphone/Localizable.xcstrings +++ b/Linphone/Localizable.xcstrings @@ -290,6 +290,38 @@ } } }, + "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" : { diff --git a/Linphone/Ressources/linphonerc-factory b/Linphone/Ressources/linphonerc-factory index eb87047ef..cc44698e3 100644 --- a/Linphone/Ressources/linphonerc-factory +++ b/Linphone/Ressources/linphonerc-factory @@ -45,8 +45,7 @@ store_friends=0 record_aware=1 [account_creator] -url=https://flexisip-staging-master.linphone.org/login # For testing -# url=https://subscribe.linphone.org/api/ +url=https://subscribe.linphone.org/api/ [lime] lime_update_threshold=86400 diff --git a/Linphone/UI/Assistant/Fragments/RegisterCodeConfirmationFragment.swift b/Linphone/UI/Assistant/Fragments/RegisterCodeConfirmationFragment.swift index 2a0107edc..ff128d410 100644 --- a/Linphone/UI/Assistant/Fragments/RegisterCodeConfirmationFragment.swift +++ b/Linphone/UI/Assistant/Fragments/RegisterCodeConfirmationFragment.swift @@ -25,7 +25,6 @@ struct RegisterCodeConfirmationFragment: View { @Environment(\.dismiss) var dismiss - @StateObject var viewModel = ViewModel() @FocusState var isFocused: Bool let textLimit = 4 @@ -40,116 +39,129 @@ struct RegisterCodeConfirmationFragment: View { 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() + 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() + + Spacer() + } + .padding(.leading) } - .padding(.leading) + .frame(width: geometry.size.width) + + Text("assistant_account_register") + .default_text_style_white_800(styleSize: 20) + .padding(.top, 20) } - .frame(width: geometry.size.width) + .padding(.top, 35) + .padding(.bottom, 10) - 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) - + ZStack { VStack { - ZStack { - - HStack(spacing: spaceBetweenBoxes) { - otpText(text: viewModel.otp1, focused: viewModel.otpField.isEmpty) - otpText(text: viewModel.otp2, focused: viewModel.otpField.count == 1) - otpText(text: viewModel.otp3, focused: viewModel.otpField.count == 2) - otpText(text: viewModel.otp4, focused: viewModel.otpField.count == 3) - } - - TextField("", text: $viewModel.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: viewModel.otpField) { _ in - limitText(textLimit) - } + Spacer() + HStack { + Spacer() + Image("confirm_sms_code_illu") + .padding(.bottom, -geometry.safeAreaInsets.bottom) } } - .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() + 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(maxWidth: sharedMainViewModel.maxWidth) - .padding(.horizontal, 20) + } + .frame(minHeight: geometry.size.height) + .onAppear { + registerViewModel.otpField = "" } } - .frame(minHeight: geometry.size.height) + + if registerViewModel.createInProgress { + PopupLoadingView() + .background(.black.opacity(0.65)) + } } } .navigationTitle("") @@ -175,54 +187,12 @@ struct RegisterCodeConfirmationFragment: View { } func limitText(_ upper: Int) { - if viewModel.otpField.count > upper { - viewModel.otpField = String(viewModel.otpField.prefix(upper)) + if registerViewModel.otpField.count > upper { + registerViewModel.otpField = String(registerViewModel.otpField.prefix(upper)) } } } -class ViewModel: ObservableObject { - - @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]) - } - - @Published var borderColor: Color = .black - var successCompletionHandler: (() -> Void)? - @Published var showResendText = false - -} - #Preview { RegisterCodeConfirmationFragment(registerViewModel: RegisterViewModel()) } diff --git a/Linphone/UI/Assistant/Fragments/RegisterFragment.swift b/Linphone/UI/Assistant/Fragments/RegisterFragment.swift index 2a9510835..45307c16b 100644 --- a/Linphone/UI/Assistant/Fragments/RegisterFragment.swift +++ b/Linphone/UI/Assistant/Fragments/RegisterFragment.swift @@ -31,7 +31,6 @@ struct RegisterFragment: View { @FocusState var isPhoneNumberFocused: Bool @FocusState var isPasswordFocused: Bool - @State private var isLinkActive = false @State private var isShowPopup = false var body: some View { @@ -103,14 +102,14 @@ struct RegisterFragment: View { HStack { Menu { - Picker("", selection: $registerViewModel.dialPlanSelected) { + Picker("", selection: $registerViewModel.dialPlanValueSelected) { ForEach(Array(registerViewModel.dialPlansLabelList.enumerated()), id: \.offset) { index, dialPlan in Text(dialPlan).tag(registerViewModel.dialPlansShortLabelList[index]) } } } label: { HStack { - Text(registerViewModel.dialPlanSelected) + Text(registerViewModel.dialPlanValueSelected) Image("caret-down") .renderingMode(.template) @@ -183,7 +182,7 @@ struct RegisterFragment: View { ) .padding(.bottom) - NavigationLink(isActive: $isLinkActive, destination: { + NavigationLink(isActive: $registerViewModel.isLinkActive, destination: { RegisterCodeConfirmationFragment(registerViewModel: registerViewModel) }, label: { Text("assistant_account_create") @@ -196,7 +195,7 @@ struct RegisterFragment: View { .padding(.vertical, 10) .background((registerViewModel.username.isEmpty || registerViewModel.phoneNumber.isEmpty || registerViewModel.passwd.isEmpty) ? Color.orangeMain100 : Color.orangeMain500) .cornerRadius(60) - .disabled(!isLinkActive) + .disabled(!registerViewModel.isLinkActive) .padding(.bottom) .simultaneousGesture( TapGesture().onEnded { @@ -283,7 +282,9 @@ struct RegisterFragment: View { titleSecondButton: Text("Continue"), actionSecondButton: { self.isShowPopup = false - self.isLinkActive = true + registerViewModel.createInProgress = true + registerViewModel.startAccountCreation() + registerViewModel.phoneNumberConfirmedByUser() } ) .background(.black.opacity(0.65)) @@ -292,6 +293,10 @@ struct RegisterFragment: View { } } + if registerViewModel.createInProgress { + PopupLoadingView() + .background(.black.opacity(0.65)) + } } } .navigationTitle("") diff --git a/Linphone/UI/Assistant/Viewmodel/RegisterViewModel.swift b/Linphone/UI/Assistant/Viewmodel/RegisterViewModel.swift index bebc94fa4..45e387c3b 100644 --- a/Linphone/UI/Assistant/Viewmodel/RegisterViewModel.swift +++ b/Linphone/UI/Assistant/Viewmodel/RegisterViewModel.swift @@ -19,29 +19,174 @@ import Foundation import linphonesw +import Combine 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 dialPlanSelected: String = "🇫🇷 +33" + @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 accountCreationToken: String? + private var accountCreatedAuthInfo: AuthInfo? + private var accountCreated: Account? + private var normalizedPhoneNumber: String? + + private var accountManagerServicesSuscriptions = Set() + private var mCoreSuscriptions = Set() + + @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() + } + + func addDelegate(core: Core) { + self.accountManagerServicesSuscriptions.insert(self.accountManagerServices!.publisher?.onRequestSuccessful?.postOnCoreQueue { + (ams: AccountManagerServices, request: AccountManagerServices.Request, data: String) in + Log.info("\(RegisterViewModel.TAG) Request \(request) was successful, data is \(data)") + switch request { + case AccountManagerServices.Request.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 AccountManagerServices.Request.SendPhoneNumberLinkingCodeBySms: + DispatchQueue.main.async { + self.createInProgress = false + self.isLinkActive = true + } + + case AccountManagerServices.Request.LinkPhoneNumberUsingCode: + let account = self.accountCreated + if account != nil { + Log.info( + "\(RegisterViewModel.TAG) Account \(account?.params?.identityAddress?.asStringUriOnly()) 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 + } catch { + } + } + + default: break + } + }) + + self.accountManagerServicesSuscriptions.insert(self.accountManagerServices!.publisher?.onRequestError?.postOnCoreQueue { + (ams: AccountManagerServices, request: AccountManagerServices.Request, 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 + } + } + + switch request { + case AccountManagerServices.Request.SendAccountCreationTokenByPush: + Log.warn("\(RegisterViewModel.TAG) Cancelling job waiting for push notification") + default: break + } + + DispatchQueue.main.async { + self.createInProgress = false + } + }) + + 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 getDialPlansList() { - coreContext.doOnCoreQueue { core in + coreContext.doOnCoreQueue { _ in let dialPlans = Factory.Instance.dialPlans dialPlans.forEach { dialPlan in self.dialPlansList.append(dialPlan) @@ -54,4 +199,232 @@ class RegisterViewModel: ObservableObject { } } } + + func getAccountCreationToken() { + coreContext.doOnCoreQueue { core in + do { + self.accountManagerServices = try core.createAccountManagerServices() + if self.accountManagerServices != nil { + self.accountManagerServices!.language = Locale.current.identifier + self.addDelegate(core: core) + } + } 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 { + pushConfig!.provider = "apns.dev" + 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() + self.accountManagerServices!.requestAccountCreationTokenByPush(pnProvider: pushConfig?.provider ?? "", pnParam: pushConfig?.param ?? "", pnPrid: pushConfig?.prid ?? "") + } 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 ?? "")" + ) + + accountManagerServices!.requestPhoneNumberLinkingCodeBySms( + sipIdentity: identity!, + phoneNumber: phoneNumberValue! + ) + } + } + } + + 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 { + try accountManagerServices!.createAccountUsingToken( + username: username, + password: passwd, + algorithm: HASHALGORITHM, + token: token! + ) + } catch { + Log.info( + "\(RegisterViewModel.TAG) Can't create account using token" + ) + } + } + } + + func phoneNumberConfirmedByUser() { + coreContext.doOnCoreQueue { core in + if self.accountManagerServices != nil { + var dialPlan: DialPlan? + + self.dialPlansList.forEach { dial in + let countryCode = self.dialPlanValueSelected.components(separatedBy: "+") + Log.info("dialPlansListdialPlansList \(dial.countryCallingCode) \(countryCode[1])") + if dial.countryCallingCode == countryCode[1] { + dialPlan = dial + } + } + 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())" + ) + accountManagerServices!.linkPhoneNumberToAccountUsingCode(sipIdentity: identity!, code: code) + } + } + } } diff --git a/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift b/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift index 3cc07eec6..8e416ccbe 100644 --- a/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift +++ b/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift @@ -400,7 +400,7 @@ class ConversationViewModel: ObservableObject { let isFirstMessageTmp = (eventLog.chatMessage?.isOutgoing ?? false) ? isFirstMessageOutgoingTmp : isFirstMessageIncomingTmp - let unreadMessagesCount = self.displayedConversation!.chatRoom.unreadMessagesCount + let unreadMessagesCount = self.displayedConversation != nil ? self.displayedConversation!.chatRoom.unreadMessagesCount : 0 var statusTmp: Message.Status? = .sending switch eventLog.chatMessage?.state { diff --git a/Linphone/UI/Main/Fragments/ToastView.swift b/Linphone/UI/Main/Fragments/ToastView.swift index b68c15604..6babc0fe0 100644 --- a/Linphone/UI/Main/Fragments/ToastView.swift +++ b/Linphone/UI/Main/Fragments/ToastView.swift @@ -171,6 +171,27 @@ struct ToastView: View { .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) default: Text("Error") From 9befe0695a42c6df1a6f655b20d505982e3bd371 Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Mon, 24 Jun 2024 09:46:29 +0200 Subject: [PATCH 272/486] Add callbacks to each request for AccountManagerServices --- .../Viewmodel/RegisterViewModel.swift | 223 ++++++++++-------- 1 file changed, 126 insertions(+), 97 deletions(-) diff --git a/Linphone/UI/Assistant/Viewmodel/RegisterViewModel.swift b/Linphone/UI/Assistant/Viewmodel/RegisterViewModel.swift index 45e387c3b..e2657deea 100644 --- a/Linphone/UI/Assistant/Viewmodel/RegisterViewModel.swift +++ b/Linphone/UI/Assistant/Viewmodel/RegisterViewModel.swift @@ -46,6 +46,7 @@ class RegisterViewModel: ObservableObject { 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? @@ -94,78 +95,6 @@ class RegisterViewModel: ObservableObject { init() { getDialPlansList() getAccountCreationToken() - } - - func addDelegate(core: Core) { - self.accountManagerServicesSuscriptions.insert(self.accountManagerServices!.publisher?.onRequestSuccessful?.postOnCoreQueue { - (ams: AccountManagerServices, request: AccountManagerServices.Request, data: String) in - Log.info("\(RegisterViewModel.TAG) Request \(request) was successful, data is \(data)") - switch request { - case AccountManagerServices.Request.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 AccountManagerServices.Request.SendPhoneNumberLinkingCodeBySms: - DispatchQueue.main.async { - self.createInProgress = false - self.isLinkActive = true - } - - case AccountManagerServices.Request.LinkPhoneNumberUsingCode: - let account = self.accountCreated - if account != nil { - Log.info( - "\(RegisterViewModel.TAG) Account \(account?.params?.identityAddress?.asStringUriOnly()) 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 - } catch { - } - } - - default: break - } - }) - - self.accountManagerServicesSuscriptions.insert(self.accountManagerServices!.publisher?.onRequestError?.postOnCoreQueue { - (ams: AccountManagerServices, request: AccountManagerServices.Request, 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 - } - } - - switch request { - case AccountManagerServices.Request.SendAccountCreationTokenByPush: - Log.warn("\(RegisterViewModel.TAG) Cancelling job waiting for push notification") - default: break - } - - DispatchQueue.main.async { - self.createInProgress = false - } - }) NotificationCenter.default.addObserver(forName: accountTokenNotification, object: nil, queue: nil) { notification in if !(self.username.isEmpty || self.passwd.isEmpty) { @@ -184,6 +113,80 @@ class RegisterViewModel: ObservableObject { } } } + func addDelegate(request: AccountManagerServicesRequest) { + coreContext.doOnCoreQueue { core in + self.accountManagerServicesSuscriptions.insert(request.publisher?.onRequestSuccessful?.postOnCoreQueue { + (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()) 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 + self.accountManagerServicesSuscriptions.removeAll() + } catch { + } + } + + default: break + } + }) + + self.accountManagerServicesSuscriptions.insert(request.publisher?.onRequestError?.postOnCoreQueue { + (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 + } + } + + switch request.type { + case .SendAccountCreationTokenByPush: + Log.warn("\(RegisterViewModel.TAG) Cancelling job waiting for push notification") + default: break + } + + DispatchQueue.main.async { + self.createInProgress = false + } + }) + } + } func getDialPlansList() { coreContext.doOnCoreQueue { _ in @@ -206,7 +209,6 @@ class RegisterViewModel: ObservableObject { self.accountManagerServices = try core.createAccountManagerServices() if self.accountManagerServices != nil { self.accountManagerServices!.language = Locale.current.identifier - self.addDelegate(core: core) } } catch { @@ -303,7 +305,17 @@ class RegisterViewModel: ObservableObject { if coreRemoteToken != nil { formatedRemoteToken = String(coreRemoteToken!.prefix(64)) pushConfig!.prid = formatedRemoteToken.uppercased() - self.accountManagerServices!.requestAccountCreationTokenByPush(pnProvider: pushConfig?.provider ?? "", pnParam: pushConfig?.param ?? "", pnPrid: pushConfig?.prid ?? "") + 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") } @@ -338,11 +350,19 @@ class RegisterViewModel: ObservableObject { Log.info( "\(RegisterViewModel.TAG) Account \(identity!.asStringUriOnly()) should now be created, asking account manager to send a confirmation code by SMS to \(phoneNumberValue ?? "")" ) - - accountManagerServices!.requestPhoneNumberLinkingCodeBySms( - sipIdentity: identity!, - phoneNumber: 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") + } } } } @@ -365,33 +385,33 @@ class RegisterViewModel: ObservableObject { ) do { - try accountManagerServices!.createAccountUsingToken( + let request = try accountManagerServices!.createNewAccountUsingTokenRequest( username: username, password: passwd, algorithm: HASHALGORITHM, token: token! ) + self.addDelegate(request: request) + request.submit() } catch { - Log.info( - "\(RegisterViewModel.TAG) Can't create account using token" - ) + Log.error("\(RegisterViewModel.TAG) Can't create account using token") } } } func phoneNumberConfirmedByUser() { - coreContext.doOnCoreQueue { core in + coreContext.doOnCoreQueue { _ in if self.accountManagerServices != nil { var dialPlan: DialPlan? - self.dialPlansList.forEach { dial in + for dial in self.dialPlansList { let countryCode = self.dialPlanValueSelected.components(separatedBy: "+") - Log.info("dialPlansListdialPlansList \(dial.countryCallingCode) \(countryCode[1])") if dial.countryCallingCode == countryCode[1] { dialPlan = dial + break } } - if (dialPlan == nil) { + if dialPlan == nil { Log.error("\(RegisterViewModel.TAG) No dial plan (country) selected!") } @@ -414,17 +434,26 @@ class RegisterViewModel: ObservableObject { } 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())" - ) - accountManagerServices!.linkPhoneNumberToAccountUsingCode(sipIdentity: identity!, code: code) + 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") } } } + } } From b5d98cc45aacd74b7ded7bbd20457fb81b8a2b9c Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Tue, 25 Jun 2024 14:55:16 +0200 Subject: [PATCH 273/486] Start group call --- Linphone.xcodeproj/project.pbxproj | 4 + Linphone/Localizable.xcstrings | 81 +++ .../Fragments/ConversationFragment.swift | 6 - Linphone/UI/Main/Fragments/ToastView.swift | 10 +- .../History/Fragments/StartCallFragment.swift | 474 +++++++++++------- .../Fragments/StartGroupCallFragment.swift | 24 + .../ViewModel/StartCallViewModel.swift | 133 ++++- .../Viewmodel/AddParticipantsViewModel.swift | 4 +- .../Extensions/UIApplicationExtension.swift | 4 + 9 files changed, 557 insertions(+), 183 deletions(-) create mode 100644 Linphone/UI/Main/History/Fragments/StartGroupCallFragment.swift diff --git a/Linphone.xcodeproj/project.pbxproj b/Linphone.xcodeproj/project.pbxproj index 59c53d4f0..e33ed4f0c 100644 --- a/Linphone.xcodeproj/project.pbxproj +++ b/Linphone.xcodeproj/project.pbxproj @@ -59,6 +59,7 @@ 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 */; }; @@ -233,6 +234,7 @@ 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 = ""; }; @@ -624,6 +626,7 @@ D732A91A2B061BD900DB42BA /* HistoryListBottomSheet.swift */, D726E43C2B19E4FE0083C415 /* StartCallFragment.swift */, D79622332B1DFE600037EACD /* DialerBottomSheet.swift */, + D71556352C297DB1009A8CEF /* StartGroupCallFragment.swift */, ); path = Fragments; sourceTree = ""; @@ -1051,6 +1054,7 @@ 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 */, diff --git a/Linphone/Localizable.xcstrings b/Linphone/Localizable.xcstrings index 90003759d..c61bc684d 100644 --- a/Linphone/Localizable.xcstrings +++ b/Linphone/Localizable.xcstrings @@ -461,12 +461,45 @@ }, "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" : { @@ -758,6 +791,54 @@ }, "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_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" + } + } + } }, "I prefere create an account" : { diff --git a/Linphone/UI/Main/Conversations/Fragments/ConversationFragment.swift b/Linphone/UI/Main/Conversations/Fragments/ConversationFragment.swift index ec8fe4443..1180f7a6a 100644 --- a/Linphone/UI/Main/Conversations/Fragments/ConversationFragment.swift +++ b/Linphone/UI/Main/Conversations/Fragments/ConversationFragment.swift @@ -570,12 +570,6 @@ struct ScrollOffsetPreferenceKey: PreferenceKey { } } -extension UIApplication { - func endEditing() { - sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil) - } -} - struct ImagePicker: UIViewControllerRepresentable { @ObservedObject var conversationViewModel: ConversationViewModel @Binding var selectedMedia: [Attachment] diff --git a/Linphone/UI/Main/Fragments/ToastView.swift b/Linphone/UI/Main/Fragments/ToastView.swift index 6babc0fe0..446279c13 100644 --- a/Linphone/UI/Main/Fragments/ToastView.swift +++ b/Linphone/UI/Main/Fragments/ToastView.swift @@ -192,7 +192,15 @@ struct ToastView: View { .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) + default: Text("Error") .multilineTextAlignment(.center) diff --git a/Linphone/UI/Main/History/Fragments/StartCallFragment.swift b/Linphone/UI/Main/History/Fragments/StartCallFragment.swift index d6c465356..4e0589afb 100644 --- a/Linphone/UI/Main/History/Fragments/StartCallFragment.swift +++ b/Linphone/UI/Main/History/Fragments/StartCallFragment.swift @@ -22,6 +22,8 @@ import linphonesw 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 @@ -35,213 +37,271 @@ struct StartCallFragment: View { @FocusState var isSearchFieldFocused: Bool @State private var delayedColor = Color.white + @FocusState var isMessageTextFocused: Bool + var resetCallView: () -> Void 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 { - DispatchQueue.global().asyncAfter(deadline: .now() + 0.2) { - magicSearch.searchForContacts( - sourceFlags: MagicSearch.Source.Friends.rawValue | MagicSearch.Source.LdapServers.rawValue) - - if callViewModel.isTransferInsteadCall == true { - callViewModel.isTransferInsteadCall = false + 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.global().asyncAfter(deadline: .now() + 0.2) { + magicSearch.searchForContacts( + sourceFlags: MagicSearch.Source.Friends.rawValue | MagicSearch.Source.LdapServers.rawValue) + + if callViewModel.isTransferInsteadCall == true { + callViewModel.isTransferInsteadCall = false + } + + resetCallView() } - resetCallView() + startCallViewModel.searchField = "" + magicSearch.currentFilterSuggestions = "" + delayColorDismiss() + withAnimation { + isShowStartCallFragment.toggle() + } } + + Text(!callViewModel.isTransferInsteadCall ? "New call" : "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("Search contact or history call", 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 + }) - startCallViewModel.searchField = "" - magicSearch.currentFilterSuggestions = "" - delayColorDismiss() - withAnimation { - isShowStartCallFragment.toggle() + 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.global().asyncAfter(deadline: .now() + 0.2) { + showingDialer = true + } + } else { + showingDialer = false + + DispatchQueue.global().asyncAfter(deadline: .now() + 0.2) { + 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) + }) + } } } - - Text(!callViewModel.isTransferInsteadCall ? "New call" : "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("Search contact or history call", 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 - }) + .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) - HStack { - Button(action: { - }, label: { - Image("magnifying-glass") + 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) - }) + .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) + } - Spacer() - - if startCallViewModel.searchField.isEmpty { - Button(action: { - if !showingDialer { - isSearchFieldFocused = false + ContactsListFragment(contactViewModel: ContactViewModel(), contactsListViewModel: ContactsListViewModel(), showingSheet: .constant(false), startCallFunc: { addr in + if callViewModel.isTransferInsteadCall { + showingDialer = false + + DispatchQueue.global().asyncAfter(deadline: .now() + 0.2) { + magicSearch.searchForContacts( + sourceFlags: MagicSearch.Source.Friends.rawValue | MagicSearch.Source.LdapServers.rawValue) - DispatchQueue.global().asyncAfter(deadline: .now() + 0.2) { - showingDialer = true + if callViewModel.isTransferInsteadCall == true { + callViewModel.isTransferInsteadCall = false } - } else { - showingDialer = false - DispatchQueue.global().asyncAfter(deadline: .now() + 0.2) { - isSearchFieldFocused = true - } + resetCallView() } - }, 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) - - ScrollView { - if !ContactsManager.shared.lastSearch.isEmpty { + delayColorDismiss() + + withAnimation { + isShowStartCallFragment.toggle() + callViewModel.blindTransferCallTo(toAddress: addr) + } + } else { + showingDialer = false + + DispatchQueue.global().asyncAfter(deadline: .now() + 0.2) { + 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) + HStack(alignment: .center) { - Text("All contacts") + Text("Suggestions") .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.global().asyncAfter(deadline: .now() + 0.2) { - 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.global().asyncAfter(deadline: .now() + 0.2) { - 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) - - HStack(alignment: .center) { - Text("Suggestions") - .default_text_style_800(styleSize: 16) - Spacer() + suggestionsList } - .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() + } } - .frame(maxWidth: .infinity) } - .background(.white) + .navigationBarHidden(true) } - .navigationBarHidden(true) } @Sendable private func delayColor() async { @@ -343,6 +403,72 @@ struct StartCallFragment: View { .listRowSeparator(.hidden) } } + + var startCallPopup: some View { + GeometryReader { geometry in + VStack(alignment: .leading) { + Text("history_group_call_start_dialog_set_subject") + .default_text_style_800(styleSize: 16) + .frame(alignment: .leading) + .padding(.bottom, 2) + + TextField("history_group_call_start_dialog_subject_hint", text: $startCallViewModel.messageText) + .default_text_style(styleSize: 15) + .frame(height: 25) + .padding(.horizontal, 20) + .padding(.vertical, 15) + .cornerRadius(60) + .overlay( + RoundedRectangle(cornerRadius: 60) + .inset(by: 0.5) + .stroke(isMessageTextFocused ? Color.orangeMain500 : Color.gray200, lineWidth: 1) + ) + .padding(.bottom) + .focused($isMessageTextFocused) + + Button(action: { + startCallViewModel.participants.removeAll() + }, label: { + Text("Cancel") + .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) + + Button(action: { + startCallViewModel.createGroupCall() + }, label: { + Text("Confirm") + .default_text_style_white_600(styleSize: 20) + .frame(height: 35) + .frame(maxWidth: .infinity) + }) + .padding(.horizontal, 20) + .padding(.vertical, 10) + .background(startCallViewModel.messageText.isEmpty ? Color.orangeMain100 : Color.orangeMain500) + .cornerRadius(60) + .disabled(startCallViewModel.messageText.isEmpty) + } + .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 { diff --git a/Linphone/UI/Main/History/Fragments/StartGroupCallFragment.swift b/Linphone/UI/Main/History/Fragments/StartGroupCallFragment.swift new file mode 100644 index 000000000..6e254127f --- /dev/null +++ b/Linphone/UI/Main/History/Fragments/StartGroupCallFragment.swift @@ -0,0 +1,24 @@ +// +// StartGroupCallFragment.swift +// Linphone +// +// Created by Benoît Martins on 24/06/2024. +// + +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/ViewModel/StartCallViewModel.swift b/Linphone/UI/Main/History/ViewModel/StartCallViewModel.swift index 0a41a9141..353633dd1 100644 --- a/Linphone/UI/Main/History/ViewModel/StartCallViewModel.swift +++ b/Linphone/UI/Main/History/ViewModel/StartCallViewModel.swift @@ -18,16 +18,147 @@ */ import linphonesw +import Combine 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 conferenceSuscriptions = Set() + init() { - CoreContext.shared.doOnCoreQueue { core in + 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("\(ScheduleMeetingViewModel.TAG) Participant \(found.address.asStringUriOnly()) already in list, skipping") + continue + } + + list.append(selectedAddr) + Log.info("\(ScheduleMeetingViewModel.TAG) Added participant \(selectedAddr.address.asStringUriOnly())") + } + Log.info("\(ScheduleMeetingViewModel.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)" + ) + } + } + + self.participants.removeAll() + + conferenceInfo.addParticipantInfos(participantInfos: participantsList) + + Log.info( + "\(StartCallViewModel.TAG) Creating group call with subject \(self.messageText) and \(participantsList.count) participant(s)" + ) + + let conferenceScheduler = try core.createConferenceScheduler() + self.conferenceAddDelegate(core: core, conferenceScheduler: conferenceScheduler) + conferenceScheduler.account = account + // Will trigger the conference creation/update automatically + conferenceScheduler.info = conferenceInfo + } catch let error { + Log.error( + "\(StartCallViewModel.TAG) createGroupCall: \(error)" + ) + } + } + } + + func conferenceAddDelegate(core: Core, conferenceScheduler: ConferenceScheduler) { + self.conferenceSuscriptions.insert(conferenceScheduler.publisher?.onStateChanged?.postOnCoreQueue { + (conferenceScheduler: ConferenceScheduler, state: ConferenceScheduler.State) in + Log.info("\(StartCallViewModel.TAG) Conference scheduler state is \(state)") + if state == ConferenceScheduler.State.Ready { + self.conferenceSuscriptions.removeAll() + + 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 { + self.conferenceSuscriptions.removeAll() + 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 + } + } + }) + } + + func startVideoCall(core: Core, conferenceAddress: Address) { + do { + TelecomManager.shared.doCallWithCore(addr: conferenceAddress, isVideo: true, isConference: true) + } catch let error { + Log.error( + "\(StartCallViewModel.TAG) StartVideoCall: \(error)" + ) + } + } } diff --git a/Linphone/UI/Main/Viewmodel/AddParticipantsViewModel.swift b/Linphone/UI/Main/Viewmodel/AddParticipantsViewModel.swift index 616692b07..3db4b0bc7 100644 --- a/Linphone/UI/Main/Viewmodel/AddParticipantsViewModel.swift +++ b/Linphone/UI/Main/Viewmodel/AddParticipantsViewModel.swift @@ -32,7 +32,9 @@ class AddParticipantsViewModel: ObservableObject { } else { Log.info("[\(AddParticipantsViewModel.TAG)] Adding participant \(addr.asStringUriOnly()) to selection") ContactAvatarModel.getAvatarModelFromAddress(address: addr) { avatarResult in - self.participantsToAdd.append(SelectedAddressModel(addr: addr, avModel: avatarResult)) + DispatchQueue.main.async { + self.participantsToAdd.append(SelectedAddressModel(addr: addr, avModel: avatarResult)) + } } } } diff --git a/Linphone/Utils/Extensions/UIApplicationExtension.swift b/Linphone/Utils/Extensions/UIApplicationExtension.swift index 09f1d5d54..6ed48ae78 100644 --- a/Linphone/Utils/Extensions/UIApplicationExtension.swift +++ b/Linphone/Utils/Extensions/UIApplicationExtension.swift @@ -35,4 +35,8 @@ extension UIApplication { } return nil } + + func endEditing() { + sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil) + } } From 2b441f35575ac84a7f86d0458d563c83d1aa32d6 Mon Sep 17 00:00:00 2001 From: QuentinArguillere Date: Tue, 21 May 2024 15:30:28 +0200 Subject: [PATCH 274/486] Upgrade MeetingListsItemModel to have a preformatted month and week string to be displayed in the meetings list view --- .../Main/Meetings/Models/MeetingModel.swift | 2 - .../Models/MeetingsListItemModel.swift | 43 ++++++++++++++++++- .../ViewModel/MeetingsListViewModel.swift | 23 ++++++++++ 3 files changed, 64 insertions(+), 4 deletions(-) diff --git a/Linphone/UI/Main/Meetings/Models/MeetingModel.swift b/Linphone/UI/Main/Meetings/Models/MeetingModel.swift index 8531d0f1e..fd7abac7a 100644 --- a/Linphone/UI/Main/Meetings/Models/MeetingModel.swift +++ b/Linphone/UI/Main/Meetings/Models/MeetingModel.swift @@ -20,7 +20,6 @@ class MeetingModel: ObservableObject { var time: String // "$startTime - $endTime" var day: String var dayNumber: String - var month: String @Published var isBroadcast: Bool @Published var subject: String @@ -41,7 +40,6 @@ class MeetingModel: ObservableObject { day = meetingDate.formatted(Date.FormatStyle().weekday(.abbreviated)) dayNumber = meetingDate.formatted(Date.FormatStyle().day(.twoDigits)) - month = meetingDate.formatted(Date.FormatStyle().month(.wide)) // February isToday = Calendar.current.isDateInToday(meetingDate) if isToday { diff --git a/Linphone/UI/Main/Meetings/Models/MeetingsListItemModel.swift b/Linphone/UI/Main/Meetings/Models/MeetingsListItemModel.swift index b56bc3291..c73e4574a 100644 --- a/Linphone/UI/Main/Meetings/Models/MeetingsListItemModel.swift +++ b/Linphone/UI/Main/Meetings/Models/MeetingsListItemModel.swift @@ -6,16 +6,55 @@ // 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 month: String = Date.now.formatted(Date.FormatStyle().month(.wide)) + var monthStr: String = "" + var weekStr: String = "" var isToday = true init(meetingModel: MeetingModel?) { model = meetingModel if let mod = meetingModel { - month = mod.month + monthStr = createMonthString(date: mod.meetingDate) + weekStr = createWeekString(date: mod.meetingDate) isToday = false + } else { + monthStr = createMonthString(date: Date.now) + weekStr = createWeekString(date: Date.now) + } + } + + func createMonthString(date: Date) -> String { + return "\(date.formatted(Date.FormatStyle().month(.wide))) \(date.formatted(Date.FormatStyle().year()))" + } + + 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)" } } } diff --git a/Linphone/UI/Main/Meetings/ViewModel/MeetingsListViewModel.swift b/Linphone/UI/Main/Meetings/ViewModel/MeetingsListViewModel.swift index 9e85634a6..cbf295431 100644 --- a/Linphone/UI/Main/Meetings/ViewModel/MeetingsListViewModel.swift +++ b/Linphone/UI/Main/Meetings/ViewModel/MeetingsListViewModel.swift @@ -28,6 +28,7 @@ class MeetingsListViewModel: ObservableObject { private var mCoreSuscriptions = Set() var selectedMeeting: ConversationModel? + @Published var sortedMeetingsList: [String: [String: [MeetingsListItemModel]]] = [:] @Published var meetingsList: [MeetingsListItemModel] = [] @Published var currentFilter = "" @@ -105,7 +106,29 @@ class MeetingsListViewModel: ObservableObject { DispatchQueue.main.sync { self.meetingsList = meetingsListTmp + self.sortMeetingsListByWeek() } } } + + func sortMeetingsListByWeek() { + var sortedList: [String: [String: [MeetingsListItemModel]]] = [:] + + var currentMonthString = "" + var currentWeekString = "" + for meeting in self.meetingsList { + + if currentMonthString != meeting.monthStr { + sortedList[currentMonthString] = [:] + currentMonthString = meeting.monthStr + } + + if currentWeekString != meeting.weekStr { + sortedList[currentMonthString]?[currentWeekString] = [] + currentWeekString = meeting.weekStr + } + sortedList[currentMonthString]?[currentWeekString]?.append(meeting) + } + self.sortedMeetingsList = sortedList + } } From dbba06593352525f275b59e078d790d77e06d70d Mon Sep 17 00:00:00 2001 From: QuentinArguillere Date: Tue, 28 May 2024 11:59:51 +0200 Subject: [PATCH 275/486] Add more formated date info in MeetingsListItemModel, and switch back to regular sorted array for conference data mangemenet --- .../Models/MeetingsListItemModel.swift | 19 +++++- .../ViewModel/MeetingsListViewModel.swift | 61 ++++--------------- 2 files changed, 30 insertions(+), 50 deletions(-) diff --git a/Linphone/UI/Main/Meetings/Models/MeetingsListItemModel.swift b/Linphone/UI/Main/Meetings/Models/MeetingsListItemModel.swift index c73e4574a..38ca8c90f 100644 --- a/Linphone/UI/Main/Meetings/Models/MeetingsListItemModel.swift +++ b/Linphone/UI/Main/Meetings/Models/MeetingsListItemModel.swift @@ -20,6 +20,9 @@ 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?) { @@ -27,15 +30,21 @@ class MeetingsListItemModel { if let mod = meetingModel { monthStr = createMonthString(date: mod.meetingDate) weekStr = createWeekString(date: mod.meetingDate) + weekDayStr = createWeekDayString(date: mod.meetingDate) + dayStr = createDayString(date: mod.meetingDate) + Log.info("debugtrace -- create new item model : \(monthStr) - \(weekStr) - \(weekDayStr) - \(dayStr)") isToday = false } else { + Log.info("debugtrace -- create new item model : TODAY") 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()))" + return "\(date.formatted(Date.FormatStyle().month(.wide))) \(date.formatted(Date.FormatStyle().year()))".capitalized } func createWeekString(date: Date) -> String { @@ -57,4 +66,12 @@ class MeetingsListItemModel { 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/MeetingsListViewModel.swift b/Linphone/UI/Main/Meetings/ViewModel/MeetingsListViewModel.swift index cbf295431..92ddd1067 100644 --- a/Linphone/UI/Main/Meetings/ViewModel/MeetingsListViewModel.swift +++ b/Linphone/UI/Main/Meetings/ViewModel/MeetingsListViewModel.swift @@ -28,7 +28,6 @@ class MeetingsListViewModel: ObservableObject { private var mCoreSuscriptions = Set() var selectedMeeting: ConversationModel? - @Published var sortedMeetingsList: [String: [String: [MeetingsListItemModel]]] = [:] @Published var meetingsList: [MeetingsListItemModel] = [] @Published var currentFilter = "" @@ -57,8 +56,8 @@ class MeetingsListViewModel: ObservableObject { var meetingsListTmp: [MeetingsListItemModel] = [] var previousModel: MeetingModel? - // var meetingForTodayFound = false - + var meetingForTodayFound = false + Log.info("debugtrace -- computeMeetingsList, \(confInfoList.count) conferences found") for confInfo in confInfoList { if confInfo.duration == 0 { continue }// This isn't a scheduled conference, don't display it var add = true @@ -73,62 +72,26 @@ class MeetingsListViewModel: ObservableObject { if add { let model = MeetingModel(conferenceInfo: confInfo) - let firstMeetingOfTheDay = (previousModel != nil) ? previousModel?.day != model.day || previousModel?.dayNumber != model.dayNumber : true - model.firstMeetingOfTheDay = firstMeetingOfTheDay - // Insert "Today" fake model before the first one of today - /* - if firstMeetingOfTheDay && model.isToday { - meetingsListTmp.append(MeetingsListItemModel(meetingModel: nil)) - meetingForTodayFound = true - } - */ - - // If no meeting was found for today, insert "Today" fake model before the next meeting to come - /* - if !meetingForTodayFound && model.isAfterToday { - meetingsListTmp.append(MeetingsListItemModel(meetingModel: nil)) - meetingForTodayFound = true - } - */ + if !meetingForTodayFound { + if model.isToday { + meetingForTodayFound = true + } 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 + } + } meetingsListTmp.append(MeetingsListItemModel(meetingModel: model)) previousModel = model } } - // If no meeting was found after today, insert "Today" fake model at the end - /* - if !meetingForTodayFound { - meetingsListTmp.append(MeetingsListItemModel(meetingModel: nil)) - } - */ - + Log.info("debugtrace -- computeMeetingsList, previous count = \(self.meetingsList.count), new count = \(meetingsListTmp.count)") DispatchQueue.main.sync { self.meetingsList = meetingsListTmp - self.sortMeetingsListByWeek() } } } - - func sortMeetingsListByWeek() { - var sortedList: [String: [String: [MeetingsListItemModel]]] = [:] - - var currentMonthString = "" - var currentWeekString = "" - for meeting in self.meetingsList { - - if currentMonthString != meeting.monthStr { - sortedList[currentMonthString] = [:] - currentMonthString = meeting.monthStr - } - - if currentWeekString != meeting.weekStr { - sortedList[currentMonthString]?[currentWeekString] = [] - currentWeekString = meeting.weekStr - } - sortedList[currentMonthString]?[currentWeekString]?.append(meeting) - } - self.sortedMeetingsList = sortedList - } } From c19f2283c754217a20b1095176dff0179a838af4 Mon Sep 17 00:00:00 2001 From: QuentinArguillere Date: Tue, 28 May 2024 12:02:02 +0200 Subject: [PATCH 276/486] Start of the new meetings view --- .../Meetings/Fragments/MeetingsFragment.swift | 122 +++++++++++++----- 1 file changed, 90 insertions(+), 32 deletions(-) diff --git a/Linphone/UI/Main/Meetings/Fragments/MeetingsFragment.swift b/Linphone/UI/Main/Meetings/Fragments/MeetingsFragment.swift index 9df4eb2c6..184cd5df6 100644 --- a/Linphone/UI/Main/Meetings/Fragments/MeetingsFragment.swift +++ b/Linphone/UI/Main/Meetings/Fragments/MeetingsFragment.swift @@ -17,45 +17,103 @@ struct MeetingsFragment: View { @State var showingSheet: Bool = false + func createMonthLine(model: MeetingsListItemModel) -> some View { + return Text(model.monthStr) + .fontWeight(.bold) + .padding(5) + .default_text_style_500(styleSize: 22) + } + + func createWeekLine(model: MeetingsListItemModel) -> some View { + return Text(model.weekStr) + .padding(.leading, 65) + .padding(.top, 3) + .padding(.bottom, 3) + .default_text_style_500(styleSize: 14) + } + + func createMeetingLine(model: MeetingsListItemModel) -> some View { + return VStack(alignment: .leading) { + if model.isToday { + Text("No meeting today") + } else { + HStack(alignment: .center) { + Image("meetings") + .renderingMode(.template) + .resizable() + .foregroundStyle(Color.grayMain2c600) + .frame(width: 24, height: 24) + .padding(.top, 3) + .padding(.bottom, -8) + Text(model.model!.subject) + .fontWeight(.bold) + .padding(.trailing, 5) + .padding(.top, 10) + .default_text_style_500(styleSize: 15) + } + Text(model.model!.time) + .padding(.top, -3) + .default_text_style_700(styleSize: 15) + } + } + .padding(.leading, 20) + } + var body: some View { VStack { List { - ForEach(0.. Date: Tue, 28 May 2024 16:08:57 +0200 Subject: [PATCH 277/486] Add shadowed rounded rectangle boxes, ajust alignments --- .../Meetings/Fragments/MeetingsFragment.swift | 27 ++++++++++++++----- 1 file changed, 20 insertions(+), 7 deletions(-) diff --git a/Linphone/UI/Main/Meetings/Fragments/MeetingsFragment.swift b/Linphone/UI/Main/Meetings/Fragments/MeetingsFragment.swift index 184cd5df6..0a65f648c 100644 --- a/Linphone/UI/Main/Meetings/Fragments/MeetingsFragment.swift +++ b/Linphone/UI/Main/Meetings/Fragments/MeetingsFragment.swift @@ -26,7 +26,7 @@ struct MeetingsFragment: View { func createWeekLine(model: MeetingsListItemModel) -> some View { return Text(model.weekStr) - .padding(.leading, 65) + .padding(.leading, 43) .padding(.top, 3) .padding(.bottom, 3) .default_text_style_500(styleSize: 14) @@ -36,6 +36,8 @@ struct MeetingsFragment: View { return VStack(alignment: .leading) { if model.isToday { Text("No meeting today") + .fontWeight(.bold) + .default_text_style_500(styleSize: 15) } else { HStack(alignment: .center) { Image("meetings") @@ -43,20 +45,24 @@ struct MeetingsFragment: View { .resizable() .foregroundStyle(Color.grayMain2c600) .frame(width: 24, height: 24) - .padding(.top, 3) .padding(.bottom, -8) Text(model.model!.subject) .fontWeight(.bold) .padding(.trailing, 5) - .padding(.top, 10) + .padding(.top, 7) .default_text_style_500(styleSize: 15) } Text(model.model!.time) - .padding(.top, -3) - .default_text_style_700(styleSize: 15) + .padding(.top, -8) + .default_text_style_500(styleSize: 15) } } .padding(.leading, 20) + .frame(height: 63) + .frame(maxWidth: .infinity, alignment: .leading) + .background(.white) + .clipShape(RoundedRectangle(cornerRadius: 20)) + .shadow(color: .black.opacity(0.2), radius: 4) } var body: some View { @@ -104,9 +110,16 @@ struct MeetingsFragment: View { .shadow(color: .black.opacity(0.2), radius: 4) */ } + .padding(.top, -5) .frame(width: 35) - createMeetingLine(model: itemModel) - Spacer() + if itemModel.isToday { + Text("No meeting today") + .fontWeight(.bold) + .padding(.leading, 20) + .default_text_style_500(styleSize: 15) + } else { + createMeetingLine(model: itemModel) + } } } else { createMeetingLine(model: itemModel) From 16a034e50dbdad04124d2ea236283db226155364 Mon Sep 17 00:00:00 2001 From: QuentinArguillere Date: Tue, 28 May 2024 16:11:05 +0200 Subject: [PATCH 278/486] Remove debug traces --- Linphone/UI/Main/Meetings/Models/MeetingsListItemModel.swift | 2 -- Linphone/UI/Main/Meetings/ViewModel/MeetingsListViewModel.swift | 2 -- 2 files changed, 4 deletions(-) diff --git a/Linphone/UI/Main/Meetings/Models/MeetingsListItemModel.swift b/Linphone/UI/Main/Meetings/Models/MeetingsListItemModel.swift index 38ca8c90f..520a98bc9 100644 --- a/Linphone/UI/Main/Meetings/Models/MeetingsListItemModel.swift +++ b/Linphone/UI/Main/Meetings/Models/MeetingsListItemModel.swift @@ -32,10 +32,8 @@ class MeetingsListItemModel { weekStr = createWeekString(date: mod.meetingDate) weekDayStr = createWeekDayString(date: mod.meetingDate) dayStr = createDayString(date: mod.meetingDate) - Log.info("debugtrace -- create new item model : \(monthStr) - \(weekStr) - \(weekDayStr) - \(dayStr)") isToday = false } else { - Log.info("debugtrace -- create new item model : TODAY") monthStr = createMonthString(date: Date.now) weekStr = createWeekString(date: Date.now) weekDayStr = createWeekDayString(date: Date.now) diff --git a/Linphone/UI/Main/Meetings/ViewModel/MeetingsListViewModel.swift b/Linphone/UI/Main/Meetings/ViewModel/MeetingsListViewModel.swift index 92ddd1067..069fb18fd 100644 --- a/Linphone/UI/Main/Meetings/ViewModel/MeetingsListViewModel.swift +++ b/Linphone/UI/Main/Meetings/ViewModel/MeetingsListViewModel.swift @@ -57,7 +57,6 @@ class MeetingsListViewModel: ObservableObject { var meetingsListTmp: [MeetingsListItemModel] = [] var previousModel: MeetingModel? var meetingForTodayFound = false - Log.info("debugtrace -- computeMeetingsList, \(confInfoList.count) conferences found") for confInfo in confInfoList { if confInfo.duration == 0 { continue }// This isn't a scheduled conference, don't display it var add = true @@ -88,7 +87,6 @@ class MeetingsListViewModel: ObservableObject { } } - Log.info("debugtrace -- computeMeetingsList, previous count = \(self.meetingsList.count), new count = \(meetingsListTmp.count)") DispatchQueue.main.sync { self.meetingsList = meetingsListTmp } From 0aeef2f0226f2ae84df2eb93a98eeb7a73b74bc4 Mon Sep 17 00:00:00 2001 From: QuentinArguillere Date: Thu, 30 May 2024 16:31:18 +0200 Subject: [PATCH 279/486] Placeholder : join the meeting waiting room when taping a meeting from the least --- .../UI/Main/Meetings/Fragments/MeetingsFragment.swift | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/Linphone/UI/Main/Meetings/Fragments/MeetingsFragment.swift b/Linphone/UI/Main/Meetings/Fragments/MeetingsFragment.swift index 0a65f648c..39679182e 100644 --- a/Linphone/UI/Main/Meetings/Fragments/MeetingsFragment.swift +++ b/Linphone/UI/Main/Meetings/Fragments/MeetingsFragment.swift @@ -63,6 +63,15 @@ struct MeetingsFragment: View { .background(.white) .clipShape(RoundedRectangle(cornerRadius: 20)) .shadow(color: .black.opacity(0.2), radius: 4) + .onTapGesture { + do { + let meetingAddress = try Factory.Instance.createAddress(addr: model.model?.address ?? "") + TelecomManager.shared.meetingWaitingRoomDisplayed = true + TelecomManager.shared.meetingWaitingRoomSelected = meetingAddress + } catch { + Log.error("[MeetingsFragment] Couldn't create address from \(model.model?.address ?? "")") + } + } } var body: some View { From 570007c2c6b1dad462335ba150c6037bd850807f Mon Sep 17 00:00:00 2001 From: QuentinArguillere Date: Thu, 20 Jun 2024 17:15:52 +0200 Subject: [PATCH 280/486] Implement conference details view with edit option. --- .../video-conference.imageset/Contents.json | 21 ++ .../video-conference.svg | 1 + Linphone/UI/Main/ContentView.swift | 14 +- .../Meetings/Fragments/MeetingFragment.swift | 243 +++++++++++++++++- .../Meetings/Fragments/MeetingsFragment.swift | 16 +- .../Fragments/ScheduleMeetingFragment.swift | 10 +- .../Main/Meetings/Models/MeetingModel.swift | 6 +- .../Meetings/ViewModel/MeetingViewModel.swift | 120 +++++---- .../ViewModel/ScheduleMeetingViewModel.swift | 64 ++++- .../Viewmodel/AddParticipantsViewModel.swift | 4 +- 10 files changed, 409 insertions(+), 90 deletions(-) create mode 100644 Linphone/Assets.xcassets/video-conference.imageset/Contents.json create mode 100644 Linphone/Assets.xcassets/video-conference.imageset/video-conference.svg 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/UI/Main/ContentView.swift b/Linphone/UI/Main/ContentView.swift index 5e76f7dcc..38802c523 100644 --- a/Linphone/UI/Main/ContentView.swift +++ b/Linphone/UI/Main/ContentView.swift @@ -241,7 +241,7 @@ struct ContentView: View { conversationViewModel.displayedConversation = nil }, label: { VStack { - Image("meetings") + Image("video-conference") .renderingMode(.template) .resizable() .foregroundStyle(self.index == 3 ? Color.orangeMain500 : Color.grayMain2c600) @@ -669,7 +669,7 @@ struct ContentView: View { conversationViewModel.displayedConversation = nil }, label: { VStack { - Image("meetings") + Image("video-conference") .renderingMode(.template) .resizable() .foregroundStyle(self.index == 3 ? Color.orangeMain500 : Color.grayMain2c600) @@ -698,7 +698,9 @@ struct ContentView: View { } } - if contactViewModel.indexDisplayedFriend != nil || historyViewModel.displayedCall != nil || conversationViewModel.displayedConversation != nil { + if contactViewModel.indexDisplayedFriend != nil || historyViewModel.displayedCall != nil || conversationViewModel.displayedConversation != nil || + scheduleMeetingViewModel.displayedMeeting != nil + { HStack(spacing: 0) { Spacer() .frame(maxWidth: @@ -739,7 +741,13 @@ struct ContentView: View { .frame(maxWidth: .infinity) .background(Color.gray100) .ignoresSafeArea(.keyboard) + } else if self.index == 3 { + MeetingFragment(scheduleMeetingViewModel: scheduleMeetingViewModel, meetingsListViewModel: meetingsListViewModel, isShowScheduleMeetingFragment: $isShowScheduleMeetingFragment) + .frame(maxWidth: .infinity) + .background(Color.gray100) + .ignoresSafeArea(.keyboard) } + } .onAppear { if !(orientation == .landscapeLeft diff --git a/Linphone/UI/Main/Meetings/Fragments/MeetingFragment.swift b/Linphone/UI/Main/Meetings/Fragments/MeetingFragment.swift index cce87d668..e1d58118b 100644 --- a/Linphone/UI/Main/Meetings/Fragments/MeetingFragment.swift +++ b/Linphone/UI/Main/Meetings/Fragments/MeetingFragment.swift @@ -17,19 +17,254 @@ * along with this program. If not, see . */ +// swiftlint:disable line_length import SwiftUI +import linphonesw struct MeetingFragment: View { - @ObservedObject var meetingViewModel: MeetingViewModel + @Environment(\.dismiss) var dismiss + + private var idiom: UIUserInterfaceIdiom { UIDevice.current.userInterfaceIdiom } + @State private var orientation = UIDevice.current.orientation + + @ObservedObject var scheduleMeetingViewModel: ScheduleMeetingViewModel + @ObservedObject var meetingsListViewModel: MeetingsListViewModel + + @State private var showDatePicker = false + @State private var showTimePicker = 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 + + @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 { - ZStack { - Text("TODO") + 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 { + scheduleMeetingViewModel.displayedMeeting = nil + } + } + Spacer() + if scheduleMeetingViewModel.myself != nil && scheduleMeetingViewModel.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() + } + } + } + Image("dots-three-vertical") + .renderingMode(.template) + .resizable() + .foregroundStyle(Color.orangeMain500) + .frame(width: 25, height: 25, alignment: .leading) + .onTapGesture { + } + } + .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(scheduleMeetingViewModel.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(scheduleMeetingViewModel.conferenceUri) + .underline() + .default_text_style(styleSize: 14) + Spacer() + + 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(scheduleMeetingViewModel.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("TODO : timezone") + .default_text_style(styleSize: 14) + 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, 15) + + Text(scheduleMeetingViewModel.description) + .default_text_style(styleSize: 14) + Spacer() + }.padding(.top, 10) + .padding(.bottom, 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 scheduleMeetingViewModel.myself != nil { + getParticipantLine(participant: scheduleMeetingViewModel.myself!) + } + ForEach(0.. some View { return Text(model.monthStr) .fontWeight(.bold) @@ -24,6 +25,7 @@ struct MeetingsFragment: View { .default_text_style_500(styleSize: 22) } + @ViewBuilder func createWeekLine(model: MeetingsListItemModel) -> some View { return Text(model.weekStr) .padding(.leading, 43) @@ -31,7 +33,7 @@ struct MeetingsFragment: View { .padding(.bottom, 3) .default_text_style_500(styleSize: 14) } - + @ViewBuilder func createMeetingLine(model: MeetingsListItemModel) -> some View { return VStack(alignment: .leading) { if model.isToday { @@ -40,7 +42,7 @@ struct MeetingsFragment: View { .default_text_style_500(styleSize: 15) } else { HStack(alignment: .center) { - Image("meetings") + Image("video-conference") .renderingMode(.template) .resizable() .foregroundStyle(Color.grayMain2c600) @@ -64,12 +66,10 @@ struct MeetingsFragment: View { .clipShape(RoundedRectangle(cornerRadius: 20)) .shadow(color: .black.opacity(0.2), radius: 4) .onTapGesture { - do { - let meetingAddress = try Factory.Instance.createAddress(addr: model.model?.address ?? "") - TelecomManager.shared.meetingWaitingRoomDisplayed = true - TelecomManager.shared.meetingWaitingRoomSelected = meetingAddress - } catch { - Log.error("[MeetingsFragment] Couldn't create address from \(model.model?.address ?? "")") + withAnimation { + if let meetingModel = model.model { + scheduleMeetingViewModel.loadExistingMeeting(meeting: meetingModel) + } } } } diff --git a/Linphone/UI/Main/Meetings/Fragments/ScheduleMeetingFragment.swift b/Linphone/UI/Main/Meetings/Fragments/ScheduleMeetingFragment.swift index b72a803ba..3e3a65890 100644 --- a/Linphone/UI/Main/Meetings/Fragments/ScheduleMeetingFragment.swift +++ b/Linphone/UI/Main/Meetings/Fragments/ScheduleMeetingFragment.swift @@ -74,11 +74,15 @@ struct ScheduleMeetingFragment: View { .padding(.leading, -10) .onTapGesture { withAnimation { + if let meeting = scheduleMeetingViewModel.displayedMeeting { + // reload meeting to cancel change from edit + scheduleMeetingViewModel.loadExistingMeeting(meeting: meeting) + } isShowScheduleMeetingFragment.toggle() } } - Text("New meeting" ) + Text("\(scheduleMeetingViewModel.displayedMeeting != nil ? "Edit" : "New") meeting" ) .multilineTextAlignment(.leading) .default_text_style_orange_800(styleSize: 16) @@ -143,10 +147,10 @@ struct ScheduleMeetingFragment: View { } */ HStack(alignment: .center, spacing: 8) { - Image("users-three") + Image("video-conference") .renderingMode(.template) .resizable() - .foregroundStyle(Color.grayMain2c600) + .foregroundStyle(Color.grayMain2c800) .frame(width: 24, height: 24) .padding(.leading, 16) TextField("Subject", text: $scheduleMeetingViewModel.subject) diff --git a/Linphone/UI/Main/Meetings/Models/MeetingModel.swift b/Linphone/UI/Main/Meetings/Models/MeetingModel.swift index fd7abac7a..ba89195dd 100644 --- a/Linphone/UI/Main/Meetings/Models/MeetingModel.swift +++ b/Linphone/UI/Main/Meetings/Models/MeetingModel.swift @@ -9,9 +9,10 @@ import linphonesw class MeetingModel: ObservableObject { - private var confInfo: ConferenceInfo + var confInfo: ConferenceInfo var id: String var meetingDate: Date + var endDate: Date var isToday: Bool var isAfterToday: Bool @@ -24,7 +25,6 @@ class MeetingModel: ObservableObject { @Published var isBroadcast: Bool @Published var subject: String @Published var address: String - @Published var firstMeetingOfTheDay: Bool = false init(conferenceInfo: ConferenceInfo) { confInfo = conferenceInfo @@ -34,7 +34,7 @@ class MeetingModel: ObservableObject { let formatter = DateFormatter() formatter.dateFormat = Locale.current.identifier == "fr_FR" ? "HH:mm" : "h:mm a" startTime = formatter.string(from: meetingDate) - let endDate = Calendar.current.date(byAdding: .minute, value: Int(confInfo.duration), to: meetingDate)! + endDate = Calendar.current.date(byAdding: .minute, value: Int(confInfo.duration), to: meetingDate)! endTime = formatter.string(from: endDate) time = "\(startTime) - \(endTime)" diff --git a/Linphone/UI/Main/Meetings/ViewModel/MeetingViewModel.swift b/Linphone/UI/Main/Meetings/ViewModel/MeetingViewModel.swift index 12e16a223..681bef225 100644 --- a/Linphone/UI/Main/Meetings/ViewModel/MeetingViewModel.swift +++ b/Linphone/UI/Main/Meetings/ViewModel/MeetingViewModel.swift @@ -22,7 +22,7 @@ import linphonesw class MeetingViewModel: ObservableObject { static let TAG = "[Meeting ViewModel]" - + /* private var coreContext = CoreContext.shared @Published var showBackbutton: Bool = false @@ -40,20 +40,20 @@ class MeetingViewModel: ObservableObject { @Published var participants: [ParticipantModel] = [] @Published var conferenceInfoFoundEvent: Bool = false - var conferenceInfo: ConferenceInfo? + var meetingModel: MeetingModel - init() { - + init(model: MeetingModel) { + meetingModel = model } func findConferenceInfo(uri: String) { coreContext.doOnCoreQueue { core in var confInfoFound = false if let address = try? Factory.Instance.createAddress(addr: uri) { - let foundConfInfo = core.findConferenceInformationFromUri(uri: address) - if foundConfInfo != nil { + + if let confInfo = core.findConferenceInformationFromUri(uri: address) { Log.info("\(MeetingViewModel.TAG) Conference info with SIP URI \(uri) was found") - self.conferenceInfo = foundConfInfo + self.meetingModel.confInfo = confInfo self.configureConferenceInfo(core: core) confInfoFound = true } else { @@ -71,68 +71,65 @@ class MeetingViewModel: ObservableObject { } private func configureConferenceInfo(core: Core) { - if let confInfo = self.conferenceInfo { + /* + timezone.postValue( + AppUtils.getFormattedString( + R.string.meeting_schedule_timezone_title, + TimeZone.getDefault().displayName + ) + .replaceFirstChar { if (it.isLowerCase()) it.titlecase(Locale.getDefault()) else it.toString() } + ) + */ + + var isEditable = false + + if let organizerAddress = meetingModel.confInfo.organizer { + let localAccount = core.accountList.first(where: { + if let address = $0.params?.identityAddress { + return organizerAddress.weakEqual(address2: address) + } else { + return false + } + }) - /* - timezone.postValue( - AppUtils.getFormattedString( - R.string.meeting_schedule_timezone_title, - TimeZone.getDefault().displayName - ) - .replaceFirstChar { if (it.isLowerCase()) it.titlecase(Locale.getDefault()) else it.toString() } - ) - */ - - var isEditable = false - - if let organizerAddress = confInfo.organizer { - let localAccount = core.accountList.first(where: { - if let address = $0.params?.identityAddress { - return organizerAddress.weakEqual(address2: address) - } else { - return false - } - }) - - isEditable = localAccount != nil - } else { - Log.error("\(MeetingViewModel.TAG) No organizer SIP URI found for: \(confInfo.uri?.asStringUriOnly() ?? "(empty)")") - } - - let startDate = Date(timeIntervalSince1970: TimeInterval(confInfo.dateTime)) - let endDate = Calendar.current.date(byAdding: .minute, value: Int(confInfo.duration), to: startDate)! - - let formatter = DateFormatter() - formatter.dateFormat = Locale.current.identifier == "fr_FR" ? "HH:mm" : "h:mm a" - let startTime = formatter.string(from: startDate) - let endTime = formatter.string(from: endDate) - let dateTime = "\(startTime) - \(endTime)" - - DispatchQueue.main.sync { - self.subject = confInfo.subject ?? "" - self.sipUri = confInfo.uri?.asStringUriOnly() ?? "" - self.description = confInfo.description - self.startDate = startDate - self.endDate = endDate - self.dateTime = dateTime - self.isEditable = isEditable - } - - self.computeParticipantsList(core: core, confInfo: confInfo) + isEditable = localAccount != nil + } else { + Log.error("\(MeetingViewModel.TAG) No organizer SIP URI found for: \(meetingModel.confInfo.uri?.asStringUriOnly() ?? "(empty)")") } + + let startDate = Date(timeIntervalSince1970: TimeInterval(meetingModel.confInfo.dateTime)) + let endDate = Calendar.current.date(byAdding: .minute, value: Int(meetingModel.confInfo.duration), to: startDate)! + + let formatter = DateFormatter() + formatter.dateFormat = Locale.current.identifier == "fr_FR" ? "HH:mm" : "h:mm a" + let startTime = formatter.string(from: startDate) + let endTime = formatter.string(from: endDate) + let dateTime = "\(startTime) - \(endTime)" + + DispatchQueue.main.async { + self.subject = self.meetingModel.confInfo.subject ?? "" + self.sipUri = self.meetingModel.confInfo.uri?.asStringUriOnly() ?? "" + self.description = self.meetingModel.confInfo.description + self.startDate = startDate + self.endDate = endDate + self.dateTime = dateTime + self.isEditable = isEditable + } + + self.computeParticipantsList() } - private func computeParticipantsList(core: Core, confInfo: ConferenceInfo) { + private func computeParticipantsList() { var speakersList: [ParticipantModel] = [] var participantsList: [ParticipantModel] = [] var allSpeaker = true - let organizer = confInfo.organizer + let organizer = meetingModel.confInfo.organizer var organizerFound = false - for pInfo in confInfo.participantInfos { + for pInfo in meetingModel.confInfo.participantInfos { if let participantAddress = pInfo.address { let isOrganizer = organizer != nil && organizer!.weakEqual(address2: participantAddress) - Log.info("\(MeetingViewModel.TAG) Conference \(confInfo.subject)[${conferenceInfo.subject}] \(isOrganizer ? "organizer: " : "participant: ") \(participantAddress.asStringUriOnly()) is a \(pInfo.role)") + Log.info("\(MeetingViewModel.TAG) Conference \(meetingModel.confInfo.subject)[${conferenceInfo.subject}] \(isOrganizer ? "organizer: " : "participant: ") \(participantAddress.asStringUriOnly()) is a \(pInfo.role)") if isOrganizer { organizerFound = true } @@ -156,10 +153,11 @@ class MeetingViewModel: ObservableObject { participantsList.append(ParticipantModel(address: organizerAddress)) } - DispatchQueue.main.sync { + DispatchQueue.main.async { self.isBroadcast = !allSpeaker - speakers = speakersList - participants = participantsList + self.speakers = speakersList + self.participants = participantsList } } + */ } diff --git a/Linphone/UI/Main/Meetings/ViewModel/ScheduleMeetingViewModel.swift b/Linphone/UI/Main/Meetings/ViewModel/ScheduleMeetingViewModel.swift index 0a43e13fc..579b34a56 100644 --- a/Linphone/UI/Main/Meetings/ViewModel/ScheduleMeetingViewModel.swift +++ b/Linphone/UI/Main/Meetings/ViewModel/ScheduleMeetingViewModel.swift @@ -38,13 +38,16 @@ class ScheduleMeetingViewModel: ObservableObject { @Published var participants: [SelectedAddressModel] = [] @Published var operationInProgress: Bool = false @Published var conferenceCreatedEvent: Bool = false + @Published var conferenceUri: String = "" var conferenceScheduler: ConferenceScheduler? private var mSchedulerSubscriptions = Set() 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)! @@ -65,7 +68,7 @@ class ScheduleMeetingViewModel: ObservableObject { 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)! computeDateLabels() @@ -94,6 +97,14 @@ class ScheduleMeetingViewModel: ObservableObject { toTime = formatter.string(from: toDate) } + func getFullDateString() -> String { + var day = fromDate.formatted(Date.FormatStyle().weekday(.abbreviated)) + var dayNumber = fromDate.formatted(Date.FormatStyle().day(.twoDigits)) + var month = fromDate.formatted(Date.FormatStyle().month(.wide)) + var year = fromDate.formatted(Date.FormatStyle().year(.defaultDigits)) + return "\(day). \(dayNumber) \(month) \(year) | \(allDayMeeting ? "All day" : "\(fromTime) - \(toTime)")" + } + private func updateTimezone() { // TODO } @@ -143,6 +154,8 @@ class ScheduleMeetingViewModel: ObservableObject { if cbVal.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 cbVal.state == ConferenceScheduler.State.Ready { @@ -207,11 +220,9 @@ class ScheduleMeetingViewModel: ObservableObject { CoreContext.shared.doOnCoreQueue { core in Log.info("\(ScheduleMeetingViewModel.TAG) Scheduling \(self.isBroadcastSelected ? "broadcast" : "meeting")") - let localAccount = core.defaultAccount - let localAddress = localAccount?.params?.identityAddress - - if let conferenceInfo = try? Factory.Instance.createConferenceInfo() { - conferenceInfo.organizer = localAddress + 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) if self.conferenceScheduler == nil { self.initConferenceSchedulerAndListeners(core: core) @@ -243,6 +254,45 @@ class ScheduleMeetingViewModel: ObservableObject { } } + func loadExistingMeeting(meeting: MeetingModel) { + DispatchQueue.main.async { + self.resetViewModelData() + self.subject = meeting.confInfo.subject ?? "" + self.description = meeting.confInfo.description ?? "" + self.fromDate = meeting.meetingDate + self.toDate = meeting.endDate + self.participants = [] + + let organizer = meeting.confInfo.organizer + CoreContext.shared.doOnCoreQueue { core in + if let myAddr = core.defaultAccount?.contactAddress { + ContactAvatarModel.getAvatarModelFromAddress(address: myAddr) { avatarResult in + DispatchQueue.main.async { + let isOrganizer = (organizer != nil) ? myAddr.weakEqual(address2: organizer!) : false + self.myself = SelectedAddressModel(addr: myAddr, avModel: avatarResult, isOrg: isOrganizer) + } + } + } + } + + for pInfo in meeting.confInfo.participantInfos { + if let addr = pInfo.address { + ContactAvatarModel.getAvatarModelFromAddress(address: addr) { avatarResult in + DispatchQueue.main.async { + let isOrganizer = (organizer != nil) ? addr.weakEqual(address2: organizer!) : false + self.participants.append(SelectedAddressModel(addr: addr, avModel: avatarResult, isOrg:isOrganizer)) + } + } + } + } + self.conferenceUri = meeting.confInfo.uri?.asStringUriOnly() ?? "" + self.computeDateLabels() + self.computeTimeLabels() + self.updateTimezone() + self.displayedMeeting = meeting + } + } + func loadExistingConferenceInfoFromUri(conferenceUri: String) { CoreContext.shared.doOnCoreQueue { core in if let conferenceAddress = core.interpretUrl(url: conferenceUri, applyInternationalPrefix: false) { diff --git a/Linphone/UI/Main/Viewmodel/AddParticipantsViewModel.swift b/Linphone/UI/Main/Viewmodel/AddParticipantsViewModel.swift index 3db4b0bc7..6e1f2d647 100644 --- a/Linphone/UI/Main/Viewmodel/AddParticipantsViewModel.swift +++ b/Linphone/UI/Main/Viewmodel/AddParticipantsViewModel.swift @@ -12,10 +12,12 @@ import Combine class SelectedAddressModel: ObservableObject { var address: Address var avatarModel: ContactAvatarModel + var isOrganizer: Bool = false - init (addr: Address, avModel: ContactAvatarModel) { + init (addr: Address, avModel: ContactAvatarModel, isOrg: Bool = false) { address = addr avatarModel = avModel + isOrganizer = isOrg } } From e74b2dd4f3d6657837fc5841d6722a599c0398c7 Mon Sep 17 00:00:00 2001 From: QuentinArguillere Date: Thu, 20 Jun 2024 17:24:57 +0200 Subject: [PATCH 281/486] Rename "ScheduleMeetingViewModel" to "MeetingViewModel" --- Linphone.xcodeproj/project.pbxproj | 4 - Linphone/LinphoneApp.swift | 8 +- Linphone/UI/Main/ContentView.swift | 12 +- .../Fragments/AddParticipantsFragment.swift | 24 +- .../Meetings/Fragments/MeetingFragment.swift | 28 +- .../Meetings/Fragments/MeetingsFragment.swift | 30 +- .../Fragments/ScheduleMeetingFragment.swift | 92 ++-- Linphone/UI/Main/Meetings/MeetingsView.swift | 32 +- .../Main/Meetings/Models/MeetingModel.swift | 24 +- .../Models/MeetingsListItemModel.swift | 24 +- .../Meetings/ViewModel/MeetingViewModel.swift | 412 +++++++++++++----- .../ViewModel/ScheduleMeetingViewModel.swift | 339 -------------- .../Viewmodel/AddParticipantsViewModel.swift | 24 +- 13 files changed, 479 insertions(+), 574 deletions(-) delete mode 100644 Linphone/UI/Main/Meetings/ViewModel/ScheduleMeetingViewModel.swift diff --git a/Linphone.xcodeproj/project.pbxproj b/Linphone.xcodeproj/project.pbxproj index e33ed4f0c..ef7cae2fa 100644 --- a/Linphone.xcodeproj/project.pbxproj +++ b/Linphone.xcodeproj/project.pbxproj @@ -12,7 +12,6 @@ 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 */; }; - 6613A0B62BAEBE5C008923A4 /* ScheduleMeetingViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6613A0B52BAEBE5C008923A4 /* ScheduleMeetingViewModel.swift */; }; 66162A202BDFC2F900DCE913 /* AddParticipantsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 66162A1F2BDFC2F900DCE913 /* AddParticipantsViewModel.swift */; }; 662B69D92B25DE18007118BF /* TelecomManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 662B69D82B25DE18007118BF /* TelecomManager.swift */; }; 662B69DB2B25DE25007118BF /* ProviderDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 662B69DA2B25DE25007118BF /* ProviderDelegate.swift */; }; @@ -192,7 +191,6 @@ 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 = ""; }; - 6613A0B52BAEBE5C008923A4 /* ScheduleMeetingViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScheduleMeetingViewModel.swift; sourceTree = ""; }; 66162A1F2BDFC2F900DCE913 /* AddParticipantsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddParticipantsViewModel.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 = ""; }; @@ -423,7 +421,6 @@ children = ( 66E56BC82BA4A6D7006CE56F /* MeetingsListViewModel.swift */, 6613A0B32BAEBE3F008923A4 /* MeetingViewModel.swift */, - 6613A0B52BAEBE5C008923A4 /* ScheduleMeetingViewModel.swift */, ); path = ViewModel; sourceTree = ""; @@ -1109,7 +1106,6 @@ 66C492012B24DB6900CEA16D /* Log.swift in Sources */, C6A5A9432C10B5ED0070FEA4 /* DecodableExtension.swift in Sources */, D714035B2BE11E00004BD8CA /* CallMediaEncryptionModel.swift in Sources */, - 6613A0B62BAEBE5C008923A4 /* ScheduleMeetingViewModel.swift in Sources */, D748BF2C2ACD82D2004844EB /* ThirdPartySipAccountLoginFragment.swift in Sources */, D7CEE0382B7A214F00FD79B7 /* ConversationsListViewModel.swift in Sources */, D74C9CF82ACACECE0021626A /* WelcomePage1Fragment.swift in Sources */, diff --git a/Linphone/LinphoneApp.swift b/Linphone/LinphoneApp.swift index 95cb4d7f8..a720905eb 100644 --- a/Linphone/LinphoneApp.swift +++ b/Linphone/LinphoneApp.swift @@ -80,7 +80,7 @@ struct LinphoneApp: App { @State private var conversationsListViewModel: ConversationsListViewModel? @State private var conversationViewModel: ConversationViewModel? @State private var meetingsListViewModel: MeetingsListViewModel? - @State private var scheduleMeetingViewModel: ScheduleMeetingViewModel? + @State private var meetingViewModel: MeetingViewModel? var body: some Scene { WindowGroup { @@ -111,7 +111,7 @@ struct LinphoneApp: App { && conversationsListViewModel != nil && conversationViewModel != nil && meetingsListViewModel != nil - && scheduleMeetingViewModel != nil { + && meetingViewModel != nil { ContentView( contactViewModel: contactViewModel!, editContactViewModel: editContactViewModel!, @@ -123,7 +123,7 @@ struct LinphoneApp: App { conversationsListViewModel: conversationsListViewModel!, conversationViewModel: conversationViewModel!, meetingsListViewModel: meetingsListViewModel!, - scheduleMeetingViewModel: scheduleMeetingViewModel! + meetingViewModel: meetingViewModel! ).onOpenURL { url in URIHandler.handleURL(url: url) } @@ -145,7 +145,7 @@ struct LinphoneApp: App { conversationsListViewModel = ConversationsListViewModel() conversationViewModel = ConversationViewModel() meetingsListViewModel = MeetingsListViewModel() - scheduleMeetingViewModel = ScheduleMeetingViewModel() + meetingViewModel = MeetingViewModel() }.onOpenURL { url in URIHandler.handleURL(url: url) } diff --git a/Linphone/UI/Main/ContentView.swift b/Linphone/UI/Main/ContentView.swift index 38802c523..c09d8a1e6 100644 --- a/Linphone/UI/Main/ContentView.swift +++ b/Linphone/UI/Main/ContentView.swift @@ -44,7 +44,7 @@ struct ContentView: View { @ObservedObject var conversationsListViewModel: ConversationsListViewModel @ObservedObject var conversationViewModel: ConversationViewModel @ObservedObject var meetingsListViewModel: MeetingsListViewModel - @ObservedObject var scheduleMeetingViewModel: ScheduleMeetingViewModel + @ObservedObject var meetingViewModel: MeetingViewModel @State var index = 0 @State private var orientation = UIDevice.current.orientation @@ -509,7 +509,7 @@ struct ContentView: View { } else if self.index == 3 { MeetingsView( meetingsListViewModel: meetingsListViewModel, - scheduleMeetingViewModel: scheduleMeetingViewModel, + meetingViewModel: meetingViewModel, isShowScheduleMeetingFragment: $isShowScheduleMeetingFragment ) } @@ -699,7 +699,7 @@ struct ContentView: View { } if contactViewModel.indexDisplayedFriend != nil || historyViewModel.displayedCall != nil || conversationViewModel.displayedConversation != nil || - scheduleMeetingViewModel.displayedMeeting != nil + meetingViewModel.displayedMeeting != nil { HStack(spacing: 0) { Spacer() @@ -742,7 +742,7 @@ struct ContentView: View { .background(Color.gray100) .ignoresSafeArea(.keyboard) } else if self.index == 3 { - MeetingFragment(scheduleMeetingViewModel: scheduleMeetingViewModel, meetingsListViewModel: meetingsListViewModel, isShowScheduleMeetingFragment: $isShowScheduleMeetingFragment) + MeetingFragment(meetingViewModel: meetingViewModel, meetingsListViewModel: meetingsListViewModel, isShowScheduleMeetingFragment: $isShowScheduleMeetingFragment) .frame(maxWidth: .infinity) .background(Color.gray100) .ignoresSafeArea(.keyboard) @@ -951,7 +951,7 @@ struct ContentView: View { if isShowScheduleMeetingFragment { ScheduleMeetingFragment( - scheduleMeetingViewModel: scheduleMeetingViewModel, + meetingViewModel: meetingViewModel, meetingsListViewModel: meetingsListViewModel, isShowScheduleMeetingFragment: $isShowScheduleMeetingFragment ) @@ -1038,7 +1038,7 @@ struct ContentView: View { conversationsListViewModel: ConversationsListViewModel(), conversationViewModel: ConversationViewModel(), meetingsListViewModel: MeetingsListViewModel(), - scheduleMeetingViewModel: ScheduleMeetingViewModel() + meetingViewModel: MeetingViewModel() ) } // swiftlint:enable type_body_length diff --git a/Linphone/UI/Main/Meetings/Fragments/AddParticipantsFragment.swift b/Linphone/UI/Main/Meetings/Fragments/AddParticipantsFragment.swift index 557f39be4..2466f59b2 100644 --- a/Linphone/UI/Main/Meetings/Fragments/AddParticipantsFragment.swift +++ b/Linphone/UI/Main/Meetings/Fragments/AddParticipantsFragment.swift @@ -1,9 +1,21 @@ -// -// ParticipantsListFragment.swift -// Linphone -// -// Created by QuentinArguillere on 16/04/2024. -// +/* + * 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 diff --git a/Linphone/UI/Main/Meetings/Fragments/MeetingFragment.swift b/Linphone/UI/Main/Meetings/Fragments/MeetingFragment.swift index e1d58118b..fd7d0c18b 100644 --- a/Linphone/UI/Main/Meetings/Fragments/MeetingFragment.swift +++ b/Linphone/UI/Main/Meetings/Fragments/MeetingFragment.swift @@ -28,7 +28,7 @@ struct MeetingFragment: View { private var idiom: UIUserInterfaceIdiom { UIDevice.current.userInterfaceIdiom } @State private var orientation = UIDevice.current.orientation - @ObservedObject var scheduleMeetingViewModel: ScheduleMeetingViewModel + @ObservedObject var meetingViewModel: MeetingViewModel @ObservedObject var meetingsListViewModel: MeetingsListViewModel @State private var showDatePicker = false @@ -87,11 +87,11 @@ struct MeetingFragment: View { .padding(.leading, -10) .onTapGesture { withAnimation { - scheduleMeetingViewModel.displayedMeeting = nil + meetingViewModel.displayedMeeting = nil } } Spacer() - if scheduleMeetingViewModel.myself != nil && scheduleMeetingViewModel.myself!.isOrganizer { + if meetingViewModel.myself != nil && meetingViewModel.myself!.isOrganizer { Image("pencil-simple") .renderingMode(.template) .resizable() @@ -126,7 +126,7 @@ struct MeetingFragment: View { .foregroundStyle(Color.grayMain2c800) .frame(width: 24, height: 24) .padding(.leading, 15) - Text(scheduleMeetingViewModel.subject) + Text(meetingViewModel.subject) .fontWeight(.bold) .default_text_style(styleSize: 20) .frame(height: 29, alignment: .leading) @@ -145,7 +145,7 @@ struct MeetingFragment: View { .foregroundStyle(Color.grayMain2c800) .frame(width: 24, height: 24) .padding(.leading, 15) - Text(scheduleMeetingViewModel.conferenceUri) + Text(meetingViewModel.conferenceUri) .underline() .default_text_style(styleSize: 14) Spacer() @@ -165,7 +165,7 @@ struct MeetingFragment: View { .foregroundStyle(Color.grayMain2c800) .frame(width: 24, height: 24) .padding(.leading, 15) - Text(scheduleMeetingViewModel.getFullDateString()) + Text(meetingViewModel.getFullDateString()) .default_text_style(styleSize: 14) Spacer() } @@ -195,7 +195,7 @@ struct MeetingFragment: View { .frame(width: 24, height: 24) .padding(.leading, 15) - Text(scheduleMeetingViewModel.description) + Text(meetingViewModel.description) .default_text_style(styleSize: 14) Spacer() }.padding(.top, 10) @@ -216,11 +216,11 @@ struct MeetingFragment: View { ScrollView { VStack(alignment: .leading, spacing: 0) { - if scheduleMeetingViewModel.myself != nil { - getParticipantLine(participant: scheduleMeetingViewModel.myself!) + if meetingViewModel.myself != nil { + getParticipantLine(participant: meetingViewModel.myself!) } - ForEach(0... + */ import SwiftUI import linphonesw @@ -11,7 +23,7 @@ import linphonesw struct MeetingsFragment: View { @ObservedObject var meetingsListViewModel: MeetingsListViewModel - @ObservedObject var scheduleMeetingViewModel: ScheduleMeetingViewModel + @ObservedObject var meetingViewModel: MeetingViewModel private var idiom: UIUserInterfaceIdiom { UIDevice.current.userInterfaceIdiom } @@ -68,7 +80,7 @@ struct MeetingsFragment: View { .onTapGesture { withAnimation { if let meetingModel = model.model { - scheduleMeetingViewModel.loadExistingMeeting(meeting: meetingModel) + meetingViewModel.loadExistingMeeting(meeting: meetingModel) } } } @@ -172,5 +184,5 @@ struct MeetingsFragment: View { } #Preview { - MeetingsFragment(meetingsListViewModel: MeetingsListViewModel(), scheduleMeetingViewModel: ScheduleMeetingViewModel()) + MeetingsFragment(meetingsListViewModel: MeetingsListViewModel(), meetingViewModel: MeetingViewModel()) } diff --git a/Linphone/UI/Main/Meetings/Fragments/ScheduleMeetingFragment.swift b/Linphone/UI/Main/Meetings/Fragments/ScheduleMeetingFragment.swift index 3e3a65890..f7d60b221 100644 --- a/Linphone/UI/Main/Meetings/Fragments/ScheduleMeetingFragment.swift +++ b/Linphone/UI/Main/Meetings/Fragments/ScheduleMeetingFragment.swift @@ -28,7 +28,7 @@ struct ScheduleMeetingFragment: View { private var idiom: UIUserInterfaceIdiom { UIDevice.current.userInterfaceIdiom } @State private var orientation = UIDevice.current.orientation - @ObservedObject var scheduleMeetingViewModel: ScheduleMeetingViewModel + @ObservedObject var meetingViewModel: MeetingViewModel @ObservedObject var meetingsListViewModel: MeetingsListViewModel @State private var delayedColor = Color.white @@ -74,15 +74,15 @@ struct ScheduleMeetingFragment: View { .padding(.leading, -10) .onTapGesture { withAnimation { - if let meeting = scheduleMeetingViewModel.displayedMeeting { + if let meeting = meetingViewModel.displayedMeeting { // reload meeting to cancel change from edit - scheduleMeetingViewModel.loadExistingMeeting(meeting: meeting) + meetingViewModel.loadExistingMeeting(meeting: meeting) } isShowScheduleMeetingFragment.toggle() } } - Text("\(scheduleMeetingViewModel.displayedMeeting != nil ? "Edit" : "New") meeting" ) + Text("\(meetingViewModel.displayedMeeting != nil ? "Edit" : "New") meeting" ) .multilineTextAlignment(.leading) .default_text_style_orange_800(styleSize: 16) @@ -98,17 +98,17 @@ struct ScheduleMeetingFragment: View { Spacer() HStack(alignment: .center) { Button(action: { - scheduleMeetingViewModel.isBroadcastSelected.toggle() + meetingViewModel.isBroadcastSelected.toggle() }, label: { Image("users-three") .renderingMode(.template) .resizable() - .foregroundStyle(scheduleMeetingViewModel.isBroadcastSelected ? .white : Color.orangeMain500) + .foregroundStyle(meetingViewModel.isBroadcastSelected ? .white : Color.orangeMain500) .frame(width: 25, height: 25) }) Text("Meeting") .default_text_style_orange_500( styleSize: 15) - .foregroundStyle(scheduleMeetingViewModel.isBroadcastSelected ? .white : Color.orangeMain500) + .foregroundStyle(meetingViewModel.isBroadcastSelected ? .white : Color.orangeMain500) } .padding(.horizontal, 40) .padding(.vertical, 10) @@ -117,13 +117,13 @@ struct ScheduleMeetingFragment: View { RoundedRectangle(cornerRadius: 60) .inset(by: 0.5) .stroke(Color.orangeMain500, lineWidth: 1) - .background(scheduleMeetingViewModel.isBroadcastSelected ? Color.orangeMain500 : Color.white) + .background(meetingViewModel.isBroadcastSelected ? Color.orangeMain500 : Color.white) ) Spacer() HStack(alignment: .center) { Button(action: { - scheduleMeetingViewModel.isBroadcastSelected.toggle() + meetingViewModel.isBroadcastSelected.toggle() }, label: { Image("slideshow") .renderingMode(.template) @@ -153,7 +153,7 @@ struct ScheduleMeetingFragment: View { .foregroundStyle(Color.grayMain2c800) .frame(width: 24, height: 24) .padding(.leading, 16) - TextField("Subject", text: $scheduleMeetingViewModel.subject) + TextField("Subject", text: $meetingViewModel.subject) .default_text_style_700(styleSize: 20) .frame(height: 29, alignment: .leading) Spacer() @@ -171,43 +171,43 @@ struct ScheduleMeetingFragment: View { .foregroundStyle(Color.grayMain2c800) .frame(width: 24, height: 24) .padding(.leading, 16) - Text(scheduleMeetingViewModel.fromDateStr) + Text(meetingViewModel.fromDateStr) .fontWeight(.bold) .default_text_style_500(styleSize: 16) .onTapGesture { setFromDate = true - selectedDate = scheduleMeetingViewModel.fromDate + selectedDate = meetingViewModel.fromDate showDatePicker.toggle() } Spacer() } - if !scheduleMeetingViewModel.allDayMeeting { + if !meetingViewModel.allDayMeeting { HStack(spacing: 8) { - Text(scheduleMeetingViewModel.fromTime) + Text(meetingViewModel.fromTime) .fontWeight(.bold) .padding(.leading, 48) .frame(height: 29, alignment: .leading) .default_text_style_500(styleSize: 16) - .opacity(scheduleMeetingViewModel.allDayMeeting ? 0 : 1) + .opacity(meetingViewModel.allDayMeeting ? 0 : 1) .onTapGesture { setFromDate = true - selectedDate = scheduleMeetingViewModel.fromDate + selectedDate = meetingViewModel.fromDate showTimePicker.toggle() } - Text(scheduleMeetingViewModel.toTime) + Text(meetingViewModel.toTime) .fontWeight(.bold) .padding(.leading, 8) .frame(height: 29, alignment: .leading) .default_text_style_500(styleSize: 16) - .opacity(scheduleMeetingViewModel.allDayMeeting ? 0 : 1) + .opacity(meetingViewModel.allDayMeeting ? 0 : 1) .onTapGesture { setFromDate = false - selectedDate = scheduleMeetingViewModel.toDate + selectedDate = meetingViewModel.toDate showTimePicker.toggle() } Spacer() - Toggle("", isOn: $scheduleMeetingViewModel.allDayMeeting) + Toggle("", isOn: $meetingViewModel.allDayMeeting) .labelsHidden() .tint(Color.orangeMain300) Text("All day") @@ -222,16 +222,16 @@ struct ScheduleMeetingFragment: View { .foregroundStyle(Color.grayMain2c800) .frame(width: 24, height: 24) .padding(.leading, 16) - Text(scheduleMeetingViewModel.toDateStr) + Text(meetingViewModel.toDateStr) .fontWeight(.bold) .default_text_style_500(styleSize: 16) .onTapGesture { setFromDate = false - selectedDate = scheduleMeetingViewModel.toDate + selectedDate = meetingViewModel.toDate showDatePicker.toggle() } Spacer() - Toggle("", isOn: $scheduleMeetingViewModel.allDayMeeting) + Toggle("", isOn: $meetingViewModel.allDayMeeting) .labelsHidden() .tint(Color.orangeMain300) Text("All day") @@ -279,7 +279,7 @@ struct ScheduleMeetingFragment: View { .frame(width: 24, height: 24) .padding(.leading, 16) - TextField("Add a description", text: $scheduleMeetingViewModel.description) + TextField("Add a description", text: $meetingViewModel.description) .default_text_style_700(styleSize: 16) } @@ -290,9 +290,9 @@ struct ScheduleMeetingFragment: View { VStack { NavigationLink(destination: { - AddParticipantsFragment(addParticipantsViewModel: addParticipantsViewModel, confirmAddParticipantsFunc: scheduleMeetingViewModel.addParticipants) + AddParticipantsFragment(addParticipantsViewModel: addParticipantsViewModel, confirmAddParticipantsFunc: meetingViewModel.addParticipants) .onAppear { - addParticipantsViewModel.participantsToAdd = scheduleMeetingViewModel.participants + addParticipantsViewModel.participantsToAdd = meetingViewModel.participants } }, label: { HStack(alignment: .center, spacing: 8) { @@ -310,20 +310,20 @@ struct ScheduleMeetingFragment: View { } }) - if !scheduleMeetingViewModel.participants.isEmpty { + if !meetingViewModel.participants.isEmpty { ScrollView { - ForEach(0.. scheduleMeetingViewModel.toDate { - scheduleMeetingViewModel.toDate = Calendar.current.date(byAdding: .second, value: Int(duration), to: selectedDate)! + if selectedDate > meetingViewModel.toDate { + meetingViewModel.toDate = Calendar.current.date(byAdding: .second, value: Int(duration), to: selectedDate)! } } else { - scheduleMeetingViewModel.toDate = selectedDate - if selectedDate < scheduleMeetingViewModel.fromDate { + 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) { - scheduleMeetingViewModel.fromDate = Date.now + meetingViewModel.fromDate = Date.now } else { - scheduleMeetingViewModel.fromDate = Calendar.current.date(byAdding: .second, value: (-1)*Int(duration), to: selectedDate)! + meetingViewModel.fromDate = Calendar.current.date(byAdding: .second, value: (-1)*Int(duration), to: selectedDate)! } } } - scheduleMeetingViewModel.computeDateLabels() - scheduleMeetingViewModel.computeTimeLabels() + meetingViewModel.computeDateLabels() + meetingViewModel.computeTimeLabels() } @Sendable private func delayColor() async { @@ -499,7 +499,7 @@ struct ScheduleMeetingFragment: View { } #Preview { - ScheduleMeetingFragment(scheduleMeetingViewModel: ScheduleMeetingViewModel() + ScheduleMeetingFragment(meetingViewModel: MeetingViewModel() , meetingsListViewModel: MeetingsListViewModel() , isShowScheduleMeetingFragment: .constant(true)) } diff --git a/Linphone/UI/Main/Meetings/MeetingsView.swift b/Linphone/UI/Main/Meetings/MeetingsView.swift index 9d82c8780..ce663f0c8 100644 --- a/Linphone/UI/Main/Meetings/MeetingsView.swift +++ b/Linphone/UI/Main/Meetings/MeetingsView.swift @@ -1,27 +1,39 @@ -// -// MeetingsView.swift -// Linphone -// -// Created by QuentinArguillere on 18/04/2024. -// +/* + * 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 { @ObservedObject var meetingsListViewModel: MeetingsListViewModel - @ObservedObject var scheduleMeetingViewModel: ScheduleMeetingViewModel + @ObservedObject var meetingViewModel: MeetingViewModel @Binding var isShowScheduleMeetingFragment: Bool var body: some View { NavigationView { ZStack(alignment: .bottomTrailing) { - MeetingsFragment(meetingsListViewModel: meetingsListViewModel, scheduleMeetingViewModel: scheduleMeetingViewModel) + MeetingsFragment(meetingsListViewModel: meetingsListViewModel, meetingViewModel: meetingViewModel) Button { withAnimation { - scheduleMeetingViewModel.resetViewModelData() + meetingViewModel.resetViewModelData() isShowScheduleMeetingFragment.toggle() } } label: { @@ -44,7 +56,7 @@ struct MeetingsView: View { #Preview { MeetingsView( meetingsListViewModel: MeetingsListViewModel(), - scheduleMeetingViewModel: ScheduleMeetingViewModel(), + meetingViewModel: MeetingViewModel(), isShowScheduleMeetingFragment: .constant(false) ) } diff --git a/Linphone/UI/Main/Meetings/Models/MeetingModel.swift b/Linphone/UI/Main/Meetings/Models/MeetingModel.swift index ba89195dd..d363a2dcd 100644 --- a/Linphone/UI/Main/Meetings/Models/MeetingModel.swift +++ b/Linphone/UI/Main/Meetings/Models/MeetingModel.swift @@ -1,9 +1,21 @@ -// -// MeetingModel.swift -// Linphone -// -// Created by QuentinArguillere on 19/03/2024. -// +/* + * 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 diff --git a/Linphone/UI/Main/Meetings/Models/MeetingsListItemModel.swift b/Linphone/UI/Main/Meetings/Models/MeetingsListItemModel.swift index 520a98bc9..644b118d5 100644 --- a/Linphone/UI/Main/Meetings/Models/MeetingsListItemModel.swift +++ b/Linphone/UI/Main/Meetings/Models/MeetingsListItemModel.swift @@ -1,9 +1,21 @@ -// -// MeetingsListItemModel.swift -// Linphone -// -// Created by QuentinArguillere on 19/03/2024. -// +/* + * 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 { diff --git a/Linphone/UI/Main/Meetings/ViewModel/MeetingViewModel.swift b/Linphone/UI/Main/Meetings/ViewModel/MeetingViewModel.swift index 681bef225..2558ef730 100644 --- a/Linphone/UI/Main/Meetings/ViewModel/MeetingViewModel.swift +++ b/Linphone/UI/Main/Meetings/ViewModel/MeetingViewModel.swift @@ -19,145 +19,321 @@ import Foundation import linphonesw +import Combine class MeetingViewModel: ObservableObject { - static let TAG = "[Meeting ViewModel]" - /* - private var coreContext = CoreContext.shared + static let TAG = "[MeetingViewModel]" - @Published var showBackbutton: Bool = false - @Published var isBroadcast: Bool = false - @Published var isEditable: Bool = false + @Published var isBroadcastSelected: Bool = false + @Published var showBroadcastHelp: Bool = false @Published var subject: String = "" - @Published var sipUri: String = "" - @Published var description: String? + @Published var description: String = "" + @Published var allDayMeeting: Bool = false + @Published var fromDateStr: String = "" + @Published var fromTime: String = "" + @Published var toDateStr: String = "" + @Published var toTime: String = "" @Published var timezone: String = "" - @Published var startDate: Date? - @Published var endDate: Date? - @Published var dateTime: 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 speakers: [ParticipantModel] = [] - @Published var participants: [ParticipantModel] = [] - @Published var conferenceInfoFoundEvent: Bool = false + var conferenceScheduler: ConferenceScheduler? + private var mSchedulerSubscriptions = Set() + 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 = "" - var meetingModel: MeetingModel - - init(model: MeetingModel) { - meetingModel = model + init() { + fromDate = Calendar.current.date(byAdding: .hour, value: 1, to: Date.now)! + toDate = Calendar.current.date(byAdding: .hour, value: 2, to: Date.now)! + computeDateLabels() + computeTimeLabels() + updateTimezone() } - func findConferenceInfo(uri: String) { - coreContext.doOnCoreQueue { core in - var confInfoFound = false - if let address = try? Factory.Instance.createAddress(addr: uri) { - - if let confInfo = core.findConferenceInformationFromUri(uri: address) { - Log.info("\(MeetingViewModel.TAG) Conference info with SIP URI \(uri) was found") - self.meetingModel.confInfo = confInfo - self.configureConferenceInfo(core: core) - confInfoFound = true - } else { - Log.error("\(MeetingViewModel.TAG) Conference info with SIP URI \(uri) couldn't be found!") - confInfoFound = false - } - } else { - Log.error("\(MeetingViewModel.TAG) Failed to parse SIP URI \(uri) as Address!") - confInfoFound = false - } - DispatchQueue.main.sync { - self.conferenceInfoFoundEvent = confInfoFound - } - } + func resetViewModelData() { + isBroadcastSelected = false + showBroadcastHelp = false + subject = "" + description = "" + allDayMeeting = false + timezone = "" + 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)! + computeDateLabels() + computeTimeLabels() + updateTimezone() } - private func configureConferenceInfo(core: Core) { - /* - timezone.postValue( - AppUtils.getFormattedString( - R.string.meeting_schedule_timezone_title, - TimeZone.getDefault().displayName - ) - .replaceFirstChar { if (it.isLowerCase()) it.titlecase(Locale.getDefault()) else it.toString() } - ) - */ - - var isEditable = false - - if let organizerAddress = meetingModel.confInfo.organizer { - let localAccount = core.accountList.first(where: { - if let address = $0.params?.identityAddress { - return organizerAddress.weakEqual(address2: address) - } else { - return false - } - }) - - isEditable = localAccount != nil - } else { - Log.error("\(MeetingViewModel.TAG) No organizer SIP URI found for: \(meetingModel.confInfo.uri?.asStringUriOnly() ?? "(empty)")") - } - - let startDate = Date(timeIntervalSince1970: TimeInterval(meetingModel.confInfo.dateTime)) - let endDate = Calendar.current.date(byAdding: .minute, value: Int(meetingModel.confInfo.duration), to: startDate)! + func computeDateLabels() { + var day = fromDate.formatted(Date.FormatStyle().weekday(.wide)) + var dayNumber = fromDate.formatted(Date.FormatStyle().day(.twoDigits)) + var month = fromDate.formatted(Date.FormatStyle().month(.wide)) + fromDateStr = "\(day) \(dayNumber), \(month)" + Log.info("\(MeetingViewModel.TAG) computed start date is \(fromDateStr)") + day = toDate.formatted(Date.FormatStyle().weekday(.wide)) + dayNumber = toDate.formatted(Date.FormatStyle().day(.twoDigits)) + month = toDate.formatted(Date.FormatStyle().month(.wide)) + toDateStr = "\(day) \(dayNumber), \(month)" + 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" - let startTime = formatter.string(from: startDate) - let endTime = formatter.string(from: endDate) - let dateTime = "\(startTime) - \(endTime)" - - DispatchQueue.main.async { - self.subject = self.meetingModel.confInfo.subject ?? "" - self.sipUri = self.meetingModel.confInfo.uri?.asStringUriOnly() ?? "" - self.description = self.meetingModel.confInfo.description - self.startDate = startDate - self.endDate = endDate - self.dateTime = dateTime - self.isEditable = isEditable - } - - self.computeParticipantsList() + fromTime = formatter.string(from: fromDate) + toTime = formatter.string(from: toDate) } - private func computeParticipantsList() { - var speakersList: [ParticipantModel] = [] - var participantsList: [ParticipantModel] = [] - var allSpeaker = true - let organizer = meetingModel.confInfo.organizer - var organizerFound = false - for pInfo in meetingModel.confInfo.participantInfos { - if let participantAddress = pInfo.address { - let isOrganizer = organizer != nil && organizer!.weakEqual(address2: participantAddress) - - Log.info("\(MeetingViewModel.TAG) Conference \(meetingModel.confInfo.subject)[${conferenceInfo.subject}] \(isOrganizer ? "organizer: " : "participant: ") \(participantAddress.asStringUriOnly()) is a \(pInfo.role)") - if isOrganizer { - organizerFound = true - } - - if pInfo.role == Participant.Role.Listener { - allSpeaker = false - participantsList.append(ParticipantModel(address: participantAddress)) - } else { - speakersList.append(ParticipantModel(address: participantAddress)) - } + func getFullDateString() -> String { + var day = fromDate.formatted(Date.FormatStyle().weekday(.abbreviated)) + var dayNumber = fromDate.formatted(Date.FormatStyle().day(.twoDigits)) + var month = fromDate.formatted(Date.FormatStyle().month(.wide)) + var year = fromDate.formatted(Date.FormatStyle().year(.defaultDigits)) + return "\(day). \(dayNumber) \(month) \(year) | \(allDayMeeting ? "All day" : "\(fromTime) - \(toTime)")" + } + + private func updateTimezone() { + // TODO + } + + 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 initConferenceSchedulerAndListeners(core: Core) { + self.conferenceScheduler = try? core.createConferenceScheduler() - if allSpeaker { - Log.info("$TAG All participants have Speaker role, considering it is a meeting") - participantsList = speakersList + self.mSchedulerSubscriptions.insert(self.conferenceScheduler?.publisher?.onStateChanged?.postOnCoreQueue { (cbVal: (conferenceScheduler: ConferenceScheduler, state: ConferenceScheduler.State)) in + + Log.info("\(MeetingViewModel.TAG) Conference state changed \(cbVal.state)") + if cbVal.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 cbVal.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") + if let chatRoomParams = try? core.createDefaultChatRoomParams() { + chatRoomParams.groupEnabled = false + chatRoomParams.backend = ChatRoom.Backend.FlexisipChat + chatRoomParams.encryptionEnabled = true + chatRoomParams.subject = "Meeting invitation" // Won't be used + self.conferenceScheduler?.sendInvitations(chatRoomParams: chatRoomParams) + } else { + Log.error("\(MeetingViewModel.TAG) Failed to create default chatroom parameters. This should not happen") + } + } else { + Log.info("\(MeetingViewModel.TAG) User didn't asked for invitations to be sent") + DispatchQueue.main.async { + self.operationInProgress = false + self.conferenceCreatedEvent = true + } + } + } + }) + + self.mSchedulerSubscriptions.insert(self.conferenceScheduler?.publisher?.onInvitationsSent?.postOnCoreQueue { (cbVal: (conferenceScheduler: ConferenceScheduler, failedInvitations: [Address])) in + + if cbVal.failedInvitations.isEmpty { + Log.info("\(MeetingViewModel.TAG) All invitations have been sent") + } else if cbVal.failedInvitations.count == self.participants.count { + Log.error("\(MeetingViewModel.TAG) No invitation sent!") + // TODO: show error toast + } else { + Log.warn("\(MeetingViewModel.TAG) \(cbVal.failedInvitations.count) invitations couldn't have been sent for:") + for failInv in cbVal.failedInvitations { + Log.warn(failInv.asStringUriOnly()) + } + // TODO: show error toast + } + + DispatchQueue.main.async { + self.operationInProgress = false + self.conferenceCreatedEvent = true + } + }) + } + + func schedule() { + if subject.isEmpty || participants.isEmpty { + Log.error("\(MeetingViewModel.TAG) Either no subject was set or no participant was selected, can't schedule meeting.") + // TODO: show red toast + return } + operationInProgress = true - if !organizerFound, let organizerAddress = organizer { - Log.info("$TAG Organizer not found in participants list, adding it to participants list") - participantsList.append(ParticipantModel(address: organizerAddress)) - } - - DispatchQueue.main.async { - self.isBroadcast = !allSpeaker - self.speakers = speakersList - self.participants = participantsList + 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) + if self.conferenceScheduler == nil { + self.initConferenceSchedulerAndListeners(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) + if self.conferenceScheduler == nil { + self.initConferenceSchedulerAndListeners(core: core) + } + + // Will trigger the conference update automatically + self.conferenceScheduler?.info = conferenceInfo + } else { + Log.error("No conference info to edit found!") + return + } + } + } + + func loadExistingMeeting(meeting: MeetingModel) { + DispatchQueue.main.async { + self.resetViewModelData() + self.subject = meeting.confInfo.subject ?? "" + self.description = meeting.confInfo.description ?? "" + self.fromDate = meeting.meetingDate + self.toDate = meeting.endDate + self.participants = [] + + let organizer = meeting.confInfo.organizer + CoreContext.shared.doOnCoreQueue { core in + if let myAddr = core.defaultAccount?.contactAddress { + ContactAvatarModel.getAvatarModelFromAddress(address: myAddr) { avatarResult in + DispatchQueue.main.async { + let isOrganizer = (organizer != nil) ? myAddr.weakEqual(address2: organizer!) : false + self.myself = SelectedAddressModel(addr: myAddr, avModel: avatarResult, isOrg: isOrganizer) + } + } + } + } + + for pInfo in meeting.confInfo.participantInfos { + if let addr = pInfo.address { + ContactAvatarModel.getAvatarModelFromAddress(address: addr) { avatarResult in + DispatchQueue.main.async { + let isOrganizer = (organizer != nil) ? addr.weakEqual(address2: organizer!) : false + self.participants.append(SelectedAddressModel(addr: addr, avModel: avatarResult, isOrg:isOrganizer)) + } + } + } + } + self.conferenceUri = meeting.confInfo.uri?.asStringUriOnly() ?? "" + self.computeDateLabels() + self.computeTimeLabels() + self.updateTimezone() + self.displayedMeeting = meeting + } + } + + func loadExistingConferenceInfoFromUri(conferenceUri: String) { + CoreContext.shared.doOnCoreQueue { core in + if let conferenceAddress = core.interpretUrl(url: conferenceUri, applyInternationalPrefix: false) { + if let conferenceInfo = core.findConferenceInformationFromUri(uri: conferenceAddress) { + + self.conferenceInfoToEdit = conferenceInfo + Log.info("\(MeetingViewModel.TAG) Found conference info matching URI \(conferenceInfo.uri?.asString()) with subject \(conferenceInfo.subject)") + + self.fromDate = Date(timeIntervalSince1970: TimeInterval(conferenceInfo.dateTime)) + self.toDate = Calendar.current.date(byAdding: .minute, value: Int(conferenceInfo.duration), to: self.fromDate)! + + var list: [SelectedAddressModel] = [] + for partInfo in conferenceInfo.participantInfos { + if let addr = partInfo.address { + ContactAvatarModel.getAvatarModelFromAddress(address: addr) { avatarResult in + let avatarModel = avatarResult + self.participants.append(SelectedAddressModel(addr: addr, avModel: avatarModel)) + Log.info("\(MeetingViewModel.TAG) Loaded participant \(addr.asStringUriOnly())") + } + } + } + Log.info("\(MeetingViewModel.TAG) \(list.count) participants loaded from found conference info") + + DispatchQueue.main.async { + self.subject = conferenceInfo.subject ?? "" + self.description = conferenceInfo.description ?? "" + self.isBroadcastSelected = false // TODO FIXME + self.computeDateLabels() + self.computeTimeLabels() + self.updateTimezone() + //self.participants = list + } + + } else { + Log.error("\(MeetingViewModel.TAG) Failed to find a conference info matching URI [${conferenceAddress.asString()}], abort") + } + } else { + Log.error("\(MeetingViewModel.TAG) Failed to parse conference URI [$conferenceUri], abort") + } + + } + } + } diff --git a/Linphone/UI/Main/Meetings/ViewModel/ScheduleMeetingViewModel.swift b/Linphone/UI/Main/Meetings/ViewModel/ScheduleMeetingViewModel.swift deleted file mode 100644 index 579b34a56..000000000 --- a/Linphone/UI/Main/Meetings/ViewModel/ScheduleMeetingViewModel.swift +++ /dev/null @@ -1,339 +0,0 @@ -/* - * 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 ScheduleMeetingViewModel: ObservableObject { - static let TAG = "[ScheduleMeetingViewModel]" - - @Published var isBroadcastSelected: Bool = false - @Published var showBroadcastHelp: Bool = false - @Published var subject: String = "" - @Published var description: String = "" - @Published var allDayMeeting: Bool = false - @Published var fromDateStr: String = "" - @Published var fromTime: String = "" - @Published var toDateStr: String = "" - @Published var toTime: String = "" - @Published var timezone: String = "" - @Published var sendInvitations: Bool = true - @Published var participants: [SelectedAddressModel] = [] - @Published var operationInProgress: Bool = false - @Published var conferenceCreatedEvent: Bool = false - @Published var conferenceUri: String = "" - - var conferenceScheduler: ConferenceScheduler? - private var mSchedulerSubscriptions = Set() - 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)! - computeDateLabels() - computeTimeLabels() - updateTimezone() - } - - func resetViewModelData() { - isBroadcastSelected = false - showBroadcastHelp = false - subject = "" - description = "" - allDayMeeting = false - timezone = "" - 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)! - computeDateLabels() - computeTimeLabels() - updateTimezone() - } - - func computeDateLabels() { - var day = fromDate.formatted(Date.FormatStyle().weekday(.wide)) - var dayNumber = fromDate.formatted(Date.FormatStyle().day(.twoDigits)) - var month = fromDate.formatted(Date.FormatStyle().month(.wide)) - fromDateStr = "\(day) \(dayNumber), \(month)" - Log.info("\(ScheduleMeetingViewModel.TAG) computed start date is \(fromDateStr)") - - day = toDate.formatted(Date.FormatStyle().weekday(.wide)) - dayNumber = toDate.formatted(Date.FormatStyle().day(.twoDigits)) - month = toDate.formatted(Date.FormatStyle().month(.wide)) - toDateStr = "\(day) \(dayNumber), \(month)" - Log.info("\(ScheduleMeetingViewModel.TAG)) computed end date is \(toDateStr)") - } - - func computeTimeLabels() { - let formatter = DateFormatter() - formatter.dateFormat = Locale.current.identifier == "fr_FR" ? "HH:mm" : "h:mm a" - fromTime = formatter.string(from: fromDate) - toTime = formatter.string(from: toDate) - } - - func getFullDateString() -> String { - var day = fromDate.formatted(Date.FormatStyle().weekday(.abbreviated)) - var dayNumber = fromDate.formatted(Date.FormatStyle().day(.twoDigits)) - var month = fromDate.formatted(Date.FormatStyle().month(.wide)) - var year = fromDate.formatted(Date.FormatStyle().year(.defaultDigits)) - return "\(day). \(dayNumber) \(month) \(year) | \(allDayMeeting ? "All day" : "\(fromTime) - \(toTime)")" - } - - private func updateTimezone() { - // TODO - } - - 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("\(ScheduleMeetingViewModel.TAG) Participant \(found.address.asStringUriOnly()) already in list, skipping") - continue - } - - list.append(selectedAddr) - Log.info("\(ScheduleMeetingViewModel.TAG) Added participant \(selectedAddr.address.asStringUriOnly())") - } - Log.info("\(ScheduleMeetingViewModel.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("\(ScheduleMeetingViewModel.TAG) Failed to create Participant Info from address \(participant.address.asStringUriOnly())") - } - } - confInfo.participantInfos = participantsInfoList - } - - private func initConferenceSchedulerAndListeners(core: Core) { - self.conferenceScheduler = try? core.createConferenceScheduler() - - self.mSchedulerSubscriptions.insert(self.conferenceScheduler?.publisher?.onStateChanged?.postOnCoreQueue { (cbVal: (conferenceScheduler: ConferenceScheduler, state: ConferenceScheduler.State)) in - - Log.info("\(ScheduleMeetingViewModel.TAG) Conference state changed \(cbVal.state)") - if cbVal.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 cbVal.state == ConferenceScheduler.State.Ready { - let conferenceAddress = self.conferenceScheduler?.info?.uri - if let confInfoToEdit = self.conferenceInfoToEdit { - Log.info("\(ScheduleMeetingViewModel.TAG) Conference info \(confInfoToEdit.uri?.asStringUriOnly() ?? "'nil'") has been updated") - } else { - Log.info("\(ScheduleMeetingViewModel.TAG) Conference info created, address will be \(conferenceAddress?.asStringUriOnly() ?? "'nil'")") - } - - if self.sendInvitations { - Log.info("\(ScheduleMeetingViewModel.TAG) User asked for invitations to be sent, let's do it") - if let chatRoomParams = try? core.createDefaultChatRoomParams() { - chatRoomParams.groupEnabled = false - chatRoomParams.backend = ChatRoom.Backend.FlexisipChat - chatRoomParams.encryptionEnabled = true - chatRoomParams.subject = "Meeting invitation" // Won't be used - self.conferenceScheduler?.sendInvitations(chatRoomParams: chatRoomParams) - } else { - Log.error("\(ScheduleMeetingViewModel.TAG) Failed to create default chatroom parameters. This should not happen") - } - } else { - Log.info("\(ScheduleMeetingViewModel.TAG) User didn't asked for invitations to be sent") - DispatchQueue.main.async { - self.operationInProgress = false - self.conferenceCreatedEvent = true - } - } - } - }) - - self.mSchedulerSubscriptions.insert(self.conferenceScheduler?.publisher?.onInvitationsSent?.postOnCoreQueue { (cbVal: (conferenceScheduler: ConferenceScheduler, failedInvitations: [Address])) in - - if cbVal.failedInvitations.isEmpty { - Log.info("\(ScheduleMeetingViewModel.TAG) All invitations have been sent") - } else if cbVal.failedInvitations.count == self.participants.count { - Log.error("\(ScheduleMeetingViewModel.TAG) No invitation sent!") - // TODO: show error toast - } else { - Log.warn("\(ScheduleMeetingViewModel.TAG) \(cbVal.failedInvitations.count) invitations couldn't have been sent for:") - for failInv in cbVal.failedInvitations { - Log.warn(failInv.asStringUriOnly()) - } - // TODO: show error toast - } - - DispatchQueue.main.async { - self.operationInProgress = false - self.conferenceCreatedEvent = true - } - }) - } - - func schedule() { - if subject.isEmpty || participants.isEmpty { - Log.error("\(ScheduleMeetingViewModel.TAG) Either no subject was set or no participant was selected, can't schedule meeting.") - // TODO: show red toast - return - } - operationInProgress = true - - CoreContext.shared.doOnCoreQueue { core in - Log.info("\(ScheduleMeetingViewModel.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) - if self.conferenceScheduler == nil { - self.initConferenceSchedulerAndListeners(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("\(ScheduleMeetingViewModel.TAG) Updating \(self.isBroadcastSelected ? "broadcast" : "meeting")") - - if let conferenceInfo = self.conferenceInfoToEdit { - self.fillConferenceInfo(confInfo: conferenceInfo) - if self.conferenceScheduler == nil { - self.initConferenceSchedulerAndListeners(core: core) - } - - // Will trigger the conference update automatically - self.conferenceScheduler?.info = conferenceInfo - } else { - Log.error("No conference info to edit found!") - return - } - } - } - - func loadExistingMeeting(meeting: MeetingModel) { - DispatchQueue.main.async { - self.resetViewModelData() - self.subject = meeting.confInfo.subject ?? "" - self.description = meeting.confInfo.description ?? "" - self.fromDate = meeting.meetingDate - self.toDate = meeting.endDate - self.participants = [] - - let organizer = meeting.confInfo.organizer - CoreContext.shared.doOnCoreQueue { core in - if let myAddr = core.defaultAccount?.contactAddress { - ContactAvatarModel.getAvatarModelFromAddress(address: myAddr) { avatarResult in - DispatchQueue.main.async { - let isOrganizer = (organizer != nil) ? myAddr.weakEqual(address2: organizer!) : false - self.myself = SelectedAddressModel(addr: myAddr, avModel: avatarResult, isOrg: isOrganizer) - } - } - } - } - - for pInfo in meeting.confInfo.participantInfos { - if let addr = pInfo.address { - ContactAvatarModel.getAvatarModelFromAddress(address: addr) { avatarResult in - DispatchQueue.main.async { - let isOrganizer = (organizer != nil) ? addr.weakEqual(address2: organizer!) : false - self.participants.append(SelectedAddressModel(addr: addr, avModel: avatarResult, isOrg:isOrganizer)) - } - } - } - } - self.conferenceUri = meeting.confInfo.uri?.asStringUriOnly() ?? "" - self.computeDateLabels() - self.computeTimeLabels() - self.updateTimezone() - self.displayedMeeting = meeting - } - } - - func loadExistingConferenceInfoFromUri(conferenceUri: String) { - CoreContext.shared.doOnCoreQueue { core in - if let conferenceAddress = core.interpretUrl(url: conferenceUri, applyInternationalPrefix: false) { - if let conferenceInfo = core.findConferenceInformationFromUri(uri: conferenceAddress) { - - self.conferenceInfoToEdit = conferenceInfo - Log.info("\(ScheduleMeetingViewModel.TAG) Found conference info matching URI \(conferenceInfo.uri?.asString()) with subject \(conferenceInfo.subject)") - - self.fromDate = Date(timeIntervalSince1970: TimeInterval(conferenceInfo.dateTime)) - self.toDate = Calendar.current.date(byAdding: .minute, value: Int(conferenceInfo.duration), to: self.fromDate)! - - var list: [SelectedAddressModel] = [] - for partInfo in conferenceInfo.participantInfos { - if let addr = partInfo.address { - ContactAvatarModel.getAvatarModelFromAddress(address: addr) { avatarResult in - let avatarModel = avatarResult - self.participants.append(SelectedAddressModel(addr: addr, avModel: avatarModel)) - Log.info("\(ScheduleMeetingViewModel.TAG) Loaded participant \(addr.asStringUriOnly())") - } - } - } - Log.info("\(ScheduleMeetingViewModel.TAG) \(list.count) participants loaded from found conference info") - - DispatchQueue.main.async { - self.subject = conferenceInfo.subject ?? "" - self.description = conferenceInfo.description ?? "" - self.isBroadcastSelected = false // TODO FIXME - self.computeDateLabels() - self.computeTimeLabels() - self.updateTimezone() - //self.participants = list - } - - } else { - Log.error("\(ScheduleMeetingViewModel.TAG) Failed to find a conference info matching URI [${conferenceAddress.asString()}], abort") - } - } else { - Log.error("\(ScheduleMeetingViewModel.TAG) Failed to parse conference URI [$conferenceUri], abort") - } - - } - } - -} diff --git a/Linphone/UI/Main/Viewmodel/AddParticipantsViewModel.swift b/Linphone/UI/Main/Viewmodel/AddParticipantsViewModel.swift index 6e1f2d647..67ee27ff8 100644 --- a/Linphone/UI/Main/Viewmodel/AddParticipantsViewModel.swift +++ b/Linphone/UI/Main/Viewmodel/AddParticipantsViewModel.swift @@ -1,9 +1,21 @@ -// -// AddParticipantsViewModel.swift -// Linphone -// -// Created by QuentinArguillere on 29/04/2024. -// +/* + * 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 From 282310f6c2d894c318af8d1d868900064d8449ad Mon Sep 17 00:00:00 2001 From: QuentinArguillere Date: Fri, 21 Jun 2024 18:07:36 +0200 Subject: [PATCH 282/486] Fix participant list in meeting details view : if myself isn't the organizer, then I was displayed twice and the actual organizer was missing --- .../Meetings/Fragments/MeetingFragment.swift | 2 +- .../Meetings/ViewModel/MeetingViewModel.swift | 39 +++++++++++++------ 2 files changed, 28 insertions(+), 13 deletions(-) diff --git a/Linphone/UI/Main/Meetings/Fragments/MeetingFragment.swift b/Linphone/UI/Main/Meetings/Fragments/MeetingFragment.swift index fd7d0c18b..7b198d4bf 100644 --- a/Linphone/UI/Main/Meetings/Fragments/MeetingFragment.swift +++ b/Linphone/UI/Main/Meetings/Fragments/MeetingFragment.swift @@ -216,7 +216,7 @@ struct MeetingFragment: View { ScrollView { VStack(alignment: .leading, spacing: 0) { - if meetingViewModel.myself != nil { + if meetingViewModel.myself != nil && meetingViewModel.myself!.isOrganizer { getParticipantLine(participant: meetingViewModel.myself!) } ForEach(0.. Date: Fri, 21 Jun 2024 18:08:07 +0200 Subject: [PATCH 283/486] Enable "share" button for conference URI in meeting details view --- .../Meetings/Fragments/MeetingFragment.swift | 27 ++++++++++++++----- 1 file changed, 21 insertions(+), 6 deletions(-) diff --git a/Linphone/UI/Main/Meetings/Fragments/MeetingFragment.swift b/Linphone/UI/Main/Meetings/Fragments/MeetingFragment.swift index 7b198d4bf..dcdc9e666 100644 --- a/Linphone/UI/Main/Meetings/Fragments/MeetingFragment.swift +++ b/Linphone/UI/Main/Meetings/Fragments/MeetingFragment.swift @@ -20,6 +20,7 @@ // swiftlint:disable line_length import SwiftUI import linphonesw +import UniformTypeIdentifiers struct MeetingFragment: View { @@ -150,12 +151,26 @@ struct MeetingFragment: View { .default_text_style(styleSize: 14) Spacer() - Image("share-network") - .renderingMode(.template) - .resizable() - .foregroundStyle(Color.grayMain2c800) - .frame(width: 25, height: 25) - .padding(.trailing, 15) + Button(action: { + UIPasteboard.general.setValue( + meetingViewModel.conferenceUri, + forPasteboardType: UTType.plainText.identifier + ) + + DispatchQueue.main.async { + ToastViewModel.shared.toastMessage = "Success_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) { From 89367bb7cdd817f410c14009f8ac7751fb6156ed Mon Sep 17 00:00:00 2001 From: QuentinArguillere Date: Fri, 21 Jun 2024 18:08:50 +0200 Subject: [PATCH 284/486] Various padding and display update, and hold ScheduleMeetingFragment in a scrollview to make it scrollable in landscape view --- .../Meetings/Fragments/MeetingsFragment.swift | 48 +- .../Fragments/ScheduleMeetingFragment.swift | 427 ++++++++---------- 2 files changed, 201 insertions(+), 274 deletions(-) diff --git a/Linphone/UI/Main/Meetings/Fragments/MeetingsFragment.swift b/Linphone/UI/Main/Meetings/Fragments/MeetingsFragment.swift index 8d4fbc425..3d2bc8780 100644 --- a/Linphone/UI/Main/Meetings/Fragments/MeetingsFragment.swift +++ b/Linphone/UI/Main/Meetings/Fragments/MeetingsFragment.swift @@ -31,7 +31,7 @@ struct MeetingsFragment: View { @ViewBuilder func createMonthLine(model: MeetingsListItemModel) -> some View { - return Text(model.monthStr) + Text(model.monthStr) .fontWeight(.bold) .padding(5) .default_text_style_500(styleSize: 22) @@ -39,15 +39,15 @@ struct MeetingsFragment: View { @ViewBuilder func createWeekLine(model: MeetingsListItemModel) -> some View { - return Text(model.weekStr) + Text(model.weekStr) .padding(.leading, 43) - .padding(.top, 3) - .padding(.bottom, 3) + .padding(.top, 5) + .padding(.bottom, 5) .default_text_style_500(styleSize: 14) } @ViewBuilder func createMeetingLine(model: MeetingsListItemModel) -> some View { - return VStack(alignment: .leading) { + VStack(alignment: .leading, spacing: 0) { if model.isToday { Text("No meeting today") .fontWeight(.bold) @@ -59,15 +59,14 @@ struct MeetingsFragment: View { .resizable() .foregroundStyle(Color.grayMain2c600) .frame(width: 24, height: 24) - .padding(.bottom, -8) + .padding(.bottom, -5) Text(model.model!.subject) .fontWeight(.bold) .padding(.trailing, 5) - .padding(.top, 7) + .padding(.top, 5) .default_text_style_500(styleSize: 15) } Text(model.model!.time) - .padding(.top, -8) .default_text_style_500(styleSize: 15) } } @@ -77,6 +76,7 @@ struct MeetingsFragment: View { .background(.white) .clipShape(RoundedRectangle(cornerRadius: 20)) .shadow(color: .black.opacity(0.2), radius: 4) + .padding(.bottom, 5) .onTapGesture { withAnimation { if let meetingModel = model.model { @@ -89,7 +89,7 @@ struct MeetingsFragment: View { var body: some View { VStack { List { - VStack(alignment: .leading) { + VStack(alignment: .leading, spacing: 0) { ForEach(0.. some View { - return GeometryReader { geometry in + GeometryReader { geometry in VStack(alignment: .leading) { Text("Select \(setFromDate ? "start" : "end") \(isTimeSelection ? "time" : "date")") .default_text_style_800(styleSize: 16) From ffe8c0fd458afd2697176ae09746a0a570547a48 Mon Sep 17 00:00:00 2001 From: QuentinArguillere Date: Mon, 24 Jun 2024 17:45:58 +0200 Subject: [PATCH 285/486] Add filtering for conferences --- Linphone/UI/Main/ContentView.swift | 21 ++++++++++---- .../ViewModel/MeetingsListViewModel.swift | 29 +++++++++++++++---- 2 files changed, 39 insertions(+), 11 deletions(-) diff --git a/Linphone/UI/Main/ContentView.swift b/Linphone/UI/Main/ContentView.swift index c09d8a1e6..6fd3e2e32 100644 --- a/Linphone/UI/Main/ContentView.swift +++ b/Linphone/UI/Main/ContentView.swift @@ -355,7 +355,7 @@ struct ContentView: View { } } } label: { - Image(index == 0 ? "funnel" : "dots-three-vertical") + Image(index == 0 ? "funnel" : (index == 3 ? "calendar" : "dots-three-vertical")) .renderingMode(.template) .resizable() .foregroundStyle(.white) @@ -390,8 +390,11 @@ struct ContentView: View { sourceFlags: MagicSearch.Source.Friends.rawValue | MagicSearch.Source.LdapServers.rawValue) } else if index == 1 { historyListViewModel.resetFilterCallLogs() - } else { + } else if index == 2 { //TODO Conversations List reset + } else if index == 3 { + meetingsListViewModel.currentFilter = "" + meetingsListViewModel.computeMeetingsList() } } label: { Image("caret-left") @@ -431,8 +434,11 @@ struct ContentView: View { sourceFlags: MagicSearch.Source.Friends.rawValue | MagicSearch.Source.LdapServers.rawValue) } else if index == 1 { historyListViewModel.filterCallLogs(filter: text) - } else { - //TODO Conversations List Filter + } else if index == 2 { + //TODO Conversations List reset + } else if index == 3 { + meetingsListViewModel.currentFilter = text + meetingsListViewModel.computeMeetingsList() } } } else { @@ -461,8 +467,11 @@ struct ContentView: View { sourceFlags: MagicSearch.Source.Friends.rawValue | MagicSearch.Source.LdapServers.rawValue) } else if index == 1 { historyListViewModel.filterCallLogs(filter: text) - } else { - //TODO Conversations List Filter + } else if index == 2 { + //TODO Conversations List reset + } else if index == 3 { + meetingsListViewModel.currentFilter = text + meetingsListViewModel.computeMeetingsList() } } } diff --git a/Linphone/UI/Main/Meetings/ViewModel/MeetingsListViewModel.swift b/Linphone/UI/Main/Meetings/ViewModel/MeetingsListViewModel.swift index 069fb18fd..d48b7578c 100644 --- a/Linphone/UI/Main/Meetings/ViewModel/MeetingsListViewModel.swift +++ b/Linphone/UI/Main/Meetings/ViewModel/MeetingsListViewModel.swift @@ -30,6 +30,7 @@ class MeetingsListViewModel: ObservableObject { @Published var meetingsList: [MeetingsListItemModel] = [] @Published var currentFilter = "" + @Published var todayIdx = 0 init() { coreContext.doOnCoreQueue { core in @@ -42,7 +43,8 @@ class MeetingsListViewModel: ObservableObject { } func computeMeetingsList() { - let filter = self.currentFilter + let filter = self.currentFilter.uppercased() + let isFiltering = !filter.isEmpty coreContext.doOnCoreQueue { core in var confInfoList: [ConferenceInfo] = [] @@ -55,8 +57,9 @@ class MeetingsListViewModel: ObservableObject { } var meetingsListTmp: [MeetingsListItemModel] = [] - var previousModel: MeetingModel? 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 @@ -72,24 +75,40 @@ class MeetingsListViewModel: ObservableObject { if add { let model = MeetingModel(conferenceInfo: confInfo) - if !meetingForTodayFound { + 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 } } - meetingsListTmp.append(MeetingsListItemModel(meetingModel: model)) - previousModel = model + 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 + } } } DispatchQueue.main.sync { + self.todayIdx = todayIdx self.meetingsList = meetingsListTmp } } } + } From 9c949e632d353b1407a4ae538a39b1c188543103 Mon Sep 17 00:00:00 2001 From: QuentinArguillere Date: Mon, 24 Jun 2024 17:46:12 +0200 Subject: [PATCH 286/486] Remove the delayed color for add aprticipants view --- .../Meetings/Fragments/AddParticipantsFragment.swift | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/Linphone/UI/Main/Meetings/Fragments/AddParticipantsFragment.swift b/Linphone/UI/Main/Meetings/Fragments/AddParticipantsFragment.swift index 2466f59b2..c49227ce3 100644 --- a/Linphone/UI/Main/Meetings/Fragments/AddParticipantsFragment.swift +++ b/Linphone/UI/Main/Meetings/Fragments/AddParticipantsFragment.swift @@ -33,7 +33,6 @@ struct AddParticipantsFragment: View { @ObservedObject var addParticipantsViewModel: AddParticipantsViewModel var confirmAddParticipantsFunc: ([SelectedAddressModel]) -> Void - @State private var delayedColor = Color.white @FocusState var isSearchFieldFocused: Bool var body: some View { @@ -41,17 +40,15 @@ struct AddParticipantsFragment: View { VStack(spacing: 16) { if #available(iOS 16.0, *) { Rectangle() - .foregroundColor(delayedColor) + .foregroundColor(Color.orangeMain500) .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) + .foregroundColor(Color.orangeMain500) .edgesIgnoringSafeArea(.top) .frame(height: 1) - .task(delayColor) } HStack { Image("caret-left") @@ -252,11 +249,6 @@ struct AddParticipantsFragment: View { .navigationBarHidden(true) } - @Sendable private func delayColor() async { - try? await Task.sleep(nanoseconds: 250_000_000) - delayedColor = Color.orangeMain500 - } - var suggestionsList: some View { ForEach(0.. Date: Tue, 25 Jun 2024 12:31:39 +0200 Subject: [PATCH 287/486] meetings list - scroll to "Today" on appear and when pressing the top right calendar button --- Linphone/UI/Main/ContentView.swift | 15 +++- .../Meetings/Fragments/MeetingsFragment.swift | 68 +++++++++++-------- .../ViewModel/MeetingsListViewModel.swift | 1 + 3 files changed, 54 insertions(+), 30 deletions(-) diff --git a/Linphone/UI/Main/ContentView.swift b/Linphone/UI/Main/ContentView.swift index 6fd3e2e32..8fd3d50b5 100644 --- a/Linphone/UI/Main/ContentView.swift +++ b/Linphone/UI/Main/ContentView.swift @@ -299,7 +299,18 @@ struct ContentView: View { } .padding(.trailing, index == 2 ? 10 : 0) - if index != 2 { + 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) + } + } else if index != 2 { Menu { if index == 0 { Button { @@ -355,7 +366,7 @@ struct ContentView: View { } } } label: { - Image(index == 0 ? "funnel" : (index == 3 ? "calendar" : "dots-three-vertical")) + Image(index == 0 ? "funnel" : "dots-three-vertical") .renderingMode(.template) .resizable() .foregroundStyle(.white) diff --git a/Linphone/UI/Main/Meetings/Fragments/MeetingsFragment.swift b/Linphone/UI/Main/Meetings/Fragments/MeetingsFragment.swift index 3d2bc8780..a012c8bdd 100644 --- a/Linphone/UI/Main/Meetings/Fragments/MeetingsFragment.swift +++ b/Linphone/UI/Main/Meetings/Fragments/MeetingsFragment.swift @@ -28,6 +28,7 @@ struct MeetingsFragment: View { private var idiom: UIUserInterfaceIdiom { UIDevice.current.userInterfaceIdiom } @State var showingSheet: Bool = false + @State var reader : ScrollViewProxy? @ViewBuilder func createMonthLine(model: MeetingsListItemModel) -> some View { @@ -76,7 +77,6 @@ struct MeetingsFragment: View { .background(.white) .clipShape(RoundedRectangle(cornerRadius: 20)) .shadow(color: .black.opacity(0.2), radius: 4) - .padding(.bottom, 5) .onTapGesture { withAnimation { if let meetingModel = model.model { @@ -88,15 +88,15 @@ struct MeetingsFragment: View { var body: some View { VStack { - List { - VStack(alignment: .leading, spacing: 0) { - ForEach(0..() From 2221d14ffcfd32cb323aca92c7675c8224ea8920 Mon Sep 17 00:00:00 2001 From: QuentinArguillere Date: Tue, 25 Jun 2024 17:42:22 +0200 Subject: [PATCH 288/486] Fix build --- Linphone/UI/Main/History/ViewModel/StartCallViewModel.swift | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Linphone/UI/Main/History/ViewModel/StartCallViewModel.swift b/Linphone/UI/Main/History/ViewModel/StartCallViewModel.swift index 353633dd1..56abb30ab 100644 --- a/Linphone/UI/Main/History/ViewModel/StartCallViewModel.swift +++ b/Linphone/UI/Main/History/ViewModel/StartCallViewModel.swift @@ -48,14 +48,14 @@ class StartCallViewModel: ObservableObject { var list = participants for selectedAddr in participantsToAdd { if let found = list.first(where: { $0.address.weakEqual(address2: selectedAddr.address) }) { - Log.info("\(ScheduleMeetingViewModel.TAG) Participant \(found.address.asStringUriOnly()) already in list, skipping") + Log.info("\(StartCallViewModel.TAG) Participant \(found.address.asStringUriOnly()) already in list, skipping") continue } list.append(selectedAddr) - Log.info("\(ScheduleMeetingViewModel.TAG) Added participant \(selectedAddr.address.asStringUriOnly())") + Log.info("\(StartCallViewModel.TAG) Added participant \(selectedAddr.address.asStringUriOnly())") } - Log.info("\(ScheduleMeetingViewModel.TAG) [\(list.count - participants.count) participants added, now there are \(list.count) participants in list") + Log.info("\(StartCallViewModel.TAG) [\(list.count - participants.count) participants added, now there are \(list.count) participants in list") participants = list } From 8875e2ba542ae432969d23b563e4e2a32d837153 Mon Sep 17 00:00:00 2001 From: QuentinArguillere Date: Tue, 25 Jun 2024 18:18:55 +0200 Subject: [PATCH 289/486] Reset conference scheduler before scheduling a new one, or it will only edit the previous one --- .../Main/Meetings/Fragments/MeetingsFragment.swift | 1 - .../Main/Meetings/ViewModel/MeetingViewModel.swift | 13 +++++-------- 2 files changed, 5 insertions(+), 9 deletions(-) diff --git a/Linphone/UI/Main/Meetings/Fragments/MeetingsFragment.swift b/Linphone/UI/Main/Meetings/Fragments/MeetingsFragment.swift index a012c8bdd..0c74e175c 100644 --- a/Linphone/UI/Main/Meetings/Fragments/MeetingsFragment.swift +++ b/Linphone/UI/Main/Meetings/Fragments/MeetingsFragment.swift @@ -144,7 +144,6 @@ struct MeetingsFragment: View { } .onReceive(NotificationCenter.default.publisher(for: MeetingsListViewModel.ScrollToTodayNotification)) { _ in withAnimation { - Log.info("debugtrace - List ScrollToTodayNotification") proxyReader.scrollTo(meetingsListViewModel.todayIdx) } } diff --git a/Linphone/UI/Main/Meetings/ViewModel/MeetingViewModel.swift b/Linphone/UI/Main/Meetings/ViewModel/MeetingViewModel.swift index 937940dcb..517b5d788 100644 --- a/Linphone/UI/Main/Meetings/ViewModel/MeetingViewModel.swift +++ b/Linphone/UI/Main/Meetings/ViewModel/MeetingViewModel.swift @@ -145,7 +145,8 @@ class MeetingViewModel: ObservableObject { confInfo.participantInfos = participantsInfoList } - private func initConferenceSchedulerAndListeners(core: Core) { + private func resetConferenceSchedulerAndListeners(core: Core) { + self.mSchedulerSubscriptions.removeAll() self.conferenceScheduler = try? core.createConferenceScheduler() self.mSchedulerSubscriptions.insert(self.conferenceScheduler?.publisher?.onStateChanged?.postOnCoreQueue { (cbVal: (conferenceScheduler: ConferenceScheduler, state: ConferenceScheduler.State)) in @@ -220,13 +221,11 @@ class MeetingViewModel: ObservableObject { 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() { + 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) - if self.conferenceScheduler == nil { - self.initConferenceSchedulerAndListeners(core: core) - } + self.resetConferenceSchedulerAndListeners(core: core) self.conferenceScheduler?.account = localAccount // Will trigger the conference creation automatically self.conferenceScheduler?.info = conferenceInfo @@ -241,9 +240,7 @@ class MeetingViewModel: ObservableObject { if let conferenceInfo = self.conferenceInfoToEdit { self.fillConferenceInfo(confInfo: conferenceInfo) - if self.conferenceScheduler == nil { - self.initConferenceSchedulerAndListeners(core: core) - } + self.resetConferenceSchedulerAndListeners(core: core) // Will trigger the conference update automatically self.conferenceScheduler?.info = conferenceInfo From 4b4632226470a85ce9ac1eae4c2b0d7112c7548f Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Thu, 27 Jun 2024 11:50:43 +0200 Subject: [PATCH 290/486] Change ZRTP SAS UI --- .../lock-key.imageset/Contents.json | 21 + .../lock-key.imageset/lock-key.svg | 1 + .../security.imageset/Contents.json | 21 + .../security.imageset/security.svg | 6 + .../shield-warning.imageset/Contents.json | 21 + .../shield-warning.svg | 1 + Linphone/Localizable.xcstrings | 293 ++++++++++- .../TelecomManager/ProviderDelegate.swift | 7 +- Linphone/TelecomManager/TelecomManager.swift | 3 + Linphone/UI/Call/CallView.swift | 146 +++++- Linphone/UI/Call/Fragments/ZRTPPopup.swift | 464 +++++++++++++----- .../UI/Call/ViewModel/CallViewModel.swift | 101 ++-- .../Utils/Extensions/AccountExtension.swift | 6 +- 13 files changed, 900 insertions(+), 191 deletions(-) create mode 100644 Linphone/Assets.xcassets/lock-key.imageset/Contents.json create mode 100644 Linphone/Assets.xcassets/lock-key.imageset/lock-key.svg create mode 100644 Linphone/Assets.xcassets/security.imageset/Contents.json create mode 100644 Linphone/Assets.xcassets/security.imageset/security.svg create mode 100644 Linphone/Assets.xcassets/shield-warning.imageset/Contents.json create mode 100644 Linphone/Assets.xcassets/shield-warning.imageset/shield-warning.svg 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/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/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/Localizable.xcstrings b/Linphone/Localizable.xcstrings index c61bc684d..5730da51f 100644 --- a/Linphone/Localizable.xcstrings +++ b/Linphone/Localizable.xcstrings @@ -204,9 +204,6 @@ }, "Appel" : { - }, - "Appel chiffré de bout en bout" : { - }, "assistant_account_create" : { "localizations" : { @@ -421,6 +418,278 @@ }, "Call transfer failed!" : { + }, + "call_action_hang_up" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hang up" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Raccrocher" + } + } + } + }, + "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" : { @@ -889,21 +1158,9 @@ }, "Joining..." : { - }, - "Key" : { - "extractionState" : "manual" - }, - "Key 1" : { - "extractionState" : "manual" - }, - "Key 2" : { - "extractionState" : "manual" }, "Last name" : { - }, - "Letters don't match!" : { - }, "Linphone" : { @@ -1101,9 +1358,6 @@ }, "Resuming" : { - }, - "Say %@ and click on the letters given by your correspondent:" : { - }, "Say something..." : { @@ -1283,9 +1537,6 @@ }, "Username error" : { - }, - "Validate the device" : { - }, "Vidéo" : { diff --git a/Linphone/TelecomManager/ProviderDelegate.swift b/Linphone/TelecomManager/ProviderDelegate.swift index 7a56be324..727c81dcb 100644 --- a/Linphone/TelecomManager/ProviderDelegate.swift +++ b/Linphone/TelecomManager/ProviderDelegate.swift @@ -225,7 +225,12 @@ extension ProviderDelegate: CXProviderDelegate { DispatchQueue.main.async { if UIApplication.shared.applicationState != .active { TelecomManager.shared.backgroundContextCall = call - TelecomManager.shared.backgroundContextCameraIsEnabled = call?.params?.videoEnabled == true || call?.callLog?.wasConference() == true + if call?.callLog != nil { + TelecomManager.shared.backgroundContextCameraIsEnabled = call?.params?.videoEnabled == true || call?.callLog?.wasConference() == true + } else { + TelecomManager.shared.backgroundContextCameraIsEnabled = call?.params?.videoEnabled == true + } + if #available(iOS 16.0, *) { if call?.cameraEnabled == true { call?.cameraEnabled = AVCaptureSession().isMultitaskingCameraAccessSupported diff --git a/Linphone/TelecomManager/TelecomManager.swift b/Linphone/TelecomManager/TelecomManager.swift index 1cb34c1dc..59f282a6d 100644 --- a/Linphone/TelecomManager/TelecomManager.swift +++ b/Linphone/TelecomManager/TelecomManager.swift @@ -44,6 +44,7 @@ class TelecomManager: ObservableObject { @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 @@ -269,6 +270,7 @@ class TelecomManager: ObservableObject { DispatchQueue.main.async { self.outgoingCallStarted = true self.callStarted = true + self.isNotVerifiedCounter = 0 if self.callInProgress == false { withAnimation { self.callInProgress = true @@ -316,6 +318,7 @@ class TelecomManager: ObservableObject { DispatchQueue.main.async { self.callStarted = true + self.isNotVerifiedCounter = 0 } } catch { Log.error("accept call failed \(error)") diff --git a/Linphone/UI/Call/CallView.swift b/Linphone/UI/Call/CallView.swift index 2d74a788b..051f0f8e6 100644 --- a/Linphone/UI/Call/CallView.swift +++ b/Linphone/UI/Call/CallView.swift @@ -177,11 +177,19 @@ struct CallView: View { } if callViewModel.zrtpPopupDisplayed == true { - ZRTPPopup(callViewModel: callViewModel) - .background(.black.opacity(0.65)) - .onTapGesture { - callViewModel.zrtpPopupDisplayed = false - } + 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 { @@ -226,7 +234,7 @@ struct CallView: View { } Text(callViewModel.displayName) - .default_text_style_white_800(styleSize: 16) + .default_text_style_white_800(styleSize: 16) if !telecomManager.outgoingCallStarted && telecomManager.callInProgress { Text("|") @@ -281,26 +289,114 @@ struct CallView: View { .frame(height: 40) .zIndex(1) - if callViewModel.isMediaEncrypted { - HStack { - Image("lock_simple") - .resizable() - .frame(width: 15, height: 15, alignment: .leading) - .padding(.leading, 50) - .padding(.top, 35) - - Text("Appel chiffré de bout en bout") - .foregroundStyle(Color.blueInfo500) - .default_text_style_white(styleSize: 12) - .padding(.top, 35) - - Spacer() + 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) } - .onTapGesture { - mediaEncryptedSheet = true - } - .frame(height: 40) - .zIndex(1) } } } diff --git a/Linphone/UI/Call/Fragments/ZRTPPopup.swift b/Linphone/UI/Call/Fragments/ZRTPPopup.swift index 332803d22..3c27f8df4 100644 --- a/Linphone/UI/Call/Fragments/ZRTPPopup.swift +++ b/Linphone/UI/Call/Fragments/ZRTPPopup.swift @@ -27,123 +27,367 @@ struct ZRTPPopup: View { @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) { - Text("Validate the device") - .default_text_style_600(styleSize: 20) - - Text("Say \(callViewModel.upperCaseAuthTokenToRead) and click on the letters given by your correspondent:") - .default_text_style(styleSize: 15) - .padding(.bottom, 20) - - HStack(spacing: 25) { - Spacer() - - HStack(alignment: .center) { - Text(callViewModel.letters1) - .default_text_style(styleSize: 30) - .frame(width: 60, height: 60) - } - .padding(10) - .background(Color.grayMain2c200) - .cornerRadius(40) - .onTapGesture { - callViewModel.updateZrtpSas(authTokenClicked: callViewModel.letters1) - callViewModel.zrtpPopupDisplayed = false - } - - HStack(alignment: .center) { - Text(callViewModel.letters2) - .default_text_style(styleSize: 30) - .frame(width: 60, height: 60) - } - .padding(10) - .background(Color.grayMain2c200) - .cornerRadius(40) - .onTapGesture { - callViewModel.updateZrtpSas(authTokenClicked: callViewModel.letters2) - callViewModel.zrtpPopupDisplayed = false - } - - Spacer() - } - .padding(.bottom, 20) - - HStack(spacing: 25) { - Spacer() - - HStack(alignment: .center) { - Text(callViewModel.letters3) - .default_text_style(styleSize: 30) - .frame(width: 60, height: 60) - } - .padding(10) - .background(Color.grayMain2c200) - .cornerRadius(40) - .onTapGesture { - callViewModel.updateZrtpSas(authTokenClicked: callViewModel.letters3) - callViewModel.zrtpPopupDisplayed = false - } - - HStack(alignment: .center) { - Text(callViewModel.letters4) - .default_text_style(styleSize: 30) - .frame(width: 60, height: 60) - } - .padding(10) - .background(Color.grayMain2c200) - .cornerRadius(40) - .onTapGesture { - callViewModel.updateZrtpSas(authTokenClicked: callViewModel.letters4) - callViewModel.zrtpPopupDisplayed = false - } - - Spacer() - } - .padding(.bottom, 20) - - HStack { - Text("Skip") - .underline() - .tint(Color.grayMain2c600) - .default_text_style_600(styleSize: 15) - .foregroundStyle(Color.grayMain2c500) - } - .frame(maxWidth: .infinity) - .padding(.bottom, 30) - .onTapGesture { - callViewModel.skipZrtpAuthentication() - callViewModel.zrtpPopupDisplayed = false - } - - Button(action: { - callViewModel.updateZrtpSas(authTokenClicked: "") - callViewModel.zrtpPopupDisplayed = false - }, label: { - Text("Letters don't match!") - .default_text_style_orange_600(styleSize: 20) - .frame(height: 35) + 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) }) - .padding(.horizontal, 20) - .padding(.vertical, 10) - .cornerRadius(60) - .overlay( - RoundedRectangle(cornerRadius: 60) - .inset(by: 0.5) - .stroke(Color.orangeMain500, lineWidth: 1) - ) - .padding(.bottom) + + 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) } - .padding(.horizontal, 20) - .padding(.vertical, 20) - .background(.white) + .background(callViewModel.cacheMismatch ? Color.orangeWarning600 : Color.blueInfo500) .cornerRadius(20) - .padding(.horizontal) + .padding(.horizontal, 2) .frame(maxHeight: .infinity) - .shadow(color: Color.orangeMain500, radius: 0, x: 0, y: 2) - .frame(maxWidth: sharedMainViewModel.maxWidth) + .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() @@ -153,5 +397,5 @@ struct ZRTPPopup: View { } #Preview { - ZRTPPopup(callViewModel: CallViewModel()) + ZRTPPopup(callViewModel: CallViewModel(), resizeView: 1) } diff --git a/Linphone/UI/Call/ViewModel/CallViewModel.swift b/Linphone/UI/Call/ViewModel/CallViewModel.swift index 741d58cf4..6fcdf3ba8 100644 --- a/Linphone/UI/Call/ViewModel/CallViewModel.swift +++ b/Linphone/UI/Call/ViewModel/CallViewModel.swift @@ -42,8 +42,11 @@ class CallViewModel: ObservableObject { @Published var upperCaseAuthTokenToRead = "" @Published var upperCaseAuthTokenToListen = "" @Published var isMediaEncrypted: Bool = false - @Published var isZrtpPq: 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 @@ -125,14 +128,14 @@ class CallViewModel: ObservableObject { } var isMediaEncryptedTmp = false - var isZrtpPqTmp = 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 - isZrtpPqTmp = self.currentCall!.currentParams!.mediaEncryption == .ZRTP + isZrtpTmp = self.currentCall!.currentParams!.mediaEncryption == .ZRTP } } @@ -200,6 +203,9 @@ class CallViewModel: ObservableObject { self.zrtpPopupDisplayed = false self.upperCaseAuthTokenToRead = "" self.upperCaseAuthTokenToListen = "" + self.isNotVerified = false + + self.updateEncryption() self.isConference = false self.participantList = [] self.activeSpeakerParticipant = nil @@ -209,7 +215,9 @@ class CallViewModel: ObservableObject { self.videoDisplayed = videoDisplayedTmp self.isOneOneCall = isOneOneCallTmp self.isMediaEncrypted = isMediaEncryptedTmp - self.isZrtpPq = isZrtpPqTmp + self.isNotEncrypted = false + self.isZrtp = isZrtpTmp + self.cacheMismatch = cacheMismatchFlag self.getCallsList() @@ -228,21 +236,35 @@ class CallViewModel: ObservableObject { }) self.callSuscriptions.insert(self.currentCall!.publisher?.onStatsUpdated?.postOnCoreQueue {(cbVal: (call: Call, stats: CallStats)) in - if self.currentCall != nil { - DispatchQueue.main.async { + DispatchQueue.main.async { + if self.currentCall != nil { self.callStatsModel.update(call: self.currentCall!, stats: cbVal.stats) } } }) - self.callSuscriptions.insert( self.currentCall!.publisher?.onAuthenticationTokenVerified?.postOnCoreQueue {(call: Call, verified: Bool) in Log.warn("[CallViewModel][ZRTPPopup] Notified that authentication token is \(verified ? "verified" : "not verified!")") - - self.updateEncryption() - if self.currentCall != nil { - self.callMediaEncryptionModel.update(call: self.currentCall!) + if verified { + self.updateEncryption() + 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 + } + } } } ) @@ -641,6 +663,7 @@ class CallViewModel: ObservableObject { telecomManager.callInProgress = true telecomManager.callDisplayed = true telecomManager.callStarted = true + telecomManager.isNotVerifiedCounter = 0 } coreContext.doOnCoreQueue { core in @@ -881,18 +904,20 @@ class CallViewModel: ObservableObject { coreContext.doOnCoreQueue { core in if core.currentCall != nil { let tokens = core.currentCall!.remoteAuthenticationTokens - DispatchQueue.main.async { - self.letters1 = tokens[0] - self.letters2 = tokens[1] - self.letters3 = tokens[2] - self.letters4 = tokens[3] + 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() { - coreContext.doOnCoreQueue { core in + coreContext.doOnCoreQueue { _ in if self.currentCall != nil && self.currentCall!.currentParams != nil { switch self.currentCall!.currentParams!.mediaEncryption { case MediaEncryption.ZRTP: @@ -918,12 +943,14 @@ class CallViewModel: ObservableObject { */ // When Post Quantum is available, ZRTP is Post Quantum - let isZrtpPqTmp = Core.getPostQuantumAvailable + let isZrtpPQTmp = Core.getPostQuantumAvailable DispatchQueue.main.async { self.isRemoteDeviceTrusted = isRemoteDeviceTrustedTmp self.isMediaEncrypted = true - self.isZrtpPq = isZrtpPqTmp + self.isZrtp = true + self.cacheMismatch = cacheMismatchFlag + self.isNotEncrypted = false if isDeviceTrusted { ToastViewModel.shared.toastMessage = "Info_call_securised" @@ -938,12 +965,18 @@ class CallViewModel: ObservableObject { case MediaEncryption.SRTP, MediaEncryption.DTLS: DispatchQueue.main.async { self.isMediaEncrypted = true - self.isZrtpPq = false + self.isZrtp = false + self.isNotEncrypted = false } - default: + case MediaEncryption.None: DispatchQueue.main.async { self.isMediaEncrypted = false - self.isZrtpPq = false + self.isZrtp = false + if self.currentCall!.state == .StreamsRunning { + self.isNotEncrypted = true + } else { + self.isNotEncrypted = false + } } } } @@ -971,26 +1004,28 @@ class CallViewModel: ObservableObject { let mySubstringSuffix = upperCaseAuthToken.suffix(2) - 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) + 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 } - - self.zrtpPopupDisplayed = true } } func transferClicked() { coreContext.doOnCoreQueue { core in - var callToTransferTo = core.calls.last { call in + let callToTransferTo = core.calls.last { call in call.state == Call.State.Paused && call.callLog?.callId != self.currentCall?.callLog?.callId } - if (callToTransferTo == nil) { + if callToTransferTo == nil { Log.error( "[CallViewModel] Couldn't find a call in Paused state to transfer current call to" ) diff --git a/Linphone/Utils/Extensions/AccountExtension.swift b/Linphone/Utils/Extensions/AccountExtension.swift index 44be51e12..7ae58904b 100644 --- a/Linphone/Utils/Extensions/AccountExtension.swift +++ b/Linphone/Utils/Extensions/AccountExtension.swift @@ -35,6 +35,10 @@ extension Account { } static func == (lhs: Account, rhs: Account) -> Bool { - return lhs.params?.identityAddress?.asString() == rhs.params?.identityAddress?.asString() + 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 + } } } From 268bff0ca3b6d9300a7e0e720a5ca4e95a7a4226 Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Fri, 28 Jun 2024 11:33:45 +0200 Subject: [PATCH 291/486] Display toast of authenticated device when ZRTP SAS is validated --- Linphone/Localizable.xcstrings | 38 ++++++++++++++----- .../UI/Call/ViewModel/CallViewModel.swift | 10 ++--- Linphone/UI/Main/Fragments/ToastView.swift | 2 +- 3 files changed, 35 insertions(+), 15 deletions(-) diff --git a/Linphone/Localizable.xcstrings b/Linphone/Localizable.xcstrings index 5730da51f..19e09775f 100644 --- a/Linphone/Localizable.xcstrings +++ b/Linphone/Localizable.xcstrings @@ -51,6 +51,9 @@ }, "%@" : { + }, + "%@ meeting" : { + }, "%lld" : { @@ -436,6 +439,23 @@ } } }, + "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" : { @@ -1155,6 +1175,9 @@ }, "Job title" : { + }, + "Join the meeting now" : { + }, "Joining..." : { @@ -1206,9 +1229,6 @@ }, "New contact" : { - }, - "New meeting" : { - }, "Next" : { @@ -1224,6 +1244,9 @@ }, "No meeting for the moment..." : { + }, + "No meeting today" : { + }, "No participant for the moment..." : { @@ -1242,6 +1265,9 @@ }, "Opération en cours..." : { + }, + "Organizer" : { + }, "Other actions" : { @@ -1463,9 +1489,6 @@ }, "The user name or password is incorrects" : { - }, - "This call is completely securised" : { - }, "This contact will be deleted definitively." : { @@ -1478,9 +1501,6 @@ }, "to Linphone" : { - }, - "TODO" : { - }, "TODO : repeat" : { diff --git a/Linphone/UI/Call/ViewModel/CallViewModel.swift b/Linphone/UI/Call/ViewModel/CallViewModel.swift index 6fcdf3ba8..bf19b6f48 100644 --- a/Linphone/UI/Call/ViewModel/CallViewModel.swift +++ b/Linphone/UI/Call/ViewModel/CallViewModel.swift @@ -205,7 +205,7 @@ class CallViewModel: ObservableObject { self.upperCaseAuthTokenToListen = "" self.isNotVerified = false - self.updateEncryption() + self.updateEncryption(withToast: false) self.isConference = false self.participantList = [] self.activeSpeakerParticipant = nil @@ -229,7 +229,7 @@ class CallViewModel: ObservableObject { } self.callSuscriptions.insert(self.currentCall!.publisher?.onEncryptionChanged?.postOnCoreQueue {(cbVal: (call: Call, on: Bool, authenticationToken: String?)) in - self.updateEncryption() + self.updateEncryption(withToast: false) if self.currentCall != nil { self.callMediaEncryptionModel.update(call: self.currentCall!) } @@ -247,7 +247,7 @@ class CallViewModel: ObservableObject { self.currentCall!.publisher?.onAuthenticationTokenVerified?.postOnCoreQueue {(call: Call, verified: Bool) in Log.warn("[CallViewModel][ZRTPPopup] Notified that authentication token is \(verified ? "verified" : "not verified!")") if verified { - self.updateEncryption() + self.updateEncryption(withToast: true) if self.currentCall != nil { self.callMediaEncryptionModel.update(call: self.currentCall!) } @@ -916,7 +916,7 @@ class CallViewModel: ObservableObject { } } - private func updateEncryption() { + private func updateEncryption(withToast: Bool) { coreContext.doOnCoreQueue { _ in if self.currentCall != nil && self.currentCall!.currentParams != nil { switch self.currentCall!.currentParams!.mediaEncryption { @@ -952,7 +952,7 @@ class CallViewModel: ObservableObject { self.cacheMismatch = cacheMismatchFlag self.isNotEncrypted = false - if isDeviceTrusted { + if isDeviceTrusted && withToast { ToastViewModel.shared.toastMessage = "Info_call_securised" ToastViewModel.shared.displayToast = true } diff --git a/Linphone/UI/Main/Fragments/ToastView.swift b/Linphone/UI/Main/Fragments/ToastView.swift index 446279c13..332e8b39b 100644 --- a/Linphone/UI/Main/Fragments/ToastView.swift +++ b/Linphone/UI/Main/Fragments/ToastView.swift @@ -82,7 +82,7 @@ struct ToastView: View { .padding(8) case "Info_call_securised": - Text("This call is completely securised") + Text("call_can_be_trusted_toast") .multilineTextAlignment(.center) .foregroundStyle(Color.blueInfo500) .default_text_style(styleSize: 15) From 5b5a5d88fae858fd1f7a9e741eb17ddffb10c28f Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Fri, 28 Jun 2024 15:30:12 +0200 Subject: [PATCH 292/486] Fix contact views --- .../ContactInnerActionsFragment.swift | 273 +++++++----------- .../Fragments/ContactInnerFragment.swift | 38 +-- .../Contacts/Model/ContactAvatarModel.swift | 12 + 3 files changed, 137 insertions(+), 186 deletions(-) diff --git a/Linphone/UI/Main/Contacts/Fragments/ContactInnerActionsFragment.swift b/Linphone/UI/Main/Contacts/Fragments/ContactInnerActionsFragment.swift index 7a3d96ce9..01e0ea646 100644 --- a/Linphone/UI/Main/Contacts/Fragments/ContactInnerActionsFragment.swift +++ b/Linphone/UI/Main/Contacts/Fragments/ContactInnerActionsFragment.swift @@ -18,6 +18,7 @@ */ import SwiftUI +import linphonesw struct ContactInnerActionsFragment: View { @@ -26,6 +27,7 @@ struct ContactInnerActionsFragment: View { @ObservedObject var contactViewModel: ContactViewModel @ObservedObject var editContactViewModel: EditContactViewModel + @ObservedObject var contactAvatarModel: ContactAvatarModel @State private var informationIsOpen = true @@ -62,126 +64,125 @@ struct ContactInnerActionsFragment: View { if informationIsOpen { VStack(spacing: 0) { - if contactViewModel.indexDisplayedFriend != nil - && contactsManager.lastSearch.count > contactViewModel.indexDisplayedFriend! - && contactsManager.lastSearch[contactViewModel.indexDisplayedFriend!].friend != nil { - ForEach(0.. contactViewModel.indexDisplayedFriend! - && contactsManager.lastSearch[contactViewModel.indexDisplayedFriend!].friend != nil - && ((contactsManager.lastSearch[contactViewModel.indexDisplayedFriend!].friend!.organization != nil - && !contactsManager.lastSearch[contactViewModel.indexDisplayedFriend!].friend!.organization!.isEmpty) - || (contactsManager.lastSearch[contactViewModel.indexDisplayedFriend!].friend!.jobTitle != nil - && !contactsManager.lastSearch[contactViewModel.indexDisplayedFriend!].friend!.jobTitle!.isEmpty)) { + if contactAvatarModel.friend != nil && (contactAvatarModel.friend!.organization != nil + && !contactAvatarModel.friend!.organization!.isEmpty) + || (contactAvatarModel.friend!.jobTitle != nil + && !contactAvatarModel.friend!.jobTitle!.isEmpty) { VStack { - if contactsManager.lastSearch[contactViewModel.indexDisplayedFriend!].friend!.organization != nil - && !contactsManager.lastSearch[contactViewModel.indexDisplayedFriend!].friend!.organization!.isEmpty { - Text("**Company :** \(contactsManager.lastSearch[contactViewModel.indexDisplayedFriend!].friend!.organization!)") + if contactAvatarModel.friend!.organization != nil + && !contactAvatarModel.friend!.organization!.isEmpty { + Text("**Company :** \(contactAvatarModel.friend!.organization!)") .default_text_style(styleSize: 14) .padding(.vertical, 15) .padding(.horizontal, 20) .frame(maxWidth: .infinity, alignment: .leading) } - if contactsManager.lastSearch[contactViewModel.indexDisplayedFriend!].friend!.jobTitle != nil - && !contactsManager.lastSearch[contactViewModel.indexDisplayedFriend!].friend!.jobTitle!.isEmpty { - Text("**Job :** \(contactsManager.lastSearch[contactViewModel.indexDisplayedFriend!].friend!.jobTitle!)") + if contactAvatarModel.friend!.jobTitle != nil + && !contactAvatarModel.friend!.jobTitle!.isEmpty { + Text("**Job :** \(contactAvatarModel.friend!.jobTitle!)") .default_text_style(styleSize: 14) .padding(.top, - contactsManager.lastSearch[contactViewModel.indexDisplayedFriend!].friend!.organization != nil - && !contactsManager.lastSearch[contactViewModel.indexDisplayedFriend!].friend!.organization!.isEmpty + contactAvatarModel.friend!.organization != nil + && !contactAvatarModel.friend!.organization!.isEmpty ? 0 : 15 ) .padding(.bottom, 15) @@ -212,11 +213,7 @@ struct ContactInnerActionsFragment: View { .background(Color.gray100) VStack(spacing: 0) { - if contactViewModel.indexDisplayedFriend != nil - && contactsManager.lastSearch.count > contactViewModel.indexDisplayedFriend! - && contactsManager.lastSearch[contactViewModel.indexDisplayedFriend!].friend != nil - && contactsManager.lastSearch[contactViewModel.indexDisplayedFriend!].friend!.nativeUri != nil - && !contactsManager.lastSearch[contactViewModel.indexDisplayedFriend!].friend!.nativeUri!.isEmpty { + if !contactAvatarModel.nativeUri.isEmpty { Button { actionEditButton() } label: { @@ -264,7 +261,7 @@ struct ContactInnerActionsFragment: View { } .simultaneousGesture( TapGesture().onEnded { - editContactViewModel.selectedEditFriend = contactsManager.lastSearch[contactViewModel.indexDisplayedFriend!].friend + editContactViewModel.selectedEditFriend = contactAvatarModel.friend! editContactViewModel.resetValues() } ) @@ -276,30 +273,21 @@ struct ContactInnerActionsFragment: View { .padding(.horizontal) Button { - if contactsManager.lastSearch[contactViewModel.indexDisplayedFriend!].friend != nil { + if contactAvatarModel.friend != nil { contactViewModel.objectWillChange.send() - contactsManager.lastSearch[contactViewModel.indexDisplayedFriend!].friend!.edit() - contactsManager.lastSearch[contactViewModel.indexDisplayedFriend!].friend!.starred.toggle() - contactsManager.lastSearch[contactViewModel.indexDisplayedFriend!].friend!.done() + contactAvatarModel.friend!.edit() + contactAvatarModel.friend!.starred.toggle() + contactAvatarModel.friend!.done() } } label: { HStack { - Image(contactViewModel.indexDisplayedFriend != nil - && contactViewModel.indexDisplayedFriend! < contactsManager.lastSearch.count - && contactsManager.lastSearch[contactViewModel.indexDisplayedFriend!].friend != nil - && contactsManager.lastSearch[contactViewModel.indexDisplayedFriend!].friend!.starred == true ? "heart-fill" : "heart") + Image(contactAvatarModel.friend != nil && contactAvatarModel.friend!.starred == true ? "heart-fill" : "heart") .renderingMode(.template) .resizable() - .foregroundStyle(contactViewModel.indexDisplayedFriend != nil - && contactViewModel.indexDisplayedFriend! < contactsManager.lastSearch.count - && contactsManager.lastSearch[contactViewModel.indexDisplayedFriend!].friend != nil - && contactsManager.lastSearch[contactViewModel.indexDisplayedFriend!].friend!.starred == true ? Color.redDanger500 : Color.grayMain2c500) + .foregroundStyle(contactAvatarModel.friend != nil && contactAvatarModel.friend!.starred == true ? Color.redDanger500 : Color.grayMain2c500) .frame(width: 25, height: 25) .padding(.all, 10) - Text(contactViewModel.indexDisplayedFriend != nil - && contactViewModel.indexDisplayedFriend! < contactsManager.lastSearch.count - && contactsManager.lastSearch[contactViewModel.indexDisplayedFriend!].friend != nil - && contactsManager.lastSearch[contactViewModel.indexDisplayedFriend!].friend!.starred == true + Text(contactAvatarModel.friend != nil && contactAvatarModel.friend!.starred == true ? "Remove from favourites" : "Add to favourites") .default_text_style(styleSize: 14) @@ -344,62 +332,8 @@ struct ContactInnerActionsFragment: View { } .padding(.horizontal) - /* - Button { - } label: { - HStack { - Image("bell-simple-slash") - .renderingMode(.template) - .resizable() - .foregroundStyle(Color.grayMain2c600) - .frame(width: 25, height: 25) - .padding(.all, 10) - - Text("Mute") - .default_text_style(styleSize: 14) - .frame(maxWidth: .infinity, alignment: .leading) - .lineLimit(1) - .fixedSize(horizontal: false, vertical: true) - Spacer() - } - .padding(.vertical, 15) - .padding(.horizontal, 20) - } - - VStack { - Divider() - } - .padding(.horizontal) - - Button { - } label: { - HStack { - Image("x-circle") - .renderingMode(.template) - .resizable() - .foregroundStyle(Color.grayMain2c600) - .frame(width: 25, height: 25) - .padding(.all, 10) - - Text("Block") - .default_text_style(styleSize: 14) - .frame(maxWidth: .infinity, alignment: .leading) - .lineLimit(1) - .fixedSize(horizontal: false, vertical: true) - Spacer() - } - .padding(.vertical, 15) - .padding(.horizontal, 20) - } - - VStack { - Divider() - } - .padding(.horizontal) - */ - Button { - if contactsManager.lastSearch[contactViewModel.indexDisplayedFriend!].friend != nil { + if contactAvatarModel != nil { isShowDeletePopup.toggle() } } label: { @@ -435,6 +369,7 @@ struct ContactInnerActionsFragment: View { ContactInnerActionsFragment( contactViewModel: ContactViewModel(), editContactViewModel: EditContactViewModel(), + contactAvatarModel: ContactAvatarModel(friend: nil, name: "", address: "", withPresence: false), showingSheet: .constant(false), showShareSheet: .constant(false), isShowDeletePopup: .constant(false), diff --git a/Linphone/UI/Main/Contacts/Fragments/ContactInnerFragment.swift b/Linphone/UI/Main/Contacts/Fragments/ContactInnerFragment.swift index 87457c6c2..0f47f37df 100644 --- a/Linphone/UI/Main/Contacts/Fragments/ContactInnerFragment.swift +++ b/Linphone/UI/Main/Contacts/Fragments/ContactInnerFragment.swift @@ -20,6 +20,7 @@ import SwiftUI import Contacts import ContactsUI +import linphonesw struct ContactInnerFragment: View { @@ -69,9 +70,7 @@ struct ContactInnerFragment: View { Spacer() if contactViewModel.indexDisplayedFriend != nil && contactViewModel.indexDisplayedFriend! < contactsManager.lastSearch.count - && contactsManager.lastSearch[contactViewModel.indexDisplayedFriend!].friend != nil - && contactsManager.lastSearch[contactViewModel.indexDisplayedFriend!].friend!.nativeUri != nil - && !contactsManager.lastSearch[contactViewModel.indexDisplayedFriend!].friend!.nativeUri!.isEmpty { + && !contactAvatarModel.nativeUri.isEmpty { Button(action: { editNativeContact() }, label: { @@ -99,7 +98,7 @@ struct ContactInnerFragment: View { } .simultaneousGesture( TapGesture().onEnded { - editContactViewModel.selectedEditFriend = contactsManager.lastSearch[contactViewModel.indexDisplayedFriend!].friend + editContactViewModel.selectedEditFriend = contactAvatarModel.friend editContactViewModel.resetValues() } ) @@ -115,24 +114,19 @@ struct ContactInnerFragment: View { VStack(spacing: 0) { VStack(spacing: 0) { VStack(spacing: 0) { - if contactViewModel.indexDisplayedFriend != nil && contactViewModel.indexDisplayedFriend! < contactsManager.lastSearch.count - && contactsManager.lastSearch[contactViewModel.indexDisplayedFriend!].friend != nil - && contactsManager.lastSearch[contactViewModel.indexDisplayedFriend!].friend!.photo != nil - && !contactsManager.lastSearch[contactViewModel.indexDisplayedFriend!].friend!.photo!.isEmpty { + if contactViewModel.indexDisplayedFriend != nil && contactViewModel.indexDisplayedFriend! < contactsManager.lastSearch.count { Avatar(contactAvatarModel: contactAvatarModel, avatarSize: 100) } else if contactViewModel.indexDisplayedFriend != nil && contactViewModel.indexDisplayedFriend! < contactsManager.lastSearch.count - && contactsManager.lastSearch[contactViewModel.indexDisplayedFriend!].friend != nil { + && contactAvatarModel != nil { Image("profil-picture-default") .resizable() .frame(width: 100, height: 100) .clipShape(Circle()) } if contactViewModel.indexDisplayedFriend != nil - && contactViewModel.indexDisplayedFriend! < contactsManager.lastSearch.count - && contactsManager.lastSearch[contactViewModel.indexDisplayedFriend!].friend != nil - && contactsManager.lastSearch[contactViewModel.indexDisplayedFriend!].friend?.name != nil { - Text((contactsManager.lastSearch[contactViewModel.indexDisplayedFriend!].friend?.name)!) + && contactViewModel.indexDisplayedFriend! < contactsManager.lastSearch.count { + Text(contactAvatarModel.name) .foregroundStyle(Color.grayMain2c700) .multilineTextAlignment(.center) .default_text_style(styleSize: 14) @@ -158,7 +152,12 @@ struct ContactInnerFragment: View { Spacer() Button(action: { - telecomManager.doCallOrJoinConf(address: contactsManager.lastSearch[contactViewModel.indexDisplayedFriend!].friend!.address!) + do { + let address = try Factory.Instance.createAddress(addr: contactAvatarModel.address) + telecomManager.doCallOrJoinConf(address: address) + } catch { + Log.error("[ContactInnerFragment] unable to create address for a new outgoing call : \(contactAvatarModel.address) \(error) ") + } }, label: { VStack { HStack(alignment: .center) { @@ -208,7 +207,12 @@ struct ContactInnerFragment: View { Spacer() Button(action: { - telecomManager.doCallOrJoinConf(address: contactsManager.lastSearch[contactViewModel.indexDisplayedFriend!].friend!.address!, isVideo: true) + 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) ") + } }, label: { VStack { HStack(alignment: .center) { @@ -236,7 +240,7 @@ struct ContactInnerFragment: View { ContactInnerActionsFragment( contactViewModel: contactViewModel, editContactViewModel: editContactViewModel, - showingSheet: $showingSheet, + contactAvatarModel: contactAvatarModel, showingSheet: $showingSheet, showShareSheet: $showShareSheet, isShowDeletePopup: $isShowDeletePopup, isShowDismissPopup: $isShowDismissPopup, @@ -271,7 +275,7 @@ struct ContactInnerFragment: View { let store = CNContactStore() let descriptor = CNContactViewController.descriptorForRequiredKeys() cnContact = try store.unifiedContact( - withIdentifier: contactsManager.lastSearch[contactViewModel.indexDisplayedFriend!].friend!.nativeUri!, + withIdentifier: contactAvatarModel.nativeUri, keysToFetch: [descriptor] ) diff --git a/Linphone/UI/Main/Contacts/Model/ContactAvatarModel.swift b/Linphone/UI/Main/Contacts/Model/ContactAvatarModel.swift index 8caf5b8eb..1ce1df3f4 100644 --- a/Linphone/UI/Main/Contacts/Model/ContactAvatarModel.swift +++ b/Linphone/UI/Main/Contacts/Model/ContactAvatarModel.swift @@ -29,6 +29,10 @@ class ContactAvatarModel: ObservableObject { let address: String + @Published var addresses: [String] + + let nativeUri: String + let withPresence: Bool? @Published var lastPresenceInfo: String @@ -41,6 +45,14 @@ class ContactAvatarModel: ObservableObject { 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 { From 5b3f412bb7b8732f3b21284f22d9cde1a772e35d Mon Sep 17 00:00:00 2001 From: QuentinArguillere Date: Thu, 27 Jun 2024 16:00:55 +0200 Subject: [PATCH 293/486] Implement meetings bottom sheet and meeting details delete action --- Linphone.xcodeproj/project.pbxproj | 6 + Linphone/UI/Main/ContentView.swift | 28 ++++- Linphone/UI/Main/Fragments/ToastView.swift | 7 ++ .../Meetings/Fragments/MeetingFragment.swift | 37 +++++-- .../Meetings/Fragments/MeetingsFragment.swift | 11 +- .../Fragments/MeetingsListBottomSheet.swift | 103 ++++++++++++++++++ Linphone/UI/Main/Meetings/MeetingsView.swift | 29 ++++- .../Meetings/ViewModel/MeetingViewModel.swift | 7 ++ .../ViewModel/MeetingsListViewModel.swift | 23 +++- 9 files changed, 236 insertions(+), 15 deletions(-) create mode 100644 Linphone/UI/Main/Meetings/Fragments/MeetingsListBottomSheet.swift diff --git a/Linphone.xcodeproj/project.pbxproj b/Linphone.xcodeproj/project.pbxproj index ef7cae2fa..0c42611c7 100644 --- a/Linphone.xcodeproj/project.pbxproj +++ b/Linphone.xcodeproj/project.pbxproj @@ -7,6 +7,7 @@ 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 */; }; @@ -29,6 +30,7 @@ 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 */; }; @@ -207,6 +209,7 @@ 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 = ""; }; C60E8F182C0F649200A06DB8 /* UIApplicationExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIApplicationExtension.swift; sourceTree = ""; }; C62817272C1B389700DBA646 /* SideMenuAccountRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SideMenuAccountRow.swift; sourceTree = ""; }; @@ -349,6 +352,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 4ED1F0A881A9ACB5977A8987 /* BuildFile in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -410,6 +414,7 @@ 66E50A4A2BD12B7800AD61CA /* MeetingsFragment.swift */, 6613A0AD2BAEB7DF008923A4 /* MeetingFragment.swift */, 6613A0AF2BAEB7F4008923A4 /* MeetingsListFragment.swift */, + 66F08C882C2AFEF700D9AE2F /* MeetingsListBottomSheet.swift */, 6646A7A22BB2E224006B842A /* ScheduleMeetingFragment.swift */, 66F626B12BCEBB86003E2DEC /* AddParticipantsFragment.swift */, ); @@ -1126,6 +1131,7 @@ 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 */, diff --git a/Linphone/UI/Main/ContentView.swift b/Linphone/UI/Main/ContentView.swift index 8fd3d50b5..6f093534a 100644 --- a/Linphone/UI/Main/ContentView.swift +++ b/Linphone/UI/Main/ContentView.swift @@ -60,6 +60,7 @@ struct ContentView: View { @State var isShowEditContactFragment = false @State var isShowStartCallFragment = false @State var isShowDismissPopup = false + @State var isShowSendCancelMeetingNotificationPopup = false @State var fullscreenVideo = false @@ -530,7 +531,8 @@ struct ContentView: View { MeetingsView( meetingsListViewModel: meetingsListViewModel, meetingViewModel: meetingViewModel, - isShowScheduleMeetingFragment: $isShowScheduleMeetingFragment + isShowScheduleMeetingFragment: $isShowScheduleMeetingFragment, + isShowSendCancelMeetingNotificationPopup: $isShowSendCancelMeetingNotificationPopup ) } } @@ -762,7 +764,7 @@ struct ContentView: View { .background(Color.gray100) .ignoresSafeArea(.keyboard) } else if self.index == 3 { - MeetingFragment(meetingViewModel: meetingViewModel, meetingsListViewModel: meetingsListViewModel, isShowScheduleMeetingFragment: $isShowScheduleMeetingFragment) + MeetingFragment(meetingViewModel: meetingViewModel, meetingsListViewModel: meetingsListViewModel, isShowScheduleMeetingFragment: $isShowScheduleMeetingFragment, isShowSendCancelMeetingNotificationPopup: $isShowSendCancelMeetingNotificationPopup) .frame(maxWidth: .infinity) .background(Color.gray100) .ignoresSafeArea(.keyboard) @@ -980,6 +982,28 @@ struct ContentView: View { .onAppear { } } + + if isShowSendCancelMeetingNotificationPopup { + PopupView(isShowPopup: $isShowSendCancelMeetingNotificationPopup, + title: Text("The meeting has been cancelled"), + content: Text("Send notification to participants ?"), + titleFirstButton: Text("Cancel"), + actionFirstButton: { self.isShowSendCancelMeetingNotificationPopup.toggle() }, + titleSecondButton: Text("Ok"), + actionSecondButton: { + if let meetingToDelete = self.meetingsListViewModel.selectedMeetingToDelete { + // We're in the meeting list view + self.meetingViewModel.sendMeetingCancelledNotifications(meeting: meetingToDelete) + self.isShowSendCancelMeetingNotificationPopup.toggle() + } + }) + .background(.black.opacity(0.65)) + .zIndex(3) + .onTapGesture { + self.isShowSendCancelMeetingNotificationPopup.toggle() + } + } + if telecomManager.meetingWaitingRoomDisplayed { MeetingWaitingRoomFragment(meetingWaitingRoomViewModel: meetingWaitingRoomViewModel) .zIndex(3) diff --git a/Linphone/UI/Main/Fragments/ToastView.swift b/Linphone/UI/Main/Fragments/ToastView.swift index 332e8b39b..406870977 100644 --- a/Linphone/UI/Main/Fragments/ToastView.swift +++ b/Linphone/UI/Main/Fragments/ToastView.swift @@ -130,6 +130,13 @@ struct ToastView: View { .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) diff --git a/Linphone/UI/Main/Meetings/Fragments/MeetingFragment.swift b/Linphone/UI/Main/Meetings/Fragments/MeetingFragment.swift index dcdc9e666..b16340c7d 100644 --- a/Linphone/UI/Main/Meetings/Fragments/MeetingFragment.swift +++ b/Linphone/UI/Main/Meetings/Fragments/MeetingFragment.swift @@ -42,6 +42,7 @@ struct MeetingFragment: View { @State var addParticipantsViewModel = AddParticipantsViewModel() @Binding var isShowScheduleMeetingFragment: Bool + @Binding var isShowSendCancelMeetingNotificationPopup: Bool @ViewBuilder func getParticipantLine(participant: SelectedAddressModel) -> some View { @@ -105,13 +106,34 @@ struct MeetingFragment: View { } } } - Image("dots-three-vertical") - .renderingMode(.template) - .resizable() - .foregroundStyle(Color.orangeMain500) - .frame(width: 25, height: 25, alignment: .leading) - .onTapGesture { + + Menu { + Button(role: .destructive) { + withAnimation { + meetingsListViewModel.selectedMeetingToDelete = meetingViewModel.displayedMeeting + meetingViewModel.displayedMeeting = nil + meetingsListViewModel.deleteSelectedMeeting() + isShowSendCancelMeetingNotificationPopup.toggle() + } + } 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) @@ -279,7 +301,8 @@ struct MeetingFragment: View { model.description = "description du meeting ça va être la bringue wesh wesh gros bien ou bien ça roule" return MeetingFragment(meetingViewModel: model , meetingsListViewModel: MeetingsListViewModel() - , isShowScheduleMeetingFragment: .constant(true)) + , isShowScheduleMeetingFragment: .constant(true) + , isShowSendCancelMeetingNotificationPopup: .constant(false)) } // swiftlint:enable line_length diff --git a/Linphone/UI/Main/Meetings/Fragments/MeetingsFragment.swift b/Linphone/UI/Main/Meetings/Fragments/MeetingsFragment.swift index 0c74e175c..f2f49aaae 100644 --- a/Linphone/UI/Main/Meetings/Fragments/MeetingsFragment.swift +++ b/Linphone/UI/Main/Meetings/Fragments/MeetingsFragment.swift @@ -27,8 +27,7 @@ struct MeetingsFragment: View { private var idiom: UIUserInterfaceIdiom { UIDevice.current.userInterfaceIdiom } - @State var showingSheet: Bool = false - @State var reader : ScrollViewProxy? + @Binding var showingSheet: Bool @ViewBuilder func createMonthLine(model: MeetingsListItemModel) -> some View { @@ -84,6 +83,10 @@ struct MeetingsFragment: View { } } } + .onLongPressGesture(minimumDuration: 0.2) { + meetingViewModel.displayedMeeting = model.model + showingSheet.toggle() + } } var body: some View { @@ -173,5 +176,7 @@ struct MeetingsFragment: View { } #Preview { - MeetingsFragment(meetingsListViewModel: MeetingsListViewModel(), meetingViewModel: MeetingViewModel()) + MeetingsFragment(meetingsListViewModel: MeetingsListViewModel(), + meetingViewModel: MeetingViewModel(), + showingSheet: .constant(false)) } diff --git a/Linphone/UI/Main/Meetings/Fragments/MeetingsListBottomSheet.swift b/Linphone/UI/Main/Meetings/Fragments/MeetingsListBottomSheet.swift new file mode 100644 index 000000000..cc37914d0 --- /dev/null +++ b/Linphone/UI/Main/Meetings/Fragments/MeetingsListBottomSheet.swift @@ -0,0 +1,103 @@ +/* + * 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 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 { + meetingsListViewModel.deleteSelectedMeeting() + CoreContext.shared.doOnCoreQueue { core in + if let organizerUri = self.meetingsListViewModel.selectedMeetingToDelete?.confInfo.organizer { + if core.defaultAccount?.contactAddress?.weakEqual(address2: organizerUri) ?? false { + DispatchQueue.main.async { + // If we are the organizer, display popup for sending + self.isShowSendCancelMeetingNotificationPopup = true + } + } + } + } + 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/MeetingsView.swift b/Linphone/UI/Main/Meetings/MeetingsView.swift index ce663f0c8..98518402e 100644 --- a/Linphone/UI/Main/Meetings/MeetingsView.swift +++ b/Linphone/UI/Main/Meetings/MeetingsView.swift @@ -20,16 +20,40 @@ 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 var body: some View { NavigationView { ZStack(alignment: .bottomTrailing) { - MeetingsFragment(meetingsListViewModel: meetingsListViewModel, meetingViewModel: meetingViewModel) + + if #available(iOS 16.0, *), idiom != .pad { + MeetingsFragment(meetingsListViewModel: meetingsListViewModel, meetingViewModel: meetingViewModel, showingSheet: $showingSheet) + .sheet(isPresented: $showingSheet) { + MeetingsListBottomSheet( + meetingsListViewModel: meetingsListViewModel, + showingSheet: $showingSheet, + isShowSendCancelMeetingNotificationPopup: $isShowSendCancelMeetingNotificationPopup + ) + .presentationDetents([.fraction(0.1)]) + } + } else { + MeetingsFragment(meetingsListViewModel: meetingsListViewModel, meetingViewModel: meetingViewModel, showingSheet: $showingSheet) + .halfSheet(showSheet: $showingSheet) { + MeetingsListBottomSheet( + meetingsListViewModel: meetingsListViewModel, + showingSheet: $showingSheet, + isShowSendCancelMeetingNotificationPopup: $isShowSendCancelMeetingNotificationPopup + ) + } onDismiss: {} + } Button { withAnimation { @@ -57,6 +81,7 @@ struct MeetingsView: View { MeetingsView( meetingsListViewModel: MeetingsListViewModel(), meetingViewModel: MeetingViewModel(), - isShowScheduleMeetingFragment: .constant(false) + isShowScheduleMeetingFragment: .constant(false), + isShowSendCancelMeetingNotificationPopup: .constant(false) ) } diff --git a/Linphone/UI/Main/Meetings/ViewModel/MeetingViewModel.swift b/Linphone/UI/Main/Meetings/ViewModel/MeetingViewModel.swift index 517b5d788..c937a2ed2 100644 --- a/Linphone/UI/Main/Meetings/ViewModel/MeetingViewModel.swift +++ b/Linphone/UI/Main/Meetings/ViewModel/MeetingViewModel.swift @@ -348,4 +348,11 @@ class MeetingViewModel: ObservableObject { } } + func sendMeetingCancelledNotifications(meeting: MeetingModel) { + Log.error("\(MeetingViewModel.TAG) - sendMeetingCancelledNotifications TODO") + //CoreContext.shared.doOnCoreQueue { core in + // self.resetConferenceSchedulerAndListeners(core: core) + // self.conferenceScheduler?.cancelConference(conferenceInfo: meeting.confInfo) + //} + } } diff --git a/Linphone/UI/Main/Meetings/ViewModel/MeetingsListViewModel.swift b/Linphone/UI/Main/Meetings/ViewModel/MeetingsListViewModel.swift index 3ba5f71bf..dad4aff90 100644 --- a/Linphone/UI/Main/Meetings/ViewModel/MeetingsListViewModel.swift +++ b/Linphone/UI/Main/Meetings/ViewModel/MeetingsListViewModel.swift @@ -27,7 +27,7 @@ class MeetingsListViewModel: ObservableObject { private var coreContext = CoreContext.shared private var mCoreSuscriptions = Set() - var selectedMeeting: ConversationModel? + var selectedMeetingToDelete: MeetingModel? @Published var meetingsList: [MeetingsListItemModel] = [] @Published var currentFilter = "" @@ -112,4 +112,25 @@ class MeetingsListViewModel: ObservableObject { } } + 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) + DispatchQueue.main.async { + 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) + ToastViewModel.shared.toastMessage = "Success_toast_meeting_deleted" + ToastViewModel.shared.displayToast = true + } + } + } + } } From bb3cf124987d7f62aeb42bddb7edc512d3a7eccb Mon Sep 17 00:00:00 2001 From: QuentinArguillere Date: Thu, 27 Jun 2024 16:04:09 +0200 Subject: [PATCH 294/486] Fix meeting cell corner radius --- .../UI/Main/Meetings/Fragments/MeetingsFragment.swift | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/Linphone/UI/Main/Meetings/Fragments/MeetingsFragment.swift b/Linphone/UI/Main/Meetings/Fragments/MeetingsFragment.swift index f2f49aaae..a95799556 100644 --- a/Linphone/UI/Main/Meetings/Fragments/MeetingsFragment.swift +++ b/Linphone/UI/Main/Meetings/Fragments/MeetingsFragment.swift @@ -74,7 +74,7 @@ struct MeetingsFragment: View { .frame(height: 63) .frame(maxWidth: .infinity, alignment: .leading) .background(.white) - .clipShape(RoundedRectangle(cornerRadius: 20)) + .clipShape(RoundedRectangle(cornerRadius: 10)) .shadow(color: .black.opacity(0.2), radius: 4) .onTapGesture { withAnimation { @@ -84,8 +84,10 @@ struct MeetingsFragment: View { } } .onLongPressGesture(minimumDuration: 0.2) { - meetingViewModel.displayedMeeting = model.model - showingSheet.toggle() + if let meetingModel = model.model { + meetingsListViewModel.selectedMeetingToDelete = meetingModel + showingSheet.toggle() + } } } From e3a04b9b2f67fc1a65bc3a6b67385fe680bb8f0a Mon Sep 17 00:00:00 2001 From: QuentinArguillere Date: Fri, 28 Jun 2024 15:46:18 +0200 Subject: [PATCH 295/486] Adjust paddings in meetings list view --- .../Meetings/Fragments/MeetingFragment.swift | 27 ++++++++++--------- .../Meetings/Fragments/MeetingsFragment.swift | 10 +++---- 2 files changed, 19 insertions(+), 18 deletions(-) diff --git a/Linphone/UI/Main/Meetings/Fragments/MeetingFragment.swift b/Linphone/UI/Main/Meetings/Fragments/MeetingFragment.swift index b16340c7d..1b433e480 100644 --- a/Linphone/UI/Main/Meetings/Fragments/MeetingFragment.swift +++ b/Linphone/UI/Main/Meetings/Fragments/MeetingFragment.swift @@ -224,19 +224,20 @@ struct MeetingFragment: View { .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, 15) - - Text(meetingViewModel.description) - .default_text_style(styleSize: 14) - Spacer() - }.padding(.top, 10) - .padding(.bottom, 10) + 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) diff --git a/Linphone/UI/Main/Meetings/Fragments/MeetingsFragment.swift b/Linphone/UI/Main/Meetings/Fragments/MeetingsFragment.swift index a95799556..02d6c4ba1 100644 --- a/Linphone/UI/Main/Meetings/Fragments/MeetingsFragment.swift +++ b/Linphone/UI/Main/Meetings/Fragments/MeetingsFragment.swift @@ -40,9 +40,8 @@ struct MeetingsFragment: View { @ViewBuilder func createWeekLine(model: MeetingsListItemModel) -> some View { Text(model.weekStr) - .padding(.leading, 43) - .padding(.top, 5) - .padding(.bottom, 5) + .padding(.leading, 50) + .padding(.vertical, 10) .default_text_style_500(styleSize: 14) } @ViewBuilder @@ -70,7 +69,7 @@ struct MeetingsFragment: View { .default_text_style_500(styleSize: 15) } } - .padding(.leading, 20) + .padding(.leading, 30) .frame(height: 63) .frame(maxWidth: .infinity, alignment: .leading) .background(.white) @@ -130,6 +129,7 @@ struct MeetingsFragment: View { Text("No meeting today") .fontWeight(.bold) .padding(.leading, 20) + .padding(.top, 10) .default_text_style_500(styleSize: 15) } else { createMeetingLine(model: itemModel) @@ -137,7 +137,7 @@ struct MeetingsFragment: View { } } else { createMeetingLine(model: itemModel) - .padding(.leading, 45) + .padding(.leading, 55) } } .id(index) From ef56935228ecaee1c5162371f1976a4e3dc09fe8 Mon Sep 17 00:00:00 2001 From: Christophe Deschamps Date: Mon, 1 Jul 2024 09:35:40 +0200 Subject: [PATCH 296/486] Collect side menu account info on core thread --- Linphone/UI/Main/Fragments/SideMenuAccountRow.swift | 6 +++--- Linphone/UI/Main/Viewmodel/AccountModel.swift | 6 ++++++ 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/Linphone/UI/Main/Fragments/SideMenuAccountRow.swift b/Linphone/UI/Main/Fragments/SideMenuAccountRow.swift index 32c3f9f5b..18a7e34da 100644 --- a/Linphone/UI/Main/Fragments/SideMenuAccountRow.swift +++ b/Linphone/UI/Main/Fragments/SideMenuAccountRow.swift @@ -28,14 +28,14 @@ struct SideMenuAccountRow: View { Avatar(contactAvatarModel: ContactAvatarModel(friend: nil, - name: model.account.displayName(), - address: model.account.params!.identityAddress!.asString(), + name: model.displayName, + address: model.address, withPresence: true), avatarSize: 45) .padding(.leading, 6) VStack { - Text(model.account.displayName()) + Text(model.displayName) .default_text_style_grey_400(styleSize: 14) .lineLimit(1) .frame(maxWidth: .infinity, alignment: .leading) diff --git a/Linphone/UI/Main/Viewmodel/AccountModel.swift b/Linphone/UI/Main/Viewmodel/AccountModel.swift index 29f1055fe..6ef1607b1 100644 --- a/Linphone/UI/Main/Viewmodel/AccountModel.swift +++ b/Linphone/UI/Main/Viewmodel/AccountModel.swift @@ -27,6 +27,8 @@ class AccountModel: ObservableObject { @Published var registrationStateAssociatedUIColor: Color = .clear @Published var notificationsCount: Int = 0 @Published var isDefaultAccount: Bool = false + @Published var displayName: String = "" + @Published var address: String = "" init(account: Account, corePublisher: CoreDelegatePublisher?) { self.account = account @@ -56,6 +58,8 @@ class AccountModel: ObservableObject { 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: @@ -75,6 +79,8 @@ class AccountModel: ObservableObject { registrationStateAssociatedUIColor = .grayMain2c500 } isDefaultAccount = isDefault + self.displayName = displayName + address.map {self.address = $0} } } From 6bb74b028d48df0c0fceae203f1edcb6c993e575 Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Mon, 1 Jul 2024 14:29:26 +0200 Subject: [PATCH 297/486] Can use username with domain in login view --- .../UI/Assistant/Viewmodel/AccountLoginViewModel.swift | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/Linphone/UI/Assistant/Viewmodel/AccountLoginViewModel.swift b/Linphone/UI/Assistant/Viewmodel/AccountLoginViewModel.swift index b4d8229e5..8172c9bfb 100644 --- a/Linphone/UI/Assistant/Viewmodel/AccountLoginViewModel.swift +++ b/Linphone/UI/Assistant/Viewmodel/AccountLoginViewModel.swift @@ -35,6 +35,14 @@ class AccountLoginViewModel: ObservableObject { func login() { coreContext.doOnCoreQueue { core in 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) { From 858094ecc5e1398a065a16cd5119b3a4b8e1c489 Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Mon, 1 Jul 2024 14:51:59 +0200 Subject: [PATCH 298/486] Use the Linphone logo in the callkit view --- Linphone/TelecomManager/ProviderDelegate.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Linphone/TelecomManager/ProviderDelegate.swift b/Linphone/TelecomManager/ProviderDelegate.swift index 727c81dcb..56448304a 100644 --- a/Linphone/TelecomManager/ProviderDelegate.swift +++ b/Linphone/TelecomManager/ProviderDelegate.swift @@ -74,7 +74,7 @@ class ProviderDelegate: NSObject { 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: "callkit_logo")?.pngData() + providerConfiguration.iconTemplateImageData = UIImage(named: "linphone")?.pngData() providerConfiguration.supportedHandleTypes = [.generic, .phoneNumber, .emailAddress] providerConfiguration.maximumCallsPerCallGroup = 10 From cef2e693eaf0e571fd273a28e8b208c210e70f87 Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Mon, 1 Jul 2024 16:19:00 +0200 Subject: [PATCH 299/486] Linphone Video displayed in Callkit notification when a video call is received --- Linphone/TelecomManager/TelecomManager.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Linphone/TelecomManager/TelecomManager.swift b/Linphone/TelecomManager/TelecomManager.swift index 59f282a6d..ce2af3f1f 100644 --- a/Linphone/TelecomManager/TelecomManager.swift +++ b/Linphone/TelecomManager/TelecomManager.swift @@ -587,7 +587,9 @@ class TelecomManager: ObservableObject { // Tha app is now registered, updated the call already existed. self.providerDelegate.updateCall(uuid: uuid!, handle: addr!.asStringUriOnly(), hasVideo: self.remoteConfVideo, displayName: displayName) } else { - self.displayIncomingCall(call: call, handle: addr!.asStringUriOnly(), hasVideo: self.remoteConfVideo, callId: callId, displayName: displayName) + let videoEnabled = call.remoteParams?.videoEnabled ?? false + let isConference = call.callLog?.wasConference() ?? false + self.displayIncomingCall(call: call, handle: addr!.asStringUriOnly(), hasVideo: videoEnabled && !isConference, callId: callId, displayName: displayName) } } /* else if UIApplication.shared.applicationState != .active { // not support callkit , use notif From 39941291775b2a9720ce42edcbdabadd6c8eb35d Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Mon, 1 Jul 2024 16:30:13 +0200 Subject: [PATCH 300/486] Use num keypad in the contact edit view --- Linphone/UI/Main/Contacts/Fragments/EditContactFragment.swift | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Linphone/UI/Main/Contacts/Fragments/EditContactFragment.swift b/Linphone/UI/Main/Contacts/Fragments/EditContactFragment.swift index d6afa15f0..e912466fe 100644 --- a/Linphone/UI/Main/Contacts/Fragments/EditContactFragment.swift +++ b/Linphone/UI/Main/Contacts/Fragments/EditContactFragment.swift @@ -375,6 +375,8 @@ struct EditContactFragment: View { HStack(alignment: .center) { TextField("Phone number", text: $editContactViewModel.phoneNumbers[index]) .default_text_style(styleSize: 15) + .textContentType(.oneTimeCode) + .keyboardType(.numberPad) .frame(height: 25) .padding(.horizontal, 20) .padding(.vertical, 15) From bdd38a80b67475fc5817c3aca6454076aa8e2043 Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Mon, 1 Jul 2024 16:47:44 +0200 Subject: [PATCH 301/486] Disable auto capitalization and auto correction in the global search bar --- Linphone/UI/Main/ContentView.swift | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Linphone/UI/Main/ContentView.swift b/Linphone/UI/Main/ContentView.swift index 6f093534a..0c47599f1 100644 --- a/Linphone/UI/Main/ContentView.swift +++ b/Linphone/UI/Main/ContentView.swift @@ -433,6 +433,8 @@ struct ContentView: View { )) .default_text_style_white_700(styleSize: 15) .padding(.all, 6) + .disableAutocorrection(true) + .autocapitalization(.none) .accentColor(.white) .scrollContentBackground(.hidden) .focused($focusedField) @@ -469,6 +471,8 @@ struct ContentView: View { .default_text_style_700(styleSize: 15) .padding(.all, 6) .focused($focusedField) + .disableAutocorrection(true) + .autocapitalization(.none) .onAppear { self.focusedField = true } From b5e3f72cf6039fdf4c32b0c42f4c0bf549e84d43 Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Tue, 2 Jul 2024 16:11:29 +0200 Subject: [PATCH 302/486] Fix display of multiple calls --- Linphone/TelecomManager/ProviderDelegate.swift | 11 ++++++++--- Linphone/TelecomManager/TelecomManager.swift | 9 +++++++++ Linphone/UI/Call/CallView.swift | 14 +++++++------- Linphone/UI/Call/Fragments/CallsListFragment.swift | 2 +- Linphone/UI/Call/ViewModel/CallViewModel.swift | 6 +++++- Linphone/UI/Main/ContentView.swift | 10 +++++----- 6 files changed, 35 insertions(+), 17 deletions(-) diff --git a/Linphone/TelecomManager/ProviderDelegate.swift b/Linphone/TelecomManager/ProviderDelegate.swift index 56448304a..8eb6a3617 100644 --- a/Linphone/TelecomManager/ProviderDelegate.swift +++ b/Linphone/TelecomManager/ProviderDelegate.swift @@ -222,13 +222,18 @@ extension ProviderDelegate: CXProviderDelegate { 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 call?.callLog != nil { - TelecomManager.shared.backgroundContextCameraIsEnabled = call?.params?.videoEnabled == true || call?.callLog?.wasConference() == true + if callLogIsNil { + TelecomManager.shared.backgroundContextCameraIsEnabled = videoEnabledTmp == true || wasConferenceTmp == true } else { - TelecomManager.shared.backgroundContextCameraIsEnabled = call?.params?.videoEnabled == true + TelecomManager.shared.backgroundContextCameraIsEnabled = videoEnabledTmp == true } if #available(iOS 16.0, *) { diff --git a/Linphone/TelecomManager/TelecomManager.swift b/Linphone/TelecomManager/TelecomManager.swift index ce2af3f1f..3f7b3f85c 100644 --- a/Linphone/TelecomManager/TelecomManager.swift +++ b/Linphone/TelecomManager/TelecomManager.swift @@ -319,6 +319,15 @@ class TelecomManager: ObservableObject { 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)") diff --git a/Linphone/UI/Call/CallView.swift b/Linphone/UI/Call/CallView.swift index 051f0f8e6..5e0fe18c7 100644 --- a/Linphone/UI/Call/CallView.swift +++ b/Linphone/UI/Call/CallView.swift @@ -1915,7 +1915,7 @@ struct CallView: View { if callViewModel.isOneOneCall { VStack { Button { - if callViewModel.calls.count < 2 { + if callViewModel.callsCounter < 2 { withAnimation { callViewModel.isTransferInsteadCall = true MagicSearchSingleton.shared.searchForSuggestions() @@ -1938,7 +1938,7 @@ struct CallView: View { .background(Color.gray500) .cornerRadius(40) - Text(callViewModel.calls.count < 2 ? "Transfer" : "Attended transfer") + Text(callViewModel.callsCounter < 2 ? "Transfer" : "Attended transfer") .foregroundStyle(.white) .default_text_style(styleSize: 15) } @@ -2039,13 +2039,13 @@ struct CallView: View { .background(Color.gray500) .cornerRadius(40) - if callViewModel.calls.count > 1 { + if callViewModel.callsCounter > 1 { VStack { HStack { Spacer() VStack { - Text("\(callViewModel.calls.count)") + Text("\(callViewModel.callsCounter)") .foregroundStyle(.white) .default_text_style(styleSize: 15) } @@ -2261,7 +2261,7 @@ struct CallView: View { .background(Color.gray500) .cornerRadius(40) - Text(callViewModel.calls.count < 2 ? "Transfer" : "Attended transfer") + Text(callViewModel.callsCounter < 2 ? "Transfer" : "Attended transfer") .foregroundStyle(.white) .default_text_style(styleSize: 15) } @@ -2365,13 +2365,13 @@ struct CallView: View { .background(Color.gray500) .cornerRadius(40) - if callViewModel.calls.count > 1 { + if callViewModel.callsCounter > 1 { VStack { HStack { Spacer() VStack { - Text("\(callViewModel.calls.count)") + Text("\(callViewModel.callsCounter)") .foregroundStyle(.white) .default_text_style(styleSize: 15) } diff --git a/Linphone/UI/Call/Fragments/CallsListFragment.swift b/Linphone/UI/Call/Fragments/CallsListFragment.swift index 171f11696..ae6bf53b5 100644 --- a/Linphone/UI/Call/Fragments/CallsListFragment.swift +++ b/Linphone/UI/Call/Fragments/CallsListFragment.swift @@ -327,7 +327,7 @@ struct CallsListFragment: View { .listStyle(.plain) .overlay( VStack { - if callViewModel.calls.isEmpty { + if callViewModel.callsCounter == 0 { Spacer() Image("illus-belledonne") .resizable() diff --git a/Linphone/UI/Call/ViewModel/CallViewModel.swift b/Linphone/UI/Call/ViewModel/CallViewModel.swift index bf19b6f48..03602c008 100644 --- a/Linphone/UI/Call/ViewModel/CallViewModel.swift +++ b/Linphone/UI/Call/ViewModel/CallViewModel.swift @@ -65,6 +65,7 @@ class CallViewModel: ObservableObject { private var mConferenceSuscriptions = Set() @Published var calls: [Call] = [] + @Published var callsCounter: Int = 0 @Published var callsContactAvatarModel: [ContactAvatarModel?] = [] let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect() @@ -106,10 +107,11 @@ class CallViewModel: ObservableObject { coreContext.doOnCoreQueue { core in if core.currentCall != nil && core.currentCall!.remoteAddress != nil { self.currentCall = core.currentCall - self.callSuscriptions.removeAll() self.mConferenceSuscriptions.removeAll() + let callsCounterTmp = core.calls.count + var videoDisplayedTmp = false do { let params = try core.createCallParams(call: self.currentCall) @@ -221,6 +223,8 @@ class CallViewModel: ObservableObject { self.getCallsList() + self.callsCounter = callsCounterTmp + if self.currentCall?.conference?.state == .Created { self.getConference() } else { diff --git a/Linphone/UI/Main/ContentView.swift b/Linphone/UI/Main/ContentView.swift index 0c47599f1..d6d1efbb3 100644 --- a/Linphone/UI/Main/ContentView.swift +++ b/Linphone/UI/Main/ContentView.swift @@ -73,7 +73,7 @@ struct ContentView: View { GeometryReader { geometry in VStack(spacing: 0) { - if telecomManager.callInProgress && !fullscreenVideo && ((!telecomManager.callDisplayed && callViewModel.calls.count == 1) || callViewModel.calls.count > 1) { + if telecomManager.callInProgress && !fullscreenVideo && ((!telecomManager.callDisplayed && callViewModel.callsCounter == 1) || callViewModel.callsCounter > 1) { HStack { Image("phone") .renderingMode(.template) @@ -82,8 +82,8 @@ struct ContentView: View { .frame(width: 26, height: 26) .padding(.leading, 10) - if callViewModel.calls.count > 1 { - Text("\(callViewModel.calls.count) appels") + if callViewModel.callsCounter > 1 { + Text("\(callViewModel.callsCounter) appels") .default_text_style_white(styleSize: 16) } else { Text("\(callViewModel.displayName)") @@ -92,7 +92,7 @@ struct ContentView: View { Spacer() - if callViewModel.calls.count == 1 { + if callViewModel.callsCounter == 1 { Text("\(callViewModel.isPaused || telecomManager.isPausedByRemote ? "En pause" : "Actif")") .default_text_style_white(styleSize: 16) .padding(.trailing, 10) @@ -1024,7 +1024,7 @@ struct ContentView: View { .onAppear { UIApplication.shared.isIdleTimerDisabled = true callViewModel.resetCallView() - if callViewModel.calls.count >= 1 { + if callViewModel.callsCounter >= 1 { DispatchQueue.main.asyncAfter(deadline: .now() + 1) { callViewModel.resetCallView() } From 4884997db606b82d77fdf37f784753f9082f69df Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Wed, 3 Jul 2024 09:38:17 +0200 Subject: [PATCH 303/486] Fix dialer in startcallview --- Linphone/Localizable.xcstrings | 12 +++++ Linphone/UI/Call/CallView.swift | 6 +++ Linphone/UI/Main/ContentView.swift | 4 ++ .../History/Fragments/DialerBottomSheet.swift | 52 +++++++++++++++++-- .../ViewModel/StartCallViewModel.swift | 9 ++++ 5 files changed, 78 insertions(+), 5 deletions(-) diff --git a/Linphone/Localizable.xcstrings b/Linphone/Localizable.xcstrings index 19e09775f..39a593c52 100644 --- a/Linphone/Localizable.xcstrings +++ b/Linphone/Localizable.xcstrings @@ -850,6 +850,9 @@ }, "Delete this contact" : { + }, + "Delete this meeting" : { + }, "Demande d’autorisations" : { @@ -1424,6 +1427,9 @@ }, "Send Logs" : { + }, + "Send notification to participants ?" : { + }, "settings_title" : { "extractionState" : "manual", @@ -1471,6 +1477,9 @@ }, "subscribe.linphone.org" : { + }, + "Successfully removed meeting" : { + }, "Suggestions" : { @@ -1486,6 +1495,9 @@ }, "Temp Help" : { + }, + "The meeting has been cancelled" : { + }, "The user name or password is incorrects" : { diff --git a/Linphone/UI/Call/CallView.swift b/Linphone/UI/Call/CallView.swift index 5e0fe18c7..cca0615f6 100644 --- a/Linphone/UI/Call/CallView.swift +++ b/Linphone/UI/Call/CallView.swift @@ -93,6 +93,8 @@ struct CallView: View { .sheet(isPresented: $showingDialer) { DialerBottomSheet( startCallViewModel: StartCallViewModel(), + callViewModel: callViewModel, + isShowStartCallFragment: $isShowStartCallFragment, showingDialer: $showingDialer, currentCall: callViewModel.currentCall ) @@ -128,6 +130,8 @@ struct CallView: View { .sheet(isPresented: $showingDialer) { DialerBottomSheet( startCallViewModel: StartCallViewModel(), + callViewModel: callViewModel, + isShowStartCallFragment: $isShowStartCallFragment, showingDialer: $showingDialer, currentCall: callViewModel.currentCall ) @@ -158,6 +162,8 @@ struct CallView: View { .halfSheet(showSheet: $showingDialer) { DialerBottomSheet( startCallViewModel: StartCallViewModel(), + callViewModel: callViewModel, + isShowStartCallFragment: $isShowStartCallFragment, showingDialer: $showingDialer, currentCall: callViewModel.currentCall ) diff --git a/Linphone/UI/Main/ContentView.swift b/Linphone/UI/Main/ContentView.swift index d6d1efbb3..395acc513 100644 --- a/Linphone/UI/Main/ContentView.swift +++ b/Linphone/UI/Main/ContentView.swift @@ -852,6 +852,8 @@ struct ContentView: View { .sheet(isPresented: $showingDialer) { DialerBottomSheet( startCallViewModel: startCallViewModel, + callViewModel: callViewModel, + isShowStartCallFragment: $isShowStartCallFragment, showingDialer: $showingDialer, currentCall: nil ) @@ -871,6 +873,8 @@ struct ContentView: View { .halfSheet(showSheet: $showingDialer) { DialerBottomSheet( startCallViewModel: startCallViewModel, + callViewModel: callViewModel, + isShowStartCallFragment: $isShowStartCallFragment, showingDialer: $showingDialer, currentCall: nil ) diff --git a/Linphone/UI/Main/History/Fragments/DialerBottomSheet.swift b/Linphone/UI/Main/History/Fragments/DialerBottomSheet.swift index 7a5a3bda9..7231934d8 100644 --- a/Linphone/UI/Main/History/Fragments/DialerBottomSheet.swift +++ b/Linphone/UI/Main/History/Fragments/DialerBottomSheet.swift @@ -33,10 +33,13 @@ struct DialerBottomSheet: View { @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? @@ -449,11 +452,50 @@ struct DialerBottomSheet: View { Button { if !startCallViewModel.searchField.isEmpty { - do { - let address = try Factory.Instance.createAddress(addr: String("sip:" + startCallViewModel.searchField + "@" + startCallViewModel.domain)) - telecomManager.doCallOrJoinConf(address: address) - } catch { + if callViewModel.isTransferInsteadCall { + showingDialer = false + DispatchQueue.global().asyncAfter(deadline: .now() + 0.2) { + 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.global().asyncAfter(deadline: .now() + 0.2) { + 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: { @@ -502,6 +544,6 @@ struct DialerBottomSheet: View { #Preview { DialerBottomSheet( - startCallViewModel: StartCallViewModel(), showingDialer: .constant(false), currentCall: nil + startCallViewModel: StartCallViewModel(), callViewModel: CallViewModel(), isShowStartCallFragment: .constant(false), showingDialer: .constant(false), currentCall: nil ) } diff --git a/Linphone/UI/Main/History/ViewModel/StartCallViewModel.swift b/Linphone/UI/Main/History/ViewModel/StartCallViewModel.swift index 56abb30ab..b5540b777 100644 --- a/Linphone/UI/Main/History/ViewModel/StartCallViewModel.swift +++ b/Linphone/UI/Main/History/ViewModel/StartCallViewModel.swift @@ -161,4 +161,13 @@ class StartCallViewModel: ObservableObject { ) } } + + func interpretAndStartCall() { + CoreContext.shared.doOnCoreQueue { core in + let address = core.interpretUrl(url: self.searchField, applyInternationalPrefix: true) + if address != nil { + TelecomManager.shared.doCallOrJoinConf(address: address!) + } + } + } } From 8afe787d2a03ffe16d21ed6ac4c743d0ec50e6c1 Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Wed, 3 Jul 2024 10:16:12 +0200 Subject: [PATCH 304/486] Remove disable_chat_feature check in Notification service --- msgNotificationService/NotificationService.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/msgNotificationService/NotificationService.swift b/msgNotificationService/NotificationService.swift index ef3f6439e..7a65dbadc 100644 --- a/msgNotificationService/NotificationService.swift +++ b/msgNotificationService/NotificationService.swift @@ -98,7 +98,7 @@ class NotificationService: UNNotificationServiceExtension { if let bestAttemptContent = bestAttemptContent { createCore() - if !lc!.config!.getBool(section: "app", key: "disable_chat_feature", defaultValue: true) { + //if !lc!.config!.getBool(section: "app", key: "disable_chat_feature", defaultValue: false) { Log.info("received push payload : \(bestAttemptContent.userInfo.debugDescription)") /* @@ -178,7 +178,7 @@ class NotificationService: UNNotificationServiceExtension { Log.info("Message not found for callid ["+callId+"]") } } - } + //} serviceExtensionTimeWillExpire() } From b92690865f558a95129190a77ae4594ae036e222 Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Wed, 3 Jul 2024 10:52:30 +0200 Subject: [PATCH 305/486] Change recording toast icon --- Linphone/UI/Main/Fragments/ToastView.swift | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/Linphone/UI/Main/Fragments/ToastView.swift b/Linphone/UI/Main/Fragments/ToastView.swift index 406870977..9ebbff919 100644 --- a/Linphone/UI/Main/Fragments/ToastView.swift +++ b/Linphone/UI/Main/Fragments/ToastView.swift @@ -33,6 +33,12 @@ struct ToastView: View { .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() From 09ea819b55f8921dc429225ddd3d8f3eb739cf8e Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Wed, 3 Jul 2024 14:16:33 +0200 Subject: [PATCH 306/486] Add error message below text fields in Register view --- .../Fragments/RegisterFragment.swift | 44 ++++++++++++++++--- .../Viewmodel/RegisterViewModel.swift | 19 ++++++++ 2 files changed, 57 insertions(+), 6 deletions(-) diff --git a/Linphone/UI/Assistant/Fragments/RegisterFragment.swift b/Linphone/UI/Assistant/Fragments/RegisterFragment.swift index 45307c16b..97f4f3a8b 100644 --- a/Linphone/UI/Assistant/Fragments/RegisterFragment.swift +++ b/Linphone/UI/Assistant/Fragments/RegisterFragment.swift @@ -91,10 +91,19 @@ struct RegisterFragment: View { .overlay( RoundedRectangle(cornerRadius: 60) .inset(by: 0.5) - .stroke(isNameFocused ? Color.orangeMain500 : Color.gray200, lineWidth: 1) + .stroke(isNameFocused ? Color.orangeMain500 : (!registerViewModel.usernameError.isEmpty ? Color.redDanger500 : Color.gray200), lineWidth: 1) ) - .padding(.bottom) .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) @@ -128,6 +137,11 @@ struct RegisterFragment: View { .autocapitalization(.none) .padding(.leading, 5) .keyboardType(.numberPad) + .onChange(of: registerViewModel.phoneNumber) { _ in + if !registerViewModel.phoneNumberError.isEmpty { + registerViewModel.phoneNumberError = "" + } + } } .frame(height: 25) .padding(.horizontal, 20) @@ -136,11 +150,15 @@ struct RegisterFragment: View { .overlay( RoundedRectangle(cornerRadius: 60) .inset(by: 0.5) - .stroke(isPhoneNumberFocused ? Color.orangeMain500 : Color.gray200, lineWidth: 1) + .stroke(isPhoneNumberFocused ? Color.orangeMain500 : (!registerViewModel.phoneNumberError.isEmpty ? Color.redDanger500 : Color.gray200), lineWidth: 1) ) - .padding(.bottom) .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) @@ -152,6 +170,11 @@ struct RegisterFragment: View { .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) @@ -159,6 +182,11 @@ struct RegisterFragment: View { .autocapitalization(.none) .frame(height: 25) .focused($isPasswordFocused) + .onChange(of: registerViewModel.passwd) { _ in + if !registerViewModel.passwordError.isEmpty { + registerViewModel.passwordError = "" + } + } } } @@ -178,9 +206,13 @@ struct RegisterFragment: View { .overlay( RoundedRectangle(cornerRadius: 60) .inset(by: 0.5) - .stroke(isPasswordFocused ? Color.orangeMain500 : Color.gray200, lineWidth: 1) + .stroke(isPasswordFocused ? Color.orangeMain500 : (!registerViewModel.passwordError.isEmpty ? Color.redDanger500 : Color.gray200), lineWidth: 1) ) - .padding(.bottom) + + Text(registerViewModel.passwordError) + .foregroundStyle(Color.redDanger500) + .default_text_style_600(styleSize: 15) + .padding(.bottom) NavigationLink(isActive: $registerViewModel.isLinkActive, destination: { RegisterCodeConfirmationFragment(registerViewModel: registerViewModel) diff --git a/Linphone/UI/Assistant/Viewmodel/RegisterViewModel.swift b/Linphone/UI/Assistant/Viewmodel/RegisterViewModel.swift index e2657deea..67ecdbec2 100644 --- a/Linphone/UI/Assistant/Viewmodel/RegisterViewModel.swift +++ b/Linphone/UI/Assistant/Viewmodel/RegisterViewModel.swift @@ -96,6 +96,10 @@ class RegisterViewModel: ObservableObject { 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 { @@ -175,6 +179,21 @@ class RegisterViewModel: ObservableObject { } } + 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") From befad07719a18fe71d4f2a197f02d2ed5ff8bbab Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Wed, 3 Jul 2024 17:22:01 +0200 Subject: [PATCH 307/486] Add sip address selector for contact view --- Linphone.xcodeproj/project.pbxproj | 4 + Linphone/Localizable.xcstrings | 17 + .../Contacts/Fragments/ContactFragment.swift | 10 +- .../Fragments/ContactInnerFragment.swift | 480 ++++++++++-------- .../Fragments/SipAddressesPopup.swift | 86 ++++ Linphone/UI/Main/ContentView.swift | 17 +- 6 files changed, 407 insertions(+), 207 deletions(-) create mode 100644 Linphone/UI/Main/Contacts/Fragments/SipAddressesPopup.swift diff --git a/Linphone.xcodeproj/project.pbxproj b/Linphone.xcodeproj/project.pbxproj index 0c42611c7..c7eb30245 100644 --- a/Linphone.xcodeproj/project.pbxproj +++ b/Linphone.xcodeproj/project.pbxproj @@ -159,6 +159,7 @@ 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 */ @@ -337,6 +338,7 @@ 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 */ @@ -711,6 +713,7 @@ D7C365072AEFAB7F00FE6142 /* ContactListBottomSheet.swift */, D7C365092AF001C300FE6142 /* EditContactFragment.swift */, D7C48DF52AFCDF4700D938CB /* ContactInnerActionsFragment.swift */, + D7F5F6402C359F3B007FCF2F /* SipAddressesPopup.swift */, ); path = Fragments; sourceTree = ""; @@ -1125,6 +1128,7 @@ 662B69D92B25DE18007118BF /* TelecomManager.swift in Sources */, D72343342ACEFFC3009AA24E /* QRScanner.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 */, diff --git a/Linphone/Localizable.xcstrings b/Linphone/Localizable.xcstrings index 39a593c52..cd6131cc6 100644 --- a/Linphone/Localizable.xcstrings +++ b/Linphone/Localizable.xcstrings @@ -792,6 +792,23 @@ }, "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" : { diff --git a/Linphone/UI/Main/Contacts/Fragments/ContactFragment.swift b/Linphone/UI/Main/Contacts/Fragments/ContactFragment.swift index 67fefca97..0d7139532 100644 --- a/Linphone/UI/Main/Contacts/Fragments/ContactFragment.swift +++ b/Linphone/UI/Main/Contacts/Fragments/ContactFragment.swift @@ -29,6 +29,7 @@ struct ContactFragment: View { @Binding var isShowDeletePopup: Bool @Binding var isShowDismissPopup: Bool + @Binding var isShowSipAddressesPopup: Bool @State private var showingSheet = false @State private var showShareSheet = false @@ -45,7 +46,8 @@ struct ContactFragment: View { isShowDeletePopup: $isShowDeletePopup, showingSheet: $showingSheet, showShareSheet: $showShareSheet, - isShowDismissPopup: $isShowDismissPopup + isShowDismissPopup: $isShowDismissPopup, + isShowSipAddressesPopup: $isShowSipAddressesPopup ) .sheet(isPresented: $showingSheet) { ContactListBottomSheet(contactViewModel: contactViewModel, showingSheet: $showingSheet) @@ -65,7 +67,8 @@ struct ContactFragment: View { isShowDeletePopup: $isShowDeletePopup, showingSheet: $showingSheet, showShareSheet: $showShareSheet, - isShowDismissPopup: $isShowDismissPopup + isShowDismissPopup: $isShowDismissPopup, + isShowSipAddressesPopup: $isShowSipAddressesPopup ) .halfSheet(showSheet: $showingSheet) { ContactListBottomSheet(contactViewModel: contactViewModel, showingSheet: $showingSheet) @@ -84,6 +87,7 @@ struct ContactFragment: View { contactViewModel: ContactViewModel(), editContactViewModel: EditContactViewModel(), isShowDeletePopup: .constant(false), - isShowDismissPopup: .constant(false) + isShowDismissPopup: .constant(false), + isShowSipAddressesPopup: .constant(false) ) } diff --git a/Linphone/UI/Main/Contacts/Fragments/ContactInnerFragment.swift b/Linphone/UI/Main/Contacts/Fragments/ContactInnerFragment.swift index 0f47f37df..01104363c 100644 --- a/Linphone/UI/Main/Contacts/Fragments/ContactInnerFragment.swift +++ b/Linphone/UI/Main/Contacts/Fragments/ContactInnerFragment.swift @@ -34,60 +34,48 @@ struct ContactInnerFragment: View { @State private var orientation = UIDevice.current.orientation - @State private var presentingEditContact = false @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 var body: some View { NavigationView { - 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 - } - } - } + ZStack { + VStack(spacing: 1) { + Rectangle() + .foregroundColor(Color.orangeMain500) + .edgesIgnoringSafeArea(.top) + .frame(height: 0) - Spacer() - if contactViewModel.indexDisplayedFriend != nil && contactViewModel.indexDisplayedFriend! < contactsManager.lastSearch.count - && !contactAvatarModel.nativeUri.isEmpty { - Button(action: { - editNativeContact() - }, label: { - Image("pencil-simple") + 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) - }) - } else { - NavigationLink(destination: EditContactFragment( - editContactViewModel: editContactViewModel, - contactViewModel: contactViewModel, - isShowEditContactFragment: .constant(false), - isShowDismissPopup: $isShowDismissPopup)) { + .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() @@ -95,175 +83,197 @@ struct ContactInnerFragment: View { .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 - && contactAvatarModel != nil { - 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: { - do { - let address = try Factory.Instance.createAddress(addr: contactAvatarModel.address) - telecomManager.doCallOrJoinConf(address: address) - } catch { - Log.error("[ContactInnerFragment] unable to create address for a new outgoing call : \(contactAvatarModel.address) \(error) ") - } - }, 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: { - - }, label: { - VStack { - HStack(alignment: .center) { - Image("chat-teardrop-text") - .renderingMode(.template) - .resizable() - //.foregroundStyle(Color.grayMain2c600) - .foregroundStyle(Color.grayMain2c300) - .frame(width: 25, height: 25) - .onTapGesture { - withAnimation { - - } - } - } - .padding(16) - .background(Color.grayMain2c200) - .cornerRadius(40) - - Text("Message") - .default_text_style(styleSize: 14) - } - }) - - Spacer() - - Button(action: { - 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) ") - } - }, 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, + }) + } else { + NavigationLink(destination: EditContactFragment( editContactViewModel: editContactViewModel, - contactAvatarModel: contactAvatarModel, showingSheet: $showingSheet, - showShareSheet: $showShareSheet, - isShowDeletePopup: $isShowDeletePopup, - isShowDismissPopup: $isShowDismissPopup, - actionEditButton: editNativeContact - ) + 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: sharedMainViewModel.maxWidth) } .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 { + 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: { + + }, label: { + VStack { + HStack(alignment: .center) { + Image("chat-teardrop-text") + .renderingMode(.template) + .resizable() + //.foregroundStyle(Color.grayMain2c600) + .foregroundStyle(Color.grayMain2c300) + .frame(width: 25, height: 25) + .onTapGesture { + withAnimation { + + } + } + } + .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 { + 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(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) + .background(.white) + .navigationBarHidden(true) + .onRotate { newOrientation in + orientation = newOrientation + } + .fullScreenCover(isPresented: $presentingEditContact) { + NavigationView { + EditContactView(contact: $cnContact) + .navigationBarTitle("Edit Contact") + .navigationBarTitleDisplayMode(.inline) + .edgesIgnoringSafeArea(.vertical) + } } } } @@ -286,6 +296,69 @@ struct ContactInnerFragment: View { print(error) } } + + var sipAddressesPopup: 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) + .background(.red) + .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.. Date: Thu, 4 Jul 2024 10:46:49 +0200 Subject: [PATCH 308/486] Add call merge feature --- .../arrows-merge.imageset/Contents.json | 21 ++++++++++++ .../arrows-merge.imageset/arrows-merge.svg | 1 + Linphone/Localizable.xcstrings | 34 +++++++++++++++++++ .../UI/Call/Fragments/CallsListFragment.swift | 31 +++++++++++++++++ .../UI/Call/ViewModel/CallViewModel.swift | 31 ++++++++++++++++- 5 files changed, 117 insertions(+), 1 deletion(-) create mode 100644 Linphone/Assets.xcassets/arrows-merge.imageset/Contents.json create mode 100644 Linphone/Assets.xcassets/arrows-merge.imageset/arrows-merge.svg 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/Localizable.xcstrings b/Linphone/Localizable.xcstrings index cd6131cc6..0e0fdb998 100644 --- a/Linphone/Localizable.xcstrings +++ b/Linphone/Localizable.xcstrings @@ -713,6 +713,40 @@ }, "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" : { diff --git a/Linphone/UI/Call/Fragments/CallsListFragment.swift b/Linphone/UI/Call/Fragments/CallsListFragment.swift index ae6bf53b5..9de87ee1a 100644 --- a/Linphone/UI/Call/Fragments/CallsListFragment.swift +++ b/Linphone/UI/Call/Fragments/CallsListFragment.swift @@ -31,6 +31,7 @@ struct CallsListFragment: View { @State private var delayedColor = Color.white @State var isShowCallsListBottomSheet: Bool = false + @State private var isShowPopup = false @Binding var isShowCallsListFragment: Bool @@ -65,6 +66,18 @@ struct CallsListFragment: View { 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) @@ -87,6 +100,24 @@ struct CallsListFragment: View { } } .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) } diff --git a/Linphone/UI/Call/ViewModel/CallViewModel.swift b/Linphone/UI/Call/ViewModel/CallViewModel.swift index 03602c008..458053ad6 100644 --- a/Linphone/UI/Call/ViewModel/CallViewModel.swift +++ b/Linphone/UI/Call/ViewModel/CallViewModel.swift @@ -158,6 +158,7 @@ class CallViewModel: ObservableObject { displayNameTmp = self.currentCall!.remoteAddress!.username! } } + DispatchQueue.main.async { self.displayName = displayNameTmp } @@ -298,7 +299,6 @@ class CallViewModel: ObservableObject { coreContext.doOnCoreQueue { core in if self.currentCall?.conference != nil { let conf = self.currentCall!.conference! - self.isConference = true let displayNameTmp = conf.subject ?? "" @@ -368,6 +368,8 @@ class CallViewModel: ObservableObject { DispatchQueue.main.async { self.displayName = displayNameTmp + self.isConference = true + self.myParticipantModel = myParticipantModelTmp self.activeSpeakerParticipant = activeSpeakerParticipantTmp @@ -1121,5 +1123,32 @@ class CallViewModel: ObservableObject { } } } + + 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 { + + } + } + } } // swiftlint:enable type_body_length From 21ab16271c3f0438818ed207dd1396e97d3a7109 Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Thu, 4 Jul 2024 12:01:18 +0200 Subject: [PATCH 309/486] Reduce size of bottom sheet when a button is clicked in callview --- Linphone/UI/Call/CallView.swift | 66 +++++++++++++++++++-- Linphone/UI/Call/Model/CallStatsModel.swift | 2 +- 2 files changed, 63 insertions(+), 5 deletions(-) diff --git a/Linphone/UI/Call/CallView.swift b/Linphone/UI/Call/CallView.swift index cca0615f6..fa9d24b8f 100644 --- a/Linphone/UI/Call/CallView.swift +++ b/Linphone/UI/Call/CallView.swift @@ -1927,6 +1927,13 @@ struct CallView: View { MagicSearchSingleton.shared.searchForSuggestions() isShowStartCallFragment.toggle() } + + DispatchQueue.global().asyncAfter(deadline: .now() + 0.3) { + telecomManager.callStarted = false + DispatchQueue.global().asyncAfter(deadline: .now() + 0.5) { + telecomManager.callStarted = true + } + } } else { callViewModel.transferClicked() } @@ -1956,6 +1963,13 @@ struct CallView: View { MagicSearchSingleton.shared.searchForSuggestions() isShowStartCallFragment.toggle() } + + DispatchQueue.global().asyncAfter(deadline: .now() + 0.3) { + telecomManager.callStarted = false + DispatchQueue.global().asyncAfter(deadline: .now() + 0.5) { + telecomManager.callStarted = true + } + } } label: { HStack { Image("phone-plus") @@ -2031,6 +2045,13 @@ struct CallView: View { withAnimation { isShowCallsListFragment.toggle() } + + DispatchQueue.global().asyncAfter(deadline: .now() + 0.3) { + telecomManager.callStarted = false + DispatchQueue.global().asyncAfter(deadline: .now() + 0.5) { + telecomManager.callStarted = true + } + } } label: { HStack { Image("phone-list") @@ -2076,6 +2097,12 @@ struct CallView: View { VStack { Button { showingDialer.toggle() + DispatchQueue.global().asyncAfter(deadline: .now() + 0.3) { + telecomManager.callStarted = false + DispatchQueue.global().asyncAfter(deadline: .now() + 0.5) { + telecomManager.callStarted = true + } + } } label: { HStack { Image("dialer") @@ -2248,10 +2275,21 @@ struct CallView: View { if callViewModel.isOneOneCall { VStack { Button { - withAnimation { - callViewModel.isTransferInsteadCall = true - MagicSearchSingleton.shared.searchForSuggestions() - isShowStartCallFragment.toggle() + if callViewModel.callsCounter < 2 { + withAnimation { + callViewModel.isTransferInsteadCall = true + MagicSearchSingleton.shared.searchForSuggestions() + isShowStartCallFragment.toggle() + } + + DispatchQueue.global().asyncAfter(deadline: .now() + 0.3) { + telecomManager.callStarted = false + DispatchQueue.global().asyncAfter(deadline: .now() + 0.5) { + telecomManager.callStarted = true + } + } + } else { + callViewModel.transferClicked() } } label: { HStack { @@ -2279,6 +2317,13 @@ struct CallView: View { MagicSearchSingleton.shared.searchForSuggestions() isShowStartCallFragment.toggle() } + + DispatchQueue.global().asyncAfter(deadline: .now() + 0.3) { + telecomManager.callStarted = false + DispatchQueue.global().asyncAfter(deadline: .now() + 0.5) { + telecomManager.callStarted = true + } + } } label: { HStack { Image("phone-plus") @@ -2357,6 +2402,13 @@ struct CallView: View { withAnimation { isShowCallsListFragment.toggle() } + + DispatchQueue.global().asyncAfter(deadline: .now() + 0.3) { + telecomManager.callStarted = false + DispatchQueue.global().asyncAfter(deadline: .now() + 0.5) { + telecomManager.callStarted = true + } + } } label: { HStack { Image("phone-list") @@ -2402,6 +2454,12 @@ struct CallView: View { VStack { Button { showingDialer.toggle() + DispatchQueue.global().asyncAfter(deadline: .now() + 0.3) { + telecomManager.callStarted = false + DispatchQueue.global().asyncAfter(deadline: .now() + 0.5) { + telecomManager.callStarted = true + } + } } label: { HStack { Image("dialer") diff --git a/Linphone/UI/Call/Model/CallStatsModel.swift b/Linphone/UI/Call/Model/CallStatsModel.swift index bfc7ef746..f5f11dc97 100644 --- a/Linphone/UI/Call/Model/CallStatsModel.swift +++ b/Linphone/UI/Call/Model/CallStatsModel.swift @@ -35,7 +35,7 @@ class CallStatsModel: ObservableObject { func update(call: Call, stats: CallStats) { coreContext.doOnCoreQueue { core in if call.params != nil { - self.isVideoEnabled = call.params!.videoEnabled && call.currentParams!.videoDirection != .Inactive + self.isVideoEnabled = call.params!.videoEnabled && call.currentParams != nil && call.currentParams!.videoDirection != .Inactive switch stats.type { case .Audio: if call.currentParams != nil { From ea921badfb6891fc082699a16dcc04148ddb49dc Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Fri, 5 Jul 2024 15:20:42 +0200 Subject: [PATCH 310/486] Replace DispatchQueue.global() instead DispatchQueue.main --- Linphone/UI/Call/CallView.swift | 36 +++++++++---------- .../Fragments/ContactsListBottomSheet.swift | 2 +- .../History/Fragments/DialerBottomSheet.swift | 4 +-- .../History/Fragments/StartCallFragment.swift | 14 ++++---- Linphone/Utils/EditContactController.swift | 2 +- 5 files changed, 29 insertions(+), 29 deletions(-) diff --git a/Linphone/UI/Call/CallView.swift b/Linphone/UI/Call/CallView.swift index fa9d24b8f..25601cccb 100644 --- a/Linphone/UI/Call/CallView.swift +++ b/Linphone/UI/Call/CallView.swift @@ -705,7 +705,7 @@ struct CallView: View { if (oldOrientation != orientation && oldOrientation != .faceUp) || (oldOrientation == .faceUp && (orientation == .landscapeLeft || orientation == .landscapeRight)) { telecomManager.callStarted = false - DispatchQueue.global().asyncAfter(deadline: .now() + 0.5) { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { telecomManager.callStarted = true } } @@ -1883,7 +1883,7 @@ struct CallView: View { if AVAudioSession.sharedInstance().availableInputs != nil && !AVAudioSession.sharedInstance().availableInputs!.filter({ $0.portType.rawValue.contains("Bluetooth") }).isEmpty { - DispatchQueue.global().asyncAfter(deadline: .now() + 0.5) { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { audioRouteSheet = true } } else { @@ -1928,9 +1928,9 @@ struct CallView: View { isShowStartCallFragment.toggle() } - DispatchQueue.global().asyncAfter(deadline: .now() + 0.3) { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { telecomManager.callStarted = false - DispatchQueue.global().asyncAfter(deadline: .now() + 0.5) { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { telecomManager.callStarted = true } } @@ -1964,9 +1964,9 @@ struct CallView: View { isShowStartCallFragment.toggle() } - DispatchQueue.global().asyncAfter(deadline: .now() + 0.3) { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { telecomManager.callStarted = false - DispatchQueue.global().asyncAfter(deadline: .now() + 0.5) { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { telecomManager.callStarted = true } } @@ -2046,9 +2046,9 @@ struct CallView: View { isShowCallsListFragment.toggle() } - DispatchQueue.global().asyncAfter(deadline: .now() + 0.3) { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { telecomManager.callStarted = false - DispatchQueue.global().asyncAfter(deadline: .now() + 0.5) { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { telecomManager.callStarted = true } } @@ -2097,9 +2097,9 @@ struct CallView: View { VStack { Button { showingDialer.toggle() - DispatchQueue.global().asyncAfter(deadline: .now() + 0.3) { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { telecomManager.callStarted = false - DispatchQueue.global().asyncAfter(deadline: .now() + 0.5) { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { telecomManager.callStarted = true } } @@ -2282,9 +2282,9 @@ struct CallView: View { isShowStartCallFragment.toggle() } - DispatchQueue.global().asyncAfter(deadline: .now() + 0.3) { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { telecomManager.callStarted = false - DispatchQueue.global().asyncAfter(deadline: .now() + 0.5) { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { telecomManager.callStarted = true } } @@ -2318,9 +2318,9 @@ struct CallView: View { isShowStartCallFragment.toggle() } - DispatchQueue.global().asyncAfter(deadline: .now() + 0.3) { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { telecomManager.callStarted = false - DispatchQueue.global().asyncAfter(deadline: .now() + 0.5) { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { telecomManager.callStarted = true } } @@ -2403,9 +2403,9 @@ struct CallView: View { isShowCallsListFragment.toggle() } - DispatchQueue.global().asyncAfter(deadline: .now() + 0.3) { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { telecomManager.callStarted = false - DispatchQueue.global().asyncAfter(deadline: .now() + 0.5) { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { telecomManager.callStarted = true } } @@ -2454,9 +2454,9 @@ struct CallView: View { VStack { Button { showingDialer.toggle() - DispatchQueue.global().asyncAfter(deadline: .now() + 0.3) { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { telecomManager.callStarted = false - DispatchQueue.global().asyncAfter(deadline: .now() + 0.5) { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { telecomManager.callStarted = true } } diff --git a/Linphone/UI/Main/Contacts/Fragments/ContactsListBottomSheet.swift b/Linphone/UI/Main/Contacts/Fragments/ContactsListBottomSheet.swift index 0237a5990..f0ca349e9 100644 --- a/Linphone/UI/Main/Contacts/Fragments/ContactsListBottomSheet.swift +++ b/Linphone/UI/Main/Contacts/Fragments/ContactsListBottomSheet.swift @@ -119,7 +119,7 @@ struct ContactsListBottomSheet: View { contactViewModel.selectedFriendToShare = contactViewModel.selectedFriend - DispatchQueue.global().asyncAfter(deadline: .now() + 0.5) { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { showShareSheet.toggle() } diff --git a/Linphone/UI/Main/History/Fragments/DialerBottomSheet.swift b/Linphone/UI/Main/History/Fragments/DialerBottomSheet.swift index 7231934d8..8328dca8b 100644 --- a/Linphone/UI/Main/History/Fragments/DialerBottomSheet.swift +++ b/Linphone/UI/Main/History/Fragments/DialerBottomSheet.swift @@ -455,7 +455,7 @@ struct DialerBottomSheet: View { if callViewModel.isTransferInsteadCall { showingDialer = false - DispatchQueue.global().asyncAfter(deadline: .now() + 0.2) { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { magicSearch.searchForContacts( sourceFlags: MagicSearch.Source.Friends.rawValue | MagicSearch.Source.LdapServers.rawValue) @@ -477,7 +477,7 @@ struct DialerBottomSheet: View { } else { showingDialer = false - DispatchQueue.global().asyncAfter(deadline: .now() + 0.2) { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { magicSearch.searchForContacts( sourceFlags: MagicSearch.Source.Friends.rawValue | MagicSearch.Source.LdapServers.rawValue) diff --git a/Linphone/UI/Main/History/Fragments/StartCallFragment.swift b/Linphone/UI/Main/History/Fragments/StartCallFragment.swift index 4e0589afb..3721884d8 100644 --- a/Linphone/UI/Main/History/Fragments/StartCallFragment.swift +++ b/Linphone/UI/Main/History/Fragments/StartCallFragment.swift @@ -62,7 +62,7 @@ struct StartCallFragment: View { .padding(.top, 2) .padding(.leading, -10) .onTapGesture { - DispatchQueue.global().asyncAfter(deadline: .now() + 0.2) { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { magicSearch.searchForContacts( sourceFlags: MagicSearch.Source.Friends.rawValue | MagicSearch.Source.LdapServers.rawValue) @@ -126,13 +126,13 @@ struct StartCallFragment: View { if !showingDialer { isSearchFieldFocused = false - DispatchQueue.global().asyncAfter(deadline: .now() + 0.2) { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { showingDialer = true } } else { showingDialer = false - DispatchQueue.global().asyncAfter(deadline: .now() + 0.2) { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { isSearchFieldFocused = true } } @@ -221,7 +221,7 @@ struct StartCallFragment: View { if callViewModel.isTransferInsteadCall { showingDialer = false - DispatchQueue.global().asyncAfter(deadline: .now() + 0.2) { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { magicSearch.searchForContacts( sourceFlags: MagicSearch.Source.Friends.rawValue | MagicSearch.Source.LdapServers.rawValue) @@ -243,7 +243,7 @@ struct StartCallFragment: View { } else { showingDialer = false - DispatchQueue.global().asyncAfter(deadline: .now() + 0.2) { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { magicSearch.searchForContacts( sourceFlags: MagicSearch.Source.Friends.rawValue | MagicSearch.Source.LdapServers.rawValue) @@ -322,7 +322,7 @@ struct StartCallFragment: View { if callViewModel.isTransferInsteadCall { showingDialer = false - DispatchQueue.global().asyncAfter(deadline: .now() + 0.2) { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { magicSearch.searchForContacts( sourceFlags: MagicSearch.Source.Friends.rawValue | MagicSearch.Source.LdapServers.rawValue) @@ -346,7 +346,7 @@ struct StartCallFragment: View { } else { showingDialer = false - DispatchQueue.global().asyncAfter(deadline: .now() + 0.2) { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { magicSearch.searchForContacts( sourceFlags: MagicSearch.Source.Friends.rawValue | MagicSearch.Source.LdapServers.rawValue) diff --git a/Linphone/Utils/EditContactController.swift b/Linphone/Utils/EditContactController.swift index a7c4c722b..10950fb85 100644 --- a/Linphone/Utils/EditContactController.swift +++ b/Linphone/Utils/EditContactController.swift @@ -26,7 +26,7 @@ struct EditContactView: UIViewControllerRepresentable { class Coordinator: NSObject, CNContactViewControllerDelegate, UINavigationControllerDelegate { func contactViewController(_ viewController: CNContactViewController, didCompleteWith contact: CNContact?) { if let cnc = contact { - DispatchQueue.global().asyncAfter(deadline: .now() + 1) { + DispatchQueue.main.asyncAfter(deadline: .now() + 1) { self.parent.contact = cnc let newContact = Contact( From c750a8cfb2c4e27ce4cc2a6bd14962ee3330ff93 Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Mon, 8 Jul 2024 09:50:08 +0200 Subject: [PATCH 311/486] Display all calls when call history filter is empty --- Linphone/UI/Main/ContentView.swift | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/Linphone/UI/Main/ContentView.swift b/Linphone/UI/Main/ContentView.swift index 8d1ae5690..96d7cc2d6 100644 --- a/Linphone/UI/Main/ContentView.swift +++ b/Linphone/UI/Main/ContentView.swift @@ -448,7 +448,11 @@ struct ContentView: View { MagicSearchSingleton.shared.searchForContacts( sourceFlags: MagicSearch.Source.Friends.rawValue | MagicSearch.Source.LdapServers.rawValue) } else if index == 1 { - historyListViewModel.filterCallLogs(filter: text) + if text.isEmpty { + historyListViewModel.resetFilterCallLogs() + } else { + historyListViewModel.filterCallLogs(filter: text) + } } else if index == 2 { //TODO Conversations List reset } else if index == 3 { From b84e11633659f40af09e7b452f9bd93982794e3e Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Mon, 8 Jul 2024 10:43:27 +0200 Subject: [PATCH 312/486] Change texts in call view when history list is empty --- Linphone/Localizable.xcstrings | 34 +++++++++++++++++++ Linphone/UI/Main/ContentView.swift | 3 +- .../History/Fragments/HistoryFragment.swift | 8 +++-- .../Fragments/HistoryListFragment.swift | 6 ++-- Linphone/UI/Main/History/HistoryView.swift | 7 ++-- 5 files changed, 50 insertions(+), 8 deletions(-) diff --git a/Linphone/Localizable.xcstrings b/Linphone/Localizable.xcstrings index 0e0fdb998..9aea334bb 100644 --- a/Linphone/Localizable.xcstrings +++ b/Linphone/Localizable.xcstrings @@ -1183,6 +1183,40 @@ } } }, + "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" : { }, diff --git a/Linphone/UI/Main/ContentView.swift b/Linphone/UI/Main/ContentView.swift index 96d7cc2d6..599c92f1a 100644 --- a/Linphone/UI/Main/ContentView.swift +++ b/Linphone/UI/Main/ContentView.swift @@ -532,7 +532,8 @@ struct ContentView: View { editContactViewModel: editContactViewModel, index: $index, isShowStartCallFragment: $isShowStartCallFragment, - isShowEditContactFragment: $isShowEditContactFragment + isShowEditContactFragment: $isShowEditContactFragment, + text: $text ) } else if self.index == 2 { ConversationsView(conversationViewModel: conversationViewModel, conversationsListViewModel: conversationsListViewModel) diff --git a/Linphone/UI/Main/History/Fragments/HistoryFragment.swift b/Linphone/UI/Main/History/Fragments/HistoryFragment.swift index bce55db80..1a4d8c6cc 100644 --- a/Linphone/UI/Main/History/Fragments/HistoryFragment.swift +++ b/Linphone/UI/Main/History/Fragments/HistoryFragment.swift @@ -30,11 +30,12 @@ struct HistoryFragment: View { @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) + HistoryListFragment(historyListViewModel: historyListViewModel, historyViewModel: historyViewModel, showingSheet: $showingSheet, text: $text) .sheet(isPresented: $showingSheet) { HistoryListBottomSheet( historyViewModel: historyViewModel, @@ -48,7 +49,7 @@ struct HistoryFragment: View { .presentationDetents([.fraction(0.2)]) } } else { - HistoryListFragment(historyListViewModel: historyListViewModel, historyViewModel: historyViewModel, showingSheet: $showingSheet) + HistoryListFragment(historyListViewModel: historyListViewModel, historyViewModel: historyViewModel, showingSheet: $showingSheet, text: $text) .halfSheet(showSheet: $showingSheet) { HistoryListBottomSheet( historyViewModel: historyViewModel, @@ -72,6 +73,7 @@ struct HistoryFragment: View { contactViewModel: ContactViewModel(), editContactViewModel: EditContactViewModel(), index: .constant(1), - isShowEditContactFragment: .constant(false) + isShowEditContactFragment: .constant(false), + text: .constant("") ) } diff --git a/Linphone/UI/Main/History/Fragments/HistoryListFragment.swift b/Linphone/UI/Main/History/Fragments/HistoryListFragment.swift index 25520b254..9df4cddf2 100644 --- a/Linphone/UI/Main/History/Fragments/HistoryListFragment.swift +++ b/Linphone/UI/Main/History/Fragments/HistoryListFragment.swift @@ -31,6 +31,7 @@ struct HistoryListFragment: View { @ObservedObject var historyViewModel: HistoryViewModel @Binding var showingSheet: Bool + @Binding var text: String var body: some View { VStack { @@ -151,8 +152,9 @@ struct HistoryListFragment: View { .scaledToFit() .clipped() .padding(.all) - Text("No call for the moment...") + Text(historyListViewModel.callLogs.isEmpty && !text.isEmpty ? "history_list_empty_with_filter_history" : "history_list_empty_history") .default_text_style_800(styleSize: 16) + .multilineTextAlignment(.center) Spacer() Spacer() } @@ -170,7 +172,7 @@ struct HistoryListFragment: View { } #Preview { - HistoryListFragment(historyListViewModel: HistoryListViewModel(), historyViewModel: HistoryViewModel(), showingSheet: .constant(false)) + HistoryListFragment(historyListViewModel: HistoryListViewModel(), historyViewModel: HistoryViewModel(), showingSheet: .constant(false), text: .constant("")) } // swiftlint:enable line_length diff --git a/Linphone/UI/Main/History/HistoryView.swift b/Linphone/UI/Main/History/HistoryView.swift index 236ff3572..ee9f01b73 100644 --- a/Linphone/UI/Main/History/HistoryView.swift +++ b/Linphone/UI/Main/History/HistoryView.swift @@ -30,6 +30,7 @@ struct HistoryView: View { @Binding var index: Int @Binding var isShowStartCallFragment: Bool @Binding var isShowEditContactFragment: Bool + @Binding var text: String var body: some View { NavigationView { @@ -40,7 +41,8 @@ struct HistoryView: View { contactViewModel: contactViewModel, editContactViewModel: editContactViewModel, index: $index, - isShowEditContactFragment: $isShowEditContactFragment + isShowEditContactFragment: $isShowEditContactFragment, + text: $text ) Button { @@ -72,6 +74,7 @@ struct HistoryView: View { contactViewModel: ContactViewModel(), editContactViewModel: EditContactViewModel(), index: .constant(1), - isShowEditContactFragment: .constant(false) + isShowEditContactFragment: .constant(false), + text: .constant("") ) } From 39c7c6a4b1cdd752237d3fbaddf95c1b71d993c2 Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Mon, 8 Jul 2024 11:22:07 +0200 Subject: [PATCH 313/486] Change values of EditContactViewModel.resetValues in main thread --- .../ViewModel/EditContactViewModel.swift | 51 ++++++++++++------- 1 file changed, 33 insertions(+), 18 deletions(-) diff --git a/Linphone/UI/Main/Contacts/ViewModel/EditContactViewModel.swift b/Linphone/UI/Main/Contacts/ViewModel/EditContactViewModel.swift index a66fa422b..25fa52755 100644 --- a/Linphone/UI/Main/Contacts/ViewModel/EditContactViewModel.swift +++ b/Linphone/UI/Main/Contacts/ViewModel/EditContactViewModel.swift @@ -37,26 +37,41 @@ class EditContactViewModel: ObservableObject { } func resetValues() { - identifier = (selectedEditFriend == nil ? "" : selectedEditFriend!.nativeUri) ?? "" - firstName = (selectedEditFriend == nil ? "" : selectedEditFriend!.vcard?.givenName) ?? "" - lastName = (selectedEditFriend == nil ? "" : selectedEditFriend!.vcard?.familyName) ?? "" - sipAddresses = [] - phoneNumbers = [] - company = (selectedEditFriend == nil ? "" : selectedEditFriend!.organization) ?? "" - jobTitle = (selectedEditFriend == nil ? "" : selectedEditFriend!.jobTitle) ?? "" - - if selectedEditFriend != nil { - selectedEditFriend?.addresses.forEach({ address in - sipAddresses.append(String(address.asStringUriOnly().dropFirst(4))) - }) + 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) ?? "" - selectedEditFriend?.phoneNumbers.forEach({ phoneNumber in - phoneNumbers.append(phoneNumber) - }) + 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("") + } } - - sipAddresses.append("") - phoneNumbers.append("") } } From 7bae9fd3429e7ac403ed840b12910467b29be164 Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Tue, 9 Jul 2024 14:05:40 +0200 Subject: [PATCH 314/486] Add participants during a conference call --- Linphone/UI/Call/CallView.swift | 7 +- .../Fragments/ParticipantsListFragment.swift | 159 +++++++++--------- .../UI/Call/ViewModel/CallViewModel.swift | 25 ++- 3 files changed, 114 insertions(+), 77 deletions(-) diff --git a/Linphone/UI/Call/CallView.swift b/Linphone/UI/Call/CallView.swift index 25601cccb..784bfa2a4 100644 --- a/Linphone/UI/Call/CallView.swift +++ b/Linphone/UI/Call/CallView.swift @@ -34,6 +34,8 @@ struct CallView: View { @ObservedObject var callViewModel: CallViewModel + @State private var addParticipantsViewModel: AddParticipantsViewModel? + private var idiom: UIUserInterfaceIdiom { UIDevice.current.userInterfaceIdiom } @State private var orientation = UIDevice.current.orientation @@ -177,9 +179,12 @@ struct CallView: View { } if isShowParticipantsListFragment { - ParticipantsListFragment(callViewModel: callViewModel, isShowParticipantsListFragment: $isShowParticipantsListFragment) + ParticipantsListFragment(callViewModel: callViewModel, addParticipantsViewModel: addParticipantsViewModel ?? AddParticipantsViewModel(), isShowParticipantsListFragment: $isShowParticipantsListFragment) .zIndex(4) .transition(.move(edge: .bottom)) + .onAppear { + addParticipantsViewModel = AddParticipantsViewModel() + } } if callViewModel.zrtpPopupDisplayed == true { diff --git a/Linphone/UI/Call/Fragments/ParticipantsListFragment.swift b/Linphone/UI/Call/Fragments/ParticipantsListFragment.swift index 31152edb7..41c77a110 100644 --- a/Linphone/UI/Call/Fragments/ParticipantsListFragment.swift +++ b/Linphone/UI/Call/Fragments/ParticipantsListFragment.swift @@ -28,6 +28,8 @@ struct ParticipantsListFragment: View { @ObservedObject var callViewModel: CallViewModel + @ObservedObject var addParticipantsViewModel: AddParticipantsViewModel + @State private var delayedColor = Color.white @Binding var isShowParticipantsListFragment: Bool @@ -36,89 +38,96 @@ struct ParticipantsListFragment: View { @State private var indexToRemove = -1 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 { - isShowParticipantsListFragment.toggle() + 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!.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() } - - Text("\(callViewModel.participantList.count + 1) \(callViewModel.participantList.isEmpty ? "Participant" : "Participants")") - .multilineTextAlignment(.leading) - .default_text_style_orange_800(styleSize: 16) - - Spacer() - + } + .padding(.trailing, 10) } - .frame(maxWidth: .infinity) - .frame(height: 50) - .padding(.horizontal) - .padding(.bottom, 4) .background(.white) - participantsList - - HStack { - Spacer() - - NavigationLink(destination: { - //AddParticipantsFragment() - }, 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) - + 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 }) - .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 + .background(.black.opacity(0.65)) + .onTapGesture { + self.isShowPopup.toggle() + indexToRemove = -1 + } } } + .navigationBarHidden(true) } - .navigationBarHidden(true) } @Sendable private func delayColor() async { @@ -256,5 +265,5 @@ struct ParticipantsListFragment: View { } #Preview { - ParticipantsListFragment(callViewModel: CallViewModel(), isShowParticipantsListFragment: .constant(true)) + ParticipantsListFragment(callViewModel: CallViewModel(), addParticipantsViewModel: AddParticipantsViewModel(), isShowParticipantsListFragment: .constant(true)) } diff --git a/Linphone/UI/Call/ViewModel/CallViewModel.swift b/Linphone/UI/Call/ViewModel/CallViewModel.swift index 458053ad6..bd849537b 100644 --- a/Linphone/UI/Call/ViewModel/CallViewModel.swift +++ b/Linphone/UI/Call/ViewModel/CallViewModel.swift @@ -25,6 +25,8 @@ import Combine // swiftlint:disable type_body_length class CallViewModel: ObservableObject { + static let TAG = "[CallViewModel]" + var coreContext = CoreContext.shared var telecomManager = TelecomManager.shared @@ -1130,7 +1132,7 @@ class CallViewModel: ObservableObject { let defaultAccount = core.defaultAccount var subject = "" - if (defaultAccount != nil && defaultAccount!.params != nil && defaultAccount!.params!.audioVideoConferenceFactoryAddress != nil) { + 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 { @@ -1150,5 +1152,26 @@ class CallViewModel: ObservableObject { } } } + + 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") + } } // swiftlint:enable type_body_length From a96ae05dd687b7c0ebfd72105c76b26ba2ec4099 Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Tue, 9 Jul 2024 16:51:35 +0200 Subject: [PATCH 315/486] Fix conference pause and resuming --- .../TelecomManager/ProviderDelegate.swift | 9 +- Linphone/TelecomManager/TelecomManager.swift | 13 ++- .../Fragments/ParticipantsListFragment.swift | 2 +- .../UI/Main/History/Model/HistoryModel.swift | 83 ++++++++++++++----- 4 files changed, 75 insertions(+), 32 deletions(-) diff --git a/Linphone/TelecomManager/ProviderDelegate.swift b/Linphone/TelecomManager/ProviderDelegate.swift index 8eb6a3617..779839a8e 100644 --- a/Linphone/TelecomManager/ProviderDelegate.swift +++ b/Linphone/TelecomManager/ProviderDelegate.swift @@ -287,12 +287,9 @@ extension ProviderDelegate: CXProviderDelegate { // attempt to resume another one. action.fulfill() } else { - if call?.conference != nil && core.callsNb > 1 { - /* - try TelecomManager.shared.lc?.enterConference() - action.fulfill() - NotificationCenter.default.post(name: Notification.Name("LinphoneCallUpdate"), object: self) - */ + 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 diff --git a/Linphone/TelecomManager/TelecomManager.swift b/Linphone/TelecomManager/TelecomManager.swift index 3f7b3f85c..2606b2267 100644 --- a/Linphone/TelecomManager/TelecomManager.swift +++ b/Linphone/TelecomManager/TelecomManager.swift @@ -456,14 +456,19 @@ class TelecomManager: ObservableObject { } } } else { - DispatchQueue.main.async { - self.remoteConfVideo = false - if call.currentParams != nil { - let remoteConfVideoTmp = call.currentParams!.videoEnabled && call.currentParams!.videoDirection == .SendRecv || call.currentParams!.videoDirection == .RecvOnly + if call.currentParams != nil { + let remoteConfVideoTmp = call.currentParams!.videoEnabled && call.currentParams!.videoDirection == .SendRecv || call.currentParams!.videoDirection == .RecvOnly + + DispatchQueue.main.async { + self.remoteConfVideo = false DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { self.remoteConfVideo = remoteConfVideoTmp } } + } else { + DispatchQueue.main.async { + self.remoteConfVideo = false + } } } diff --git a/Linphone/UI/Call/Fragments/ParticipantsListFragment.swift b/Linphone/UI/Call/Fragments/ParticipantsListFragment.swift index 41c77a110..e80c723e6 100644 --- a/Linphone/UI/Call/Fragments/ParticipantsListFragment.swift +++ b/Linphone/UI/Call/Fragments/ParticipantsListFragment.swift @@ -81,7 +81,7 @@ struct ParticipantsListFragment: View { HStack { Spacer() - if callViewModel.myParticipantModel!.isAdmin { + if callViewModel.myParticipantModel != nil && callViewModel.myParticipantModel!.isAdmin { NavigationLink(destination: { AddParticipantsFragment(addParticipantsViewModel: addParticipantsViewModel, confirmAddParticipantsFunc: callViewModel.addParticipants) .onAppear { diff --git a/Linphone/UI/Main/History/Model/HistoryModel.swift b/Linphone/UI/Main/History/Model/HistoryModel.swift index cc356f2f3..4a996b190 100644 --- a/Linphone/UI/Main/History/Model/HistoryModel.swift +++ b/Linphone/UI/Main/History/Model/HistoryModel.swift @@ -26,9 +26,9 @@ class HistoryModel: ObservableObject { static let TAG = "[History Model]" - let callLog: CallLog + var callLog: CallLog - let id: String + var id: String @Published var subject: String @Published var isConf: Bool @Published var addressLinphone: Address @@ -38,35 +38,76 @@ class HistoryModel: ObservableObject { @Published var status: Call.Status @Published var startDate: time_t @Published var duration: Int - @Published var addressFriend: Friend? = nil - @Published var avatarModel: ContactAvatarModel? = nil + @Published var addressFriend: Friend? + @Published var avatarModel: ContactAvatarModel? init(callLog: CallLog) { self.callLog = callLog - self.id = callLog.callId ?? "" - self.subject = callLog.conferenceInfo != nil && callLog.conferenceInfo!.subject != nil ? callLog.conferenceInfo!.subject! : "" - self.isConf = callLog.conferenceInfo != nil + self.id = "" + self.subject = "" + self.isConf = false - let addressLinphoneTmp = callLog.dir == .Outgoing && callLog.toAddress != nil ? callLog.toAddress! : callLog.fromAddress! - self.addressLinphone = addressLinphoneTmp - //let addressLinphone = callLog.dir == .Outgoing && callLog.toAddress != nil ? callLog.toAddress! : callLog.fromAddress! - self.address = addressLinphoneTmp.asStringUriOnly() + self.addressLinphone = callLog.dir == .Outgoing && callLog.toAddress != nil ? callLog.toAddress! : callLog.fromAddress! + self.address = "" - let addressNameTmp = callLog.conferenceInfo != nil && callLog.conferenceInfo!.subject != nil - ? callLog.conferenceInfo!.subject! - : (addressLinphoneTmp.username != nil ? addressLinphoneTmp.username ?? "" : addressLinphoneTmp.displayName ?? "") + self.addressName = "" - self.addressName = addressNameTmp + self.isOutgoing = false - self.isOutgoing = callLog.dir == .Outgoing + self.status = .Success - self.status = callLog.status + self.startDate = 0 - self.startDate = callLog.startDate + self.duration = 0 - self.duration = callLog.duration - - refreshAvatarModel() + 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() { From afa7496e822ef2bcc31f0592491c837101e9ae68 Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Tue, 9 Jul 2024 17:38:26 +0200 Subject: [PATCH 316/486] Change text in views when lists are empty --- Linphone/Localizable.xcstrings | 81 ++++++++++++++++--- .../UI/Call/Fragments/CallsListFragment.swift | 2 +- Linphone/UI/Main/Contacts/ContactsView.swift | 6 +- .../Contacts/Fragments/ContactsFragment.swift | 7 +- .../Fragments/ContactsInnerFragment.swift | 5 +- Linphone/UI/Main/ContentView.swift | 8 +- .../Conversations/ConversationsView.swift | 6 +- .../Fragments/ConversationsFragment.swift | 7 +- .../Fragments/ConversationsListFragment.swift | 6 +- .../Fragments/HistoryListFragment.swift | 2 +- .../Meetings/Fragments/MeetingsFragment.swift | 5 +- Linphone/UI/Main/Meetings/MeetingsView.swift | 8 +- 12 files changed, 107 insertions(+), 36 deletions(-) diff --git a/Linphone/Localizable.xcstrings b/Linphone/Localizable.xcstrings index 9aea334bb..02f4a8cea 100644 --- a/Linphone/Localizable.xcstrings +++ b/Linphone/Localizable.xcstrings @@ -846,6 +846,23 @@ }, "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…" + } + } + } }, "Content" : { @@ -868,6 +885,23 @@ }, "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" : { @@ -1269,12 +1303,31 @@ }, "Joining..." : { + }, + "Key" : { + "extractionState" : "manual" }, "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" : { @@ -1296,6 +1349,22 @@ }, "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…" + } + } + } }, "Message" : { @@ -1320,18 +1389,6 @@ }, "Next" : { - }, - "No call for the moment..." : { - - }, - "No contacts for the moment..." : { - - }, - "No conversation for the moment..." : { - - }, - "No meeting for the moment..." : { - }, "No meeting today" : { diff --git a/Linphone/UI/Call/Fragments/CallsListFragment.swift b/Linphone/UI/Call/Fragments/CallsListFragment.swift index 9de87ee1a..4c14a816f 100644 --- a/Linphone/UI/Call/Fragments/CallsListFragment.swift +++ b/Linphone/UI/Call/Fragments/CallsListFragment.swift @@ -365,7 +365,7 @@ struct CallsListFragment: View { .scaledToFit() .clipped() .padding(.all) - Text("No call for the moment...") + Text("history_list_empty_history") .default_text_style_800(styleSize: 16) Spacer() Spacer() diff --git a/Linphone/UI/Main/Contacts/ContactsView.swift b/Linphone/UI/Main/Contacts/ContactsView.swift index 80a9fa5d1..b6492c4fe 100644 --- a/Linphone/UI/Main/Contacts/ContactsView.swift +++ b/Linphone/UI/Main/Contacts/ContactsView.swift @@ -27,11 +27,12 @@ struct ContactsView: View { @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) + ContactsFragment(contactViewModel: contactViewModel, isShowDeletePopup: $isShowDeletePopup, text: $text) Button { withAnimation { @@ -66,6 +67,7 @@ struct ContactsView: View { historyViewModel: HistoryViewModel(), editContactViewModel: EditContactViewModel(), isShowEditContactFragment: .constant(false), - isShowDeletePopup: .constant(false) + isShowDeletePopup: .constant(false), + text: .constant("") ) } diff --git a/Linphone/UI/Main/Contacts/Fragments/ContactsFragment.swift b/Linphone/UI/Main/Contacts/Fragments/ContactsFragment.swift index 3a453d5d6..7ed99e3bb 100644 --- a/Linphone/UI/Main/Contacts/Fragments/ContactsFragment.swift +++ b/Linphone/UI/Main/Contacts/Fragments/ContactsFragment.swift @@ -29,11 +29,12 @@ struct ContactsFragment: View { @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) + ContactsInnerFragment(contactViewModel: contactViewModel, showingSheet: $showingSheet, text: $text) .sheet(isPresented: $showingSheet) { ContactsListBottomSheet( contactViewModel: contactViewModel, @@ -49,7 +50,7 @@ struct ContactsFragment: View { .edgesIgnoringSafeArea(.bottom) } } else { - ContactsInnerFragment(contactViewModel: contactViewModel, showingSheet: $showingSheet) + ContactsInnerFragment(contactViewModel: contactViewModel, showingSheet: $showingSheet, text: $text) .halfSheet(showSheet: $showingSheet) { ContactsListBottomSheet( contactViewModel: contactViewModel, @@ -68,5 +69,5 @@ struct ContactsFragment: View { } #Preview { - ContactsFragment(contactViewModel: ContactViewModel(), isShowDeletePopup: .constant(false)) + 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 index a2cc390f9..8fe87a939 100644 --- a/Linphone/UI/Main/Contacts/Fragments/ContactsInnerFragment.swift +++ b/Linphone/UI/Main/Contacts/Fragments/ContactsInnerFragment.swift @@ -29,6 +29,7 @@ struct ContactsInnerFragment: View { @State private var isFavoriteOpen = true @Binding var showingSheet: Bool + @Binding var text: String var body: some View { VStack(alignment: .leading) { @@ -87,7 +88,7 @@ struct ContactsInnerFragment: View { .scaledToFit() .clipped() .padding(.all) - Text("No contacts for the moment...") + Text(!text.isEmpty ? "list_filter_no_result_found" : "contacts_list_empty") .default_text_style_800(styleSize: 16) Spacer() Spacer() @@ -102,5 +103,5 @@ struct ContactsInnerFragment: View { } #Preview { - ContactsInnerFragment(contactViewModel: ContactViewModel(), showingSheet: .constant(false)) + ContactsInnerFragment(contactViewModel: ContactViewModel(), showingSheet: .constant(false), text: .constant("")) } diff --git a/Linphone/UI/Main/ContentView.swift b/Linphone/UI/Main/ContentView.swift index 599c92f1a..066e9c7a9 100644 --- a/Linphone/UI/Main/ContentView.swift +++ b/Linphone/UI/Main/ContentView.swift @@ -522,7 +522,8 @@ struct ContentView: View { historyViewModel: historyViewModel, editContactViewModel: editContactViewModel, isShowEditContactFragment: $isShowEditContactFragment, - isShowDeletePopup: $isShowDeleteContactPopup + isShowDeletePopup: $isShowDeleteContactPopup, + text: $text ) } else if self.index == 1 { HistoryView( @@ -536,13 +537,14 @@ struct ContentView: View { text: $text ) } else if self.index == 2 { - ConversationsView(conversationViewModel: conversationViewModel, conversationsListViewModel: conversationsListViewModel) + ConversationsView(conversationViewModel: conversationViewModel, conversationsListViewModel: conversationsListViewModel, text: $text) } else if self.index == 3 { MeetingsView( meetingsListViewModel: meetingsListViewModel, meetingViewModel: meetingViewModel, isShowScheduleMeetingFragment: $isShowScheduleMeetingFragment, - isShowSendCancelMeetingNotificationPopup: $isShowSendCancelMeetingNotificationPopup + isShowSendCancelMeetingNotificationPopup: $isShowSendCancelMeetingNotificationPopup, + text: $text ) } } diff --git a/Linphone/UI/Main/Conversations/ConversationsView.swift b/Linphone/UI/Main/Conversations/ConversationsView.swift index 161368b2c..0a72dd314 100644 --- a/Linphone/UI/Main/Conversations/ConversationsView.swift +++ b/Linphone/UI/Main/Conversations/ConversationsView.swift @@ -23,11 +23,12 @@ struct ConversationsView: View { @ObservedObject var conversationViewModel: ConversationViewModel @ObservedObject var conversationsListViewModel: ConversationsListViewModel + @Binding var text: String var body: some View { NavigationView { ZStack(alignment: .bottomTrailing) { - ConversationsFragment(conversationViewModel: conversationViewModel, conversationsListViewModel: conversationsListViewModel) + ConversationsFragment(conversationViewModel: conversationViewModel, conversationsListViewModel: conversationsListViewModel, text: $text) Button { } label: { @@ -51,6 +52,7 @@ struct ConversationsView: View { ConversationsListFragment( conversationViewModel: ConversationViewModel(), conversationsListViewModel: ConversationsListViewModel(), - showingSheet: .constant(false) + showingSheet: .constant(false), + text: .constant("") ) } diff --git a/Linphone/UI/Main/Conversations/Fragments/ConversationsFragment.swift b/Linphone/UI/Main/Conversations/Fragments/ConversationsFragment.swift index 791a5f480..2509fa12c 100644 --- a/Linphone/UI/Main/Conversations/Fragments/ConversationsFragment.swift +++ b/Linphone/UI/Main/Conversations/Fragments/ConversationsFragment.swift @@ -27,11 +27,12 @@ struct ConversationsFragment: View { 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) + ConversationsListFragment(conversationViewModel: conversationViewModel, conversationsListViewModel: conversationsListViewModel, showingSheet: $showingSheet, text: $text) .sheet(isPresented: $showingSheet) { ConversationsListBottomSheet( conversationsListViewModel: conversationsListViewModel, @@ -40,7 +41,7 @@ struct ConversationsFragment: View { .presentationDetents([.fraction(0.4)]) } } else { - ConversationsListFragment(conversationViewModel: conversationViewModel, conversationsListViewModel: conversationsListViewModel, showingSheet: $showingSheet) + ConversationsListFragment(conversationViewModel: conversationViewModel, conversationsListViewModel: conversationsListViewModel, showingSheet: $showingSheet, text: $text) .halfSheet(showSheet: $showingSheet) { ConversationsListBottomSheet( conversationsListViewModel: conversationsListViewModel, @@ -53,5 +54,5 @@ struct ConversationsFragment: View { } #Preview { - ConversationsFragment(conversationViewModel: ConversationViewModel(), conversationsListViewModel: ConversationsListViewModel()) + ConversationsFragment(conversationViewModel: ConversationViewModel(), conversationsListViewModel: ConversationsListViewModel(), text: .constant("")) } diff --git a/Linphone/UI/Main/Conversations/Fragments/ConversationsListFragment.swift b/Linphone/UI/Main/Conversations/Fragments/ConversationsListFragment.swift index e7c33020b..0e064b39a 100644 --- a/Linphone/UI/Main/Conversations/Fragments/ConversationsListFragment.swift +++ b/Linphone/UI/Main/Conversations/Fragments/ConversationsListFragment.swift @@ -26,6 +26,7 @@ struct ConversationsListFragment: View { @ObservedObject var conversationsListViewModel: ConversationsListViewModel @Binding var showingSheet: Bool + @Binding var text: String var body: some View { VStack { @@ -162,7 +163,7 @@ struct ConversationsListFragment: View { .scaledToFit() .clipped() .padding(.all) - Text("No conversation for the moment...") + Text(!text.isEmpty ? "list_filter_no_result_found" : "conversations_list_empty") .default_text_style_800(styleSize: 16) Spacer() Spacer() @@ -180,6 +181,7 @@ struct ConversationsListFragment: View { ConversationsListFragment( conversationViewModel: ConversationViewModel(), conversationsListViewModel: ConversationsListViewModel(), - showingSheet: .constant(false) + showingSheet: .constant(false), + text: .constant("") ) } diff --git a/Linphone/UI/Main/History/Fragments/HistoryListFragment.swift b/Linphone/UI/Main/History/Fragments/HistoryListFragment.swift index 9df4cddf2..620d7211e 100644 --- a/Linphone/UI/Main/History/Fragments/HistoryListFragment.swift +++ b/Linphone/UI/Main/History/Fragments/HistoryListFragment.swift @@ -152,7 +152,7 @@ struct HistoryListFragment: View { .scaledToFit() .clipped() .padding(.all) - Text(historyListViewModel.callLogs.isEmpty && !text.isEmpty ? "history_list_empty_with_filter_history" : "history_list_empty_history") + Text(!text.isEmpty ? "list_filter_no_result_found" : "history_list_empty_history") .default_text_style_800(styleSize: 16) .multilineTextAlignment(.center) Spacer() diff --git a/Linphone/UI/Main/Meetings/Fragments/MeetingsFragment.swift b/Linphone/UI/Main/Meetings/Fragments/MeetingsFragment.swift index 02d6c4ba1..d3911ab4a 100644 --- a/Linphone/UI/Main/Meetings/Fragments/MeetingsFragment.swift +++ b/Linphone/UI/Main/Meetings/Fragments/MeetingsFragment.swift @@ -28,6 +28,7 @@ struct MeetingsFragment: View { private var idiom: UIUserInterfaceIdiom { UIDevice.current.userInterfaceIdiom } @Binding var showingSheet: Bool + @Binding var text: String @ViewBuilder func createMonthLine(model: MeetingsListItemModel) -> some View { @@ -162,7 +163,7 @@ struct MeetingsFragment: View { .scaledToFit() .clipped() .padding(.all) - Text("No meeting for the moment...") + Text(!text.isEmpty ? "list_filter_no_result_found" : "meetings_list_empty") .default_text_style_800(styleSize: 16) Spacer() Spacer() @@ -180,5 +181,5 @@ struct MeetingsFragment: View { #Preview { MeetingsFragment(meetingsListViewModel: MeetingsListViewModel(), meetingViewModel: MeetingViewModel(), - showingSheet: .constant(false)) + showingSheet: .constant(false), text: .constant("")) } diff --git a/Linphone/UI/Main/Meetings/MeetingsView.swift b/Linphone/UI/Main/Meetings/MeetingsView.swift index 98518402e..94c108486 100644 --- a/Linphone/UI/Main/Meetings/MeetingsView.swift +++ b/Linphone/UI/Main/Meetings/MeetingsView.swift @@ -29,13 +29,14 @@ struct MeetingsView: View { @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) + MeetingsFragment(meetingsListViewModel: meetingsListViewModel, meetingViewModel: meetingViewModel, showingSheet: $showingSheet, text: $text) .sheet(isPresented: $showingSheet) { MeetingsListBottomSheet( meetingsListViewModel: meetingsListViewModel, @@ -45,7 +46,7 @@ struct MeetingsView: View { .presentationDetents([.fraction(0.1)]) } } else { - MeetingsFragment(meetingsListViewModel: meetingsListViewModel, meetingViewModel: meetingViewModel, showingSheet: $showingSheet) + MeetingsFragment(meetingsListViewModel: meetingsListViewModel, meetingViewModel: meetingViewModel, showingSheet: $showingSheet, text: $text) .halfSheet(showSheet: $showingSheet) { MeetingsListBottomSheet( meetingsListViewModel: meetingsListViewModel, @@ -82,6 +83,7 @@ struct MeetingsView: View { meetingsListViewModel: MeetingsListViewModel(), meetingViewModel: MeetingViewModel(), isShowScheduleMeetingFragment: .constant(false), - isShowSendCancelMeetingNotificationPopup: .constant(false) + isShowSendCancelMeetingNotificationPopup: .constant(false), + text: .constant("") ) } From 1ee6ef3150dbf743dab6247fcf0fe1f4036a16ab Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Wed, 10 Jul 2024 11:41:02 +0200 Subject: [PATCH 317/486] Disable auto answer replacing calls --- Linphone/Core/CoreContext.swift | 1 + Linphone/Ressources/linphonerc-factory | 2 +- Linphone/TelecomManager/TelecomManager.swift | 8 +++++--- Linphone/UI/Call/ViewModel/CallViewModel.swift | 10 ++++++++-- 4 files changed, 15 insertions(+), 6 deletions(-) diff --git a/Linphone/Core/CoreContext.swift b/Linphone/Core/CoreContext.swift index 7ae9798a6..5e749d05c 100644 --- a/Linphone/Core/CoreContext.swift +++ b/Linphone/Core/CoreContext.swift @@ -125,6 +125,7 @@ final class CoreContext: ObservableObject { self.mCore.videoPreviewEnabled = false self.mCore.fecEnabled = true self.mCore.friendListSubscriptionEnabled = true + self.mCore.config!.setBool(section: "sip", key: "auto_answer_replacing_calls", value: false) self.mCoreSuscriptions.insert(self.mCore.publisher?.onGlobalStateChanged?.postOnCoreQueue { (cbVal: (core: Core, state: GlobalState, message: String)) in if cbVal.state == GlobalState.On { diff --git a/Linphone/Ressources/linphonerc-factory b/Linphone/Ressources/linphonerc-factory index cc44698e3..429ef55c8 100644 --- a/Linphone/Ressources/linphonerc-factory +++ b/Linphone/Ressources/linphonerc-factory @@ -15,7 +15,7 @@ accept_any_encryption=1 guess_hostname=1 register_only_when_network_is_up=1 auto_net_state_mon=1 -auto_answer_replacing_calls=1 +auto_answer_replacing_calls=0 ping_with_options=0 use_cpim=1 zrtp_key_agreements_suites=MS_ZRTP_KEY_AGREEMENT_K255_KYB512 diff --git a/Linphone/TelecomManager/TelecomManager.swift b/Linphone/TelecomManager/TelecomManager.swift index 2606b2267..fdf00c209 100644 --- a/Linphone/TelecomManager/TelecomManager.swift +++ b/Linphone/TelecomManager/TelecomManager.swift @@ -566,6 +566,7 @@ class TelecomManager: ObservableObject { } } #endif + /* if call.replacedCall != nil { self.endCallKitReplacedCall = false @@ -578,7 +579,8 @@ class TelecomManager: ObservableObject { self.providerDelegate.uuids.updateValue(uuid!, forKey: callInfo!.callId) self.providerDelegate.updateCall(uuid: uuid!, handle: addr!.asStringUriOnly(), hasVideo: self.remoteConfVideo, displayName: displayName) } - } else if TelecomManager.callKitEnabled(core: core) { + } else */ + if TelecomManager.callKitEnabled(core: core) { /* let isConference = isConferenceCall(call: call) let isEarlyConference = isConference && CallsViewModel.shared.currentCallData.value??.isConferenceCall.value != true // Conference info not be received yet. @@ -593,9 +595,9 @@ class TelecomManager: ObservableObject { } */ let uuid = self.providerDelegate.uuids["\(callId)"] - if call.replacedCall == nil { + //if call.replacedCall == nil { TelecomManager.uuidReplacedCall = callId - } + //} if uuid != nil { // Tha app is now registered, updated the call already existed. diff --git a/Linphone/UI/Call/ViewModel/CallViewModel.swift b/Linphone/UI/Call/ViewModel/CallViewModel.swift index bd849537b..d7590d93a 100644 --- a/Linphone/UI/Call/ViewModel/CallViewModel.swift +++ b/Linphone/UI/Call/ViewModel/CallViewModel.swift @@ -144,8 +144,12 @@ class CallViewModel: ObservableObject { } let directionTmp = self.currentCall!.dir - let remoteAddressStringTmp = String(self.currentCall!.remoteAddress!.asStringUriOnly().dropFirst(4)) - let remoteAddressTmp = self.currentCall!.remoteAddress! + + let remoteAddressTmp = self.currentCall!.remoteAddress!.clone() + remoteAddressTmp!.clean() + + let remoteAddressStringTmp = remoteAddressTmp != nil ? String(remoteAddressTmp!.asStringUriOnly().dropFirst(4)) : "" + var displayNameTmp = "" if self.currentCall?.conference != nil { displayNameTmp = self.currentCall?.conference?.subject ?? "" @@ -161,6 +165,8 @@ class CallViewModel: ObservableObject { } } + + DispatchQueue.main.async { self.displayName = displayNameTmp } From 340db54af1cfcd0398895a16d027bd559949801c Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Wed, 10 Jul 2024 12:12:22 +0200 Subject: [PATCH 318/486] Set value when initializing AudioRouteBottomSheet --- Linphone/UI/Call/CallView.swift | 21 ++++++++++----------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/Linphone/UI/Call/CallView.swift b/Linphone/UI/Call/CallView.swift index 784bfa2a4..bbd0d27b3 100644 --- a/Linphone/UI/Call/CallView.swift +++ b/Linphone/UI/Call/CallView.swift @@ -2619,17 +2619,16 @@ struct CallView: View { // swiftlint:enable function_body_length func getAudioRouteImage() { - imageAudioRoute = AVAudioSession.sharedInstance().currentRoute.outputs.filter({ $0.portType.rawValue == "Speaker" }).isEmpty - ? ( - AVAudioSession.sharedInstance().currentRoute.outputs.filter({ $0.portType.rawValue.contains("Bluetooth") }).isEmpty - ? ( - callViewModel.isHeadPhoneAvailable() - ? "headset" - : "speaker-slash" - ) - : "bluetooth" - ) - : "speaker-high" + 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 + } } } From 06557fa3a3dcdaaa09cd0e7fa19c06d01544e5e0 Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Wed, 10 Jul 2024 12:25:58 +0200 Subject: [PATCH 319/486] Fix display of incoming call type (Audio or Video) --- Linphone/TelecomManager/TelecomManager.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Linphone/TelecomManager/TelecomManager.swift b/Linphone/TelecomManager/TelecomManager.swift index fdf00c209..7ddcf79fe 100644 --- a/Linphone/TelecomManager/TelecomManager.swift +++ b/Linphone/TelecomManager/TelecomManager.swift @@ -605,7 +605,8 @@ class TelecomManager: ObservableObject { } else { let videoEnabled = call.remoteParams?.videoEnabled ?? false let isConference = call.callLog?.wasConference() ?? false - self.displayIncomingCall(call: call, handle: addr!.asStringUriOnly(), hasVideo: videoEnabled && !isConference, callId: callId, displayName: displayName) + let videoDir = call.remoteParams?.videoDirection != MediaDirection.Inactive + self.displayIncomingCall(call: call, handle: addr!.asStringUriOnly(), hasVideo: videoEnabled && videoDir && !isConference, callId: callId, displayName: displayName) } } /* else if UIApplication.shared.applicationState != .active { // not support callkit , use notif From 8d425c40e661cc4b1fefde38e6d7ccd29c3b1ea3 Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Wed, 10 Jul 2024 13:51:51 +0200 Subject: [PATCH 320/486] Fix fullcreen mode in one to one calls --- Linphone/UI/Call/CallView.swift | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Linphone/UI/Call/CallView.swift b/Linphone/UI/Call/CallView.swift index bbd0d27b3..c3904d19a 100644 --- a/Linphone/UI/Call/CallView.swift +++ b/Linphone/UI/Call/CallView.swift @@ -213,6 +213,7 @@ struct CallView: View { } .onAppear { callViewModel.enableAVAudioSession() + fullscreenVideo = false if geo.size.width < 350 || geo.size.height < 350 { buttonSize = 45.0 } @@ -530,6 +531,8 @@ struct CallView: View { callViewModel.videoDisplayed = true } } + + fullscreenVideo = false } } From 65a98c6030f297ecda8e1e9ec4d97a936a0e298b Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Wed, 10 Jul 2024 14:12:59 +0200 Subject: [PATCH 321/486] Set default Bluetooth device when available in MeetingWaitingRoom --- .../UI/Call/MeetingWaitingRoomFragment.swift | 38 +++++++++++++------ .../MeetingWaitingRoomViewModel.swift | 17 --------- 2 files changed, 27 insertions(+), 28 deletions(-) diff --git a/Linphone/UI/Call/MeetingWaitingRoomFragment.swift b/Linphone/UI/Call/MeetingWaitingRoomFragment.swift index 521bc5434..33e93806c 100644 --- a/Linphone/UI/Call/MeetingWaitingRoomFragment.swift +++ b/Linphone/UI/Call/MeetingWaitingRoomFragment.swift @@ -50,11 +50,12 @@ struct MeetingWaitingRoomFragment: View { } .onAppear { meetingWaitingRoomViewModel.enableAVAudioSession() - - do { - try AVAudioSession.sharedInstance().overrideOutputAudioPort(.speaker) - } catch _ { - + if AVAudioSession.sharedInstance().currentRoute.outputs.filter({ $0.portType.rawValue.contains("Bluetooth") }).isEmpty { + do { + try AVAudioSession.sharedInstance().overrideOutputAudioPort(.speaker) + } catch _ { + + } } } .onDisappear { @@ -70,10 +71,12 @@ struct MeetingWaitingRoomFragment: View { .onAppear { meetingWaitingRoomViewModel.enableAVAudioSession() - do { - try AVAudioSession.sharedInstance().overrideOutputAudioPort(.speaker) - } catch _ { - + if AVAudioSession.sharedInstance().currentRoute.outputs.filter({ $0.portType.rawValue.contains("Bluetooth") }).isEmpty { + do { + try AVAudioSession.sharedInstance().overrideOutputAudioPort(.speaker) + } catch _ { + + } } } .onDisappear { @@ -314,9 +317,9 @@ struct MeetingWaitingRoomFragment: View { .resizable() .foregroundStyle(.white) .frame(width: 32, height: 32) - .onAppear(perform: meetingWaitingRoomViewModel.getAudioRouteImage) + .onAppear(perform: getAudioRouteImage) .onReceive(pub) { _ in - self.meetingWaitingRoomViewModel.getAudioRouteImage() + self.getAudioRouteImage() } } } @@ -550,6 +553,19 @@ struct MeetingWaitingRoomFragment: View { .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 { diff --git a/Linphone/UI/Call/ViewModel/MeetingWaitingRoomViewModel.swift b/Linphone/UI/Call/ViewModel/MeetingWaitingRoomViewModel.swift index 72cf45f22..ee07b19a0 100644 --- a/Linphone/UI/Call/ViewModel/MeetingWaitingRoomViewModel.swift +++ b/Linphone/UI/Call/ViewModel/MeetingWaitingRoomViewModel.swift @@ -217,23 +217,6 @@ class MeetingWaitingRoomViewModel: ObservableObject { } } - func getAudioRouteImage() { - print("AVAudioSessionAVAudioSession getAudioRouteImage \(imageAudioRoute)") - imageAudioRoute = AVAudioSession.sharedInstance().currentRoute.outputs.filter({ $0.portType.rawValue == "Speaker" }).isEmpty - ? ( - AVAudioSession.sharedInstance().currentRoute.outputs.filter({ $0.portType.rawValue.contains("Bluetooth") }).isEmpty - ? ( - isHeadPhoneAvailable() - ? "headset" - : "speaker-slash" - ) - : "bluetooth" - ) - : "speaker-high" - - print("AVAudioSessionAVAudioSession getAudioRouteImage \(imageAudioRoute)") - } - func isHeadPhoneAvailable() -> Bool { guard let availableInputs = AVAudioSession.sharedInstance().availableInputs else {return false} for inputDevice in availableInputs { From f21831ab2c0da3dbbdfb0006a5a25eb2b5657814 Mon Sep 17 00:00:00 2001 From: QuentinArguillere Date: Fri, 2 Aug 2024 09:40:26 +0200 Subject: [PATCH 322/486] Fix margin and unused fields --- Linphone/UI/Main/Meetings/Fragments/MeetingsFragment.swift | 5 +++-- .../Main/Meetings/Fragments/ScheduleMeetingFragment.swift | 6 ++++-- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/Linphone/UI/Main/Meetings/Fragments/MeetingsFragment.swift b/Linphone/UI/Main/Meetings/Fragments/MeetingsFragment.swift index d3911ab4a..a5ca04c26 100644 --- a/Linphone/UI/Main/Meetings/Fragments/MeetingsFragment.swift +++ b/Linphone/UI/Main/Meetings/Fragments/MeetingsFragment.swift @@ -107,7 +107,7 @@ struct MeetingsFragment: View { if index == 0 || itemModel.dayStr != meetingsListViewModel.meetingsList[index-1].dayStr || itemModel.weekStr != meetingsListViewModel.meetingsList[index-1].weekStr { - HStack(alignment: .top) { + HStack(alignment: .top, spacing: 0) { VStack(alignment: .center, spacing: 0) { Text(itemModel.weekDayStr) .default_text_style_500(styleSize: 14) @@ -134,11 +134,12 @@ struct MeetingsFragment: View { .default_text_style_500(styleSize: 15) } else { createMeetingLine(model: itemModel) + .padding(.leading, 10) } } } else { createMeetingLine(model: itemModel) - .padding(.leading, 55) + .padding(.leading, 45) } } .id(index) diff --git a/Linphone/UI/Main/Meetings/Fragments/ScheduleMeetingFragment.swift b/Linphone/UI/Main/Meetings/Fragments/ScheduleMeetingFragment.swift index 775d8bc14..767a6d10a 100644 --- a/Linphone/UI/Main/Meetings/Fragments/ScheduleMeetingFragment.swift +++ b/Linphone/UI/Main/Meetings/Fragments/ScheduleMeetingFragment.swift @@ -198,7 +198,7 @@ struct ScheduleMeetingFragment: View { .default_text_style_500(styleSize: 16) Spacer() } - + /* HStack(alignment: .center, spacing: 10) { Image("arrow-clockwise") .renderingMode(.template) @@ -212,7 +212,7 @@ struct ScheduleMeetingFragment: View { .default_text_style_500(styleSize: 16) Spacer() } - + */ Rectangle() .foregroundStyle(.clear) .frame(height: 1) @@ -348,6 +348,8 @@ struct ScheduleMeetingFragment: View { getDatePopup(isTimeSelection: true) } } + .navigationTitle("") + .navigationBarHidden(true) } .navigationViewStyle(StackNavigationViewStyle()) } From 8692d628f51eda8e3630f0263aebfe2e7a46cefa Mon Sep 17 00:00:00 2001 From: QuentinArguillere Date: Tue, 13 Aug 2024 09:51:05 +0200 Subject: [PATCH 323/486] Add TimeZone extension to produce a formated string of the form "GMT+x:00 - Timezone Identifier" --- Linphone.xcodeproj/project.pbxproj | 4 ++++ .../Utils/Extensions/TimeZoneExtension.swift | 16 ++++++++++++++++ 2 files changed, 20 insertions(+) create mode 100644 Linphone/Utils/Extensions/TimeZoneExtension.swift diff --git a/Linphone.xcodeproj/project.pbxproj b/Linphone.xcodeproj/project.pbxproj index c7eb30245..51b076f89 100644 --- a/Linphone.xcodeproj/project.pbxproj +++ b/Linphone.xcodeproj/project.pbxproj @@ -14,6 +14,7 @@ 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 */; }; @@ -195,6 +196,7 @@ 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 = ""; }; @@ -395,6 +397,7 @@ C6DC4E3C2C199C4E009096FD /* BundleExtenion.swift */, C628172D2C1C3A3600DBA646 /* AccountExtension.swift */, C62817312C1C400A00DBA646 /* StringExtension.swift */, + 66246C692C622AE900973E97 /* TimeZoneExtension.swift */, ); path = Extensions; sourceTree = ""; @@ -1127,6 +1130,7 @@ 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 */, diff --git a/Linphone/Utils/Extensions/TimeZoneExtension.swift b/Linphone/Utils/Extensions/TimeZoneExtension.swift new file mode 100644 index 000000000..3be68d124 --- /dev/null +++ b/Linphone/Utils/Extensions/TimeZoneExtension.swift @@ -0,0 +1,16 @@ +// +// TimeZoneExtension.swift +// Linphone +// +// Created by QuentinArguillere on 06/08/2024. +// + +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)" + } +} From 6c072aafa076dd57678c61fcef78f18578732132 Mon Sep 17 00:00:00 2001 From: QuentinArguillere Date: Tue, 13 Aug 2024 09:51:24 +0200 Subject: [PATCH 324/486] Fix build (activatedAudioSession parameter name changed) --- Linphone/TelecomManager/ProviderDelegate.swift | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Linphone/TelecomManager/ProviderDelegate.swift b/Linphone/TelecomManager/ProviderDelegate.swift index 779839a8e..7e6db61bf 100644 --- a/Linphone/TelecomManager/ProviderDelegate.swift +++ b/Linphone/TelecomManager/ProviderDelegate.swift @@ -308,7 +308,7 @@ extension ProviderDelegate: CXProviderDelegate { // Log.info("Assuming AudioSession is active when executing a CXSetHeldCallAction with isOnHold=false.") - core.activateAudioSession(actived: true) + core.activateAudioSession(activated: true) TelecomManager.shared.callkitAudioSessionActivated = true } } @@ -396,7 +396,7 @@ extension ProviderDelegate: CXProviderDelegate { func provider(_ provider: CXProvider, didActivate audioSession: AVAudioSession) { CoreContext.shared.doOnCoreQueue { core in Log.info("CallKit: audio session activated.") - core.activateAudioSession(actived: true) + core.activateAudioSession(activated: true) TelecomManager.shared.callkitAudioSessionActivated = true } } @@ -404,7 +404,7 @@ extension ProviderDelegate: CXProviderDelegate { func provider(_ provider: CXProvider, didDeactivate audioSession: AVAudioSession) { CoreContext.shared.doOnCoreQueue { core in Log.info("CallKit: audio session deactivated.") - core.activateAudioSession(actived: false) + core.activateAudioSession(activated: false) TelecomManager.shared.callkitAudioSessionActivated = nil } } From 3588d9711624399f9c2b2cbd64bb5b4d1794e05e Mon Sep 17 00:00:00 2001 From: QuentinArguillere Date: Tue, 13 Aug 2024 09:52:19 +0200 Subject: [PATCH 325/486] Add TimeZone management in meeting scheduling/editing --- .../Meetings/Fragments/MeetingFragment.swift | 2 +- .../Meetings/Fragments/MeetingsFragment.swift | 2 +- .../Fragments/ScheduleMeetingFragment.swift | 26 ++++++++-- .../Meetings/ViewModel/MeetingViewModel.swift | 52 ++++++++++++------- 4 files changed, 56 insertions(+), 26 deletions(-) diff --git a/Linphone/UI/Main/Meetings/Fragments/MeetingFragment.swift b/Linphone/UI/Main/Meetings/Fragments/MeetingFragment.swift index 1b433e480..995cefb12 100644 --- a/Linphone/UI/Main/Meetings/Fragments/MeetingFragment.swift +++ b/Linphone/UI/Main/Meetings/Fragments/MeetingFragment.swift @@ -214,7 +214,7 @@ struct MeetingFragment: View { .foregroundStyle(Color.grayMain2c800) .frame(width: 24, height: 24) .padding(.leading, 15) - Text("TODO : timezone") + Text(meetingViewModel.currentTimeZone.formattedString()) .default_text_style(styleSize: 14) Spacer() } diff --git a/Linphone/UI/Main/Meetings/Fragments/MeetingsFragment.swift b/Linphone/UI/Main/Meetings/Fragments/MeetingsFragment.swift index a5ca04c26..faf2fa9ae 100644 --- a/Linphone/UI/Main/Meetings/Fragments/MeetingsFragment.swift +++ b/Linphone/UI/Main/Meetings/Fragments/MeetingsFragment.swift @@ -66,7 +66,7 @@ struct MeetingsFragment: View { .padding(.top, 5) .default_text_style_500(styleSize: 15) } - Text(model.model!.time) + Text(model.model!.time) // this time string is formatted for the current timezone, we use the selected timezone only when displaying details .default_text_style_500(styleSize: 15) } } diff --git a/Linphone/UI/Main/Meetings/Fragments/ScheduleMeetingFragment.swift b/Linphone/UI/Main/Meetings/Fragments/ScheduleMeetingFragment.swift index 767a6d10a..5f435027e 100644 --- a/Linphone/UI/Main/Meetings/Fragments/ScheduleMeetingFragment.swift +++ b/Linphone/UI/Main/Meetings/Fragments/ScheduleMeetingFragment.swift @@ -192,33 +192,49 @@ struct ScheduleMeetingFragment: View { .foregroundStyle(Color.grayMain2c800) .frame(width: 24, height: 24) .padding(.leading, 15) - Text("TODO : timezone") + Text("Time Zone:") .fontWeight(.bold) - .padding(.leading, 5) .default_text_style_500(styleSize: 16) + Picker(selection: $meetingViewModel.selectedTimezoneIdx, label: EmptyView() ) { + ForEach(0..() var conferenceInfoToEdit: ConferenceInfo? @@ -52,9 +55,21 @@ class MeetingViewModel: ObservableObject { 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 == currentTimeZone.identifier}) ?? 0 computeDateLabels() computeTimeLabels() - updateTimezone() } func resetViewModelData() { @@ -63,7 +78,6 @@ class MeetingViewModel: ObservableObject { subject = "" description = "" allDayMeeting = false - timezone = "" sendInvitations = true participants = [] operationInProgress = false @@ -73,26 +87,32 @@ class MeetingViewModel: ObservableObject { toDate = Calendar.current.date(byAdding: .hour, value: 2, to: Date.now)! computeDateLabels() computeTimeLabels() - updateTimezone() + } + + func updateTimezone(timeZone: TimeZone) { + currentTimeZone = timeZone + computeDateLabels() + computeTimeLabels() } func computeDateLabels() { - var day = fromDate.formatted(Date.FormatStyle().weekday(.wide)) - var dayNumber = fromDate.formatted(Date.FormatStyle().day(.twoDigits)) - var month = fromDate.formatted(Date.FormatStyle().month(.wide)) - fromDateStr = "\(day) \(dayNumber), \(month)" - Log.info("\(MeetingViewModel.TAG) computed start date is \(fromDateStr)") + var weekDayFormat = Date.FormatStyle().weekday(.wide) + weekDayFormat.timeZone = currentTimeZone + var dayNumberFormat = Date.FormatStyle().day(.twoDigits) + dayNumberFormat.timeZone = currentTimeZone + var monthFormat = Date.FormatStyle().month(.wide) + monthFormat.timeZone = currentTimeZone - day = toDate.formatted(Date.FormatStyle().weekday(.wide)) - dayNumber = toDate.formatted(Date.FormatStyle().day(.twoDigits)) - month = toDate.formatted(Date.FormatStyle().month(.wide)) - toDateStr = "\(day) \(dayNumber), \(month)" + fromDateStr = "\(fromDate.formatted(weekDayFormat)) \(fromDate.formatted(dayNumberFormat)), \(fromDate.formatted(monthFormat))" + Log.info("\(MeetingViewModel.TAG) computed start date is \(fromDateStr)") + toDateStr = "\(toDate.formatted(weekDayFormat)) \(toDate.formatted(dayNumberFormat)), \(toDate.formatted(monthFormat))" 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 = currentTimeZone fromTime = formatter.string(from: fromDate) toTime = formatter.string(from: toDate) } @@ -105,10 +125,6 @@ class MeetingViewModel: ObservableObject { return "\(day). \(dayNumber) \(month) \(year) | \(allDayMeeting ? "All day" : "\(fromTime) - \(toTime)")" } - private func updateTimezone() { - // TODO - } - func addParticipants(participantsToAdd: [SelectedAddressModel]) { var list = participants for selectedAddr in participantsToAdd { @@ -300,7 +316,6 @@ class MeetingViewModel: ObservableObject { self.conferenceUri = meeting.confInfo.uri?.asStringUriOnly() ?? "" self.computeDateLabels() self.computeTimeLabels() - self.updateTimezone() self.displayedMeeting = meeting } } @@ -334,7 +349,6 @@ class MeetingViewModel: ObservableObject { self.isBroadcastSelected = false // TODO FIXME self.computeDateLabels() self.computeTimeLabels() - self.updateTimezone() //self.participants = list } From c5f780eead27a8ab680b6aeaccd4640e9fed02fb Mon Sep 17 00:00:00 2001 From: QuentinArguillere Date: Tue, 13 Aug 2024 09:53:29 +0200 Subject: [PATCH 326/486] Upgrade meeting description UI (better text field for longer text, added maximum size) --- .../Meetings/Fragments/MeetingFragment.swift | 11 +++--- .../Fragments/ScheduleMeetingFragment.swift | 37 ++++++++++++++++--- 2 files changed, 37 insertions(+), 11 deletions(-) diff --git a/Linphone/UI/Main/Meetings/Fragments/MeetingFragment.swift b/Linphone/UI/Main/Meetings/Fragments/MeetingFragment.swift index 995cefb12..4141c6341 100644 --- a/Linphone/UI/Main/Meetings/Fragments/MeetingFragment.swift +++ b/Linphone/UI/Main/Meetings/Fragments/MeetingFragment.swift @@ -170,6 +170,7 @@ struct MeetingFragment: View { .padding(.leading, 15) Text(meetingViewModel.conferenceUri) .underline() + .lineLimit(1) .default_text_style(styleSize: 14) Spacer() @@ -237,12 +238,12 @@ struct MeetingFragment: View { .default_text_style(styleSize: 14) Spacer() }.padding(.vertical, 10) + Rectangle() + .foregroundStyle(.clear) + .frame(height: 1) + .background(Color.gray200) } - Rectangle() - .foregroundStyle(.clear) - .frame(height: 1) - .background(Color.gray200) HStack(alignment: .top, spacing: 10) { Image("users") @@ -261,7 +262,7 @@ struct MeetingFragment: View { getParticipantLine(participant: meetingViewModel.participants[index]) } } - }.frame(maxHeight: 170) + }.frame(maxHeight: .infinity) Spacer() }.padding(.top, 10) diff --git a/Linphone/UI/Main/Meetings/Fragments/ScheduleMeetingFragment.swift b/Linphone/UI/Main/Meetings/Fragments/ScheduleMeetingFragment.swift index 5f435027e..f722b91b5 100644 --- a/Linphone/UI/Main/Meetings/Fragments/ScheduleMeetingFragment.swift +++ b/Linphone/UI/Main/Meetings/Fragments/ScheduleMeetingFragment.swift @@ -43,6 +43,7 @@ struct ScheduleMeetingFragment: View { @State var selectedMinutes: Int = 0 @State var addParticipantsViewModel = AddParticipantsViewModel() + @FocusState var isDescriptionTextFocused: Bool var body: some View { NavigationView { @@ -185,6 +186,7 @@ struct ScheduleMeetingFragment: View { .default_text_style_500(styleSize: 16) .padding(.trailing, 15) } } + HStack(alignment: .center, spacing: 10) { Image("earth") .renderingMode(.template) @@ -242,9 +244,32 @@ struct ScheduleMeetingFragment: View { .frame(width: 24, height: 24) .padding(.leading, 16) - TextField("Add a description", text: $meetingViewModel.description) - .default_text_style_700(styleSize: 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) @@ -258,7 +283,7 @@ struct ScheduleMeetingFragment: View { addParticipantsViewModel.participantsToAdd = meetingViewModel.participants } }, label: { - HStack(alignment: .center, spacing: 8) { + HStack(alignment: .center, spacing: 10) { Image("users") .renderingMode(.template) .resizable() @@ -298,7 +323,7 @@ struct ScheduleMeetingFragment: View { } } } - }.frame(maxHeight: 170) + }.frame(maxHeight: .infinity) } } Rectangle() @@ -306,7 +331,7 @@ struct ScheduleMeetingFragment: View { .frame(height: 1) .background(Color.gray200) - HStack(spacing: 8) { + HStack(spacing: 10) { Toggle("", isOn: $meetingViewModel.sendInvitations) .padding(.leading, 16) .labelsHidden() From 69a878d245a06c3269dc8343437ae67a2755c71f Mon Sep 17 00:00:00 2001 From: QuentinArguillere Date: Tue, 13 Aug 2024 09:53:44 +0200 Subject: [PATCH 327/486] Add forgoten localizable file update for timezones --- Linphone/Localizable.xcstrings | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/Linphone/Localizable.xcstrings b/Linphone/Localizable.xcstrings index 02f4a8cea..2c1ea76e2 100644 --- a/Linphone/Localizable.xcstrings +++ b/Linphone/Localizable.xcstrings @@ -1646,6 +1646,9 @@ }, "This contact will be deleted definitively." : { + }, + "Time Zone:" : { + }, "Title" : { @@ -1655,12 +1658,6 @@ }, "to Linphone" : { - }, - "TODO : repeat" : { - - }, - "TODO : timezone" : { - }, "Transfer" : { From bd3b8d8731d4a7b7a142732f42245bc2bcac9e27 Mon Sep 17 00:00:00 2001 From: QuentinArguillere Date: Tue, 13 Aug 2024 11:24:35 +0200 Subject: [PATCH 328/486] Fix flexiApi push parameter for push token reception : need to adapt the pn-provider to the build (DEBUG or not) --- Linphone/UI/Assistant/Viewmodel/RegisterViewModel.swift | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/Linphone/UI/Assistant/Viewmodel/RegisterViewModel.swift b/Linphone/UI/Assistant/Viewmodel/RegisterViewModel.swift index 67ecdbec2..5ed6af21d 100644 --- a/Linphone/UI/Assistant/Viewmodel/RegisterViewModel.swift +++ b/Linphone/UI/Assistant/Viewmodel/RegisterViewModel.swift @@ -314,7 +314,12 @@ class RegisterViewModel: ObservableObject { let pushConfig = core.pushNotificationConfig if pushConfig != nil && self.accountManagerServices != nil { - pushConfig!.provider = "apns.dev" +#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 From a8515374284e9970f547df8f61f395357db75f6c Mon Sep 17 00:00:00 2001 From: QuentinArguillere Date: Tue, 13 Aug 2024 11:26:51 +0200 Subject: [PATCH 329/486] Light rework of timezone management: we only use it and store it during Meeting creation/edition. After, we reset and display everything based on current device timezone --- .../Meetings/Fragments/MeetingFragment.swift | 2 +- .../Fragments/ScheduleMeetingFragment.swift | 2 +- .../Meetings/ViewModel/MeetingViewModel.swift | 31 +++++++++---------- 3 files changed, 16 insertions(+), 19 deletions(-) diff --git a/Linphone/UI/Main/Meetings/Fragments/MeetingFragment.swift b/Linphone/UI/Main/Meetings/Fragments/MeetingFragment.swift index 4141c6341..8d0385256 100644 --- a/Linphone/UI/Main/Meetings/Fragments/MeetingFragment.swift +++ b/Linphone/UI/Main/Meetings/Fragments/MeetingFragment.swift @@ -215,7 +215,7 @@ struct MeetingFragment: View { .foregroundStyle(Color.grayMain2c800) .frame(width: 24, height: 24) .padding(.leading, 15) - Text(meetingViewModel.currentTimeZone.formattedString()) + Text(meetingViewModel.selectedTimezone.formattedString()) .default_text_style(styleSize: 14) Spacer() } diff --git a/Linphone/UI/Main/Meetings/Fragments/ScheduleMeetingFragment.swift b/Linphone/UI/Main/Meetings/Fragments/ScheduleMeetingFragment.swift index f722b91b5..74083c4b5 100644 --- a/Linphone/UI/Main/Meetings/Fragments/ScheduleMeetingFragment.swift +++ b/Linphone/UI/Main/Meetings/Fragments/ScheduleMeetingFragment.swift @@ -128,7 +128,7 @@ struct ScheduleMeetingFragment: View { showDatePicker.toggle() } Spacer() - } + }.padding(.bottom, -5) if !meetingViewModel.allDayMeeting { HStack(spacing: 10) { diff --git a/Linphone/UI/Main/Meetings/ViewModel/MeetingViewModel.swift b/Linphone/UI/Main/Meetings/ViewModel/MeetingViewModel.swift index ff6de14df..e5c7bee89 100644 --- a/Linphone/UI/Main/Meetings/ViewModel/MeetingViewModel.swift +++ b/Linphone/UI/Main/Meetings/ViewModel/MeetingViewModel.swift @@ -40,7 +40,7 @@ class MeetingViewModel: ObservableObject { @Published var conferenceUri: String = "" @Published var selectedTimezoneIdx = 0 - var currentTimeZone = TimeZone.current + var selectedTimezone = TimeZone.current var knownTimezones : [String] = [] var conferenceScheduler: ConferenceScheduler? @@ -67,7 +67,7 @@ class MeetingViewModel: ObservableObject { } }) knownTimezones = tzIds - selectedTimezoneIdx = knownTimezones.firstIndex(where: {$0 == currentTimeZone.identifier}) ?? 0 + selectedTimezoneIdx = knownTimezones.firstIndex(where: {$0 == selectedTimezone.identifier}) ?? 0 computeDateLabels() computeTimeLabels() } @@ -85,44 +85,41 @@ class MeetingViewModel: ObservableObject { 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) { - currentTimeZone = timeZone + selectedTimezone = timeZone computeDateLabels() computeTimeLabels() } func computeDateLabels() { - var weekDayFormat = Date.FormatStyle().weekday(.wide) - weekDayFormat.timeZone = currentTimeZone - var dayNumberFormat = Date.FormatStyle().day(.twoDigits) - dayNumberFormat.timeZone = currentTimeZone - var monthFormat = Date.FormatStyle().month(.wide) - monthFormat.timeZone = currentTimeZone + let formatter = DateFormatter() + formatter.timeZone = selectedTimezone + formatter.dateFormat = "EEEE d MMM" - fromDateStr = "\(fromDate.formatted(weekDayFormat)) \(fromDate.formatted(dayNumberFormat)), \(fromDate.formatted(monthFormat))" + fromDateStr = formatter.string(from: fromDate) Log.info("\(MeetingViewModel.TAG) computed start date is \(fromDateStr)") - toDateStr = "\(toDate.formatted(weekDayFormat)) \(toDate.formatted(dayNumberFormat)), \(toDate.formatted(monthFormat))" + fromDateStr = 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 = currentTimeZone + formatter.timeZone = selectedTimezone fromTime = formatter.string(from: fromDate) toTime = formatter.string(from: toDate) } func getFullDateString() -> String { - var day = fromDate.formatted(Date.FormatStyle().weekday(.abbreviated)) - var dayNumber = fromDate.formatted(Date.FormatStyle().day(.twoDigits)) - var month = fromDate.formatted(Date.FormatStyle().month(.wide)) - var year = fromDate.formatted(Date.FormatStyle().year(.defaultDigits)) - return "\(day). \(dayNumber) \(month) \(year) | \(allDayMeeting ? "All day" : "\(fromTime) - \(toTime)")" + let formatter = DateFormatter() + formatter.dateFormat = "EEE d MMM yyyy" + return "\(formatter.string(from: fromDate)) | \(allDayMeeting ? "All day" : "\(fromTime) - \(toTime)")" } func addParticipants(participantsToAdd: [SelectedAddressModel]) { From ca7bedfd1421313fed922633253d6705f3eec25c Mon Sep 17 00:00:00 2001 From: QuentinArguillere Date: Tue, 13 Aug 2024 11:27:07 +0200 Subject: [PATCH 330/486] Fix animation when displaying meeting details --- Linphone/UI/Main/Meetings/ViewModel/MeetingViewModel.swift | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/Linphone/UI/Main/Meetings/ViewModel/MeetingViewModel.swift b/Linphone/UI/Main/Meetings/ViewModel/MeetingViewModel.swift index e5c7bee89..d0faa0278 100644 --- a/Linphone/UI/Main/Meetings/ViewModel/MeetingViewModel.swift +++ b/Linphone/UI/Main/Meetings/ViewModel/MeetingViewModel.swift @@ -264,8 +264,8 @@ class MeetingViewModel: ObservableObject { } } + // Warning: must be called from core queue. Removed the dispatchQueue.main.async in order to have the animation properly trigger. func loadExistingMeeting(meeting: MeetingModel) { - DispatchQueue.main.async { self.resetViewModelData() self.subject = meeting.confInfo.subject ?? "" self.description = meeting.confInfo.description ?? "" @@ -314,7 +314,6 @@ class MeetingViewModel: ObservableObject { self.computeDateLabels() self.computeTimeLabels() self.displayedMeeting = meeting - } } func loadExistingConferenceInfoFromUri(conferenceUri: String) { From 30d9baf7667a5c39d2cec6477a58919126b5286e Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Mon, 19 Aug 2024 15:32:21 +0200 Subject: [PATCH 331/486] Remove AVAudioSession start and stop in call view --- Linphone/UI/Call/CallView.swift | 4 ---- Linphone/UI/Call/ViewModel/CallViewModel.swift | 16 ---------------- 2 files changed, 20 deletions(-) diff --git a/Linphone/UI/Call/CallView.swift b/Linphone/UI/Call/CallView.swift index c3904d19a..4df149dc0 100644 --- a/Linphone/UI/Call/CallView.swift +++ b/Linphone/UI/Call/CallView.swift @@ -212,15 +212,11 @@ struct CallView: View { } } .onAppear { - callViewModel.enableAVAudioSession() fullscreenVideo = false if geo.size.width < 350 || geo.size.height < 350 { buttonSize = 45.0 } } - .onDisappear { - callViewModel.disableAVAudioSession() - } } } diff --git a/Linphone/UI/Call/ViewModel/CallViewModel.swift b/Linphone/UI/Call/ViewModel/CallViewModel.swift index d7590d93a..6782337b0 100644 --- a/Linphone/UI/Call/ViewModel/CallViewModel.swift +++ b/Linphone/UI/Call/ViewModel/CallViewModel.swift @@ -89,22 +89,6 @@ class CallViewModel: ObservableObject { } } - func enableAVAudioSession() { - do { - try AVAudioSession.sharedInstance().setActive(true) - } catch _ { - - } - } - - func disableAVAudioSession() { - do { - try AVAudioSession.sharedInstance().setActive(false) - } catch _ { - - } - } - func resetCallView() { coreContext.doOnCoreQueue { core in if core.currentCall != nil && core.currentCall!.remoteAddress != nil { From 4e2a7d4158af3de18d8fe0df80cc1e68ba7b5c48 Mon Sep 17 00:00:00 2001 From: QuentinArguillere Date: Fri, 16 Aug 2024 17:00:02 +0200 Subject: [PATCH 332/486] Rework timezone picker to avoid animation lag when displaying schedule meeting view --- .../Fragments/ScheduleMeetingFragment.swift | 94 +++++++++++++------ 1 file changed, 65 insertions(+), 29 deletions(-) diff --git a/Linphone/UI/Main/Meetings/Fragments/ScheduleMeetingFragment.swift b/Linphone/UI/Main/Meetings/Fragments/ScheduleMeetingFragment.swift index 74083c4b5..eab916422 100644 --- a/Linphone/UI/Main/Meetings/Fragments/ScheduleMeetingFragment.swift +++ b/Linphone/UI/Main/Meetings/Fragments/ScheduleMeetingFragment.swift @@ -34,6 +34,7 @@ struct ScheduleMeetingFragment: View { @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 @@ -194,41 +195,29 @@ struct ScheduleMeetingFragment: View { .foregroundStyle(Color.grayMain2c800) .frame(width: 24, height: 24) .padding(.leading, 15) - Text("Time Zone:") + Text("Time Zone: \(meetingViewModel.selectedTimezone.formattedString())") .fontWeight(.bold) - .default_text_style_500(styleSize: 16) - Picker(selection: $meetingViewModel.selectedTimezoneIdx, label: EmptyView() ) { - ForEach(0.. Date: Fri, 16 Aug 2024 17:02:55 +0200 Subject: [PATCH 333/486] Fix typo that deleted the end date string of a meeting --- Linphone/UI/Main/Meetings/ViewModel/MeetingViewModel.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Linphone/UI/Main/Meetings/ViewModel/MeetingViewModel.swift b/Linphone/UI/Main/Meetings/ViewModel/MeetingViewModel.swift index d0faa0278..362d7f405 100644 --- a/Linphone/UI/Main/Meetings/ViewModel/MeetingViewModel.swift +++ b/Linphone/UI/Main/Meetings/ViewModel/MeetingViewModel.swift @@ -104,7 +104,7 @@ class MeetingViewModel: ObservableObject { fromDateStr = formatter.string(from: fromDate) Log.info("\(MeetingViewModel.TAG) computed start date is \(fromDateStr)") - fromDateStr = formatter.string(from: toDate) + toDateStr = formatter.string(from: toDate) Log.info("\(MeetingViewModel.TAG)) computed end date is \(toDateStr)") } From d729f7570afd4ec409ab1126f93ca968558a8dfb Mon Sep 17 00:00:00 2001 From: QuentinArguillere Date: Sun, 18 Aug 2024 22:45:23 +0200 Subject: [PATCH 334/486] Fix top button alignment in meetings view --- Linphone/UI/Main/ContentView.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Linphone/UI/Main/ContentView.swift b/Linphone/UI/Main/ContentView.swift index 066e9c7a9..4e7417526 100644 --- a/Linphone/UI/Main/ContentView.swift +++ b/Linphone/UI/Main/ContentView.swift @@ -312,6 +312,7 @@ struct ContentView: View { .frame(width: 25, height: 25, alignment: .leading) .padding(.all, 10) } + .padding(.trailing, 10) } else if index != 2 { Menu { if index == 0 { From fcce09843e80627145c6d8ab1bbade546bba0549 Mon Sep 17 00:00:00 2001 From: QuentinArguillere Date: Sun, 18 Aug 2024 22:57:54 +0200 Subject: [PATCH 335/486] Meeting cancellation: only delete meeting when pressing one of the confirmation popup buttons. --- Linphone/UI/Main/ContentView.swift | 14 ++++++++++---- .../Main/Meetings/Fragments/MeetingFragment.swift | 2 -- .../Fragments/MeetingsListBottomSheet.swift | 1 - .../Main/Meetings/ViewModel/MeetingViewModel.swift | 9 ++++----- 4 files changed, 14 insertions(+), 12 deletions(-) diff --git a/Linphone/UI/Main/ContentView.swift b/Linphone/UI/Main/ContentView.swift index 4e7417526..83eb74a19 100644 --- a/Linphone/UI/Main/ContentView.swift +++ b/Linphone/UI/Main/ContentView.swift @@ -1016,13 +1016,19 @@ struct ContentView: View { if isShowSendCancelMeetingNotificationPopup { PopupView(isShowPopup: $isShowSendCancelMeetingNotificationPopup, - title: Text("The meeting has been cancelled"), + title: Text("The meeting will be cancelled"), content: Text("Send notification to participants ?"), - titleFirstButton: Text("Cancel"), - actionFirstButton: { self.isShowSendCancelMeetingNotificationPopup.toggle() }, - titleSecondButton: Text("Ok"), + 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 { + meetingsListViewModel.deleteSelectedMeeting() // We're in the meeting list view self.meetingViewModel.sendMeetingCancelledNotifications(meeting: meetingToDelete) self.isShowSendCancelMeetingNotificationPopup.toggle() diff --git a/Linphone/UI/Main/Meetings/Fragments/MeetingFragment.swift b/Linphone/UI/Main/Meetings/Fragments/MeetingFragment.swift index 8d0385256..5a597944a 100644 --- a/Linphone/UI/Main/Meetings/Fragments/MeetingFragment.swift +++ b/Linphone/UI/Main/Meetings/Fragments/MeetingFragment.swift @@ -111,8 +111,6 @@ struct MeetingFragment: View { Button(role: .destructive) { withAnimation { meetingsListViewModel.selectedMeetingToDelete = meetingViewModel.displayedMeeting - meetingViewModel.displayedMeeting = nil - meetingsListViewModel.deleteSelectedMeeting() isShowSendCancelMeetingNotificationPopup.toggle() } } label: { diff --git a/Linphone/UI/Main/Meetings/Fragments/MeetingsListBottomSheet.swift b/Linphone/UI/Main/Meetings/Fragments/MeetingsListBottomSheet.swift index cc37914d0..1f609e1e8 100644 --- a/Linphone/UI/Main/Meetings/Fragments/MeetingsListBottomSheet.swift +++ b/Linphone/UI/Main/Meetings/Fragments/MeetingsListBottomSheet.swift @@ -53,7 +53,6 @@ struct MeetingsListBottomSheet: View { } Button { - meetingsListViewModel.deleteSelectedMeeting() CoreContext.shared.doOnCoreQueue { core in if let organizerUri = self.meetingsListViewModel.selectedMeetingToDelete?.confInfo.organizer { if core.defaultAccount?.contactAddress?.weakEqual(address2: organizerUri) ?? false { diff --git a/Linphone/UI/Main/Meetings/ViewModel/MeetingViewModel.swift b/Linphone/UI/Main/Meetings/ViewModel/MeetingViewModel.swift index 362d7f405..ef07cd954 100644 --- a/Linphone/UI/Main/Meetings/ViewModel/MeetingViewModel.swift +++ b/Linphone/UI/Main/Meetings/ViewModel/MeetingViewModel.swift @@ -359,10 +359,9 @@ class MeetingViewModel: ObservableObject { } func sendMeetingCancelledNotifications(meeting: MeetingModel) { - Log.error("\(MeetingViewModel.TAG) - sendMeetingCancelledNotifications TODO") - //CoreContext.shared.doOnCoreQueue { core in - // self.resetConferenceSchedulerAndListeners(core: core) - // self.conferenceScheduler?.cancelConference(conferenceInfo: meeting.confInfo) - //} + CoreContext.shared.doOnCoreQueue { core in + self.resetConferenceSchedulerAndListeners(core: core) + self.conferenceScheduler?.cancelConference(conferenceInfo: meeting.confInfo) + } } } From 3ef0db8645b1c271cc8d05883c837fd11d1a898f Mon Sep 17 00:00:00 2001 From: QuentinArguillere Date: Sun, 18 Aug 2024 23:24:42 +0200 Subject: [PATCH 336/486] Cancel without popup when deleting a meeting where we are not the organizer --- Linphone/UI/Main/Meetings/Fragments/MeetingFragment.swift | 8 +++++++- .../Main/Meetings/Fragments/MeetingsListBottomSheet.swift | 5 ++++- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/Linphone/UI/Main/Meetings/Fragments/MeetingFragment.swift b/Linphone/UI/Main/Meetings/Fragments/MeetingFragment.swift index 5a597944a..4115036e5 100644 --- a/Linphone/UI/Main/Meetings/Fragments/MeetingFragment.swift +++ b/Linphone/UI/Main/Meetings/Fragments/MeetingFragment.swift @@ -111,7 +111,13 @@ struct MeetingFragment: View { Button(role: .destructive) { withAnimation { meetingsListViewModel.selectedMeetingToDelete = meetingViewModel.displayedMeeting - isShowSendCancelMeetingNotificationPopup.toggle() + 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 { diff --git a/Linphone/UI/Main/Meetings/Fragments/MeetingsListBottomSheet.swift b/Linphone/UI/Main/Meetings/Fragments/MeetingsListBottomSheet.swift index 1f609e1e8..dd56924e0 100644 --- a/Linphone/UI/Main/Meetings/Fragments/MeetingsListBottomSheet.swift +++ b/Linphone/UI/Main/Meetings/Fragments/MeetingsListBottomSheet.swift @@ -56,10 +56,13 @@ struct MeetingsListBottomSheet: View { 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 { - // If we are the organizer, display popup for sending self.isShowSendCancelMeetingNotificationPopup = true } + } else { + // If we are not the organizer, delete meeting locally without popup + meetingsListViewModel.deleteSelectedMeeting() } } } From 62db361b137cd3fcee76c66a293ca21db9ab9904 Mon Sep 17 00:00:00 2001 From: QuentinArguillere Date: Sun, 18 Aug 2024 23:26:43 +0200 Subject: [PATCH 337/486] Remove Subject and Description text focus when tapping elsewhere on the screen in meeting creation / edition --- .../Main/Meetings/Fragments/ScheduleMeetingFragment.swift | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/Linphone/UI/Main/Meetings/Fragments/ScheduleMeetingFragment.swift b/Linphone/UI/Main/Meetings/Fragments/ScheduleMeetingFragment.swift index eab916422..0f198a9dc 100644 --- a/Linphone/UI/Main/Meetings/Fragments/ScheduleMeetingFragment.swift +++ b/Linphone/UI/Main/Meetings/Fragments/ScheduleMeetingFragment.swift @@ -45,6 +45,7 @@ struct ScheduleMeetingFragment: View { @State var addParticipantsViewModel = AddParticipantsViewModel() @FocusState var isDescriptionTextFocused: Bool + @FocusState var isSubjectTextFocused: Bool var body: some View { NavigationView { @@ -103,6 +104,7 @@ struct ScheduleMeetingFragment: View { .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() @@ -332,6 +334,9 @@ struct ScheduleMeetingFragment: View { Spacer() } .background(.white) + }.onTapGesture { + isDescriptionTextFocused = false + isSubjectTextFocused = false } Button { @@ -349,6 +354,8 @@ struct ScheduleMeetingFragment: View { } .padding() + + if meetingViewModel.operationInProgress { HStack { Spacer() From 6143724f0c673585f8928cef4a0cb2643c244b6b Mon Sep 17 00:00:00 2001 From: QuentinArguillere Date: Mon, 19 Aug 2024 11:08:56 +0200 Subject: [PATCH 338/486] Add "meeting_plus" icon --- .../meeting_plus.imageset/Contents.json | 21 +++++++++++++++++++ .../meeting_plus.imageset/meeting_plus.svg | 4 ++++ Linphone/UI/Main/Meetings/MeetingsView.swift | 2 +- 3 files changed, 26 insertions(+), 1 deletion(-) create mode 100644 Linphone/Assets.xcassets/meeting_plus.imageset/Contents.json create mode 100644 Linphone/Assets.xcassets/meeting_plus.imageset/meeting_plus.svg 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/UI/Main/Meetings/MeetingsView.swift b/Linphone/UI/Main/Meetings/MeetingsView.swift index 94c108486..a4f37074e 100644 --- a/Linphone/UI/Main/Meetings/MeetingsView.swift +++ b/Linphone/UI/Main/Meetings/MeetingsView.swift @@ -62,7 +62,7 @@ struct MeetingsView: View { isShowScheduleMeetingFragment.toggle() } } label: { - Image("plus-circle") + Image("meeting_plus") .renderingMode(.template) .foregroundStyle(.white) .padding() From 153c2ae238206d7e35e8b1978931adcc0270e67a Mon Sep 17 00:00:00 2001 From: QuentinArguillere Date: Mon, 19 Aug 2024 17:50:24 +0200 Subject: [PATCH 339/486] Fix license --- .../Utils/Extensions/TimeZoneExtension.swift | 24 ++++++++++++++----- 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/Linphone/Utils/Extensions/TimeZoneExtension.swift b/Linphone/Utils/Extensions/TimeZoneExtension.swift index 3be68d124..6a97070f3 100644 --- a/Linphone/Utils/Extensions/TimeZoneExtension.swift +++ b/Linphone/Utils/Extensions/TimeZoneExtension.swift @@ -1,9 +1,21 @@ -// -// TimeZoneExtension.swift -// Linphone -// -// Created by QuentinArguillere on 06/08/2024. -// +/* + * 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 From aee88f8c872330c57890d8f8700b85235996938a Mon Sep 17 00:00:00 2001 From: QuentinArguillere Date: Mon, 19 Aug 2024 17:50:56 +0200 Subject: [PATCH 340/486] Localizable strings --- Linphone/Localizable.xcstrings | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/Linphone/Localizable.xcstrings b/Linphone/Localizable.xcstrings index 2c1ea76e2..47e7b0f18 100644 --- a/Linphone/Localizable.xcstrings +++ b/Linphone/Localizable.xcstrings @@ -763,6 +763,9 @@ } } } + }, + "Cancel for me only" : { + }, "Ce mode vous permet d’être interopérable avec d’autres services SIP.\nVos communications seront chiffrées de point à point. " : { @@ -1563,6 +1566,9 @@ } } } + }, + "Send cancellation notifications" : { + }, "Send invitations to participants" : { @@ -1638,7 +1644,7 @@ "Temp Help" : { }, - "The meeting has been cancelled" : { + "The meeting will be cancelled" : { }, "The user name or password is incorrects" : { From 8f8877b759e7875b51d7b3d396b4fec3e1eae7a4 Mon Sep 17 00:00:00 2001 From: QuentinArguillere Date: Mon, 19 Aug 2024 17:52:40 +0200 Subject: [PATCH 341/486] Reset core scheduler before cancelling meetings --- Linphone/UI/Main/Meetings/ViewModel/MeetingViewModel.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Linphone/UI/Main/Meetings/ViewModel/MeetingViewModel.swift b/Linphone/UI/Main/Meetings/ViewModel/MeetingViewModel.swift index ef07cd954..3512dc846 100644 --- a/Linphone/UI/Main/Meetings/ViewModel/MeetingViewModel.swift +++ b/Linphone/UI/Main/Meetings/ViewModel/MeetingViewModel.swift @@ -360,7 +360,7 @@ class MeetingViewModel: ObservableObject { func sendMeetingCancelledNotifications(meeting: MeetingModel) { CoreContext.shared.doOnCoreQueue { core in - self.resetConferenceSchedulerAndListeners(core: core) + self.conferenceScheduler = try? core.createConferenceScheduler() self.conferenceScheduler?.cancelConference(conferenceInfo: meeting.confInfo) } } From 4ec30c477d2282eba0fc46367a5edef8a3ef982c Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Wed, 10 Jul 2024 16:27:36 +0200 Subject: [PATCH 342/486] Add a search filter for the conversation list --- Linphone/UI/Main/ContentView.swift | 10 ++++++--- .../Model/ConversationModel.swift | 21 ++++++++++++++----- .../ConversationsListViewModel.swift | 21 ++++++++++++++++--- .../ViewModel/HistoryListViewModel.swift | 2 +- 4 files changed, 42 insertions(+), 12 deletions(-) diff --git a/Linphone/UI/Main/ContentView.swift b/Linphone/UI/Main/ContentView.swift index 83eb74a19..f41f07872 100644 --- a/Linphone/UI/Main/ContentView.swift +++ b/Linphone/UI/Main/ContentView.swift @@ -405,7 +405,7 @@ struct ContentView: View { } else if index == 1 { historyListViewModel.resetFilterCallLogs() } else if index == 2 { - //TODO Conversations List reset + conversationsListViewModel.resetFilterConversations() } else if index == 3 { meetingsListViewModel.currentFilter = "" meetingsListViewModel.computeMeetingsList() @@ -455,7 +455,11 @@ struct ContentView: View { historyListViewModel.filterCallLogs(filter: text) } } else if index == 2 { - //TODO Conversations List reset + if text.isEmpty { + conversationsListViewModel.resetFilterConversations() + } else { + conversationsListViewModel.filterConversations(filter: text) + } } else if index == 3 { meetingsListViewModel.currentFilter = text meetingsListViewModel.computeMeetingsList() @@ -490,7 +494,7 @@ struct ContentView: View { } else if index == 1 { historyListViewModel.filterCallLogs(filter: text) } else if index == 2 { - //TODO Conversations List reset + conversationsListViewModel.filterConversations(filter: text) } else if index == 3 { meetingsListViewModel.currentFilter = text meetingsListViewModel.computeMeetingsList() diff --git a/Linphone/UI/Main/Conversations/Model/ConversationModel.swift b/Linphone/UI/Main/Conversations/Model/ConversationModel.swift index f2f3e27eb..b222a532c 100644 --- a/Linphone/UI/Main/Conversations/Model/ConversationModel.swift +++ b/Linphone/UI/Main/Conversations/Model/ConversationModel.swift @@ -37,6 +37,7 @@ class ConversationModel: ObservableObject { 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 @@ -158,15 +159,17 @@ class ConversationModel: ObservableObject { ? self.contactsManager.getFriendWithAddress(address: self.chatRoom.participants.first?.address) : nil + var subjectTmp = "" + if self.isGroup { - self.subject = self.chatRoom.subject! + subjectTmp = self.chatRoom.subject! } else if addressFriend != nil { - self.subject = addressFriend!.name! + subjectTmp = addressFriend!.name! } else { if self.chatRoom.participants.first != nil && self.chatRoom.participants.first!.address != nil { - self.subject = self.chatRoom.participants.first!.address!.displayName != nil + subjectTmp = self.chatRoom.participants.first!.address!.displayName != nil ? self.chatRoom.participants.first!.address!.displayName! : self.chatRoom.participants.first!.address!.username! @@ -182,19 +185,27 @@ class ConversationModel: ObservableObject { }) ?? ContactAvatarModel( friend: nil, - name: self.subject, + name: subjectTmp, address: addressTmp, withPresence: false ) : ContactAvatarModel( friend: nil, - name: self.subject, + name: subjectTmp, address: 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 } } } diff --git a/Linphone/UI/Main/Conversations/ViewModel/ConversationsListViewModel.swift b/Linphone/UI/Main/Conversations/ViewModel/ConversationsListViewModel.swift index 71d92dac4..f5caace40 100644 --- a/Linphone/UI/Main/Conversations/ViewModel/ConversationsListViewModel.swift +++ b/Linphone/UI/Main/Conversations/ViewModel/ConversationsListViewModel.swift @@ -29,6 +29,8 @@ class ConversationsListViewModel: ObservableObject { private var mCoreSuscriptions = Set() @Published var conversationsList: [ConversationModel] = [] + var conversationsListTmp: [ConversationModel] = [] + @Published var unreadMessages: Int = 0 var selectedConversation: ConversationModel? @@ -43,17 +45,17 @@ class ConversationsListViewModel: ObservableObject { let account = core.defaultAccount let chatRooms = account?.chatRooms != nil ? account!.chatRooms : core.chatRooms - var conversationsListTmp: [ConversationModel] = [] + self.conversationsListTmp = [] chatRooms.forEach { chatRoom in if filter.isEmpty { let model = ConversationModel(chatRoom: chatRoom) - conversationsListTmp.append(model) + self.conversationsListTmp.append(model) } } DispatchQueue.main.async { - self.conversationsList = conversationsListTmp + self.conversationsList = self.conversationsListTmp } self.updateUnreadMessagesCount() @@ -198,4 +200,17 @@ class ConversationsListViewModel: ObservableObject { } } } + + 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 + } } diff --git a/Linphone/UI/Main/History/ViewModel/HistoryListViewModel.swift b/Linphone/UI/Main/History/ViewModel/HistoryListViewModel.swift index 3fb1db0f8..036365ef2 100644 --- a/Linphone/UI/Main/History/ViewModel/HistoryListViewModel.swift +++ b/Linphone/UI/Main/History/ViewModel/HistoryListViewModel.swift @@ -195,7 +195,7 @@ class HistoryListViewModel: ObservableObject { func filterCallLogs(filter: String) { callLogs.removeAll() callLogsTmp.forEach { callLog in - if callLog.addressName.contains(filter) { + if callLog.addressName.lowercased().contains(filter.lowercased()) { callLogs.append(callLog) } } From da68a15694017377189b500241e0283432c2a88c Mon Sep 17 00:00:00 2001 From: "benoit.martins" Date: Thu, 11 Jul 2024 17:24:28 +0200 Subject: [PATCH 343/486] Create conversation --- Linphone.xcodeproj/project.pbxproj | 32 +- Linphone/LinphoneApp.swift | 4 + Linphone/Localizable.xcstrings | 57 ++- .../Fragments/ContactsListFragment.swift | 1 + Linphone/UI/Main/ContentView.swift | 46 +- .../Conversations/ConversationsView.swift | 9 + .../Fragments/ConversationsListFragment.swift | 24 +- .../Fragments/StartConversationFragment.swift | 397 ++++++++++++++++++ .../StartConversationViewModel.swift | 320 ++++++++++++++ .../History/Fragments/StartCallFragment.swift | 22 +- Linphone/UI/Main/History/HistoryView.swift | 5 +- Linphone/Utils/LinphoneUtils.swift | 5 + 12 files changed, 873 insertions(+), 49 deletions(-) create mode 100644 Linphone/UI/Main/Conversations/Fragments/StartConversationFragment.swift create mode 100644 Linphone/UI/Main/Conversations/ViewModel/StartConversationViewModel.swift diff --git a/Linphone.xcodeproj/project.pbxproj b/Linphone.xcodeproj/project.pbxproj index 51b076f89..ce886bd33 100644 --- a/Linphone.xcodeproj/project.pbxproj +++ b/Linphone.xcodeproj/project.pbxproj @@ -109,6 +109,8 @@ 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 */; }; @@ -288,6 +290,8 @@ 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 = ""; }; @@ -813,6 +817,7 @@ children = ( D7CEE0372B7A214F00FD79B7 /* ConversationsListViewModel.swift */, D70A26EF2B7D02E6006CC8FC /* ConversationViewModel.swift */, + D759CB652C3FBE1D00AC35E8 /* StartConversationViewModel.swift */, ); path = ViewModel; sourceTree = ""; @@ -826,6 +831,7 @@ D70A26F12B7F5D95006CC8FC /* ConversationFragment.swift */, D71968912B86369D00DF4459 /* ChatBubbleView.swift */, D72A9A042B9750A1000DC093 /* UIList.swift */, + D759CB632C3FBD4200AC35E8 /* StartConversationFragment.swift */, ); path = Fragments; sourceTree = ""; @@ -1043,6 +1049,7 @@ D78E062A2BEA698E00CE3783 /* MediaEncryptedSheetBottomSheet.swift in Sources */, D7B5678E2B28888F00DE63EB /* CallView.swift in Sources */, D71A0E192B485ADF0002C6CD /* ViewExtension.swift in Sources */, + D759CB642C3FBD4200AC35E8 /* StartConversationFragment.swift in Sources */, D750D3392AD3E6EE00EC99C5 /* PopupLoadingView.swift in Sources */, D7E6D0492AE933AD00A57AAF /* FavoriteContactsListFragment.swift in Sources */, 662B69DB2B25DE25007118BF /* ProviderDelegate.swift in Sources */, @@ -1054,6 +1061,7 @@ 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 */, @@ -1193,6 +1201,7 @@ GCC_PREPROCESSOR_DEFINITIONS = ( "$(inherited)", "DEBUG=1", + "USE_CRASHLYTICS=1", ); GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = msgNotificationService/Info.plist; @@ -1206,7 +1215,7 @@ ); LOCALIZATION_PREFERS_STRING_CATALOGS = YES; MARKETING_VERSION = 1.0; - OTHER_SWIFT_FLAGS = "$(inherited)"; + OTHER_SWIFT_FLAGS = "$(inherited) -DUSE_CRASHLYTICS"; PRODUCT_BUNDLE_IDENTIFIER = org.linphone.phone.msgNotificationService; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -1232,7 +1241,10 @@ DEVELOPMENT_TEAM = Z2V957B3D6; ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; - GCC_PREPROCESSOR_DEFINITIONS = "$(inherited)"; + GCC_PREPROCESSOR_DEFINITIONS = ( + "$(inherited)", + "USE_CRASHLYTICS=1", + ); GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = msgNotificationService/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = msgNotificationService; @@ -1245,7 +1257,7 @@ ); LOCALIZATION_PREFERS_STRING_CATALOGS = YES; MARKETING_VERSION = 1.0; - OTHER_SWIFT_FLAGS = "$(inherited)"; + OTHER_SWIFT_FLAGS = "$(inherited) -DUSE_CRASHLYTICS"; PRODUCT_BUNDLE_IDENTIFIER = org.linphone.phone.msgNotificationService; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -1382,7 +1394,7 @@ ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = NO; CODE_SIGN_ENTITLEMENTS = Linphone/Linphone.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 10; + CURRENT_PROJECT_VERSION = 27; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_ASSET_PATHS = "\"Linphone/Preview Content\""; @@ -1392,6 +1404,7 @@ GCC_PREPROCESSOR_DEFINITIONS = ( "$(inherited)", "DEBUG=1", + "USE_CRASHLYTICS=1", ); GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = Linphone/Info.plist; @@ -1418,7 +1431,7 @@ "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; MACOSX_DEPLOYMENT_TARGET = 13.3; MARKETING_VERSION = 6.0.0; - OTHER_SWIFT_FLAGS = "$(inherited)"; + OTHER_SWIFT_FLAGS = "$(inherited) -DUSE_CRASHLYTICS"; PRODUCT_BUNDLE_IDENTIFIER = org.linphone.phone; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; @@ -1438,13 +1451,16 @@ ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = NO; CODE_SIGN_ENTITLEMENTS = Linphone/Linphone.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 10; + CURRENT_PROJECT_VERSION = 27; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_ASSET_PATHS = "\"Linphone/Preview Content\""; DEVELOPMENT_TEAM = Z2V957B3D6; ENABLE_HARDENED_RUNTIME = YES; ENABLE_PREVIEWS = YES; - GCC_PREPROCESSOR_DEFINITIONS = "$(inherited)"; + 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"; @@ -1470,7 +1486,7 @@ "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; MACOSX_DEPLOYMENT_TARGET = 13.3; MARKETING_VERSION = 6.0.0; - OTHER_SWIFT_FLAGS = "$(inherited)"; + OTHER_SWIFT_FLAGS = "$(inherited) -DUSE_CRASHLYTICS"; PRODUCT_BUNDLE_IDENTIFIER = org.linphone.phone; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; diff --git a/Linphone/LinphoneApp.swift b/Linphone/LinphoneApp.swift index a720905eb..c67122247 100644 --- a/Linphone/LinphoneApp.swift +++ b/Linphone/LinphoneApp.swift @@ -75,6 +75,7 @@ struct LinphoneApp: App { @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? @@ -106,6 +107,7 @@ struct LinphoneApp: App { && historyViewModel != nil && historyListViewModel != nil && startCallViewModel != nil + && startConversationViewModel != nil && callViewModel != nil && meetingWaitingRoomViewModel != nil && conversationsListViewModel != nil @@ -118,6 +120,7 @@ struct LinphoneApp: App { historyViewModel: historyViewModel!, historyListViewModel: historyListViewModel!, startCallViewModel: startCallViewModel!, + startConversationViewModel: startConversationViewModel!, callViewModel: callViewModel!, meetingWaitingRoomViewModel: meetingWaitingRoomViewModel!, conversationsListViewModel: conversationsListViewModel!, @@ -140,6 +143,7 @@ struct LinphoneApp: App { historyViewModel = HistoryViewModel() historyListViewModel = HistoryListViewModel() startCallViewModel = StartCallViewModel() + startConversationViewModel = StartConversationViewModel() callViewModel = CallViewModel() meetingWaitingRoomViewModel = MeetingWaitingRoomViewModel() conversationsListViewModel = ConversationsListViewModel() diff --git a/Linphone/Localizable.xcstrings b/Linphone/Localizable.xcstrings index 47e7b0f18..e2bafb067 100644 --- a/Linphone/Localizable.xcstrings +++ b/Linphone/Localizable.xcstrings @@ -1188,6 +1188,40 @@ } } }, + "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" : { @@ -1306,9 +1340,6 @@ }, "Joining..." : { - }, - "Key" : { - "extractionState" : "manual" }, "Last name" : { @@ -1389,6 +1420,23 @@ }, "New contact" : { + }, + "new_conversation_title" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "New conversation" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Créer une conversation" + } + } + } }, "Next" : { @@ -1541,9 +1589,6 @@ }, "Search contact" : { - }, - "Search contact or history call" : { - }, "Sécurisé" : { diff --git a/Linphone/UI/Main/Contacts/Fragments/ContactsListFragment.swift b/Linphone/UI/Main/Contacts/Fragments/ContactsListFragment.swift index 9c57fb39d..dec374cfc 100644 --- a/Linphone/UI/Main/Contacts/Fragments/ContactsListFragment.swift +++ b/Linphone/UI/Main/Contacts/Fragments/ContactsListFragment.swift @@ -87,6 +87,7 @@ struct ContactsListFragment: View { withAnimation { contactViewModel.indexDisplayedFriend = index } + if index < contactsManager.lastSearch.count && contactsManager.lastSearch[index].friend != nil && contactsManager.lastSearch[index].friend!.address != nil { startCallFunc(contactsManager.lastSearch[index].friend!.address!) } diff --git a/Linphone/UI/Main/ContentView.swift b/Linphone/UI/Main/ContentView.swift index f41f07872..307bae4d9 100644 --- a/Linphone/UI/Main/ContentView.swift +++ b/Linphone/UI/Main/ContentView.swift @@ -39,6 +39,7 @@ struct ContentView: View { @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 @@ -59,6 +60,7 @@ struct ContentView: View { @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 isShowSipAddressesPopup = false @@ -70,7 +72,7 @@ struct ContentView: View { var body: some View { let pub = NotificationCenter.default - .publisher(for: NSNotification.Name("ContactLoaded")) + .publisher(for: NSNotification.Name("ContactLoaded")) GeometryReader { geometry in VStack(spacing: 0) { @@ -103,9 +105,9 @@ struct ContentView: View { .frame(height: 30) .background(Color.greenSuccess500) .onTapGesture { - withAnimation { - telecomManager.callDisplayed = true - } + withAnimation { + telecomManager.callDisplayed = true + } } } @@ -542,7 +544,12 @@ struct ContentView: View { text: $text ) } else if self.index == 2 { - ConversationsView(conversationViewModel: conversationViewModel, conversationsListViewModel: conversationsListViewModel, text: $text) + ConversationsView( + conversationViewModel: conversationViewModel, + conversationsListViewModel: conversationsListViewModel, + text: $text, + isShowStartConversationFragment: $isShowStartConversationFragment + ) } else if self.index == 3 { MeetingsView( meetingsListViewModel: meetingsListViewModel, @@ -778,16 +785,16 @@ struct ContentView: View { } } else if self.index == 2 { ConversationFragment(conversationViewModel: conversationViewModel, conversationsListViewModel: conversationsListViewModel) - .frame(maxWidth: .infinity) - .background(Color.gray100) - .ignoresSafeArea(.keyboard) + .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) + .frame(maxWidth: .infinity) + .background(Color.gray100) + .ignoresSafeArea(.keyboard) } - + } .onAppear { if !(orientation == .landscapeLeft @@ -888,7 +895,7 @@ struct ContentView: View { DialerBottomSheet( startCallViewModel: startCallViewModel, callViewModel: callViewModel, - isShowStartCallFragment: $isShowStartCallFragment, + isShowStartCallFragment: $isShowStartCallFragment, showingDialer: $showingDialer, currentCall: nil ) @@ -896,6 +903,16 @@ struct ContentView: View { } } + 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( @@ -1082,7 +1099,7 @@ struct ContentView: View { .onReceive(pub) { _ in conversationsListViewModel.refreshContactAvatarModel() historyListViewModel.refreshHistoryAvatarModel() - } + } } .overlay { if isMenuOpen { @@ -1118,6 +1135,7 @@ struct ContentView: View { historyViewModel: HistoryViewModel(), historyListViewModel: HistoryListViewModel(), startCallViewModel: StartCallViewModel(), + startConversationViewModel: StartConversationViewModel(), callViewModel: CallViewModel(), meetingWaitingRoomViewModel: MeetingWaitingRoomViewModel(), conversationsListViewModel: ConversationsListViewModel(), diff --git a/Linphone/UI/Main/Conversations/ConversationsView.swift b/Linphone/UI/Main/Conversations/ConversationsView.swift index 0a72dd314..ea219b8a7 100644 --- a/Linphone/UI/Main/Conversations/ConversationsView.swift +++ b/Linphone/UI/Main/Conversations/ConversationsView.swift @@ -25,12 +25,21 @@ struct ConversationsView: View { @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("plus-circle") .renderingMode(.template) diff --git a/Linphone/UI/Main/Conversations/Fragments/ConversationsListFragment.swift b/Linphone/UI/Main/Conversations/Fragments/ConversationsListFragment.swift index 0e064b39a..3fe7b7f84 100644 --- a/Linphone/UI/Main/Conversations/Fragments/ConversationsListFragment.swift +++ b/Linphone/UI/Main/Conversations/Fragments/ConversationsListFragment.swift @@ -135,21 +135,25 @@ struct ConversationsListFragment: View { .listRowSeparator(.hidden) .background(.white) .onTapGesture { - if conversationViewModel.displayedConversation != nil { - conversationViewModel.displayedConversation = nil - conversationViewModel.resetMessage() - conversationViewModel.changeDisplayedChatRoom(conversationModel: conversationsListViewModel.conversationsList[index]) - - conversationViewModel.getMessages() - } else { - withAnimation { + if index < conversationsListViewModel.conversationsList.count { + if conversationViewModel.displayedConversation != nil { + conversationViewModel.displayedConversation = nil + conversationViewModel.resetMessage() conversationViewModel.changeDisplayedChatRoom(conversationModel: conversationsListViewModel.conversationsList[index]) + + conversationViewModel.getMessages() + } else { + withAnimation { + conversationViewModel.changeDisplayedChatRoom(conversationModel: conversationsListViewModel.conversationsList[index]) + } } } } .onLongPressGesture(minimumDuration: 0.2) { - conversationsListViewModel.selectedConversation = conversationsListViewModel.conversationsList[index] - showingSheet.toggle() + if index < conversationsListViewModel.conversationsList.count { + conversationsListViewModel.selectedConversation = conversationsListViewModel.conversationsList[index] + showingSheet.toggle() + } } } } diff --git a/Linphone/UI/Main/Conversations/Fragments/StartConversationFragment.swift b/Linphone/UI/Main/Conversations/Fragments/StartConversationFragment.swift new file mode 100644 index 000000000..1e4ee9e2a --- /dev/null +++ b/Linphone/UI/Main/Conversations/Fragments/StartConversationFragment.swift @@ -0,0 +1,397 @@ +/* + * 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 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.2) { + 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("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 + DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { + magicSearch.searchForContacts( + sourceFlags: MagicSearch.Source.Friends.rawValue | MagicSearch.Source.LdapServers.rawValue + ) + } + + startConversationViewModel.searchField = "" + magicSearch.currentFilterSuggestions = "" + delayColorDismiss() + + 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 { + isShowStartConversationFragment = false + + if startConversationViewModel.displayedConversation != nil { + if self.conversationViewModel.displayedConversation != nil { + self.conversationViewModel.displayedConversation = nil + self.conversationViewModel.resetMessage() + self.conversationViewModel.changeDisplayedChatRoom(conversationModel: startConversationViewModel.displayedConversation!) + + self.conversationViewModel.getMessages() + } else { + withAnimation { + self.conversationViewModel.changeDisplayedChatRoom(conversationModel: startConversationViewModel.displayedConversation!) + } + } + + startConversationViewModel.displayedConversation = nil + } + } + } + } + .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 linphonesw +import Combine + +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 chatRoomSuscriptions = Set() + + 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 + } + + operationInProgress = true + + let groupChatRoomSubject = subject + val params: ChatRoomParams = coreContext.core.createDefaultChatRoomParams() + params.isGroupEnabled = true + params.subject = groupChatRoomSubject + params.backend = ChatRoom.Backend.FlexisipChat + params.isEncryptionEnabled = true + + val participants = arrayListOf
() + for (participant in selection.value.orEmpty()) { + participants.add(participant.address) + } + val localAddress = account.params.identityAddress + + val participantsArray = arrayOf
() + val chatRoom = core.createChatRoom( + params, + localAddress, + participants.toArray(participantsArray) + ) + if (chatRoom != null) { + if (params.backend == ChatRoom.Backend.FlexisipChat) { + if (chatRoom.state == ChatRoom.State.Created) { + val id = LinphoneUtils.getChatRoomId(chatRoom) + Log.i( + "$TAG Group conversation [$id] ($groupChatRoomSubject) has been created" + ) + operationInProgress.postValue(false) + chatRoomCreatedEvent.postValue( + Event( + Pair( + chatRoom.localAddress.asStringUriOnly(), + chatRoom.peerAddress.asStringUriOnly() + ) + ) + ) + } else { + Log.i( + "$TAG Conversation [$groupChatRoomSubject] isn't in Created state yet, wait for it" + ) + chatRoom.addListener(chatRoomListener) + } + } else { + val id = LinphoneUtils.getChatRoomId(chatRoom) + Log.i("$TAG Conversation successfully created [$id] ($groupChatRoomSubject)") + operationInProgress.postValue(false) + chatRoomCreatedEvent.postValue( + Event( + Pair( + chatRoom.localAddress.asStringUriOnly(), + chatRoom.peerAddress.asStringUriOnly() + ) + ) + ) + } + } else { + Log.e("$TAG Failed to create group conversation [$groupChatRoomSubject]!") + operationInProgress.postValue(false) + chatRoomCreationErrorEvent.postValue( + Event(R.string.conversation_failed_to_create_toast) + ) + } + } + } + */ + + 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 self.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 !self.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 + } + /* + chatRoomCreationErrorEvent.postValue( + Event(R.string.conversation_invalid_participant_due_to_security_mode_toast) + ) + */ + 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") + + /* + chatRoomCreatedEvent.postValue( + Event( + Pair( + chatRoom.localAddress.asStringUriOnly(), + chatRoom.peerAddress.asStringUriOnly() + ) + ) + ) + */ + } 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) + DispatchQueue.main.async { + self.operationInProgress = false + self.displayedConversation = model + } + /* + chatRoomCreatedEvent.postValue( + Event( + Pair( + chatRoom.localAddress.asStringUriOnly(), + chatRoom.peerAddress.asStringUriOnly() + ) + ) + ) + */ + } + } 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!) + DispatchQueue.main.async { + self.operationInProgress = false + self.displayedConversation = model + } + + + /* + chatRoomCreatedEvent.postValue( + Event( + Pair( + existingChatRoom.localAddress.asStringUriOnly(), + existingChatRoom.peerAddress.asStringUriOnly() + ) + ) + ) + */ + } + } catch { + DispatchQueue.main.async { + self.operationInProgress = false + } + Log.error("\(StartConversationViewModel.TAG) Failed to create 1-1 conversation with \(remote.asStringUriOnly())!") + } + } + } + + func chatRoomAddDelegate(core: Core, chatRoom: ChatRoom) { + self.chatRoomSuscriptions.insert(chatRoom.publisher?.onConferenceJoined?.postOnCoreQueue { + (chatRoom: ChatRoom, eventLog: 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") + self.chatRoomSuscriptions.removeAll() + + DispatchQueue.main.async { + let model = ConversationModel(chatRoom: chatRoom) + self.operationInProgress = false + self.displayedConversation = model + } + + /* + chatRoomCreatedEvent.postValue( + Event( + Pair( + chatRoom.localAddress.asStringUriOnly(), + chatRoom.peerAddress.asStringUriOnly() + ) + ) + ) + */ + } else if state == ChatRoom.State.CreationFailed { + Log.error("\(StartConversationViewModel.TAG) Conversation \(id) creation has failed!") + self.chatRoomSuscriptions.removeAll() + DispatchQueue.main.async { + self.operationInProgress = false + } + /* + chatRoomCreationErrorEvent.postValue( + Event(R.string.conversation_failed_to_create_toast) + ) + */ + } + }) + } + + func isEndToEndEncryptionMandatory() -> Bool { + return false // TODO: Will be done later in SDK + } +} diff --git a/Linphone/UI/Main/History/Fragments/StartCallFragment.swift b/Linphone/UI/Main/History/Fragments/StartCallFragment.swift index 3721884d8..1f11924e0 100644 --- a/Linphone/UI/Main/History/Fragments/StartCallFragment.swift +++ b/Linphone/UI/Main/History/Fragments/StartCallFragment.swift @@ -81,7 +81,7 @@ struct StartCallFragment: View { } } - Text(!callViewModel.isTransferInsteadCall ? "New call" : "Transfer call to") + Text(!callViewModel.isTransferInsteadCall ? "history_call_start_title" : "Transfer call to") .multilineTextAlignment(.leading) .default_text_style_orange_800(styleSize: 16) @@ -96,7 +96,7 @@ struct StartCallFragment: View { VStack(spacing: 0) { ZStack(alignment: .trailing) { - TextField("Search contact or history call", text: $startCallViewModel.searchField) + TextField("history_call_start_search_bar_filter_hint", text: $startCallViewModel.searchField) .default_text_style(styleSize: 15) .frame(height: 25) .focused($isSearchFieldFocused) @@ -266,16 +266,18 @@ struct StartCallFragment: View { }) .padding(.horizontal, 16) - HStack(alignment: .center) { - Text("Suggestions") - .default_text_style_800(styleSize: 16) + if !contactsManager.lastSearchSuggestions.isEmpty { + HStack(alignment: .center) { + Text("Suggestions") + .default_text_style_800(styleSize: 16) + + Spacer() + } + .padding(.vertical, 10) + .padding(.horizontal, 16) - Spacer() + suggestionsList } - .padding(.vertical, 10) - .padding(.horizontal, 16) - - suggestionsList } } .frame(maxWidth: .infinity) diff --git a/Linphone/UI/Main/History/HistoryView.swift b/Linphone/UI/Main/History/HistoryView.swift index ee9f01b73..b60bbadba 100644 --- a/Linphone/UI/Main/History/HistoryView.swift +++ b/Linphone/UI/Main/History/HistoryView.swift @@ -47,9 +47,12 @@ struct HistoryView: View { Button { withAnimation { - MagicSearchSingleton.shared.searchForSuggestions() isShowStartCallFragment.toggle() } + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { + MagicSearchSingleton.shared.searchForSuggestions() + } } label: { Image("phone-plus") .renderingMode(.template) diff --git a/Linphone/Utils/LinphoneUtils.swift b/Linphone/Utils/LinphoneUtils.swift index 9b6a4e065..d959bbf0e 100644 --- a/Linphone/Utils/LinphoneUtils.swift +++ b/Linphone/Utils/LinphoneUtils.swift @@ -64,4 +64,9 @@ class LinphoneUtils: NSObject { 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 + } } From e0d5254648d244cf509d5f72722967377f854391 Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Fri, 12 Jul 2024 17:43:19 +0200 Subject: [PATCH 344/486] Create group conversation --- Linphone.xcodeproj/project.pbxproj | 24 +- .../conversation.imageset/Contents.json | 21 ++ .../conversation.imageset/conversation.svg | 8 + Linphone/Localizable.xcstrings | 85 +++++ .../Fragments/SipAddressesPopup.swift | 24 +- .../Conversations/ConversationsView.swift | 2 +- .../Fragments/StartConversationFragment.swift | 40 +-- .../StartGroupConversationFragment.swift | 36 ++ .../StartConversationViewModel.swift | 323 ++++++++++-------- Linphone/UI/Main/Fragments/ToastView.swift | 15 +- .../Fragments/StartGroupCallFragment.swift | 24 +- .../Utils/Extensions/DecodableExtension.swift | 34 +- .../Utils/Extensions/EncodableExtension.swift | 34 +- .../Extensions/UIApplicationExtension.swift | 34 +- Linphone/Utils/FileUtils.swift | 34 +- .../SingleSignOn/OIDAuthStateExtension.swift | 34 +- 16 files changed, 493 insertions(+), 279 deletions(-) create mode 100644 Linphone/Assets.xcassets/conversation.imageset/Contents.json create mode 100644 Linphone/Assets.xcassets/conversation.imageset/conversation.svg create mode 100644 Linphone/UI/Main/Conversations/Fragments/StartGroupConversationFragment.swift diff --git a/Linphone.xcodeproj/project.pbxproj b/Linphone.xcodeproj/project.pbxproj index ce886bd33..7915fe26b 100644 --- a/Linphone.xcodeproj/project.pbxproj +++ b/Linphone.xcodeproj/project.pbxproj @@ -128,6 +128,7 @@ 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 */; }; @@ -309,6 +310,7 @@ 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 = ""; }; @@ -832,6 +834,7 @@ D71968912B86369D00DF4459 /* ChatBubbleView.swift */, D72A9A042B9750A1000DC093 /* UIList.swift */, D759CB632C3FBD4200AC35E8 /* StartConversationFragment.swift */, + D7A0ACBA2C415D630043AE79 /* StartGroupConversationFragment.swift */, ); path = Fragments; sourceTree = ""; @@ -1115,6 +1118,7 @@ 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 */, D7E6ADF52B9876ED0009A2BC /* Attachment.swift in Sources */, @@ -1201,7 +1205,6 @@ GCC_PREPROCESSOR_DEFINITIONS = ( "$(inherited)", "DEBUG=1", - "USE_CRASHLYTICS=1", ); GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = msgNotificationService/Info.plist; @@ -1215,7 +1218,7 @@ ); LOCALIZATION_PREFERS_STRING_CATALOGS = YES; MARKETING_VERSION = 1.0; - OTHER_SWIFT_FLAGS = "$(inherited) -DUSE_CRASHLYTICS"; + OTHER_SWIFT_FLAGS = "$(inherited)"; PRODUCT_BUNDLE_IDENTIFIER = org.linphone.phone.msgNotificationService; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -1241,10 +1244,7 @@ DEVELOPMENT_TEAM = Z2V957B3D6; ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; - GCC_PREPROCESSOR_DEFINITIONS = ( - "$(inherited)", - "USE_CRASHLYTICS=1", - ); + GCC_PREPROCESSOR_DEFINITIONS = "$(inherited)"; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = msgNotificationService/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = msgNotificationService; @@ -1257,7 +1257,7 @@ ); LOCALIZATION_PREFERS_STRING_CATALOGS = YES; MARKETING_VERSION = 1.0; - OTHER_SWIFT_FLAGS = "$(inherited) -DUSE_CRASHLYTICS"; + OTHER_SWIFT_FLAGS = "$(inherited)"; PRODUCT_BUNDLE_IDENTIFIER = org.linphone.phone.msgNotificationService; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -1404,7 +1404,6 @@ GCC_PREPROCESSOR_DEFINITIONS = ( "$(inherited)", "DEBUG=1", - "USE_CRASHLYTICS=1", ); GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = Linphone/Info.plist; @@ -1431,7 +1430,7 @@ "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"; + OTHER_SWIFT_FLAGS = "$(inherited)"; PRODUCT_BUNDLE_IDENTIFIER = org.linphone.phone; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; @@ -1457,10 +1456,7 @@ DEVELOPMENT_TEAM = Z2V957B3D6; ENABLE_HARDENED_RUNTIME = YES; ENABLE_PREVIEWS = YES; - GCC_PREPROCESSOR_DEFINITIONS = ( - "$(inherited)", - "USE_CRASHLYTICS=1", - ); + GCC_PREPROCESSOR_DEFINITIONS = "$(inherited)"; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = Linphone/Info.plist; INFOPLIST_KEY_NSCameraUsageDescription = "Camera usage is required for video VOIP calls"; @@ -1486,7 +1482,7 @@ "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"; + OTHER_SWIFT_FLAGS = "$(inherited)"; PRODUCT_BUNDLE_IDENTIFIER = org.linphone.phone; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; 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/Localizable.xcstrings b/Linphone/Localizable.xcstrings index e2bafb067..d887ef0c0 100644 --- a/Linphone/Localizable.xcstrings +++ b/Linphone/Localizable.xcstrings @@ -886,6 +886,74 @@ } } }, + "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_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_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é." + } + } + } + }, "Conversations" : { }, @@ -1420,6 +1488,23 @@ }, "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", diff --git a/Linphone/UI/Main/Contacts/Fragments/SipAddressesPopup.swift b/Linphone/UI/Main/Contacts/Fragments/SipAddressesPopup.swift index 8eadcb687..ffd1df3cb 100644 --- a/Linphone/UI/Main/Contacts/Fragments/SipAddressesPopup.swift +++ b/Linphone/UI/Main/Contacts/Fragments/SipAddressesPopup.swift @@ -1,9 +1,21 @@ -// -// SipAddressesPopup.swift -// Linphone -// -// Created by Benoît Martins on 03/07/2024. -// +/* + * 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 diff --git a/Linphone/UI/Main/Conversations/ConversationsView.swift b/Linphone/UI/Main/Conversations/ConversationsView.swift index ea219b8a7..3918ed6c8 100644 --- a/Linphone/UI/Main/Conversations/ConversationsView.swift +++ b/Linphone/UI/Main/Conversations/ConversationsView.swift @@ -41,7 +41,7 @@ struct ConversationsView: View { MagicSearchSingleton.shared.searchForSuggestions() } } label: { - Image("plus-circle") + Image("conversation") .renderingMode(.template) .foregroundStyle(.white) .padding() diff --git a/Linphone/UI/Main/Conversations/Fragments/StartConversationFragment.swift b/Linphone/UI/Main/Conversations/Fragments/StartConversationFragment.swift index 1e4ee9e2a..25ae0ac87 100644 --- a/Linphone/UI/Main/Conversations/Fragments/StartConversationFragment.swift +++ b/Linphone/UI/Main/Conversations/Fragments/StartConversationFragment.swift @@ -136,7 +136,7 @@ struct StartConversationFragment: View { .padding(.horizontal) NavigationLink(destination: { - //StartGroupConversationFragment(startConversationViewModel: startConversationViewModel) + StartGroupConversationFragment(startConversationViewModel: startConversationViewModel) }, label: { HStack { HStack(alignment: .center) { @@ -150,9 +150,10 @@ struct StartConversationFragment: View { .background(Color.orangeMain500) .cornerRadius(40) - Text("history_call_start_create_group_call") + Text("new_conversation_create_group") .foregroundStyle(.black) .default_text_style_800(styleSize: 16) + .lineLimit(1) Spacer() @@ -184,16 +185,6 @@ struct StartConversationFragment: View { } ContactsListFragment(contactViewModel: ContactViewModel(), contactsListViewModel: ContactsListViewModel(), showingSheet: .constant(false), startCallFunc: { addr in - DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { - magicSearch.searchForContacts( - sourceFlags: MagicSearch.Source.Friends.rawValue | MagicSearch.Source.LdapServers.rawValue - ) - } - - startConversationViewModel.searchField = "" - magicSearch.currentFilterSuggestions = "" - delayColorDismiss() - withAnimation { startConversationViewModel.createOneToOneChatRoomWith(remote: addr) } @@ -232,6 +223,16 @@ struct StartConversationFragment: View { PopupLoadingView() .background(.black.opacity(0.65)) .onDisappear { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { + magicSearch.searchForContacts( + sourceFlags: MagicSearch.Source.Friends.rawValue | MagicSearch.Source.LdapServers.rawValue + ) + } + + startConversationViewModel.searchField = "" + magicSearch.currentFilterSuggestions = "" + delayColorDismiss() + isShowStartConversationFragment = false if startConversationViewModel.displayedConversation != nil { @@ -271,15 +272,6 @@ struct StartConversationFragment: View { 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/ViewModel/StartConversationViewModel.swift b/Linphone/UI/Main/Conversations/ViewModel/StartConversationViewModel.swift index 90f8754f5..5c60bb45c 100644 --- a/Linphone/UI/Main/Conversations/ViewModel/StartConversationViewModel.swift +++ b/Linphone/UI/Main/Conversations/ViewModel/StartConversationViewModel.swift @@ -61,83 +61,105 @@ class StartConversationViewModel: ObservableObject { participants = list } - /* func createGroupChatRoom() { coreContext.doOnCoreQueue { core in let account = core.defaultAccount - if (account == nil) { + if account == nil { Log.error( "\(StartConversationViewModel.TAG) No default account found, can't create group conversation!" ) return } - - operationInProgress = true - - let groupChatRoomSubject = subject - val params: ChatRoomParams = coreContext.core.createDefaultChatRoomParams() - params.isGroupEnabled = true - params.subject = groupChatRoomSubject - params.backend = ChatRoom.Backend.FlexisipChat - params.isEncryptionEnabled = true - - val participants = arrayListOf
() - for (participant in selection.value.orEmpty()) { - participants.add(participant.address) + + DispatchQueue.main.async { + self.operationInProgress = true } - val localAddress = account.params.identityAddress - - val participantsArray = arrayOf
() - val chatRoom = core.createChatRoom( - params, - localAddress, - participants.toArray(participantsArray) - ) - if (chatRoom != null) { - if (params.backend == ChatRoom.Backend.FlexisipChat) { - if (chatRoom.state == ChatRoom.State.Created) { - val id = LinphoneUtils.getChatRoomId(chatRoom) - Log.i( - "$TAG Group conversation [$id] ($groupChatRoomSubject) has been created" - ) - operationInProgress.postValue(false) - chatRoomCreatedEvent.postValue( - Event( - Pair( - chatRoom.localAddress.asStringUriOnly(), - chatRoom.peerAddress.asStringUriOnly() - ) - ) - ) - } else { - Log.i( - "$TAG Conversation [$groupChatRoomSubject] isn't in Created state yet, wait for it" - ) - chatRoom.addListener(chatRoomListener) - } - } else { - val id = LinphoneUtils.getChatRoomId(chatRoom) - Log.i("$TAG Conversation successfully created [$id] ($groupChatRoomSubject)") - operationInProgress.postValue(false) - chatRoomCreatedEvent.postValue( - Event( - Pair( - chatRoom.localAddress.asStringUriOnly(), - chatRoom.peerAddress.asStringUriOnly() - ) - ) - ) + + 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 } - } else { - Log.e("$TAG Failed to create group conversation [$groupChatRoomSubject]!") - operationInProgress.postValue(false) - chatRoomCreationErrorEvent.postValue( - Event(R.string.conversation_failed_to_create_toast) - ) } } } - */ func createOneToOneChatRoomWith(remote: Address) { coreContext.doOnCoreQueue { core in @@ -184,12 +206,9 @@ class StartConversationViewModel: ObservableObject { ) DispatchQueue.main.async { self.operationInProgress = false + ToastViewModel.shared.toastMessage = "Failed_to_create_conversation_invalid_participant_error" + ToastViewModel.shared.displayToast = true } - /* - chatRoomCreationErrorEvent.postValue( - Event(R.string.conversation_invalid_participant_due_to_security_mode_toast) - ) - */ return } @@ -207,16 +226,22 @@ class StartConversationViewModel: ObservableObject { let id = LinphoneUtils.getChatRoomId(room: chatRoom) Log.info("\(StartConversationViewModel.TAG) 1-1 conversation \(id) has been created") - /* - chatRoomCreatedEvent.postValue( - Event( - Pair( - chatRoom.localAddress.asStringUriOnly(), - chatRoom.peerAddress.asStringUriOnly() - ) - ) - ) - */ + 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) @@ -226,20 +251,21 @@ class StartConversationViewModel: ObservableObject { Log.info("\(StartConversationViewModel.TAG) Conversation successfully created \(id)") let model = ConversationModel(chatRoom: chatRoom) - DispatchQueue.main.async { - self.operationInProgress = false - self.displayedConversation = model + 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 + } } - /* - chatRoomCreatedEvent.postValue( - Event( - Pair( - chatRoom.localAddress.asStringUriOnly(), - chatRoom.peerAddress.asStringUriOnly() - ) - ) - ) - */ } } else { Log.warn( @@ -247,74 +273,87 @@ class StartConversationViewModel: ObservableObject { ) let model = ConversationModel(chatRoom: existingChatRoom!) - DispatchQueue.main.async { - self.operationInProgress = false - self.displayedConversation = model + 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 + } } - - - /* - chatRoomCreatedEvent.postValue( - Event( - Pair( - existingChatRoom.localAddress.asStringUriOnly(), - existingChatRoom.peerAddress.asStringUriOnly() - ) - ) - ) - */ } } 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.chatRoomSuscriptions.insert(chatRoom.publisher?.onConferenceJoined?.postOnCoreQueue { - (chatRoom: ChatRoom, eventLog: 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") - self.chatRoomSuscriptions.removeAll() - + func chatRoomAddDelegate(core: Core, chatRoom: ChatRoom) { + self.chatRoomSuscriptions.insert(chatRoom.publisher?.onConferenceJoined?.postOnCoreQueue { + (chatRoom: ChatRoom, eventLog: 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") + self.chatRoomSuscriptions.removeAll() + + let model = ConversationModel(chatRoom: chatRoom) + if self.operationInProgress == false { DispatchQueue.main.async { - let model = ConversationModel(chatRoom: chatRoom) + self.operationInProgress = true + } + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { self.operationInProgress = false self.displayedConversation = model } - - /* - chatRoomCreatedEvent.postValue( - Event( - Pair( - chatRoom.localAddress.asStringUriOnly(), - chatRoom.peerAddress.asStringUriOnly() - ) - ) - ) - */ - } else if state == ChatRoom.State.CreationFailed { - Log.error("\(StartConversationViewModel.TAG) Conversation \(id) creation has failed!") - self.chatRoomSuscriptions.removeAll() + } else { DispatchQueue.main.async { self.operationInProgress = false + self.displayedConversation = model } - /* - chatRoomCreationErrorEvent.postValue( - Event(R.string.conversation_failed_to_create_toast) - ) - */ } - }) - } + } else if state == ChatRoom.State.CreationFailed { + Log.error("\(StartConversationViewModel.TAG) Conversation \(id) creation has failed!") + self.chatRoomSuscriptions.removeAll() + DispatchQueue.main.async { + self.operationInProgress = false + ToastViewModel.shared.toastMessage = "Failed_to_create_conversation_error" + ToastViewModel.shared.displayToast = true + } + } + }) - func isEndToEndEncryptionMandatory() -> Bool { - return false // TODO: Will be done later in SDK - } + self.chatRoomSuscriptions.insert(chatRoom.publisher?.onStateChanged?.postOnCoreQueue { + (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!") + self.chatRoomSuscriptions.removeAll() + DispatchQueue.main.async { + self.operationInProgress = false + ToastViewModel.shared.toastMessage = "Failed_to_create_conversation_error" + ToastViewModel.shared.displayToast = true + } + } + }) + } + + func isEndToEndEncryptionMandatory() -> Bool { + return false // TODO: Will be done later in SDK + } } diff --git a/Linphone/UI/Main/Fragments/ToastView.swift b/Linphone/UI/Main/Fragments/ToastView.swift index 9ebbff919..d918e4140 100644 --- a/Linphone/UI/Main/Fragments/ToastView.swift +++ b/Linphone/UI/Main/Fragments/ToastView.swift @@ -206,7 +206,6 @@ struct ToastView: View { .default_text_style(styleSize: 15) .padding(8) - case "Failed_to_create_group_call_error": Text("conference_failed_to_create_group_call_toast") .multilineTextAlignment(.center) @@ -214,6 +213,20 @@ struct ToastView: View { .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) + .default_text_style(styleSize: 15) + .padding(8) + default: Text("Error") .multilineTextAlignment(.center) diff --git a/Linphone/UI/Main/History/Fragments/StartGroupCallFragment.swift b/Linphone/UI/Main/History/Fragments/StartGroupCallFragment.swift index 6e254127f..9e5386f4e 100644 --- a/Linphone/UI/Main/History/Fragments/StartGroupCallFragment.swift +++ b/Linphone/UI/Main/History/Fragments/StartGroupCallFragment.swift @@ -1,9 +1,21 @@ -// -// StartGroupCallFragment.swift -// Linphone -// -// Created by Benoît Martins on 24/06/2024. -// +/* + * 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 diff --git a/Linphone/Utils/Extensions/DecodableExtension.swift b/Linphone/Utils/Extensions/DecodableExtension.swift index 71ccb81c5..5b6b7c93e 100644 --- a/Linphone/Utils/Extensions/DecodableExtension.swift +++ b/Linphone/Utils/Extensions/DecodableExtension.swift @@ -1,21 +1,21 @@ /* -* Copyright (c) 2010-2020 Belledonne Communications SARL. -* -* This file is part of linhome -* -* This program is free software: you can redistribute it and/or modify -* it under the terms of the GNU General Public License as published by -* the Free Software Foundation, either version 3 of the License, or -* (at your option) any later version. -* -* This program is distributed in the hope that it will be useful, -* but WITHOUT ANY WARRANTY; without even the implied warranty of -* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -* GNU General Public License for more details. -* -* You should have received a copy of the GNU General Public License -* along with this program. If not, see . -*/ + * 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 diff --git a/Linphone/Utils/Extensions/EncodableExtension.swift b/Linphone/Utils/Extensions/EncodableExtension.swift index 8c0e75710..08eea64a4 100644 --- a/Linphone/Utils/Extensions/EncodableExtension.swift +++ b/Linphone/Utils/Extensions/EncodableExtension.swift @@ -1,21 +1,21 @@ /* -* Copyright (c) 2010-2020 Belledonne Communications SARL. -* -* This file is part of linhome -* -* This program is free software: you can redistribute it and/or modify -* it under the terms of the GNU General Public License as published by -* the Free Software Foundation, either version 3 of the License, or -* (at your option) any later version. -* -* This program is distributed in the hope that it will be useful, -* but WITHOUT ANY WARRANTY; without even the implied warranty of -* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -* GNU General Public License for more details. -* -* You should have received a copy of the GNU General Public License -* along with this program. If not, see . -*/ + * 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 diff --git a/Linphone/Utils/Extensions/UIApplicationExtension.swift b/Linphone/Utils/Extensions/UIApplicationExtension.swift index 6ed48ae78..158b25515 100644 --- a/Linphone/Utils/Extensions/UIApplicationExtension.swift +++ b/Linphone/Utils/Extensions/UIApplicationExtension.swift @@ -1,21 +1,21 @@ /* -* Copyright (c) 2010-2020 Belledonne Communications SARL. -* -* This file is part of linhome -* -* This program is free software: you can redistribute it and/or modify -* it under the terms of the GNU General Public License as published by -* the Free Software Foundation, either version 3 of the License, or -* (at your option) any later version. -* -* This program is distributed in the hope that it will be useful, -* but WITHOUT ANY WARRANTY; without even the implied warranty of -* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -* GNU General Public License for more details. -* -* You should have received a copy of the GNU General Public License -* along with this program. If not, see . -*/ + * 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 diff --git a/Linphone/Utils/FileUtils.swift b/Linphone/Utils/FileUtils.swift index 97dc886e1..a7f8323d8 100644 --- a/Linphone/Utils/FileUtils.swift +++ b/Linphone/Utils/FileUtils.swift @@ -1,21 +1,21 @@ /* -* Copyright (c) 2010-2023 Belledonne Communications SARL. -* -* This file is part of linhome -* -* This program is free software: you can redistribute it and/or modify -* it under the terms of the GNU General Public License as published by -* the Free Software Foundation, either version 3 of the License, or -* (at your option) any later version. -* -* This program is distributed in the hope that it will be useful, -* but WITHOUT ANY WARRANTY; without even the implied warranty of -* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -* GNU General Public License for more details. -* -* You should have received a copy of the GNU General Public License -* along with this program. If not, see . -*/ + * 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 diff --git a/Linphone/Utils/SingleSignOn/OIDAuthStateExtension.swift b/Linphone/Utils/SingleSignOn/OIDAuthStateExtension.swift index f9f541529..c68409782 100644 --- a/Linphone/Utils/SingleSignOn/OIDAuthStateExtension.swift +++ b/Linphone/Utils/SingleSignOn/OIDAuthStateExtension.swift @@ -1,21 +1,21 @@ /* -* Copyright (c) 2010-2020 Belledonne Communications SARL. -* -* This file is part of linhome -* -* This program is free software: you can redistribute it and/or modify -* it under the terms of the GNU General Public License as published by -* the Free Software Foundation, either version 3 of the License, or -* (at your option) any later version. -* -* This program is distributed in the hope that it will be useful, -* but WITHOUT ANY WARRANTY; without even the implied warranty of -* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -* GNU General Public License for more details. -* -* You should have received a copy of the GNU General Public License -* along with this program. If not, see . -*/ + * 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 From c78a77268ef09adb185f6c1c5a4f0c3d18c399f6 Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Mon, 15 Jul 2024 13:36:32 +0200 Subject: [PATCH 345/486] Access to conversation from the contact --- .../Contacts/Fragments/ContactFragment.swift | 14 +- .../Fragments/ContactInnerFragment.swift | 91 ++------ .../Fragments/SipAddressesPopup.swift | 23 +- .../Contacts/ViewModel/ContactViewModel.swift | 198 ++++++++++++++++++ Linphone/UI/Main/ContentView.swift | 27 ++- .../StartConversationViewModel.swift | 6 +- 6 files changed, 275 insertions(+), 84 deletions(-) diff --git a/Linphone/UI/Main/Contacts/Fragments/ContactFragment.swift b/Linphone/UI/Main/Contacts/Fragments/ContactFragment.swift index 0d7139532..bc127ce8b 100644 --- a/Linphone/UI/Main/Contacts/Fragments/ContactFragment.swift +++ b/Linphone/UI/Main/Contacts/Fragments/ContactFragment.swift @@ -26,10 +26,12 @@ struct ContactFragment: View { @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 @@ -42,12 +44,14 @@ struct ContactFragment: View { contactAvatarModel: ContactsManager.shared.avatarListModel[indexDisplayed], contactViewModel: contactViewModel, editContactViewModel: editContactViewModel, + conversationViewModel: conversationViewModel, cnContact: CNContact(), isShowDeletePopup: $isShowDeletePopup, showingSheet: $showingSheet, showShareSheet: $showShareSheet, isShowDismissPopup: $isShowDismissPopup, - isShowSipAddressesPopup: $isShowSipAddressesPopup + isShowSipAddressesPopup: $isShowSipAddressesPopup, + isShowSipAddressesPopupType: $isShowSipAddressesPopupType ) .sheet(isPresented: $showingSheet) { ContactListBottomSheet(contactViewModel: contactViewModel, showingSheet: $showingSheet) @@ -63,12 +67,14 @@ struct ContactFragment: View { contactAvatarModel: ContactsManager.shared.avatarListModel[indexDisplayed], contactViewModel: contactViewModel, editContactViewModel: editContactViewModel, + conversationViewModel: conversationViewModel, cnContact: CNContact(), isShowDeletePopup: $isShowDeletePopup, showingSheet: $showingSheet, showShareSheet: $showShareSheet, isShowDismissPopup: $isShowDismissPopup, - isShowSipAddressesPopup: $isShowSipAddressesPopup + isShowSipAddressesPopup: $isShowSipAddressesPopup, + isShowSipAddressesPopupType: $isShowSipAddressesPopupType ) .halfSheet(showSheet: $showingSheet) { ContactListBottomSheet(contactViewModel: contactViewModel, showingSheet: $showingSheet) @@ -86,8 +92,10 @@ struct ContactFragment: View { ContactFragment( contactViewModel: ContactViewModel(), editContactViewModel: EditContactViewModel(), + conversationViewModel: ConversationViewModel(), isShowDeletePopup: .constant(false), isShowDismissPopup: .constant(false), - isShowSipAddressesPopup: .constant(false) + isShowSipAddressesPopup: .constant(false), + isShowSipAddressesPopupType: .constant(0) ) } diff --git a/Linphone/UI/Main/Contacts/Fragments/ContactInnerFragment.swift b/Linphone/UI/Main/Contacts/Fragments/ContactInnerFragment.swift index 01104363c..c1b97c9e1 100644 --- a/Linphone/UI/Main/Contacts/Fragments/ContactInnerFragment.swift +++ b/Linphone/UI/Main/Contacts/Fragments/ContactInnerFragment.swift @@ -31,6 +31,7 @@ struct ContactInnerFragment: View { @ObservedObject var contactAvatarModel: ContactAvatarModel @ObservedObject var contactViewModel: ContactViewModel @ObservedObject var editContactViewModel: EditContactViewModel + @ObservedObject var conversationViewModel: ConversationViewModel @State private var orientation = UIDevice.current.orientation @@ -42,6 +43,7 @@ struct ContactInnerFragment: View { @Binding var showShareSheet: Bool @Binding var isShowDismissPopup: Bool @Binding var isShowSipAddressesPopup: Bool + @Binding var isShowSipAddressesPopupType: Int var body: some View { NavigationView { @@ -161,6 +163,7 @@ struct ContactInnerFragment: View { Log.error("[ContactInnerFragment] unable to create address for a new outgoing call : \(contactAvatarModel.address) \(error) ") } } else { + isShowSipAddressesPopupType = 0 isShowSipAddressesPopup = true } }, label: { @@ -184,21 +187,25 @@ struct ContactInnerFragment: View { 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) - .foregroundStyle(Color.grayMain2c300) + .foregroundStyle(Color.grayMain2c600) .frame(width: 25, height: 25) - .onTapGesture { - withAnimation { - - } - } } .padding(16) .background(Color.grayMain2c200) @@ -220,6 +227,7 @@ struct ContactInnerFragment: View { Log.error("[ContactInnerFragment] unable to create address for a new outgoing call : \(contactAvatarModel.address) \(error) ") } } else { + isShowSipAddressesPopupType = 2 isShowSipAddressesPopup = true } }, label: { @@ -296,69 +304,6 @@ struct ContactInnerFragment: View { print(error) } } - - var sipAddressesPopup: 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) - .background(.red) - .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..() + 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) { + self.chatRoomSuscriptions.insert(chatRoom.publisher?.onConferenceJoined?.postOnCoreQueue { + (chatRoom: ChatRoom, eventLog: 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") + self.chatRoomSuscriptions.removeAll() + + 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!") + self.chatRoomSuscriptions.removeAll() + DispatchQueue.main.async { + self.operationInProgress = false + ToastViewModel.shared.toastMessage = "Failed_to_create_conversation_error" + ToastViewModel.shared.displayToast = true + } + } + }) + + self.chatRoomSuscriptions.insert(chatRoom.publisher?.onStateChanged?.postOnCoreQueue { + (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!") + self.chatRoomSuscriptions.removeAll() + DispatchQueue.main.async { + self.operationInProgress = false + ToastViewModel.shared.toastMessage = "Failed_to_create_conversation_error" + ToastViewModel.shared.displayToast = true + } + } + }) + } } diff --git a/Linphone/UI/Main/ContentView.swift b/Linphone/UI/Main/ContentView.swift index 307bae4d9..95a09f43f 100644 --- a/Linphone/UI/Main/ContentView.swift +++ b/Linphone/UI/Main/ContentView.swift @@ -64,6 +64,7 @@ struct ContentView: View { @State var isShowDismissPopup = false @State var isShowSendCancelMeetingNotificationPopup = false @State var isShowSipAddressesPopup = false + @State var isShowSipAddressesPopupType = 0 //0 to call, 1 to message, 2 to video call @State var fullscreenVideo = false @@ -760,9 +761,11 @@ struct ContentView: View { ContactFragment( contactViewModel: contactViewModel, editContactViewModel: editContactViewModel, + conversationViewModel: conversationViewModel, isShowDeletePopup: $isShowDeleteContactPopup, isShowDismissPopup: $isShowDismissPopup, - isShowSipAddressesPopup: $isShowSipAddressesPopup + isShowSipAddressesPopup: $isShowSipAddressesPopup, + isShowSipAddressesPopupType: $isShowSipAddressesPopupType ) .frame(maxWidth: .infinity) .background(Color.gray100) @@ -1014,7 +1017,8 @@ struct ContentView: View { SipAddressesPopup( contactAvatarModel: ContactsManager.shared.avatarListModel[contactViewModel.indexDisplayedFriend != nil ? contactViewModel.indexDisplayedFriend! : 0], contactViewModel: contactViewModel, - isShowSipAddressesPopup: $isShowSipAddressesPopup + isShowSipAddressesPopup: $isShowSipAddressesPopup, + isShowSipAddressesPopupType: $isShowSipAddressesPopupType ) .background(.black.opacity(0.65)) .zIndex(3) @@ -1023,6 +1027,25 @@ struct ContentView: View { } } + if contactViewModel.operationInProgress { + PopupLoadingView() + .background(.black.opacity(0.65)) + .zIndex(3) + .onDisappear { + if contactViewModel.displayedConversation != nil { + contactViewModel.indexDisplayedFriend = 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, diff --git a/Linphone/UI/Main/Conversations/ViewModel/StartConversationViewModel.swift b/Linphone/UI/Main/Conversations/ViewModel/StartConversationViewModel.swift index 5c60bb45c..fec47193a 100644 --- a/Linphone/UI/Main/Conversations/ViewModel/StartConversationViewModel.swift +++ b/Linphone/UI/Main/Conversations/ViewModel/StartConversationViewModel.swift @@ -182,11 +182,11 @@ class StartConversationViewModel: ObservableObject { params.ephemeralLifetime = 0 // Make sure ephemeral is disabled by default let sameDomain = remote.domain == account?.params?.domain ?? "" - if self.isEndToEndEncryptionMandatory() && sameDomain { + 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 !self.isEndToEndEncryptionMandatory() { + } 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" @@ -353,7 +353,7 @@ class StartConversationViewModel: ObservableObject { }) } - func isEndToEndEncryptionMandatory() -> Bool { + public static func isEndToEndEncryptionMandatory() -> Bool { return false // TODO: Will be done later in SDK } } From 2e2dac680790a26fb5c6d7904f67de4e00c4130b Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Mon, 15 Jul 2024 14:00:44 +0200 Subject: [PATCH 346/486] Access to conversation from the history detail --- Linphone/UI/Main/ContentView.swift | 14 +- .../Fragments/HistoryContactFragment.swift | 10 +- .../History/ViewModel/HistoryViewModel.swift | 198 ++++++++++++++++++ 3 files changed, 212 insertions(+), 10 deletions(-) diff --git a/Linphone/UI/Main/ContentView.swift b/Linphone/UI/Main/ContentView.swift index 95a09f43f..028208ce9 100644 --- a/Linphone/UI/Main/ContentView.swift +++ b/Linphone/UI/Main/ContentView.swift @@ -1027,21 +1027,31 @@ struct ContentView: View { } } - if contactViewModel.operationInProgress { + if contactViewModel.operationInProgress || historyViewModel.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 + historyViewModel.displayedConversation = nil + } + } else if historyViewModel.displayedConversation != nil { + historyViewModel.displayedCall = nil + index = 2 + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + withAnimation { + self.conversationViewModel.changeDisplayedChatRoom(conversationModel: historyViewModel.displayedConversation!) + } + historyViewModel.displayedConversation = nil } - } } } diff --git a/Linphone/UI/Main/History/Fragments/HistoryContactFragment.swift b/Linphone/UI/Main/History/Fragments/HistoryContactFragment.swift index aef551046..2bbbac2ae 100644 --- a/Linphone/UI/Main/History/Fragments/HistoryContactFragment.swift +++ b/Linphone/UI/Main/History/Fragments/HistoryContactFragment.swift @@ -289,21 +289,15 @@ struct HistoryContactFragment: View { Spacer() Button(action: { - + contactViewModel.createOneToOneChatRoomWith(remote: historyViewModel.displayedCall!.addressLinphone) }, label: { VStack { HStack(alignment: .center) { Image("chat-teardrop-text") .renderingMode(.template) .resizable() - //.foregroundStyle(Color.grayMain2c600) - .foregroundStyle(Color.grayMain2c300) + .foregroundStyle(Color.grayMain2c600) .frame(width: 25, height: 25) - .onTapGesture { - withAnimation { - - } - } } .padding(16) .background(Color.grayMain2c200) diff --git a/Linphone/UI/Main/History/ViewModel/HistoryViewModel.swift b/Linphone/UI/Main/History/ViewModel/HistoryViewModel.swift index 95be5eb66..2114e113f 100644 --- a/Linphone/UI/Main/History/ViewModel/HistoryViewModel.swift +++ b/Linphone/UI/Main/History/ViewModel/HistoryViewModel.swift @@ -19,6 +19,7 @@ import Foundation import linphonesw +import Combine class HistoryViewModel: ObservableObject { @@ -26,5 +27,202 @@ class HistoryViewModel: ObservableObject { var selectedCall: HistoryModel? + @Published var operationInProgress: Bool = false + @Published var displayedConversation: ConversationModel? + + private var chatRoomSuscriptions = Set() + 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) { + self.chatRoomSuscriptions.insert(chatRoom.publisher?.onConferenceJoined?.postOnCoreQueue { + (chatRoom: ChatRoom, eventLog: 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") + self.chatRoomSuscriptions.removeAll() + + 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!") + self.chatRoomSuscriptions.removeAll() + DispatchQueue.main.async { + self.operationInProgress = false + ToastViewModel.shared.toastMessage = "Failed_to_create_conversation_error" + ToastViewModel.shared.displayToast = true + } + } + }) + + self.chatRoomSuscriptions.insert(chatRoom.publisher?.onStateChanged?.postOnCoreQueue { + (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!") + self.chatRoomSuscriptions.removeAll() + DispatchQueue.main.async { + self.operationInProgress = false + ToastViewModel.shared.toastMessage = "Failed_to_create_conversation_error" + ToastViewModel.shared.displayToast = true + } + } + }) + } } From 76d4a8cdb37059d3306b919958aba5e1919771b5 Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Mon, 15 Jul 2024 16:10:42 +0200 Subject: [PATCH 347/486] Access to conversation from a call --- Linphone/UI/Call/CallView.swift | 111 ++++++++-- .../UI/Call/ViewModel/CallViewModel.swift | 197 ++++++++++++++++++ Linphone/UI/Main/ContentView.swift | 11 +- .../Fragments/ConversationFragment.swift | 9 +- 4 files changed, 305 insertions(+), 23 deletions(-) diff --git a/Linphone/UI/Call/CallView.swift b/Linphone/UI/Call/CallView.swift index 4df149dc0..a5895da25 100644 --- a/Linphone/UI/Call/CallView.swift +++ b/Linphone/UI/Call/CallView.swift @@ -33,6 +33,8 @@ struct CallView: View { @ObservedObject private var contactsManager = ContactsManager.shared @ObservedObject var callViewModel: CallViewModel + @ObservedObject var conversationViewModel: ConversationViewModel + @ObservedObject var conversationsListViewModel: ConversationsListViewModel @State private var addParticipantsViewModel: AddParticipantsViewModel? @@ -60,6 +62,7 @@ struct CallView: View { @State var isShowCallsListFragment: Bool = false @State var isShowParticipantsListFragment: Bool = false @Binding var isShowStartCallFragment: Bool + @Binding var isShowConversationFragment: Bool @State var buttonSize = 60.0 @@ -187,6 +190,19 @@ struct CallView: View { } } + if isShowConversationFragment && conversationViewModel.displayedConversation != nil { + ConversationFragment(conversationViewModel: conversationViewModel, conversationsListViewModel: conversationsListViewModel, isShowConversationFragment: $isShowConversationFragment) + .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 @@ -2156,20 +2172,49 @@ struct CallView: View { HStack(spacing: 0) { VStack { Button { + if callViewModel.isOneOneCall && callViewModel.remoteAddress != nil { + callViewModel.createOneToOneChatRoomWith(remote: callViewModel.remoteAddress!) + } } label: { HStack { - Image("chat-teardrop-text") - .renderingMode(.template) - .resizable() - .foregroundStyle(Color.gray500) - .frame(width: 32, height: 32) + 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 { + if conversationViewModel.displayedConversation != nil { + conversationViewModel.displayedConversation = nil + conversationViewModel.resetMessage() + conversationViewModel.changeDisplayedChatRoom(conversationModel: callViewModel.displayedConversation!) + + conversationViewModel.getMessages() + withAnimation { + isShowConversationFragment = true + } + } else { + conversationViewModel.changeDisplayedChatRoom(conversationModel: callViewModel.displayedConversation!) + withAnimation { + isShowConversationFragment = true + } + } + } + } + } } } .buttonStyle(PressedButtonStyle(buttonSize: buttonSize)) .frame(width: buttonSize, height: buttonSize) - .background(.white) + .background(callViewModel.isOneOneCall ? Color.gray500 : .white) .cornerRadius(40) - .disabled(true) + .disabled(!callViewModel.isOneOneCall) Text("Messages") .foregroundStyle(.white) @@ -2510,20 +2555,49 @@ struct CallView: View { VStack { Button { + if callViewModel.isOneOneCall && callViewModel.remoteAddress != nil { + callViewModel.createOneToOneChatRoomWith(remote: callViewModel.remoteAddress!) + } } label: { HStack { - Image("chat-teardrop-text") - .renderingMode(.template) - .resizable() - .foregroundStyle(Color.gray500) - .frame(width: 32, height: 32) + 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 { + if conversationViewModel.displayedConversation != nil { + conversationViewModel.displayedConversation = nil + conversationViewModel.resetMessage() + conversationViewModel.changeDisplayedChatRoom(conversationModel: callViewModel.displayedConversation!) + + conversationViewModel.getMessages() + withAnimation { + isShowConversationFragment = true + } + } else { + conversationViewModel.changeDisplayedChatRoom(conversationModel: callViewModel.displayedConversation!) + withAnimation { + isShowConversationFragment = true + } + } + } + } + } } } .buttonStyle(PressedButtonStyle(buttonSize: buttonSize)) .frame(width: buttonSize, height: buttonSize) - .background(.white) + .background(callViewModel.isOneOneCall ? Color.gray500 : .white) .cornerRadius(40) - .disabled(true) + .disabled(!callViewModel.isOneOneCall) Text("Messages") .foregroundStyle(.white) @@ -2725,7 +2799,14 @@ struct PressedButtonStyle: ButtonStyle { } #Preview { - CallView(callViewModel: CallViewModel(), fullscreenVideo: .constant(false), isShowStartCallFragment: .constant(false)) + CallView( + callViewModel: CallViewModel(), + conversationViewModel: ConversationViewModel(), + conversationsListViewModel: ConversationsListViewModel(), + fullscreenVideo: .constant(false), + isShowStartCallFragment: .constant(false), + isShowConversationFragment: .constant(false) + ) } // swiftlint:enable type_body_length // swiftlint:enable line_length diff --git a/Linphone/UI/Call/ViewModel/CallViewModel.swift b/Linphone/UI/Call/ViewModel/CallViewModel.swift index 6782337b0..50e57d7a1 100644 --- a/Linphone/UI/Call/ViewModel/CallViewModel.swift +++ b/Linphone/UI/Call/ViewModel/CallViewModel.swift @@ -81,6 +81,11 @@ class CallViewModel: ObservableObject { @Published var letters3: String = "CC" @Published var letters4: String = "DD" + @Published var operationInProgress: Bool = false + @Published var displayedConversation: ConversationModel? + + private var chatRoomSuscriptions = Set() + init() { do { try AVAudioSession.sharedInstance().setCategory(.playAndRecord, mode: .voiceChat, options: .allowBluetooth) @@ -1163,5 +1168,197 @@ class CallViewModel: ObservableObject { 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.chatRoomSuscriptions.insert(chatRoom.publisher?.onConferenceJoined?.postOnCoreQueue { + (chatRoom: ChatRoom, eventLog: 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") + self.chatRoomSuscriptions.removeAll() + + 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!") + self.chatRoomSuscriptions.removeAll() + DispatchQueue.main.async { + self.operationInProgress = false + ToastViewModel.shared.toastMessage = "Failed_to_create_conversation_error" + ToastViewModel.shared.displayToast = true + } + } + }) + + self.chatRoomSuscriptions.insert(chatRoom.publisher?.onStateChanged?.postOnCoreQueue { + (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!") + self.chatRoomSuscriptions.removeAll() + DispatchQueue.main.async { + self.operationInProgress = false + ToastViewModel.shared.toastMessage = "Failed_to_create_conversation_error" + ToastViewModel.shared.displayToast = true + } + } + }) + } } // swiftlint:enable type_body_length diff --git a/Linphone/UI/Main/ContentView.swift b/Linphone/UI/Main/ContentView.swift index 028208ce9..c5ccb47f8 100644 --- a/Linphone/UI/Main/ContentView.swift +++ b/Linphone/UI/Main/ContentView.swift @@ -65,6 +65,7 @@ struct ContentView: View { @State var isShowSendCancelMeetingNotificationPopup = 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 @@ -77,7 +78,7 @@ struct ContentView: View { GeometryReader { geometry in VStack(spacing: 0) { - if telecomManager.callInProgress && !fullscreenVideo && ((!telecomManager.callDisplayed && callViewModel.callsCounter == 1) || callViewModel.callsCounter > 1) { + if (telecomManager.callInProgress && !fullscreenVideo && ((!telecomManager.callDisplayed && callViewModel.callsCounter == 1) || callViewModel.callsCounter > 1)) || isShowConversationFragment { HStack { Image("phone") .renderingMode(.template) @@ -771,7 +772,7 @@ struct ContentView: View { .background(Color.gray100) .ignoresSafeArea(.keyboard) } else if self.index == 1 { - if historyViewModel.displayedCall!.avatarModel != nil { + if historyViewModel.displayedCall != nil && historyViewModel.displayedCall!.avatarModel != nil { HistoryContactFragment( contactAvatarModel: historyViewModel.displayedCall!.avatarModel!, historyViewModel: historyViewModel, @@ -787,7 +788,7 @@ struct ContentView: View { .ignoresSafeArea(.keyboard) } } else if self.index == 2 { - ConversationFragment(conversationViewModel: conversationViewModel, conversationsListViewModel: conversationsListViewModel) + ConversationFragment(conversationViewModel: conversationViewModel, conversationsListViewModel: conversationsListViewModel, isShowConversationFragment: $isShowConversationFragment) .frame(maxWidth: .infinity) .background(Color.gray100) .ignoresSafeArea(.keyboard) @@ -1034,14 +1035,12 @@ struct ContentView: View { .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 - historyViewModel.displayedConversation = nil } } else if historyViewModel.displayedConversation != nil { historyViewModel.displayedCall = nil @@ -1105,7 +1104,7 @@ struct ContentView: View { } if telecomManager.callDisplayed && ((telecomManager.callInProgress && telecomManager.outgoingCallStarted) || telecomManager.callConnected) && !telecomManager.meetingWaitingRoomDisplayed { - CallView(callViewModel: callViewModel, fullscreenVideo: $fullscreenVideo, isShowStartCallFragment: $isShowStartCallFragment) + CallView(callViewModel: callViewModel, conversationViewModel: conversationViewModel, conversationsListViewModel: conversationsListViewModel, fullscreenVideo: $fullscreenVideo, isShowStartCallFragment: $isShowStartCallFragment, isShowConversationFragment: $isShowConversationFragment) .zIndex(5) .transition(.scale.combined(with: .move(edge: .top))) .onAppear { diff --git a/Linphone/UI/Main/Conversations/Fragments/ConversationFragment.swift b/Linphone/UI/Main/Conversations/Fragments/ConversationFragment.swift index 1180f7a6a..a8dcf144a 100644 --- a/Linphone/UI/Main/Conversations/Fragments/ConversationFragment.swift +++ b/Linphone/UI/Main/Conversations/Fragments/ConversationFragment.swift @@ -49,6 +49,8 @@ struct ConversationFragment: View { @State private var mediasIsLoading = false + @Binding var isShowConversationFragment: Bool + var body: some View { NavigationView { GeometryReader { geometry in @@ -60,8 +62,8 @@ struct ConversationFragment: View { .frame(height: 0) HStack { - if !(orientation == .landscapeLeft || orientation == .landscapeRight - || UIScreen.main.bounds.size.width > UIScreen.main.bounds.size.height) { + if (!(orientation == .landscapeLeft || orientation == .landscapeRight + || UIScreen.main.bounds.size.width > UIScreen.main.bounds.size.height)) || isShowConversationFragment { Image("caret-left") .renderingMode(.template) .resizable() @@ -72,6 +74,9 @@ struct ConversationFragment: View { .padding(.leading, -10) .onTapGesture { withAnimation { + if isShowConversationFragment { + isShowConversationFragment = false + } conversationViewModel.displayedConversation = nil } } From 69c3648c1523624faa21e897d38ac2e42282a9e5 Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Mon, 15 Jul 2024 16:39:18 +0200 Subject: [PATCH 348/486] Fix first message sent in chatroom --- .../Main/Conversations/Fragments/ConversationFragment.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Linphone/UI/Main/Conversations/Fragments/ConversationFragment.swift b/Linphone/UI/Main/Conversations/Fragments/ConversationFragment.swift index a8dcf144a..13130fa90 100644 --- a/Linphone/UI/Main/Conversations/Fragments/ConversationFragment.swift +++ b/Linphone/UI/Main/Conversations/Fragments/ConversationFragment.swift @@ -496,7 +496,9 @@ struct ConversationFragment: View { } } else { Button { - NotificationCenter.default.post(name: .onScrollToBottom, object: nil) + if conversationViewModel.displayedConversationHistorySize > 0 { + NotificationCenter.default.post(name: .onScrollToBottom, object: nil) + } conversationViewModel.sendMessage() } label: { Image("paper-plane-tilt") From 00eb9c4f7cf415de82e2d18c6102c279bdeebd73 Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Tue, 16 Jul 2024 11:13:27 +0200 Subject: [PATCH 349/486] Fix start conversation view in landscape --- .../Conversations/Fragments/StartConversationFragment.swift | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Linphone/UI/Main/Conversations/Fragments/StartConversationFragment.swift b/Linphone/UI/Main/Conversations/Fragments/StartConversationFragment.swift index 25ae0ac87..30db791b5 100644 --- a/Linphone/UI/Main/Conversations/Fragments/StartConversationFragment.swift +++ b/Linphone/UI/Main/Conversations/Fragments/StartConversationFragment.swift @@ -253,8 +253,10 @@ struct StartConversationFragment: View { } } } + .navigationTitle("") .navigationBarHidden(true) } + .navigationViewStyle(StackNavigationViewStyle()) } @Sendable private func delayColor() async { From 39363f109604e7e73937ed96f2c002641b014f1e Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Tue, 16 Jul 2024 14:17:31 +0200 Subject: [PATCH 350/486] Fix downloaded image crash and display error image instead --- Linphone/Core/CoreContext.swift | 1 + .../ViewModel/ConversationViewModel.swift | 41 +++++++++++++++++-- 2 files changed, 38 insertions(+), 4 deletions(-) diff --git a/Linphone/Core/CoreContext.swift b/Linphone/Core/CoreContext.swift index 5e749d05c..120aeab2b 100644 --- a/Linphone/Core/CoreContext.swift +++ b/Linphone/Core/CoreContext.swift @@ -125,6 +125,7 @@ final class CoreContext: ObservableObject { 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.mCoreSuscriptions.insert(self.mCore.publisher?.onGlobalStateChanged?.postOnCoreQueue { (cbVal: (core: Core, state: GlobalState, message: String)) in diff --git a/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift b/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift index 8e416ccbe..e59068dc7 100644 --- a/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift +++ b/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift @@ -135,7 +135,18 @@ class ConversationViewModel: ObservableObject { 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) + //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, + url: path!, + type: .image + ) + attachmentList.append(attachment) + } } else { if content.type != "video" { let path = URL(string: self.getNewFilePath(name: content.name ?? "")) @@ -238,7 +249,18 @@ class ConversationViewModel: ObservableObject { 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) + //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, + url: path!, + type: .image + ) + attachmentList.append(attachment) + } } else { if content.type != "video" { let path = URL(string: self.getNewFilePath(name: content.name ?? "")) @@ -339,7 +361,18 @@ class ConversationViewModel: ObservableObject { contentText = content.utf8Text ?? "" } else { if content.filePath == nil || content.filePath!.isEmpty { - self.downloadContent(chatMessage: eventLog.chatMessage!, content: content) + //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, + url: path!, + type: .image + ) + attachmentList.append(attachment) + } } else if content.name != nil && !content.name!.isEmpty { if content.type != "video" { let path = URL(string: self.getNewFilePath(name: content.name ?? "")) @@ -601,7 +634,7 @@ class ConversationViewModel: ObservableObject { func downloadContent(chatMessage: ChatMessage, content: Content) { //Log.debug("[ConversationViewModel] Starting downloading content for file \(model.fileName)") - if content.filePath == nil || content.filePath!.isEmpty { + if !chatMessage.isFileTransferInProgress && (content.filePath == nil || content.filePath!.isEmpty) { let contentName = content.name if contentName != nil { let isImage = FileUtil.isExtensionImage(path: contentName!) From 46673014648b2988d44ab68f0ad718d9731f95b2 Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Wed, 17 Jul 2024 17:49:06 +0200 Subject: [PATCH 351/486] Add a click listener to the message to display the emoji selector and message functions --- Linphone.xcodeproj/project.pbxproj | 4 + .../forward.imageset/Contents.json | 21 + .../forward.imageset/forward.svg | 5 + .../plus-circle.imageset/plus-circle.svg | 2 +- .../reply.imageset/Contents.json | 21 + .../Assets.xcassets/reply.imageset/reply.svg | 5 + Linphone/Localizable.xcstrings | 100 ++ .../Fragments/ChatBubbleView.swift | 116 ++- .../Fragments/ConversationFragment.swift | 970 +++++++++++------- .../Fragments/ConversationsListFragment.swift | 2 + .../ViewModel/ConversationViewModel.swift | 2 + Linphone/Utils/TouchFeedback.swift | 30 + 12 files changed, 846 insertions(+), 432 deletions(-) create mode 100644 Linphone/Assets.xcassets/forward.imageset/Contents.json create mode 100644 Linphone/Assets.xcassets/forward.imageset/forward.svg create mode 100644 Linphone/Assets.xcassets/reply.imageset/Contents.json create mode 100644 Linphone/Assets.xcassets/reply.imageset/reply.svg create mode 100644 Linphone/Utils/TouchFeedback.swift diff --git a/Linphone.xcodeproj/project.pbxproj b/Linphone.xcodeproj/project.pbxproj index 7915fe26b..f46abb965 100644 --- a/Linphone.xcodeproj/project.pbxproj +++ b/Linphone.xcodeproj/project.pbxproj @@ -125,6 +125,7 @@ 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 */; }; @@ -307,6 +308,7 @@ 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 = ""; }; @@ -494,6 +496,7 @@ D7173EBD2B7A5C0A00BCC481 /* LinphoneUtils.swift */, C67586AF2C09F247002E77BF /* URIHandler.swift */, C6A5A9462C10B64A0070FEA4 /* SingleSignOn */, + D79F2D092C47F4BF0038FA07 /* TouchFeedback.swift */, ); path = Utils; sourceTree = ""; @@ -1114,6 +1117,7 @@ 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 */, 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/plus-circle.imageset/plus-circle.svg b/Linphone/Assets.xcassets/plus-circle.imageset/plus-circle.svg index 051b34cda..0365c1a4e 100644 --- a/Linphone/Assets.xcassets/plus-circle.imageset/plus-circle.svg +++ b/Linphone/Assets.xcassets/plus-circle.imageset/plus-circle.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file 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/Localizable.xcstrings b/Linphone/Localizable.xcstrings index d887ef0c0..9942036c4 100644 --- a/Linphone/Localizable.xcstrings +++ b/Linphone/Localizable.xcstrings @@ -132,6 +132,21 @@ }, "|" : { + }, + "❤️" : { + + }, + "👍" : { + + }, + "😂" : { + + }, + "😢" : { + + }, + "😮" : { + }, "0" : { @@ -1468,6 +1483,91 @@ } } }, + "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" : { }, diff --git a/Linphone/UI/Main/Conversations/Fragments/ChatBubbleView.swift b/Linphone/UI/Main/Conversations/Fragments/ChatBubbleView.swift index 38d796517..6f691cd00 100644 --- a/Linphone/UI/Main/Conversations/Fragments/ChatBubbleView.swift +++ b/Linphone/UI/Main/Conversations/Fragments/ChatBubbleView.swift @@ -28,10 +28,14 @@ struct ChatBubbleView: View { let geometryProxy: GeometryProxy + @State private var ticker = Ticker() + @State private var isPressed: Bool = false + @State private var timePassed: TimeInterval? + var body: some View { VStack { if !message.text.isEmpty || !message.attachments.isEmpty { - HStack { + HStack(alignment: .top, content: { if message.isOutgoing { Spacer() } @@ -44,19 +48,11 @@ struct ChatBubbleView: View { avatarSize: 35 ) .padding(.top, 30) - - Spacer() } } else if conversationViewModel.displayedConversation != nil && conversationViewModel.displayedConversation!.isGroup && !message.isOutgoing { VStack { - Avatar( - contactAvatarModel: ContactAvatarModel(friend: nil, name: "??", address: "", withPresence: false), - avatarSize: 35 - ) - - Spacer() } - .hidden() + .padding(.leading, 43) } VStack(alignment: .leading, spacing: 0) { @@ -67,33 +63,6 @@ struct ChatBubbleView: View { .padding(.bottom, 2) } ZStack { - if conversationViewModel.displayedConversation != nil && conversationViewModel.displayedConversation!.isGroup && message.isFirstMessage { - VStack { - if message.isOutgoing { - Spacer() - } - - HStack { - if message.isOutgoing { - Spacer() - } - - VStack { - } - .frame(width: 15, height: 15) - .background(message.isOutgoing ? Color.orangeMain100 : Color.grayMain2c100) - .clipShape(RoundedRectangle(cornerRadius: 2)) - - if !message.isOutgoing { - Spacer() - } - } - - if !message.isOutgoing { - Spacer() - } - } - } HStack { if message.isOutgoing { @@ -137,7 +106,11 @@ struct ChatBubbleView: View { } .padding(.all, 15) .background(message.isOutgoing ? Color.orangeMain100 : Color.grayMain2c100) - .clipShape(RoundedRectangle(cornerRadius: 16)) + .clipShape(RoundedRectangle(cornerRadius: 3)) + .roundedCorner( + 16, + corners: message.isOutgoing && message.isFirstMessage ? [.topLeft, .topRight, .bottomLeft] : + (!message.isOutgoing && message.isFirstMessage ? [.topRight, .bottomRight, .bottomLeft] : [.allCorners])) if !message.isOutgoing { Spacer() @@ -150,11 +123,33 @@ struct ChatBubbleView: View { if !message.isOutgoing { Spacer() } - } + }) .padding(.leading, message.isOutgoing ? 40 : 0) .padding(.trailing, !message.isOutgoing ? 40 : 0) } } + .onTapGesture {} + .onLongPressGesture(minimumDuration: .infinity, maximumDistance: .infinity, pressing: { (value) in + self.isPressed = value + if value == true { + self.timePassed = 0 + self.ticker.start(interval: 0.2) + } + + }, perform: {}) + .onReceive(ticker.objectWillChange) { (_) in + // Stop timer and reset the start date if the button in not pressed + guard self.isPressed else { + self.ticker.stop() + return + } + + self.timePassed = self.ticker.timeIntervalSinceStarted + withAnimation { + conversationViewModel.selectedMessage = message + } + + } } @ViewBuilder @@ -375,6 +370,49 @@ struct GifImageView: UIViewRepresentable { } } +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) ) + } +} + /* #Preview { ChatBubbleView(conversationViewModel: ConversationViewModel(), index: 0) diff --git a/Linphone/UI/Main/Conversations/Fragments/ConversationFragment.swift b/Linphone/UI/Main/Conversations/Fragments/ConversationFragment.swift index 13130fa90..e1ba2d369 100644 --- a/Linphone/UI/Main/Conversations/Fragments/ConversationFragment.swift +++ b/Linphone/UI/Main/Conversations/Fragments/ConversationFragment.swift @@ -19,6 +19,7 @@ import SwiftUI +// swiftlint:disable type_body_length struct ConversationFragment: View { @State private var orientation = UIDevice.current.orientation @@ -54,222 +55,130 @@ struct ConversationFragment: View { var body: some View { NavigationView { GeometryReader { geometry in - 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 + 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 } - 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 { - } 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("See contact") - Spacer() - Image("user-circle") - .resizable() - .frame(width: 25, height: 25, alignment: .leading) - .padding(.all, 10) - } } + 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 { - isMenuOpen = false } label: { - HStack { - Text("Copy SIP address") - Spacer() - Image("copy") - .resizable() - .frame(width: 25, height: 25, alignment: .leading) - .padding(.all, 10) - } + Image("phone") + .renderingMode(.template) + .resizable() + .foregroundStyle(Color.grayMain2c500) + .frame(width: 25, height: 25, alignment: .leading) + .padding(.all, 10) + .padding(.top, 4) } - Button(role: .destructive) { - isMenuOpen = false - } 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.grayMain2c500) - .frame(width: 25, height: 25, alignment: .leading) - .padding(.all, 10) - .padding(.top, 4) - } - .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, - isScrolledToBottom: $isScrolledToBottom, - showMessageMenuOnLongPress: showMessageMenuOnLongPress, - geometryProxy: geometry, - sections: conversationViewModel.conversationMessagesSection - ) - - if !isScrolledToBottom { + Menu { Button { - NotificationCenter.default.post(name: .onScrollToBottom, object: nil) + isMenuOpen = false } 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() - } - } + HStack { + Text("See contact") + Spacer() + Image("user-circle") + .resizable() + .frame(width: 25, height: 25, alignment: .leading) + .padding(.all, 10) } - } - .frame(width: 50, height: 50) - .padding() + + Button { + isMenuOpen = false + } 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 + } 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.grayMain2c500) + .frame(width: 25, height: 25, alignment: .leading) + .padding(.all, 10) + .padding(.top, 4) + } + .onTapGesture { + isMenuOpen = true } } - .onTapGesture { - UIApplication.shared.endEditing() - } - .onAppear { - conversationViewModel.getMessages() - } - .onDisappear { - conversationViewModel.resetMessage() - } - } else { - ScrollViewReader { proxy in + .frame(maxWidth: .infinity) + .frame(height: 50) + .padding(.horizontal) + .padding(.bottom, 4) + .background(.white) + + if #available(iOS 16.0, *) { 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.2) { - conversationViewModel.getOldMessages() - } - } - - if index == 0 { - displayFloatingButton = false - } - } - .onDisappear { - if index == 0 { - displayFloatingButton = true - } - } - } - } - } - .scaleEffect(x: 1, y: -1, anchor: .center) - .listStyle(.plain) + UIList(viewModel: viewModel, + paginationState: paginationState, + conversationViewModel: conversationViewModel, + conversationsListViewModel: conversationsListViewModel, + isScrolledToBottom: $isScrolledToBottom, + showMessageMenuOnLongPress: showMessageMenuOnLongPress, + geometryProxy: geometry, + sections: conversationViewModel.conversationMessagesSection + ) - if displayFloatingButton { + if !isScrolledToBottom { Button { - if conversationViewModel.conversationMessagesSection.first != nil && conversationViewModel.conversationMessagesSection.first!.rows.first != nil { - withAnimation { - proxy.scrollTo(conversationViewModel.conversationMessagesSection.first!.rows.first!.id) - } - } + NotificationCenter.default.post(name: .onScrollToBottom, object: nil) } label: { ZStack { @@ -321,216 +230,492 @@ struct ConversationFragment: View { .onDisappear { conversationViewModel.resetMessage() } - } - } - - 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) + } 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.2) { + conversationViewModel.getOldMessages() + } + } - if attachment.type == .video { - Image("play-fill") - .renderingMode(.template) - .resizable() - .foregroundStyle(.white) - .frame(width: 40, height: 40, alignment: .leading) + if index == 0 { + displayFloatingButton = false } } - } placeholder: { - ProgressView() - } - .layoutPriority(-1) - .onTapGesture { - if conversationViewModel.mediasToSend.count == 1 { - withAnimation { - conversationViewModel.mediasToSend.removeAll() + .onDisappear { + if index == 0 { + displayFloatingButton = true } - } else { - guard let index = self.conversationViewModel.mediasToSend.firstIndex(of: attachment) else { return } - self.conversationViewModel.mediasToSend.remove(at: index) + } + } + } + } + .scaleEffect(x: 1, y: -1, anchor: .center) + .listStyle(.plain) + + if displayFloatingButton { + Button { + if conversationViewModel.conversationMessagesSection.first != nil && conversationViewModel.conversationMessagesSection.first!.rows.first != nil { + withAnimation { + proxy.scrollTo(conversationViewModel.conversationMessagesSection.first!.rows.first!.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() } } } - .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) { - 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) - } 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) - - if conversationViewModel.messageText.isEmpty { - Text("Say something...") - .padding(.leading, 4) - .opacity(conversationViewModel.messageText.isEmpty ? 1 : 0) - .foregroundStyle(Color.gray300) - .default_text_style(styleSize: 15) + .frame(width: 50, height: 50) + .padding() } } .onTapGesture { - isMessageTextFocused = true + UIApplication.shared.endEditing() } - } - - if conversationViewModel.messageText.isEmpty && conversationViewModel.mediasToSend.isEmpty { - Button { - } label: { - Image("microphone") - .renderingMode(.template) - .resizable() - .foregroundStyle(Color.grayMain2c500) - .frame(width: 28, height: 28, alignment: .leading) - .padding(.all, 6) - .padding(.top, 4) + .onAppear { + conversationViewModel.getMessages() } - } 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)) + .onDisappear { + conversationViewModel.resetMessage() } - .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) + + 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) { + 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) + } 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) + + if conversationViewModel.messageText.isEmpty { + Text("Say something...") + .padding(.leading, 4) + .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 { + } 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) + } + .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) - 25 + VStack { + Spacer() + + VStack { + HStack { + if conversationViewModel.selectedMessage!.isOutgoing { + Spacer() + } + + HStack { + Button { + } label: { + Text("👍") + .default_text_style(styleSize: iconSize > 50 ? 50 : iconSize) + } + .padding(.horizontal, 5) + + Button { + } label: { + Text("❤️") + .default_text_style(styleSize: iconSize > 50 ? 50 : iconSize) + } + .padding(.horizontal, 5) + + Button { + } label: { + Text("😂") + .default_text_style(styleSize: iconSize > 50 ? 50 : iconSize) + } + .padding(.horizontal, 5) + + Button { + } label: { + Text("😮") + .default_text_style(styleSize: iconSize > 50 ? 50 : iconSize) + } + .padding(.horizontal, 5) + + Button { + } label: { + Text("😢") + .default_text_style(styleSize: iconSize > 50 ? 50 : iconSize) + } + .padding(.horizontal, 5) + + 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!.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, message: conversationViewModel.selectedMessage!, geometryProxy: geometry) + .padding(.horizontal, 10) + .padding(.vertical, 1) + .shadow(color: .black.opacity(0.1), radius: 10) + + HStack { + if conversationViewModel.selectedMessage!.isOutgoing { + Spacer() + } + + VStack { + Button { + } 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() + + Button { + } 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 { + } 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!.isOutgoing { + Spacer() + } + } + .frame(maxWidth: .infinity) + .padding(.horizontal, 10) + .padding(.leading, conversationViewModel.displayedConversation!.isGroup ? 43 : 0) + .shadow(color: .black.opacity(0.1), radius: 10) + } + + Spacer() + } + .frame(maxWidth: .infinity) + .background(.gray.opacity(0.1)) + .onTapGesture { + withAnimation { + conversationViewModel.selectedMessage = nil + } + } + .onAppear { + touchFeedback() + } + .onDisappear { + if conversationViewModel.selectedMessage != nil { + conversationViewModel.selectedMessage = nil + } } - .frame(maxWidth: .infinity, minHeight: 60) - .padding(.top, 12) - .padding(.bottom, geometry.safeAreaInsets.bottom > 0 ? (isMessageTextFocused ? 12 : 0) : 12) - .padding(.horizontal, 10) - .background(Color.gray100) } } .background(.white) @@ -569,6 +754,7 @@ struct ConversationFragment: View { .navigationViewStyle(.stack) } } +// swiftlint:enable type_body_length struct ScrollOffsetPreferenceKey: PreferenceKey { static var defaultValue: CGPoint = .zero diff --git a/Linphone/UI/Main/Conversations/Fragments/ConversationsListFragment.swift b/Linphone/UI/Main/Conversations/Fragments/ConversationsListFragment.swift index 3fe7b7f84..c6aeabb78 100644 --- a/Linphone/UI/Main/Conversations/Fragments/ConversationsListFragment.swift +++ b/Linphone/UI/Main/Conversations/Fragments/ConversationsListFragment.swift @@ -138,11 +138,13 @@ struct ConversationsListFragment: View { if index < conversationsListViewModel.conversationsList.count { if conversationViewModel.displayedConversation != nil { conversationViewModel.displayedConversation = nil + conversationViewModel.selectedMessage = nil conversationViewModel.resetMessage() conversationViewModel.changeDisplayedChatRoom(conversationModel: conversationsListViewModel.conversationsList[index]) conversationViewModel.getMessages() } else { + conversationViewModel.selectedMessage = nil withAnimation { conversationViewModel.changeDisplayedChatRoom(conversationModel: conversationsListViewModel.conversationsList[index]) } diff --git a/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift b/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift index e59068dc7..69a5baec6 100644 --- a/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift +++ b/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift @@ -42,6 +42,8 @@ class ConversationViewModel: ObservableObject { @Published var mediasToSend: [Attachment] = [] var maxMediaCount = 12 + @Published var selectedMessage: Message? + init() {} func addConversationDelegate() { 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 + } + } From 674290434262ffde129396959f64b9b680b152bd Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Thu, 18 Jul 2024 15:13:26 +0200 Subject: [PATCH 352/486] Fix UI change from history view to message --- Linphone/UI/Main/ContentView.swift | 12 +- .../History/ViewModel/HistoryViewModel.swift | 197 ------------------ 2 files changed, 2 insertions(+), 207 deletions(-) diff --git a/Linphone/UI/Main/ContentView.swift b/Linphone/UI/Main/ContentView.swift index c5ccb47f8..085d3eb3e 100644 --- a/Linphone/UI/Main/ContentView.swift +++ b/Linphone/UI/Main/ContentView.swift @@ -1028,13 +1028,14 @@ struct ContentView: View { } } - if contactViewModel.operationInProgress || historyViewModel.operationInProgress { + 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 { @@ -1042,15 +1043,6 @@ struct ContentView: View { } contactViewModel.displayedConversation = nil } - } else if historyViewModel.displayedConversation != nil { - historyViewModel.displayedCall = nil - index = 2 - DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { - withAnimation { - self.conversationViewModel.changeDisplayedChatRoom(conversationModel: historyViewModel.displayedConversation!) - } - historyViewModel.displayedConversation = nil - } } } } diff --git a/Linphone/UI/Main/History/ViewModel/HistoryViewModel.swift b/Linphone/UI/Main/History/ViewModel/HistoryViewModel.swift index 2114e113f..04c763272 100644 --- a/Linphone/UI/Main/History/ViewModel/HistoryViewModel.swift +++ b/Linphone/UI/Main/History/ViewModel/HistoryViewModel.swift @@ -27,202 +27,5 @@ class HistoryViewModel: ObservableObject { var selectedCall: HistoryModel? - @Published var operationInProgress: Bool = false - @Published var displayedConversation: ConversationModel? - - private var chatRoomSuscriptions = Set() - 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) { - self.chatRoomSuscriptions.insert(chatRoom.publisher?.onConferenceJoined?.postOnCoreQueue { - (chatRoom: ChatRoom, eventLog: 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") - self.chatRoomSuscriptions.removeAll() - - 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!") - self.chatRoomSuscriptions.removeAll() - DispatchQueue.main.async { - self.operationInProgress = false - ToastViewModel.shared.toastMessage = "Failed_to_create_conversation_error" - ToastViewModel.shared.displayToast = true - } - } - }) - - self.chatRoomSuscriptions.insert(chatRoom.publisher?.onStateChanged?.postOnCoreQueue { - (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!") - self.chatRoomSuscriptions.removeAll() - DispatchQueue.main.async { - self.operationInProgress = false - ToastViewModel.shared.toastMessage = "Failed_to_create_conversation_error" - ToastViewModel.shared.displayToast = true - } - } - }) - } } From 5c8281564446b6653382a7efeade01629192c4b7 Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Fri, 19 Jul 2024 16:20:01 +0200 Subject: [PATCH 353/486] Add reaction feature --- Linphone/Localizable.xcstrings | 3 + Linphone/UI/Call/CallView.swift | 2 +- .../Fragments/ContactListBottomSheet.swift | 2 +- Linphone/UI/Main/ContentView.swift | 13 ++ .../Fragments/ChatBubbleView.swift | 109 +++++++++----- .../Fragments/ConversationFragment.swift | 67 ++++++--- .../UI/Main/Conversations/Model/Message.swift | 23 ++- .../ViewModel/ConversationViewModel.swift | 142 +++++++++++++++++- Linphone/UI/Main/Fragments/ToastView.swift | 9 +- .../Fragments/HistoryContactFragment.swift | 2 +- .../Fragments/HistoryListBottomSheet.swift | 2 +- .../Meetings/Fragments/MeetingFragment.swift | 2 +- Linphone/Utils/Avatar.swift | 4 +- 13 files changed, 305 insertions(+), 75 deletions(-) diff --git a/Linphone/Localizable.xcstrings b/Linphone/Localizable.xcstrings index 9942036c4..74082046b 100644 --- a/Linphone/Localizable.xcstrings +++ b/Linphone/Localizable.xcstrings @@ -1570,6 +1570,9 @@ }, "Message" : { + }, + "Message copied into clipboard" : { + }, "Messages" : { diff --git a/Linphone/UI/Call/CallView.swift b/Linphone/UI/Call/CallView.swift index a5895da25..b771b28ed 100644 --- a/Linphone/UI/Call/CallView.swift +++ b/Linphone/UI/Call/CallView.swift @@ -625,7 +625,7 @@ struct CallView: View { ) DispatchQueue.main.async { - ToastViewModel.shared.toastMessage = "Success_copied_into_clipboard" + ToastViewModel.shared.toastMessage = "Success_address_copied_into_clipboard" ToastViewModel.shared.displayToast = true } }, label: { diff --git a/Linphone/UI/Main/Contacts/Fragments/ContactListBottomSheet.swift b/Linphone/UI/Main/Contacts/Fragments/ContactListBottomSheet.swift index f9c006e0a..a103ea00d 100644 --- a/Linphone/UI/Main/Contacts/Fragments/ContactListBottomSheet.swift +++ b/Linphone/UI/Main/Contacts/Fragments/ContactListBottomSheet.swift @@ -69,7 +69,7 @@ struct ContactListBottomSheet: View { dismiss() } - ToastViewModel.shared.toastMessage = "Success_copied_into_clipboard" + ToastViewModel.shared.toastMessage = "Success_address_copied_into_clipboard" ToastViewModel.shared.displayToast.toggle() } label: { diff --git a/Linphone/UI/Main/ContentView.swift b/Linphone/UI/Main/ContentView.swift index 085d3eb3e..3e0245555 100644 --- a/Linphone/UI/Main/ContentView.swift +++ b/Linphone/UI/Main/ContentView.swift @@ -127,6 +127,7 @@ struct ContentView: View { self.index = 0 historyViewModel.displayedCall = nil conversationViewModel.displayedConversation = nil + meetingViewModel.displayedMeeting = nil }, label: { VStack { Image("address-book") @@ -171,6 +172,7 @@ struct ContentView: View { self.index = 1 contactViewModel.indexDisplayedFriend = nil conversationViewModel.displayedConversation = nil + meetingViewModel.displayedMeeting = nil if historyListViewModel.missedCallsCount > 0 { historyListViewModel.resetMissedCallsCount() } @@ -219,6 +221,7 @@ struct ContentView: View { self.index = 2 historyViewModel.displayedCall = nil contactViewModel.indexDisplayedFriend = nil + meetingViewModel.displayedMeeting = nil }, label: { VStack { Image("chat-teardrop-text") @@ -275,6 +278,11 @@ struct ContentView: View { } VStack(spacing: 0) { + Rectangle() + .foregroundColor(Color.orangeMain500) + .edgesIgnoringSafeArea(.top) + .frame(height: 1) + if searchIsActive == false { HStack { Image("profile-image-example") @@ -392,6 +400,7 @@ struct ContentView: View { .padding(.top, 2.5) .padding(.bottom, 2.5) .background(Color.orangeMain500) + .roundedCorner(10, corners: [.bottomRight, .bottomLeft]) } else { HStack { Button { @@ -523,6 +532,7 @@ struct ContentView: View { .padding(.horizontal) .padding(.bottom, 5) .background(Color.orangeMain500) + .roundedCorner(10, corners: [.bottomRight, .bottomLeft]) } if self.index == 0 { @@ -592,6 +602,7 @@ struct ContentView: View { self.index = 0 historyViewModel.displayedCall = nil conversationViewModel.displayedConversation = nil + meetingViewModel.displayedMeeting = nil }, label: { VStack { Image("address-book") @@ -638,6 +649,7 @@ struct ContentView: View { self.index = 1 contactViewModel.indexDisplayedFriend = nil conversationViewModel.displayedConversation = nil + meetingViewModel.displayedMeeting = nil if historyListViewModel.missedCallsCount > 0 { historyListViewModel.resetMissedCallsCount() } @@ -688,6 +700,7 @@ struct ContentView: View { self.index = 2 historyViewModel.displayedCall = nil contactViewModel.indexDisplayedFriend = nil + meetingViewModel.displayedMeeting = nil }, label: { VStack { Image("chat-teardrop-text") diff --git a/Linphone/UI/Main/Conversations/Fragments/ChatBubbleView.swift b/Linphone/UI/Main/Conversations/Fragments/ChatBubbleView.swift index 6f691cd00..bef6589ca 100644 --- a/Linphone/UI/Main/Conversations/Fragments/ChatBubbleView.swift +++ b/Linphone/UI/Main/Conversations/Fragments/ChatBubbleView.swift @@ -70,52 +70,89 @@ struct ChatBubbleView: View { } VStack(alignment: message.isOutgoing ? .trailing : .leading) { - if !message.attachments.isEmpty { - messageAttachments() - } - - if !message.text.isEmpty { - Text(message.text) - .foregroundStyle(Color.grayMain2c700) - .default_text_style(styleSize: 16) - } - - HStack(alignment: .center) { - Text(conversationViewModel.getMessageTime(startDate: message.dateReceived)) - .foregroundStyle(Color.grayMain2c500) - .default_text_style_300(styleSize: 14) - .padding(.top, 1) + VStack(alignment: message.isOutgoing ? .trailing : .leading) { + if !message.attachments.isEmpty { + messageAttachments() + } - if (conversationViewModel.displayedConversation != nil && conversationViewModel.displayedConversation!.isGroup) || message.isOutgoing { - if message.status == .sending { - ProgressView() - .progressViewStyle(CircularProgressViewStyle(tint: .orangeMain500)) - .frame(width: 15, height: 15) - .padding(.top, 1) - } else if message.status != nil { - Image(conversationViewModel.getImageIMDN(status: message.status!)) - .renderingMode(.template) - .resizable() - .foregroundStyle(Color.orangeMain500) - .frame(width: 15, height: 15) - .padding(.top, 1) + if !message.text.isEmpty { + Text(message.text) + .foregroundStyle(Color.grayMain2c700) + .default_text_style(styleSize: 16) + } + + HStack(alignment: .center) { + Text(conversationViewModel.getMessageTime(startDate: message.dateReceived)) + .foregroundStyle(Color.grayMain2c500) + .default_text_style_300(styleSize: 14) + .padding(.top, 1) + + if (conversationViewModel.displayedConversation != nil && conversationViewModel.displayedConversation!.isGroup) || message.isOutgoing { + if message.status == .sending { + ProgressView() + .progressViewStyle(CircularProgressViewStyle(tint: .orangeMain500)) + .frame(width: 15, height: 15) + .padding(.top, 1) + } else if message.status != nil { + Image(conversationViewModel.getImageIMDN(status: message.status!)) + .renderingMode(.template) + .resizable() + .foregroundStyle(Color.orangeMain500) + .frame(width: 15, height: 15) + .padding(.top, 1) + } } } + .padding(.top, -4) + } + .padding(.all, 15) + .background(message.isOutgoing ? Color.orangeMain100 : Color.grayMain2c100) + .clipShape(RoundedRectangle(cornerRadius: 3)) + .roundedCorner( + 16, + corners: message.isOutgoing && message.isFirstMessage ? [.topLeft, .topRight, .bottomLeft] : + (!message.isOutgoing && message.isFirstMessage ? [.topRight, .bottomRight, .bottomLeft] : [.allCorners])) + + if !message.reactions.isEmpty { + HStack { + ForEach(0.. 50 ? 50 : iconSize) } - .padding(.horizontal, 5) + .padding(.horizontal, 8) + .background(conversationViewModel.selectedMessage?.ownReaction == "👍" ? Color.gray200 : .white) + .cornerRadius(10) Button { + conversationViewModel.sendReaction(emoji: "❤️") } label: { Text("❤️") .default_text_style(styleSize: iconSize > 50 ? 50 : iconSize) } - .padding(.horizontal, 5) + .padding(.horizontal, 8) + .background(conversationViewModel.selectedMessage?.ownReaction == "❤️" ? Color.gray200 : .white) + .cornerRadius(10) Button { + conversationViewModel.sendReaction(emoji: "😂") } label: { Text("😂") .default_text_style(styleSize: iconSize > 50 ? 50 : iconSize) } - .padding(.horizontal, 5) + .padding(.horizontal, 8) + .background(conversationViewModel.selectedMessage?.ownReaction == "😂" ? Color.gray200 : .white) + .cornerRadius(10) Button { + conversationViewModel.sendReaction(emoji: "😮") } label: { Text("😮") .default_text_style(styleSize: iconSize > 50 ? 50 : iconSize) } - .padding(.horizontal, 5) + .padding(.horizontal, 8) + .background(conversationViewModel.selectedMessage?.ownReaction == "😮" ? Color.gray200 : .white) + .cornerRadius(10) Button { + conversationViewModel.sendReaction(emoji: "😢") } label: { Text("😢") .default_text_style(styleSize: iconSize > 50 ? 50 : iconSize) } - .padding(.horizontal, 5) + .padding(.horizontal, 8) + .background(conversationViewModel.selectedMessage?.ownReaction == "😢" ? Color.gray200 : .white) + .cornerRadius(10) Button { } label: { @@ -635,22 +651,33 @@ struct ConversationFragment: View { Divider() - Button { - } label: { - HStack { - Text("menu_copy_chat_message") - .default_text_style(styleSize: 15) - Spacer() - Image("copy") - .resizable() - .frame(width: 20, height: 20, alignment: .leading) + if !conversationViewModel.selectedMessage!.text.isEmpty { + Button { + UIPasteboard.general.setValue( + conversationViewModel.selectedMessage!.text, + 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) } - .padding(.vertical, 5) - .padding(.horizontal, 20) + + Divider() } - Divider() - Button { } label: { HStack { @@ -698,8 +725,6 @@ struct ConversationFragment: View { .padding(.leading, conversationViewModel.displayedConversation!.isGroup ? 43 : 0) .shadow(color: .black.opacity(0.1), radius: 10) } - - Spacer() } .frame(maxWidth: .infinity) .background(.gray.opacity(0.1)) diff --git a/Linphone/UI/Main/Conversations/Model/Message.swift b/Linphone/UI/Main/Conversations/Model/Message.swift index adbd9bc78..97ea3f44d 100644 --- a/Linphone/UI/Main/Conversations/Model/Message.swift +++ b/Linphone/UI/Main/Conversations/Model/Message.swift @@ -73,6 +73,8 @@ public struct Message: Identifiable, Hashable { public var attachments: [Attachment] public var recording: Recording? public var replyMessage: ReplyMessage? + public var ownReaction: String + public var reactions: [String] public init( id: String, @@ -85,7 +87,9 @@ public struct Message: Identifiable, Hashable { text: String = "", attachments: [Attachment] = [], recording: Recording? = nil, - replyMessage: ReplyMessage? = nil + replyMessage: ReplyMessage? = nil, + ownReaction: String = "", + reactions: [String] = [] ) { self.id = id self.status = status @@ -98,6 +102,8 @@ public struct Message: Identifiable, Hashable { self.attachments = attachments self.recording = recording self.replyMessage = replyMessage + self.ownReaction = ownReaction + self.reactions = reactions } public static func makeMessage( @@ -131,7 +137,9 @@ public struct Message: Identifiable, Hashable { text: draft.text, attachments: attachments, recording: draft.recording, - replyMessage: draft.replyMessage + replyMessage: draft.replyMessage, + ownReaction: draft.ownReaction, + reactions: draft.reactions ) } } @@ -144,7 +152,7 @@ extension Message { extension Message: Equatable { public static func == (lhs: Message, rhs: Message) -> Bool { - lhs.id == rhs.id && lhs.status == rhs.status && lhs.isFirstMessage == rhs.isFirstMessage + lhs.id == rhs.id && lhs.status == rhs.status && lhs.isFirstMessage == rhs.isFirstMessage && lhs.ownReaction == rhs.ownReaction && lhs.reactions == rhs.reactions } } @@ -217,6 +225,8 @@ public struct DraftMessage { 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, @@ -227,7 +237,10 @@ public struct DraftMessage { medias: [Media], recording: Recording?, replyMessage: ReplyMessage?, - createdAt: Date) { + createdAt: Date, + ownReaction: String, + reactions: [String] + ) { self.id = id self.isOutgoing = isOutgoing self.dateReceived = dateReceived @@ -238,6 +251,8 @@ public struct DraftMessage { self.recording = recording self.replyMessage = replyMessage self.createdAt = createdAt + self.ownReaction = ownReaction + self.reactions = reactions } } diff --git a/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift b/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift index 69a5baec6..01ce53fd3 100644 --- a/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift +++ b/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift @@ -35,6 +35,7 @@ class ConversationViewModel: ObservableObject { @Published var messageText: String = "" private var chatRoomSuscriptions = Set() + private var chatMessageSuscriptions = Set() @Published var conversationMessagesSection: [MessagesSection] = [] @Published var participantConversationModel: [ContactAvatarModel] = [] @@ -60,8 +61,73 @@ class ConversationViewModel: ObservableObject { } } + func addChatMessageDelegate(message: ChatMessage) { + coreContext.doOnCoreQueue { _ in + if self.displayedConversation != nil { + /* + self.chatMessageSuscriptions.insert(message.publisher?.onMsgStateChanged?.postOnCoreQueue {(cbValue: (message: ChatMessage, state: ChatMessage.State)) in + var statusTmp: Message.Status? = .sending + switch cbValue.message.state { + case .InProgress: + statusTmp = .sending + case .Delivered: + statusTmp = .sent + case .DeliveredToUser: + statusTmp = .received + case .Displayed: + statusTmp = .read + default: + statusTmp = nil + } + + let indexMessage = self.conversationMessagesSection[0].rows.firstIndex(where: {$0.id == message.messageId}) + + DispatchQueue.main.async { + if indexMessage != nil { + self.objectWillChange.send() + self.conversationMessagesSection[0].rows[indexMessage!].status = statusTmp + } + } + }) + */ + + self.chatMessageSuscriptions.insert(message.publisher?.onNewMessageReaction?.postOnCoreQueue {(cbValue: (message: ChatMessage, reaction: ChatMessageReaction)) in + + let indexMessage = self.conversationMessagesSection[0].rows.firstIndex(where: {$0.id == message.messageId}) + var reactionsTmp: [String] = [] + cbValue.message.reactions.forEach({ chatMessageReaction in + reactionsTmp.append(chatMessageReaction.body) + }) + + DispatchQueue.main.async { + if indexMessage != nil { + self.objectWillChange.send() + self.conversationMessagesSection[0].rows[indexMessage!].reactions = reactionsTmp + } + } + }) + + self.chatMessageSuscriptions.insert(message.publisher?.onReactionRemoved?.postOnCoreQueue {(cbValue: (message: ChatMessage, address: Address)) in + let indexMessage = self.conversationMessagesSection[0].rows.firstIndex(where: {$0.id == message.messageId}) + var reactionsTmp: [String] = [] + cbValue.message.reactions.forEach({ chatMessageReaction in + reactionsTmp.append(chatMessageReaction.body) + }) + + DispatchQueue.main.async { + if indexMessage != nil { + self.objectWillChange.send() + self.conversationMessagesSection[0].rows[indexMessage!].reactions = reactionsTmp + } + } + }) + } + } + } + func removeConversationDelegate() { self.chatRoomSuscriptions.removeAll() + self.chatMessageSuscriptions.removeAll() } func getHistorySize() { @@ -210,19 +276,28 @@ class ConversationViewModel: ObservableObject { statusTmp = nil } + var reactionsTmp: [String] = [] + eventLog.chatMessage?.reactions.forEach({ chatMessageReaction in + reactionsTmp.append(chatMessageReaction.body) + }) + if eventLog.chatMessage != nil { conversationMessage.append( Message( - id: UUID().uuidString, + id: eventLog.chatMessage?.messageId ?? UUID().uuidString, status: statusTmp, isOutgoing: eventLog.chatMessage?.isOutgoing ?? false, dateReceived: eventLog.chatMessage?.time ?? 0, address: addressCleaned?.asStringUriOnly() ?? "", isFirstMessage: isFirstMessageTmp, text: contentText, - attachments: attachmentList + attachments: attachmentList, + ownReaction: eventLog.chatMessage?.ownReaction?.body ?? "", + reactions: reactionsTmp ) ) + + self.addChatMessageDelegate(message: eventLog.chatMessage!) } } @@ -324,19 +399,28 @@ class ConversationViewModel: ObservableObject { statusTmp = nil } + var reactionsTmp: [String] = [] + eventLog.chatMessage?.reactions.forEach({ chatMessageReaction in + reactionsTmp.append(chatMessageReaction.body) + }) + if eventLog.chatMessage != nil { conversationMessagesTmp.insert( Message( - id: UUID().uuidString, + id: eventLog.chatMessage?.messageId ?? UUID().uuidString, status: statusTmp, isOutgoing: eventLog.chatMessage?.isOutgoing ?? false, dateReceived: eventLog.chatMessage?.time ?? 0, address: addressCleaned?.asStringUriOnly() ?? "", isFirstMessage: isFirstMessageTmp, text: contentText, - attachments: attachmentList + attachments: attachmentList, + ownReaction: eventLog.chatMessage?.ownReaction?.body ?? "", + reactions: reactionsTmp ), at: 0 ) + + self.addChatMessageDelegate(message: eventLog.chatMessage!) } } @@ -451,18 +535,27 @@ class ConversationViewModel: ObservableObject { statusTmp = nil } + var reactionsTmp: [String] = [] + eventLog.chatMessage?.reactions.forEach({ chatMessageReaction in + reactionsTmp.append(chatMessageReaction.body) + }) + if eventLog.chatMessage != nil { let message = Message( - id: UUID().uuidString, + id: eventLog.chatMessage?.messageId ?? UUID().uuidString, status: statusTmp, isOutgoing: eventLog.chatMessage?.isOutgoing ?? false, dateReceived: eventLog.chatMessage?.time ?? 0, address: addressCleaned?.asStringUriOnly() ?? "", isFirstMessage: isFirstMessageTmp, text: contentText, - attachments: attachmentList + attachments: attachmentList, + ownReaction: eventLog.chatMessage?.ownReaction?.body ?? "", + reactions: reactionsTmp ) + self.addChatMessageDelegate(message: eventLog.chatMessage!) + DispatchQueue.main.async { if !self.conversationMessagesSection.isEmpty && !self.conversationMessagesSection[0].rows.isEmpty @@ -729,6 +822,43 @@ class ConversationViewModel: ObservableObject { } } + func sendReaction(emoji: String) { + coreContext.doOnCoreQueue { _ in + if self.selectedMessage != nil { + Log.info("[ConversationViewModel] Sending reaction \(emoji) to message with ID \(self.selectedMessage!.id)") + let messageToSendReaction = self.displayedConversation!.chatRoom.findMessage(messageId: self.selectedMessage!.id) + 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!].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!.id)") + } + } + } + } + } + + func resend() { + coreContext.doOnCoreQueue { _ in + if self.selectedMessage != nil { + Log.info("[ConversationViewModel] Re-sending message with ID \(self.selectedMessage!.id)") + let messageToResend = self.displayedConversation!.chatRoom.findMessage(messageId: self.selectedMessage!.id) + if messageToResend != nil { + messageToResend!.send() + } + } + } + } } struct LinphoneCustomEventLog: Hashable { var id = UUID() diff --git a/Linphone/UI/Main/Fragments/ToastView.swift b/Linphone/UI/Main/Fragments/ToastView.swift index d918e4140..de0bbf2b9 100644 --- a/Linphone/UI/Main/Fragments/ToastView.swift +++ b/Linphone/UI/Main/Fragments/ToastView.swift @@ -80,13 +80,20 @@ struct ToastView: View { .default_text_style(styleSize: 15) .padding(8) - case "Success_copied_into_clipboard": + 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) diff --git a/Linphone/UI/Main/History/Fragments/HistoryContactFragment.swift b/Linphone/UI/Main/History/Fragments/HistoryContactFragment.swift index 2bbbac2ae..4aa868f20 100644 --- a/Linphone/UI/Main/History/Fragments/HistoryContactFragment.swift +++ b/Linphone/UI/Main/History/Fragments/HistoryContactFragment.swift @@ -135,7 +135,7 @@ struct HistoryContactFragment: View { ) } - ToastViewModel.shared.toastMessage = "Success_copied_into_clipboard" + ToastViewModel.shared.toastMessage = "Success_address_copied_into_clipboard" ToastViewModel.shared.displayToast.toggle() } label: { diff --git a/Linphone/UI/Main/History/Fragments/HistoryListBottomSheet.swift b/Linphone/UI/Main/History/Fragments/HistoryListBottomSheet.swift index 04b1403cf..19705fcd4 100644 --- a/Linphone/UI/Main/History/Fragments/HistoryListBottomSheet.swift +++ b/Linphone/UI/Main/History/Fragments/HistoryListBottomSheet.swift @@ -155,7 +155,7 @@ struct HistoryListBottomSheet: View { dismiss() } - ToastViewModel.shared.toastMessage = "Success_copied_into_clipboard" + ToastViewModel.shared.toastMessage = "Success_address_copied_into_clipboard" ToastViewModel.shared.displayToast.toggle() } label: { diff --git a/Linphone/UI/Main/Meetings/Fragments/MeetingFragment.swift b/Linphone/UI/Main/Meetings/Fragments/MeetingFragment.swift index 4115036e5..cf4b7d793 100644 --- a/Linphone/UI/Main/Meetings/Fragments/MeetingFragment.swift +++ b/Linphone/UI/Main/Meetings/Fragments/MeetingFragment.swift @@ -185,7 +185,7 @@ struct MeetingFragment: View { ) DispatchQueue.main.async { - ToastViewModel.shared.toastMessage = "Success_copied_into_clipboard" + ToastViewModel.shared.toastMessage = "Success_address_copied_into_clipboard" ToastViewModel.shared.displayToast = true } }, label: { diff --git a/Linphone/Utils/Avatar.swift b/Linphone/Utils/Avatar.swift index bb7a6d028..a87908184 100644 --- a/Linphone/Utils/Avatar.swift +++ b/Linphone/Utils/Avatar.swift @@ -58,8 +58,8 @@ struct Avatar: View { Image(contactAvatarModel.presenceStatus == .Online ? "presence-online" : "presence-busy") .resizable() .frame(width: avatarSize/4, height: avatarSize/4) - .padding(.trailing, avatarSize == 50 ? 1 : 3) - .padding(.bottom, avatarSize == 50 ? 1 : 3) + .padding(.trailing, avatarSize == 50 || avatarSize == 35 ? 1 : 3) + .padding(.bottom, avatarSize == 50 || avatarSize == 35 ? 1 : 3) } } } From f5c074e0bca64e648f8fd13bd72298fb5cef958d Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Mon, 12 Aug 2024 17:33:38 +0200 Subject: [PATCH 354/486] Fix updateUIView crash when get old messages in ConversationViewModel --- .../ViewModel/ConversationViewModel.swift | 103 +++++++++--------- 1 file changed, 51 insertions(+), 52 deletions(-) diff --git a/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift b/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift index 01ce53fd3..28f13fb03 100644 --- a/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift +++ b/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift @@ -62,65 +62,64 @@ class ConversationViewModel: ObservableObject { } func addChatMessageDelegate(message: ChatMessage) { - coreContext.doOnCoreQueue { _ in + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { if self.displayedConversation != nil { - /* - self.chatMessageSuscriptions.insert(message.publisher?.onMsgStateChanged?.postOnCoreQueue {(cbValue: (message: ChatMessage, state: ChatMessage.State)) in - var statusTmp: Message.Status? = .sending - switch cbValue.message.state { - case .InProgress: - statusTmp = .sending - case .Delivered: - statusTmp = .sent - case .DeliveredToUser: - statusTmp = .received - case .Displayed: - statusTmp = .read - default: - statusTmp = nil - } - - let indexMessage = self.conversationMessagesSection[0].rows.firstIndex(where: {$0.id == message.messageId}) - - DispatchQueue.main.async { - if indexMessage != nil { - self.objectWillChange.send() - self.conversationMessagesSection[0].rows[indexMessage!].status = statusTmp + self.coreContext.doOnCoreQueue { _ in + self.chatMessageSuscriptions.insert(message.publisher?.onMsgStateChanged?.postOnCoreQueue {(cbValue: (message: ChatMessage, state: ChatMessage.State)) in + var statusTmp: Message.Status? = .sending + switch cbValue.message.state { + case .InProgress: + statusTmp = .sending + case .Delivered: + statusTmp = .sent + case .DeliveredToUser: + statusTmp = .received + case .Displayed: + statusTmp = .read + default: + statusTmp = nil + } + + let indexMessage = self.conversationMessagesSection[0].rows.firstIndex(where: {$0.id == message.messageId}) + + DispatchQueue.main.async { + if indexMessage != nil { + self.objectWillChange.send() + self.conversationMessagesSection[0].rows[indexMessage!].status = statusTmp + } } - } - }) - */ - - self.chatMessageSuscriptions.insert(message.publisher?.onNewMessageReaction?.postOnCoreQueue {(cbValue: (message: ChatMessage, reaction: ChatMessageReaction)) in - - let indexMessage = self.conversationMessagesSection[0].rows.firstIndex(where: {$0.id == message.messageId}) - var reactionsTmp: [String] = [] - cbValue.message.reactions.forEach({ chatMessageReaction in - reactionsTmp.append(chatMessageReaction.body) }) - DispatchQueue.main.async { - if indexMessage != nil { - self.objectWillChange.send() - self.conversationMessagesSection[0].rows[indexMessage!].reactions = reactionsTmp + self.chatMessageSuscriptions.insert(message.publisher?.onNewMessageReaction?.postOnCoreQueue {(cbValue: (message: ChatMessage, reaction: ChatMessageReaction)) in + let indexMessage = self.conversationMessagesSection[0].rows.firstIndex(where: {$0.id == message.messageId}) + var reactionsTmp: [String] = [] + cbValue.message.reactions.forEach({ chatMessageReaction in + reactionsTmp.append(chatMessageReaction.body) + }) + + DispatchQueue.main.async { + if indexMessage != nil { + self.objectWillChange.send() + self.conversationMessagesSection[0].rows[indexMessage!].reactions = reactionsTmp + } } - } - }) - - self.chatMessageSuscriptions.insert(message.publisher?.onReactionRemoved?.postOnCoreQueue {(cbValue: (message: ChatMessage, address: Address)) in - let indexMessage = self.conversationMessagesSection[0].rows.firstIndex(where: {$0.id == message.messageId}) - var reactionsTmp: [String] = [] - cbValue.message.reactions.forEach({ chatMessageReaction in - reactionsTmp.append(chatMessageReaction.body) }) - DispatchQueue.main.async { - if indexMessage != nil { - self.objectWillChange.send() - self.conversationMessagesSection[0].rows[indexMessage!].reactions = reactionsTmp + self.chatMessageSuscriptions.insert(message.publisher?.onReactionRemoved?.postOnCoreQueue {(cbValue: (message: ChatMessage, address: Address)) in + let indexMessage = self.conversationMessagesSection[0].rows.firstIndex(where: {$0.id == message.messageId}) + var reactionsTmp: [String] = [] + cbValue.message.reactions.forEach({ chatMessageReaction in + reactionsTmp.append(chatMessageReaction.body) + }) + + DispatchQueue.main.async { + if indexMessage != nil { + self.objectWillChange.send() + self.conversationMessagesSection[0].rows[indexMessage!].reactions = reactionsTmp + } } - } - }) + }) + } } } } @@ -312,7 +311,7 @@ class ConversationViewModel: ObservableObject { func getOldMessages() { coreContext.doOnCoreQueue { _ in - if self.displayedConversation != nil { + if self.displayedConversation != nil && self.displayedConversationHistorySize > self.conversationMessagesSection[0].rows.count{ let historyEvents = self.displayedConversation!.chatRoom.getHistoryRangeEvents(begin: self.conversationMessagesSection[0].rows.count, end: self.conversationMessagesSection[0].rows.count + 30) var conversationMessagesTmp: [Message] = [] From 5d27d11c06144a1a46bfbac2b9d8f586c297a5b4 Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Mon, 12 Aug 2024 17:34:58 +0200 Subject: [PATCH 355/486] Change name of activateAudioSession parameter --- Linphone/TelecomManager/ProviderDelegate.swift | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Linphone/TelecomManager/ProviderDelegate.swift b/Linphone/TelecomManager/ProviderDelegate.swift index 7e6db61bf..fbc097cdd 100644 --- a/Linphone/TelecomManager/ProviderDelegate.swift +++ b/Linphone/TelecomManager/ProviderDelegate.swift @@ -307,9 +307,9 @@ extension ProviderDelegate: CXProviderDelegate { // 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 + Log.info("Assuming AudioSession is active when executing a CXSetHeldCallAction with isOnHold=false.") + core.activateAudioSession(activated: true) + TelecomManager.shared.callkitAudioSessionActivated = true } } } From 8045c4af2dbfbb9e3399971ce9b9dc7fc98e4b90 Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Tue, 13 Aug 2024 14:26:50 +0200 Subject: [PATCH 356/486] Fix crash when scrolling in chat room --- .../ViewModel/ConversationViewModel.swift | 59 ++++++++++++++++++- 1 file changed, 57 insertions(+), 2 deletions(-) diff --git a/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift b/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift index 28f13fb03..c41d22749 100644 --- a/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift +++ b/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift @@ -43,6 +43,8 @@ class ConversationViewModel: ObservableObject { @Published var mediasToSend: [Attachment] = [] var maxMediaCount = 12 + var oldMessageReceived = false + @Published var selectedMessage: Message? init() {} @@ -132,7 +134,7 @@ class ConversationViewModel: ObservableObject { func getHistorySize() { coreContext.doOnCoreQueue { _ in if self.displayedConversation != nil { - let historySize = self.displayedConversation!.chatRoom.historySize + let historySize = self.displayedConversation!.chatRoom.historyEventsSize DispatchQueue.main.async { self.displayedConversationHistorySize = historySize } @@ -297,6 +299,21 @@ class ConversationViewModel: ObservableObject { ) self.addChatMessageDelegate(message: eventLog.chatMessage!) + } else { + conversationMessage.insert( + Message( + id: UUID().uuidString, + status: nil, + isOutgoing: false, + dateReceived: 0, + address: "", + isFirstMessage: false, + text: "", + attachments: [], + ownReaction: "", + reactions: [] + ), at: 0 + ) } } @@ -311,7 +328,8 @@ class ConversationViewModel: ObservableObject { func getOldMessages() { coreContext.doOnCoreQueue { _ in - if self.displayedConversation != nil && self.displayedConversationHistorySize > self.conversationMessagesSection[0].rows.count{ + if self.displayedConversation != nil && 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: [Message] = [] @@ -420,6 +438,21 @@ class ConversationViewModel: ObservableObject { ) self.addChatMessageDelegate(message: eventLog.chatMessage!) + } else { + conversationMessagesTmp.insert( + Message( + id: UUID().uuidString, + status: nil, + isOutgoing: false, + dateReceived: 0, + address: "", + isFirstMessage: false, + text: "", + attachments: [], + ownReaction: "", + reactions: [] + ), at: 0 + ) } } @@ -429,6 +462,7 @@ class ConversationViewModel: ObservableObject { self.conversationMessagesSection[0].rows[self.conversationMessagesSection[0].rows.count - 1].isFirstMessage = false } self.conversationMessagesSection[0].rows.append(contentsOf: conversationMessagesTmp.reversed()) + self.oldMessageReceived = false } } } @@ -573,6 +607,27 @@ class ConversationViewModel: ObservableObject { self.displayedConversationUnreadMessagesCount = unreadMessagesCount } } + } else { + let 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.conversationMessagesSection.append(MessagesSection(date: Date(), rows: [message])) + } else { + self.conversationMessagesSection[0].rows.insert(message, at: 0) + } + } } } } From 3ba5fd5f3847e162f91abbb0db1c395822735146 Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Wed, 14 Aug 2024 14:37:46 +0200 Subject: [PATCH 357/486] Add swipe action in message list --- .../reply-reversed.imageset/Contents.json | 21 ++ .../reply-reversed.svg | 5 + Linphone/Localizable.xcstrings | 20 ++ .../Fragments/ChatBubbleView.swift | 271 +++++++++--------- .../Fragments/ConversationFragment.swift | 59 +++- .../Main/Conversations/Fragments/UIList.swift | 14 + .../Main/Conversations/Model/Attachment.swift | 8 +- .../UI/Main/Conversations/Model/Message.swift | 7 +- .../ViewModel/ConversationViewModel.swift | 61 ++++ Linphone/Utils/PhotoPicker.swift | 4 +- 10 files changed, 325 insertions(+), 145 deletions(-) create mode 100644 Linphone/Assets.xcassets/reply-reversed.imageset/Contents.json create mode 100644 Linphone/Assets.xcassets/reply-reversed.imageset/reply-reversed.svg 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/Localizable.xcstrings b/Linphone/Localizable.xcstrings index 74082046b..c4ead7d27 100644 --- a/Linphone/Localizable.xcstrings +++ b/Linphone/Localizable.xcstrings @@ -27,6 +27,9 @@ }, "*" : { + }, + "**%@**" : { + }, "**Camera** : Pour capturer votre vidéo lors des appels vidéo et conférence." : { @@ -969,6 +972,23 @@ } } }, + "conversation_reply_to_message_title" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Replying to: " + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "En réponse à : " + } + } + } + }, "Conversations" : { }, diff --git a/Linphone/UI/Main/Conversations/Fragments/ChatBubbleView.swift b/Linphone/UI/Main/Conversations/Fragments/ChatBubbleView.swift index bef6589ca..75070b266 100644 --- a/Linphone/UI/Main/Conversations/Fragments/ChatBubbleView.swift +++ b/Linphone/UI/Main/Conversations/Fragments/ChatBubbleView.swift @@ -33,159 +33,168 @@ struct ChatBubbleView: View { @State private var timePassed: TimeInterval? var body: some View { - VStack { - if !message.text.isEmpty || !message.attachments.isEmpty { - HStack(alignment: .top, content: { - if message.isOutgoing { - Spacer() - } - - if conversationViewModel.displayedConversation != nil && conversationViewModel.displayedConversation!.isGroup && !message.isOutgoing && message.isFirstMessage { - VStack { - Avatar( - contactAvatarModel: conversationViewModel.participantConversationModel.first(where: {$0.address == message.address}) ?? - ContactAvatarModel(friend: nil, name: "??", address: "", withPresence: false), - avatarSize: 35 - ) - .padding(.top, 30) + HStack { + VStack { + if !message.text.isEmpty || !message.attachments.isEmpty { + HStack(alignment: .top, content: { + if message.isOutgoing { + Spacer() } - } else if conversationViewModel.displayedConversation != nil && conversationViewModel.displayedConversation!.isGroup && !message.isOutgoing { - VStack { - } - .padding(.leading, 43) - } - - VStack(alignment: .leading, spacing: 0) { + if conversationViewModel.displayedConversation != nil && conversationViewModel.displayedConversation!.isGroup && !message.isOutgoing && message.isFirstMessage { - Text(conversationViewModel.participantConversationModel.first(where: {$0.address == message.address})?.name ?? "") - .default_text_style(styleSize: 12) - .padding(.top, 10) - .padding(.bottom, 2) + VStack { + Avatar( + contactAvatarModel: conversationViewModel.participantConversationModel.first(where: {$0.address == message.address}) ?? + ContactAvatarModel(friend: nil, name: "??", address: "", withPresence: false), + avatarSize: 35 + ) + .padding(.top, 30) + } + } else if conversationViewModel.displayedConversation != nil && conversationViewModel.displayedConversation!.isGroup && !message.isOutgoing { + VStack { + } + .padding(.leading, 43) } - ZStack { - - HStack { - if message.isOutgoing { - Spacer() - } + + VStack(alignment: .leading, spacing: 0) { + if conversationViewModel.displayedConversation != nil && conversationViewModel.displayedConversation!.isGroup && !message.isOutgoing && message.isFirstMessage { + Text(conversationViewModel.participantConversationModel.first(where: {$0.address == message.address})?.name ?? "") + .default_text_style(styleSize: 12) + .padding(.top, 10) + .padding(.bottom, 2) + } + ZStack { - VStack(alignment: message.isOutgoing ? .trailing : .leading) { + HStack { + if message.isOutgoing { + Spacer() + } + VStack(alignment: message.isOutgoing ? .trailing : .leading) { - if !message.attachments.isEmpty { - messageAttachments() - } - - if !message.text.isEmpty { - Text(message.text) - .foregroundStyle(Color.grayMain2c700) - .default_text_style(styleSize: 16) - } - - HStack(alignment: .center) { - Text(conversationViewModel.getMessageTime(startDate: message.dateReceived)) - .foregroundStyle(Color.grayMain2c500) - .default_text_style_300(styleSize: 14) - .padding(.top, 1) + VStack(alignment: message.isOutgoing ? .trailing : .leading) { + if !message.attachments.isEmpty { + messageAttachments() + } - if (conversationViewModel.displayedConversation != nil && conversationViewModel.displayedConversation!.isGroup) || message.isOutgoing { - if message.status == .sending { - ProgressView() - .progressViewStyle(CircularProgressViewStyle(tint: .orangeMain500)) - .frame(width: 15, height: 15) - .padding(.top, 1) - } else if message.status != nil { - Image(conversationViewModel.getImageIMDN(status: message.status!)) - .renderingMode(.template) - .resizable() - .foregroundStyle(Color.orangeMain500) - .frame(width: 15, height: 15) - .padding(.top, 1) + if !message.text.isEmpty { + Text(message.text) + .foregroundStyle(Color.grayMain2c700) + .default_text_style(styleSize: 16) + } + + HStack(alignment: .center) { + Text(conversationViewModel.getMessageTime(startDate: message.dateReceived)) + .foregroundStyle(Color.grayMain2c500) + .default_text_style_300(styleSize: 14) + .padding(.top, 1) + + if (conversationViewModel.displayedConversation != nil && conversationViewModel.displayedConversation!.isGroup) || message.isOutgoing { + if message.status == .sending { + ProgressView() + .progressViewStyle(CircularProgressViewStyle(tint: .orangeMain500)) + .frame(width: 15, height: 15) + .padding(.top, 1) + } else if message.status != nil { + Image(conversationViewModel.getImageIMDN(status: message.status!)) + .renderingMode(.template) + .resizable() + .foregroundStyle(Color.orangeMain500) + .frame(width: 15, height: 15) + .padding(.top, 1) + } } } + .padding(.top, -4) } - .padding(.top, -4) - } - .padding(.all, 15) - .background(message.isOutgoing ? Color.orangeMain100 : Color.grayMain2c100) - .clipShape(RoundedRectangle(cornerRadius: 3)) - .roundedCorner( - 16, - corners: message.isOutgoing && message.isFirstMessage ? [.topLeft, .topRight, .bottomLeft] : - (!message.isOutgoing && message.isFirstMessage ? [.topRight, .bottomRight, .bottomLeft] : [.allCorners])) - - if !message.reactions.isEmpty { - HStack { - ForEach(0..= scrollView.contentSize.height - scrollView.frame.height - 200 } + + func tableView(_ tableView: UITableView, leadingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? { + + let archiveAction = UIContextualAction(style: .normal, title: "") { action, view, completionHandler in + self.conversationViewModel.replyToMessage(index: indexPath.row) + completionHandler(true) + } + + archiveAction.image = UIImage(named: "reply-reversed")! + + let configuration = UISwipeActionsConfiguration(actions: [archiveAction]) + + return configuration + } } } diff --git a/Linphone/UI/Main/Conversations/Model/Attachment.swift b/Linphone/UI/Main/Conversations/Model/Attachment.swift index 39d456e79..0e84668f1 100644 --- a/Linphone/UI/Main/Conversations/Model/Attachment.swift +++ b/Linphone/UI/Main/Conversations/Model/Attachment.swift @@ -45,18 +45,20 @@ public enum AttachmentType: String, Codable { 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 init(id: String, thumbnail: URL, full: URL, type: AttachmentType) { + public init(id: String, name: String, thumbnail: URL, full: URL, type: AttachmentType) { self.id = id + self.name = name self.thumbnail = thumbnail self.full = full self.type = type } - public init(id: String, url: URL, type: AttachmentType) { - self.init(id: id, thumbnail: url, full: url, type: type) + public init(id: String, name: String, url: URL, type: AttachmentType) { + self.init(id: id, name: name, thumbnail: url, full: url, type: type) } } diff --git a/Linphone/UI/Main/Conversations/Model/Message.swift b/Linphone/UI/Main/Conversations/Model/Message.swift index 97ea3f44d..495848fde 100644 --- a/Linphone/UI/Main/Conversations/Model/Message.swift +++ b/Linphone/UI/Main/Conversations/Model/Message.swift @@ -70,6 +70,7 @@ public struct Message: Identifiable, Hashable { 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? @@ -85,6 +86,7 @@ public struct Message: Identifiable, Hashable { address: String, isFirstMessage: Bool = false, text: String = "", + attachmentsNames: String = "", attachments: [Attachment] = [], recording: Recording? = nil, replyMessage: ReplyMessage? = nil, @@ -99,6 +101,7 @@ public struct Message: Identifiable, Hashable { self.isFirstMessage = isFirstMessage self.address = address self.text = text + self.attachmentsNames = attachmentsNames self.attachments = attachments self.recording = recording self.replyMessage = replyMessage @@ -117,12 +120,12 @@ public struct Message: Identifiable, Hashable { switch media.type { case .image: - return Attachment(id: UUID().uuidString, url: thumbnailURL, type: .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, thumbnail: thumbnailURL, full: fullURL, type: .video) + return Attachment(id: UUID().uuidString, name: "", thumbnail: thumbnailURL, full: fullURL, type: .video) } } diff --git a/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift b/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift index c41d22749..6d2ff4472 100644 --- a/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift +++ b/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift @@ -46,6 +46,7 @@ class ConversationViewModel: ObservableObject { var oldMessageReceived = false @Published var selectedMessage: Message? + @Published var messageToReply: Message? init() {} @@ -180,6 +181,15 @@ class ConversationViewModel: ObservableObject { } } } + + 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) + } + } + } } } } @@ -188,6 +198,10 @@ class ConversationViewModel: ObservableObject { self.getHistorySize() self.getUnreadMessagesCount() self.getParticipantConversationModel() + + self.mediasToSend.removeAll() + self.messageToReply = nil + coreContext.doOnCoreQueue { _ in if self.displayedConversation != nil { let historyEvents = self.displayedConversation!.chatRoom.getHistoryRangeEvents(begin: 0, end: 30) @@ -195,6 +209,7 @@ class ConversationViewModel: ObservableObject { var conversationMessage: [Message] = [] historyEvents.enumerated().forEach { index, eventLog in + var attachmentNameList: String = "" var attachmentList: [Attachment] = [] var contentText = "" @@ -211,9 +226,11 @@ class ConversationViewModel: ObservableObject { let attachment = Attachment( id: UUID().uuidString, + name: content.name!, url: path!, type: .image ) + attachmentNameList += ", \(content.name!)" attachmentList.append(attachment) } } else { @@ -224,9 +241,11 @@ class ConversationViewModel: ObservableObject { let attachment = Attachment( id: UUID().uuidString, + name: content.name!, url: path!, type: (content.name?.lowercased().hasSuffix("gif"))! ? .gif : .image ) + attachmentNameList += ", \(content.name!)" attachmentList.append(attachment) } } else if content.type == "video" { @@ -237,10 +256,12 @@ class ConversationViewModel: ObservableObject { let attachment = Attachment( id: UUID().uuidString, + name: content.name!, thumbnail: pathThumbnail!, full: path!, type: .video ) + attachmentNameList += ", \(content.name!)" attachmentList.append(attachment) } } @@ -282,6 +303,10 @@ class ConversationViewModel: ObservableObject { reactionsTmp.append(chatMessageReaction.body) }) + if !attachmentNameList.isEmpty { + attachmentNameList = String(attachmentNameList.dropFirst(2)) + } + if eventLog.chatMessage != nil { conversationMessage.append( Message( @@ -292,6 +317,7 @@ class ConversationViewModel: ObservableObject { address: addressCleaned?.asStringUriOnly() ?? "", isFirstMessage: isFirstMessageTmp, text: contentText, + attachmentsNames: attachmentNameList, attachments: attachmentList, ownReaction: eventLog.chatMessage?.ownReaction?.body ?? "", reactions: reactionsTmp @@ -334,6 +360,7 @@ class ConversationViewModel: ObservableObject { var conversationMessagesTmp: [Message] = [] historyEvents.enumerated().reversed().forEach { index, eventLog in + var attachmentNameList: String = "" var attachmentList: [Attachment] = [] var contentText = "" @@ -350,9 +377,11 @@ class ConversationViewModel: ObservableObject { let attachment = Attachment( id: UUID().uuidString, + name: content.name!, url: path!, type: .image ) + attachmentNameList += ", \(content.name!)" attachmentList.append(attachment) } } else { @@ -363,9 +392,11 @@ class ConversationViewModel: ObservableObject { let attachment = Attachment( id: UUID().uuidString, + name: content.name!, url: path!, type: (content.name?.lowercased().hasSuffix("gif"))! ? .gif : .image ) + attachmentNameList += ", \(content.name!)" attachmentList.append(attachment) } } else if content.type == "video" { @@ -376,10 +407,12 @@ class ConversationViewModel: ObservableObject { let attachment = Attachment( id: UUID().uuidString, + name: content.name!, thumbnail: pathThumbnail!, full: path!, type: .video ) + attachmentNameList += ", \(content.name!)" attachmentList.append(attachment) } } @@ -421,6 +454,10 @@ class ConversationViewModel: ObservableObject { reactionsTmp.append(chatMessageReaction.body) }) + if !attachmentNameList.isEmpty { + attachmentNameList = String(attachmentNameList.dropFirst(2)) + } + if eventLog.chatMessage != nil { conversationMessagesTmp.insert( Message( @@ -431,6 +468,7 @@ class ConversationViewModel: ObservableObject { address: addressCleaned?.asStringUriOnly() ?? "", isFirstMessage: isFirstMessageTmp, text: contentText, + attachmentsNames: attachmentNameList, attachments: attachmentList, ownReaction: eventLog.chatMessage?.ownReaction?.body ?? "", reactions: reactionsTmp @@ -471,6 +509,7 @@ class ConversationViewModel: ObservableObject { func getNewMessages(eventLogs: [EventLog]) { eventLogs.enumerated().forEach { index, eventLog in + var attachmentNameList: String = "" var attachmentList: [Attachment] = [] var contentText = "" @@ -487,9 +526,11 @@ class ConversationViewModel: ObservableObject { let attachment = Attachment( id: UUID().uuidString, + name: content.name!, url: path!, type: .image ) + attachmentNameList += ", \(content.name!)" attachmentList.append(attachment) } } else if content.name != nil && !content.name!.isEmpty { @@ -500,9 +541,11 @@ class ConversationViewModel: ObservableObject { let attachment = Attachment( id: UUID().uuidString, + name: content.name!, url: path!, type: (content.name?.lowercased().hasSuffix("gif"))! ? .gif : .image ) + attachmentNameList += ", \(content.name!)" attachmentList.append(attachment) } } else if content.type == "video" { @@ -513,10 +556,12 @@ class ConversationViewModel: ObservableObject { let attachment = Attachment( id: UUID().uuidString, + name: content.name!, thumbnail: pathThumbnail!, full: path!, type: .video ) + attachmentNameList += ", \(content.name!)" attachmentList.append(attachment) } } @@ -573,6 +618,10 @@ class ConversationViewModel: ObservableObject { reactionsTmp.append(chatMessageReaction.body) }) + if !attachmentNameList.isEmpty { + attachmentNameList = String(attachmentNameList.dropFirst(2)) + } + if eventLog.chatMessage != nil { let message = Message( id: eventLog.chatMessage?.messageId ?? UUID().uuidString, @@ -582,6 +631,7 @@ class ConversationViewModel: ObservableObject { address: addressCleaned?.asStringUriOnly() ?? "", isFirstMessage: isFirstMessageTmp, text: contentText, + attachmentsNames: attachmentNameList, attachments: attachmentList, ownReaction: eventLog.chatMessage?.ownReaction?.body ?? "", reactions: reactionsTmp @@ -636,6 +686,17 @@ class ConversationViewModel: ObservableObject { 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 sendMessage() { coreContext.doOnCoreQueue { _ in //val messageToReplyTo = chatMessageToReplyTo diff --git a/Linphone/Utils/PhotoPicker.swift b/Linphone/Utils/PhotoPicker.swift index 6811d85b7..5bc4befb9 100644 --- a/Linphone/Utils/PhotoPicker.swift +++ b/Linphone/Utils/PhotoPicker.swift @@ -79,7 +79,7 @@ struct PhotoPicker: UIViewControllerRepresentable { 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, url: urlImage!, type: .image) + let attachment = Attachment(id: UUID().uuidString, name: urlFile!.lastPathComponent, url: urlImage!, type: .image) medias.append(attachment) } } catch { @@ -98,7 +98,7 @@ struct PhotoPicker: UIViewControllerRepresentable { let urlThumbnail = getURLThumbnail(name: urlFile!.lastPathComponent) if urlImage != nil { - let attachment = Attachment(id: UUID().uuidString, thumbnail: urlThumbnail, full: urlImage!, type: .video) + let attachment = Attachment(id: UUID().uuidString, name: urlFile!.lastPathComponent, thumbnail: urlThumbnail, full: urlImage!, type: .video) medias.append(attachment) } } catch { From 846a93849895a1713609a69fb93d55c5dc86c596 Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Tue, 20 Aug 2024 10:03:02 +0200 Subject: [PATCH 358/486] Add reply bubble message --- .../Fragments/ChatBubbleView.swift | 36 +++++- .../UI/Main/Conversations/Model/Message.swift | 3 + .../ViewModel/ConversationViewModel.swift | 108 ++++++++++++++++++ 3 files changed, 146 insertions(+), 1 deletion(-) diff --git a/Linphone/UI/Main/Conversations/Fragments/ChatBubbleView.swift b/Linphone/UI/Main/Conversations/Fragments/ChatBubbleView.swift index 75070b266..d88f2f016 100644 --- a/Linphone/UI/Main/Conversations/Fragments/ChatBubbleView.swift +++ b/Linphone/UI/Main/Conversations/Fragments/ChatBubbleView.swift @@ -63,8 +63,42 @@ struct ChatBubbleView: View { .padding(.top, 10) .padding(.bottom, 2) } + + if message.replyMessage != nil { + HStack { + if message.isOutgoing { + Spacer() + } + + VStack(alignment: message.isOutgoing ? .trailing : .leading) { + VStack(alignment: message.isOutgoing ? .trailing : .leading) { + if !message.replyMessage!.text.isEmpty { + Text(message.replyMessage!.text) + .foregroundStyle(Color.grayMain2c700) + .default_text_style(styleSize: 16) + .lineLimit(/*@START_MENU_TOKEN@*/2/*@END_MENU_TOKEN@*/) + } else if !message.replyMessage!.attachmentsNames.isEmpty { + Text(message.replyMessage!.attachmentsNames) + .foregroundStyle(Color.grayMain2c700) + .default_text_style(styleSize: 16) + .lineLimit(/*@START_MENU_TOKEN@*/2/*@END_MENU_TOKEN@*/) + } + } + .padding(.all, 15) + .padding(.bottom, 20) + .background(Color.gray200) + .clipShape(RoundedRectangle(cornerRadius: 16)) + } + + if !message.isOutgoing { + Spacer() + } + } + .frame(maxWidth: .infinity) + .padding(.bottom, -20) + } + ZStack { - HStack { if message.isOutgoing { Spacer() diff --git a/Linphone/UI/Main/Conversations/Model/Message.swift b/Linphone/UI/Main/Conversations/Model/Message.swift index 495848fde..067621a79 100644 --- a/Linphone/UI/Main/Conversations/Model/Message.swift +++ b/Linphone/UI/Main/Conversations/Model/Message.swift @@ -183,6 +183,7 @@ public struct ReplyMessage: Codable, Identifiable, Hashable { 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? @@ -192,6 +193,7 @@ public struct ReplyMessage: Codable, Identifiable, Hashable { text: String = "", isOutgoing: Bool, dateReceived: time_t, + attachmentsNames: String = "", attachments: [Attachment] = [], recording: Recording? = nil) { @@ -201,6 +203,7 @@ public struct ReplyMessage: Codable, Identifiable, Hashable { self.text = text self.isOutgoing = isOutgoing self.dateReceived = dateReceived + self.attachmentsNames = attachmentsNames self.attachments = attachments self.recording = recording } diff --git a/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift b/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift index 6d2ff4472..2bc81ec94 100644 --- a/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift +++ b/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift @@ -307,6 +307,37 @@ class ConversationViewModel: ObservableObject { attachmentNameList = String(attachmentNameList.dropFirst(2)) } + var replyMessageTmp: ReplyMessage? + if eventLog.chatMessage?.replyMessage != nil { + let addressReplyCleaned = eventLog.chatMessage?.replyMessage?.fromAddress?.clone() + addressCleaned?.clean() + + 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( Message( @@ -319,6 +350,7 @@ class ConversationViewModel: ObservableObject { text: contentText, attachmentsNames: attachmentNameList, attachments: attachmentList, + replyMessage: replyMessageTmp, ownReaction: eventLog.chatMessage?.ownReaction?.body ?? "", reactions: reactionsTmp ) @@ -458,6 +490,37 @@ class ConversationViewModel: ObservableObject { attachmentNameList = String(attachmentNameList.dropFirst(2)) } + var replyMessageTmp: ReplyMessage? + if eventLog.chatMessage?.replyMessage != nil { + let addressReplyCleaned = eventLog.chatMessage?.replyMessage?.fromAddress?.clone() + addressCleaned?.clean() + + 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( Message( @@ -470,6 +533,7 @@ class ConversationViewModel: ObservableObject { text: contentText, attachmentsNames: attachmentNameList, attachments: attachmentList, + replyMessage: replyMessageTmp, ownReaction: eventLog.chatMessage?.ownReaction?.body ?? "", reactions: reactionsTmp ), at: 0 @@ -622,6 +686,37 @@ class ConversationViewModel: ObservableObject { attachmentNameList = String(attachmentNameList.dropFirst(2)) } + var replyMessageTmp: ReplyMessage? + if eventLog.chatMessage?.replyMessage != nil { + let addressReplyCleaned = eventLog.chatMessage?.replyMessage?.fromAddress?.clone() + addressCleaned?.clean() + + 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 { let message = Message( id: eventLog.chatMessage?.messageId ?? UUID().uuidString, @@ -633,6 +728,7 @@ class ConversationViewModel: ObservableObject { text: contentText, attachmentsNames: attachmentNameList, attachments: attachmentList, + replyMessage: replyMessageTmp, ownReaction: eventLog.chatMessage?.ownReaction?.body ?? "", reactions: reactionsTmp ) @@ -707,6 +803,18 @@ class ConversationViewModel: ObservableObject { let message = try? self.displayedConversation!.chatRoom.createEmptyMessage() //} + /* + var message: Message? + if messageToReply != nil { + let chatMessageToReply = try? self.displayedConversation!.chatRoom.findMessage(messageId: messageToReply!.id) + 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 { From e870b7475877b25db3bacab5b4228e2b082feb13 Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Wed, 21 Aug 2024 10:38:58 +0200 Subject: [PATCH 359/486] Click on the reply message to scroll to it --- .../Fragments/ChatBubbleView.swift | 11 +- .../Main/Conversations/Fragments/UIList.swift | 41 +- .../ViewModel/ConversationViewModel.swift | 466 +++++++++++++----- 3 files changed, 386 insertions(+), 132 deletions(-) diff --git a/Linphone/UI/Main/Conversations/Fragments/ChatBubbleView.swift b/Linphone/UI/Main/Conversations/Fragments/ChatBubbleView.swift index d88f2f016..d89e39460 100644 --- a/Linphone/UI/Main/Conversations/Fragments/ChatBubbleView.swift +++ b/Linphone/UI/Main/Conversations/Fragments/ChatBubbleView.swift @@ -85,9 +85,16 @@ struct ChatBubbleView: View { } } .padding(.all, 15) - .padding(.bottom, 20) + .padding(.bottom, 15) .background(Color.gray200) - .clipShape(RoundedRectangle(cornerRadius: 16)) + .clipShape(RoundedRectangle(cornerRadius: 1)) + .roundedCorner( + 16, + corners: message.isOutgoing ? [.topLeft, .topRight, .bottomLeft] : [.topLeft, .topRight, .bottomRight] + ) + } + .onTapGesture { + conversationViewModel.scrollToMessage(message: message) } if !message.isOutgoing { diff --git a/Linphone/UI/Main/Conversations/Fragments/UIList.swift b/Linphone/UI/Main/Conversations/Fragments/UIList.swift index 201e49164..db7870bbd 100644 --- a/Linphone/UI/Main/Conversations/Fragments/UIList.swift +++ b/Linphone/UI/Main/Conversations/Fragments/UIList.swift @@ -22,6 +22,7 @@ import SwiftUI public extension Notification.Name { static let onScrollToBottom = Notification.Name("onScrollToBottom") + static let onScrollToIndex = Notification.Name("onScrollToIndex") } struct UIList: UIViewRepresentable { @@ -41,7 +42,7 @@ struct UIList: UIViewRepresentable { func makeUIView(context: Context) -> UITableView { let tableView = UITableView(frame: .zero, style: .grouped) - tableView.contentInset = UIEdgeInsets(top: -10, left: 0, bottom: -20, right: 0) + tableView.contentInset = UIEdgeInsets(top: -10, left: 0, bottom: 0, right: 0) tableView.translatesAutoresizingMaskIntoConstraints = false tableView.separatorStyle = .none tableView.dataSource = context.coordinator @@ -58,7 +59,37 @@ struct UIList: UIViewRepresentable { NotificationCenter.default.addObserver(forName: .onScrollToBottom, object: nil, queue: nil) { _ in DispatchQueue.main.async { if !context.coordinator.sections.isEmpty { - tableView.scrollToRow(at: IndexPath(row: 0, section: 0), at: .bottom, animated: true) + if context.coordinator.sections.first != nil + && conversationViewModel.conversationMessagesSection.first != nil + && conversationViewModel.displayedConversation != nil + && context.coordinator.sections.first!.chatRoomID == conversationViewModel.displayedConversation!.id + && context.coordinator.sections.first!.rows.count == conversationViewModel.conversationMessagesSection.first!.rows.count { + tableView.scrollToRow(at: IndexPath(row: 0, section: 0), at: .bottom, animated: true) + } else { + NotificationCenter.default.removeObserver(self, name: .onScrollToBottom, object: nil) + } + } + } + } + + NotificationCenter.default.addObserver(forName: .onScrollToIndex, object: nil, queue: nil) { notification in + DispatchQueue.main.async { + if !context.coordinator.sections.isEmpty { + if context.coordinator.sections.first != nil + && conversationViewModel.conversationMessagesSection.first != nil + && conversationViewModel.displayedConversation != nil + && context.coordinator.sections.first!.chatRoomID == conversationViewModel.displayedConversation!.id + && context.coordinator.sections.first!.rows.count == 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 { + tableView.scrollToRow(at: IndexPath(row: index, section: 0), at: .bottom, animated: animated) + } + } + } + } else { + NotificationCenter.default.removeObserver(self, name: .onScrollToIndex, object: nil) + } } } } @@ -308,7 +339,7 @@ struct UIList: UIViewRepresentable { if #available(iOS 16.0, *) { tableViewCell.contentConfiguration = UIHostingConfiguration { ChatBubbleView(conversationViewModel: conversationViewModel, message: row, geometryProxy: geometryProxy) - .padding(.vertical, 1) + .padding(.vertical, 2) .padding(.horizontal, 10) .onTapGesture { } } @@ -358,6 +389,7 @@ struct UIList: UIViewRepresentable { struct MessagesSection: Equatable { let date: Date + let chatRoomID: String var rows: [Message] static var formatter = { @@ -366,8 +398,9 @@ struct MessagesSection: Equatable { return formatter }() - init(date: Date, rows: [Message]) { + init(date: Date, chatRoomID: String, rows: [Message]) { self.date = date + self.chatRoomID = chatRoomID self.rows = rows } diff --git a/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift b/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift index 2bc81ec94..619e1f9e5 100644 --- a/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift +++ b/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift @@ -376,8 +376,8 @@ class ConversationViewModel: ObservableObject { } DispatchQueue.main.async { - if self.conversationMessagesSection.isEmpty { - self.conversationMessagesSection.append(MessagesSection(date: Date(), rows: conversationMessage.reversed())) + if self.conversationMessagesSection.isEmpty && self.displayedConversation != nil { + self.conversationMessagesSection.append(MessagesSection(date: Date(), chatRoomID: self.displayedConversation!.id, rows: conversationMessage.reversed())) } } } @@ -743,8 +743,8 @@ class ConversationViewModel: ObservableObject { self.conversationMessagesSection[0].rows[0].isFirstMessage = false } - if self.conversationMessagesSection.isEmpty { - self.conversationMessagesSection.append(MessagesSection(date: Date(), rows: [message])) + 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) } @@ -768,8 +768,8 @@ class ConversationViewModel: ObservableObject { ) DispatchQueue.main.async { - if self.conversationMessagesSection.isEmpty { - self.conversationMessagesSection.append(MessagesSection(date: Date(), rows: [message])) + 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) } @@ -793,136 +793,350 @@ class ConversationViewModel: ObservableObject { } } - func sendMessage() { + func scrollToMessage(message: Message) { coreContext.doOnCoreQueue { _ in - //val messageToReplyTo = chatMessageToReplyTo - //val message = if (messageToReplyTo != null) { - //Log.i("$TAG Sending message as reply to [${messageToReplyTo.messageId}]") - //chatRoom.createReplyMessage(messageToReplyTo) - //} else { - let message = try? self.displayedConversation!.chatRoom.createEmptyMessage() - //} - - /* - var message: Message? - if messageToReply != nil { - let chatMessageToReply = try? self.displayedConversation!.chatRoom.findMessage(messageId: messageToReply!.id) - 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 (isVoiceRecording.value == true && voiceMessageRecorder.file != null) { - stopVoiceRecorder() - val content = voiceMessageRecorder.createContent() - if (content != null) { - Log.i( - "$TAG Voice recording content created, file name is ${content.name} and duration is ${content.fileDuration}" - ) - message.addContent(content) - } else { - Log.e("$TAG Voice recording content couldn't be created!") - } - } 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 { + if message.replyMessage != nil { + if let indexMessage = self.conversationMessagesSection[0].rows.firstIndex(where: {$0.id == 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) - 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) - */ + var historyEvents = self.displayedConversation!.chatRoom.getHistoryRangeBetween( + firstEvent: firstEventLog!.first, + lastEvent: lastEventLog, + filters: UInt(ChatRoom.HistoryFilter([.ChatMessage, .InfoNoDevice]).rawValue) + ) - 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 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: [Message] = [] + + historyEvents.enumerated().reversed().forEach { index, eventLog in + var attachmentNameList: String = "" + var attachmentList: [Attachment] = [] + var contentText = "" - let filePathTmp = newPath?.absoluteString - content.filePath = String(filePathTmp!.dropFirst(7)) - message!.addFileContent(content: content) - } catch { - Log.error(error.localizedDescription) + 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: .image + ) + attachmentNameList += ", \(content.name!)" + attachmentList.append(attachment) + } + } else { + if content.type != "video" { + let path = URL(string: self.getNewFilePath(name: content.name ?? "")) + + if path != nil { + let attachment = + Attachment( + id: UUID().uuidString, + name: content.name!, + url: path!, + type: (content.name?.lowercased().hasSuffix("gif"))! ? .gif : .image + ) + 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 + ) + 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() + + 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 + default: + statusTmp = nil + } + + 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() + addressCleaned?.clean() + + 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( + Message( + id: 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, + ownReaction: eventLog.chatMessage?.ownReaction?.body ?? "", + reactions: reactionsTmp + ), at: 0 + ) + + self.addChatMessageDelegate(message: eventLog.chatMessage!) + } else { + conversationMessagesTmp.insert( + 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?.address == conversationMessagesTmp.last?.address { + self.conversationMessagesSection[0].rows[self.conversationMessagesSection[0].rows.count - 1].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] + ) + } } } - } 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 { - withAnimation { - self.mediasToSend.removeAll() + } + } + + func sendMessage() { + coreContext.doOnCoreQueue { _ in + do { + var message: ChatMessage? + if self.messageToReply != nil { + let chatMessageToReply = self.displayedConversation!.chatRoom.findMessage(messageId: self.messageToReply!.id) + if chatMessageToReply != nil { + message = try self.displayedConversation!.chatRoom.createReplyMessage(message: chatMessageToReply!) + } + self.messageToReply = nil + } else { + message = try self.displayedConversation!.chatRoom.createEmptyMessage() } - self.messageText = "" + + let toSend = self.messageText.trimmingCharacters(in: .whitespacesAndNewlines) + if !toSend.isEmpty { + if message != nil { + message!.addUtf8TextContent(text: toSend) + } + } + + /* + if (isVoiceRecording.value == true && voiceMessageRecorder.file != null) { + stopVoiceRecorder() + val content = voiceMessageRecorder.createContent() + if (content != null) { + Log.i( + "$TAG Voice recording content created, file name is ${content.name} and duration is ${content.fileDuration}" + ) + message.addContent(content) + } else { + Log.e("$TAG Voice recording content couldn't be created!") + } + } 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 { + 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 { + } - - /* - 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 - */ } } From 885c14ef9c69394429d4be8ac1bdc7cbca1574cf Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Wed, 21 Aug 2024 16:10:28 +0200 Subject: [PATCH 360/486] Fix crash when sending a message and crash in conversation list --- .../Fragments/ConversationsListFragment.swift | 226 +++++++++--------- .../ViewModel/ConversationViewModel.swift | 10 +- 2 files changed, 119 insertions(+), 117 deletions(-) diff --git a/Linphone/UI/Main/Conversations/Fragments/ConversationsListFragment.swift b/Linphone/UI/Main/Conversations/Fragments/ConversationsListFragment.swift index c6aeabb78..4b2c27d74 100644 --- a/Linphone/UI/Main/Conversations/Fragments/ConversationsListFragment.swift +++ b/Linphone/UI/Main/Conversations/Fragments/ConversationsListFragment.swift @@ -32,129 +32,131 @@ struct ConversationsListFragment: View { VStack { List { ForEach(0.. 0) { view in - view.default_text_style_700(styleSize: 14) - } - .default_text_style(styleSize: 14) - .frame(maxWidth: .infinity, alignment: .leading) - .lineLimit(1) - - Text(conversationsListViewModel.conversationsList[index].lastMessageText) - .foregroundStyle(Color.grayMain2c400) - .if(conversationsListViewModel.conversationsList[index].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 !conversationsListViewModel.conversationsList[index].encryptionEnabled { - Image("warning-circle") - .renderingMode(.template) - .resizable() - .foregroundStyle(Color.redDanger500) - .frame(width: 18, height: 18, alignment: .trailing) - } + VStack(spacing: 0) { + Spacer() - Text(conversationsListViewModel.getCallTime(startDate: conversationsListViewModel.conversationsList[index].lastUpdateTime)) - .foregroundStyle(Color.grayMain2c400) - .default_text_style(styleSize: 14) - .lineLimit(1) - } - - Spacer() - - HStack { - if conversationsListViewModel.conversationsList[index].isMuted == false - && !(!conversationsListViewModel.conversationsList[index].lastMessageText.isEmpty - && conversationsListViewModel.conversationsList[index].lastMessageIsOutgoing == true) - && conversationsListViewModel.conversationsList[index].unreadMessagesCount == 0 { - Text("") - .frame(width: 18, height: 18, alignment: .trailing) - } - - if conversationsListViewModel.conversationsList[index].isMuted { - Image("bell-slash") - .renderingMode(.template) - .resizable() - .foregroundStyle(Color.orangeMain500) - .frame(width: 18, height: 18, alignment: .trailing) - } - - if !conversationsListViewModel.conversationsList[index].lastMessageText.isEmpty - && conversationsListViewModel.conversationsList[index].lastMessageIsOutgoing == true { - let imageName = LinphoneUtils.getChatIconState(chatState: conversationsListViewModel.conversationsList[index].lastMessageState) - Image(imageName) - .renderingMode(.template) - .resizable() - .foregroundStyle(Color.orangeMain500) - .frame(width: 18, height: 18, alignment: .trailing) - } - - if conversationsListViewModel.conversationsList[index].unreadMessagesCount > 0 { - HStack { - Text( - conversationsListViewModel.conversationsList[index].unreadMessagesCount < 99 - ? String(conversationsListViewModel.conversationsList[index].unreadMessagesCount) - : "99+" - ) - .foregroundStyle(.white) - .default_text_style(styleSize: 10) - .lineLimit(1) + Text(conversationsListViewModel.conversationsList[index].subject) + .foregroundStyle(Color.grayMain2c800) + .if(conversationsListViewModel.conversationsList[index].unreadMessagesCount > 0) { view in + view.default_text_style_700(styleSize: 14) } - .frame(width: 18, height: 18) - .background(Color.redDanger500) - .cornerRadius(50) - } + .default_text_style(styleSize: 14) + .frame(maxWidth: .infinity, alignment: .leading) + .lineLimit(1) + + Text(conversationsListViewModel.conversationsList[index].lastMessageText) + .foregroundStyle(Color.grayMain2c400) + .if(conversationsListViewModel.conversationsList[index].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() - } - .padding(.trailing, 10) - } - .frame(height: 50) - .buttonStyle(.borderless) - .listRowInsets(EdgeInsets(top: 6, leading: 20, bottom: 6, trailing: 20)) - .listRowSeparator(.hidden) - .background(.white) - .onTapGesture { - if index < conversationsListViewModel.conversationsList.count { - if conversationViewModel.displayedConversation != nil { - conversationViewModel.displayedConversation = nil - conversationViewModel.selectedMessage = nil - conversationViewModel.resetMessage() - conversationViewModel.changeDisplayedChatRoom(conversationModel: conversationsListViewModel.conversationsList[index]) + + VStack(alignment: .trailing, spacing: 0) { + Spacer() - conversationViewModel.getMessages() - } else { - conversationViewModel.selectedMessage = nil - withAnimation { + HStack { + if !conversationsListViewModel.conversationsList[index].encryptionEnabled { + Image("warning-circle") + .renderingMode(.template) + .resizable() + .foregroundStyle(Color.redDanger500) + .frame(width: 18, height: 18, alignment: .trailing) + } + + Text(conversationsListViewModel.getCallTime(startDate: conversationsListViewModel.conversationsList[index].lastUpdateTime)) + .foregroundStyle(Color.grayMain2c400) + .default_text_style(styleSize: 14) + .lineLimit(1) + } + + Spacer() + + HStack { + if conversationsListViewModel.conversationsList[index].isMuted == false + && !(!conversationsListViewModel.conversationsList[index].lastMessageText.isEmpty + && conversationsListViewModel.conversationsList[index].lastMessageIsOutgoing == true) + && conversationsListViewModel.conversationsList[index].unreadMessagesCount == 0 { + Text("") + .frame(width: 18, height: 18, alignment: .trailing) + } + + if conversationsListViewModel.conversationsList[index].isMuted { + Image("bell-slash") + .renderingMode(.template) + .resizable() + .foregroundStyle(Color.orangeMain500) + .frame(width: 18, height: 18, alignment: .trailing) + } + + if !conversationsListViewModel.conversationsList[index].lastMessageText.isEmpty + && conversationsListViewModel.conversationsList[index].lastMessageIsOutgoing == true { + let imageName = LinphoneUtils.getChatIconState(chatState: conversationsListViewModel.conversationsList[index].lastMessageState) + Image(imageName) + .renderingMode(.template) + .resizable() + .foregroundStyle(Color.orangeMain500) + .frame(width: 18, height: 18, alignment: .trailing) + } + + if conversationsListViewModel.conversationsList[index].unreadMessagesCount > 0 { + HStack { + Text( + conversationsListViewModel.conversationsList[index].unreadMessagesCount < 99 + ? String(conversationsListViewModel.conversationsList[index].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) + .onTapGesture { + if index < conversationsListViewModel.conversationsList.count { + if conversationViewModel.displayedConversation != nil { + conversationViewModel.displayedConversation = nil + conversationViewModel.selectedMessage = nil + conversationViewModel.resetMessage() conversationViewModel.changeDisplayedChatRoom(conversationModel: conversationsListViewModel.conversationsList[index]) + + conversationViewModel.getMessages() + } else { + conversationViewModel.selectedMessage = nil + withAnimation { + conversationViewModel.changeDisplayedChatRoom(conversationModel: conversationsListViewModel.conversationsList[index]) + } } } } - } - .onLongPressGesture(minimumDuration: 0.2) { - if index < conversationsListViewModel.conversationsList.count { - conversationsListViewModel.selectedConversation = conversationsListViewModel.conversationsList[index] - showingSheet.toggle() + .onLongPressGesture(minimumDuration: 0.2) { + if index < conversationsListViewModel.conversationsList.count { + conversationsListViewModel.selectedConversation = conversationsListViewModel.conversationsList[index] + showingSheet.toggle() + } } } } diff --git a/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift b/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift index 619e1f9e5..33ae49c0a 100644 --- a/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift +++ b/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift @@ -80,7 +80,7 @@ class ConversationViewModel: ObservableObject { case .Displayed: statusTmp = .read default: - statusTmp = nil + statusTmp = .sending } let indexMessage = self.conversationMessagesSection[0].rows.firstIndex(where: {$0.id == message.messageId}) @@ -295,7 +295,7 @@ class ConversationViewModel: ObservableObject { case .Displayed: statusTmp = .read default: - statusTmp = nil + statusTmp = .sending } var reactionsTmp: [String] = [] @@ -478,7 +478,7 @@ class ConversationViewModel: ObservableObject { case .Displayed: statusTmp = .read default: - statusTmp = nil + statusTmp = .sending } var reactionsTmp: [String] = [] @@ -674,7 +674,7 @@ class ConversationViewModel: ObservableObject { case .Displayed: statusTmp = .read default: - statusTmp = nil + statusTmp = .sending } var reactionsTmp: [String] = [] @@ -912,7 +912,7 @@ class ConversationViewModel: ObservableObject { case .Displayed: statusTmp = .read default: - statusTmp = nil + statusTmp = .sending } var reactionsTmp: [String] = [] From 1abc35de0c452dceef2b881479aa94bc46b8cc61 Mon Sep 17 00:00:00 2001 From: QuentinArguillere Date: Tue, 20 Aug 2024 18:20:07 +0200 Subject: [PATCH 361/486] Remove all access to core.Accounts from main thread. Directly store [AccountModel] in coreContext published variable --- Linphone/Core/CoreContext.swift | 52 +++++++++++++------ Linphone/UI/Main/Fragments/SideMenu.swift | 5 +- Linphone/UI/Main/Viewmodel/AccountModel.swift | 35 +++++++------ 3 files changed, 58 insertions(+), 34 deletions(-) diff --git a/Linphone/Core/CoreContext.swift b/Linphone/Core/CoreContext.swift index 120aeab2b..987a2ebbd 100644 --- a/Linphone/Core/CoreContext.swift +++ b/Linphone/Core/CoreContext.swift @@ -40,7 +40,7 @@ final class CoreContext: ObservableObject { @Published var loggingInProgress: Bool = false @Published var hasDefaultAccount: Bool = false @Published var coreIsStarted: Bool = false - @Published var accounts: [Account] = [] + @Published var accounts: [AccountModel] = [] private var mCore: Core! private var mIterateSuscription: AnyCancellable? @@ -128,6 +128,7 @@ final class CoreContext: ObservableObject { self.mCore.maxSizeForAutoDownloadIncomingFiles = 0 self.mCore.config!.setBool(section: "sip", key: "auto_answer_replacing_calls", value: false) + self.mCoreSuscriptions.insert(self.mCore.publisher?.onGlobalStateChanged?.postOnCoreQueue { (cbVal: (core: Core, state: GlobalState, message: String)) in if cbVal.state == GlobalState.On { #if DEBUG @@ -142,18 +143,23 @@ final class CoreContext: ObservableObject { account.params = newParams } - self.actionsToPerformOnCoreQueueWhenCoreIsStarted.forEach {$0(cbVal.core)} + self.actionsToPerformOnCoreQueueWhenCoreIsStarted.forEach { $0(cbVal.core) } self.actionsToPerformOnCoreQueueWhenCoreIsStarted.removeAll() + let hasDefaultAccount = self.mCore.defaultAccount != nil ? true : false + var accountModels: [AccountModel] = [] + for account in self.mCore.accountList { + accountModels.append(AccountModel(account: account, corePublisher: self.mCore.publisher)) + } DispatchQueue.main.async { - if cbVal.state == GlobalState.On { - self.hasDefaultAccount = self.mCore.defaultAccount != nil ? true : false - self.coreIsStarted = true - self.accounts = self.mCore.accountList - } else if cbVal.state == GlobalState.Off { - self.hasDefaultAccount = false - self.coreIsStarted = false - } + self.hasDefaultAccount = hasDefaultAccount + self.coreIsStarted = true + self.accounts = accountModels + } + } else if cbVal.state == GlobalState.Off { + DispatchQueue.main.async { + self.hasDefaultAccount = false + self.coreIsStarted = false } } }) @@ -162,11 +168,15 @@ final class CoreContext: ObservableObject { // In this case, we want to know about the account registration status self.mCoreSuscriptions.insert(self.mCore.publisher?.onConfiguringStatus?.postOnCoreQueue { (cbVal: (core: Core, status: ConfiguringState, message: String)) in Log.info("New configuration state is \(cbVal.status) = \(cbVal.message)\n") + var accountModels: [AccountModel] = [] + for account in self.mCore.accountList { + accountModels.append(AccountModel(account: account, corePublisher: self.mCore.publisher)) + } DispatchQueue.main.async { if cbVal.status == ConfiguringState.Successful { ToastViewModel.shared.toastMessage = "Successful" ToastViewModel.shared.displayToast = true - self.accounts = self.mCore.accountList + self.accounts = accountModels } } /* @@ -288,13 +298,25 @@ final class CoreContext: ObservableObject { }) self.mCoreSuscriptions.insert(self.mCore.publisher?.onAccountAdded? - .postOnMainQueue { _ in - self.accounts = self.mCore.accountList + .postOnCoreQueue { _ in + var accountModels: [AccountModel] = [] + for account in self.mCore.accountList { + accountModels.append(AccountModel(account: account, corePublisher: self.mCore.publisher)) + } + DispatchQueue.main.async { + self.accounts = accountModels + } }) self.mCoreSuscriptions.insert(self.mCore.publisher?.onAccountRemoved? - .postOnMainQueue { _ in - self.accounts = self.mCore.accountList + .postOnCoreQueue { _ in + var accountModels: [AccountModel] = [] + for account in self.mCore.accountList { + accountModels.append(AccountModel(account: account, corePublisher: self.mCore.publisher)) + } + DispatchQueue.main.async { + self.accounts = accountModels + } }) self.mIterateSuscription = Timer.publish(every: 0.02, on: .main, in: .common) diff --git a/Linphone/UI/Main/Fragments/SideMenu.swift b/Linphone/UI/Main/Fragments/SideMenu.swift index c3ab8b756..2b388ca57 100644 --- a/Linphone/UI/Main/Fragments/SideMenu.swift +++ b/Linphone/UI/Main/Fragments/SideMenu.swift @@ -66,10 +66,7 @@ struct SideMenu: View { List { ForEach(0..() + init(account: Account, corePublisher: CoreDelegatePublisher?) { self.account = account + + mSuscriptions.insert(account.publisher?.onRegistrationStateChanged?.postOnCoreQueue { _ in + self.update() + }) + mSuscriptions.insert(corePublisher?.onChatRoomRead?.postOnCoreQueue( + receiveValue: { _ in + self.computeNotificationsCount() + })) + mSuscriptions.insert(corePublisher?.onMessagesReceived?.postOnCoreQueue( + receiveValue: { _ in + self.computeNotificationsCount() + })) + mSuscriptions.insert(corePublisher?.onCallStateChanged?.postOnCoreQueue( + receiveValue: { _ in + self.computeNotificationsCount() + })) + CoreContext.shared.doOnCoreQueue { _ in self.update() } - account.publisher?.onRegistrationStateChanged?.postOnCoreQueue { _ in - self.update() - } - corePublisher?.onChatRoomRead?.postOnCoreQueue( - receiveValue: { _ in - self.computeNotificationsCount() - }) - corePublisher?.onMessagesReceived?.postOnCoreQueue( - receiveValue: { _ in - self.computeNotificationsCount() - }) - corePublisher?.onCallStateChanged?.postOnCoreQueue( - receiveValue: { _ in - self.computeNotificationsCount() - }) } private func update() { From 179fbaff14cd827b2b2b86f5712a9d6dd088b997 Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Wed, 21 Aug 2024 17:22:55 +0200 Subject: [PATCH 362/486] GlobalState.Off check removed from core initialization --- Linphone/Core/CoreContext.swift | 5 ----- 1 file changed, 5 deletions(-) diff --git a/Linphone/Core/CoreContext.swift b/Linphone/Core/CoreContext.swift index 987a2ebbd..13e3e3252 100644 --- a/Linphone/Core/CoreContext.swift +++ b/Linphone/Core/CoreContext.swift @@ -156,11 +156,6 @@ final class CoreContext: ObservableObject { self.coreIsStarted = true self.accounts = accountModels } - } else if cbVal.state == GlobalState.Off { - DispatchQueue.main.async { - self.hasDefaultAccount = false - self.coreIsStarted = false - } } }) From 4eaee9d36d05f20cc9571b608feec070760a0971 Mon Sep 17 00:00:00 2001 From: QuentinArguillere Date: Tue, 20 Aug 2024 10:08:38 +0200 Subject: [PATCH 363/486] Add Crashlytics script build phase to msgNotificationService target in pbxproj --- Linphone.xcodeproj/project.pbxproj | 32 ++++++++++++++++++++++++++---- 1 file changed, 28 insertions(+), 4 deletions(-) diff --git a/Linphone.xcodeproj/project.pbxproj b/Linphone.xcodeproj/project.pbxproj index f46abb965..6c4eeda84 100644 --- a/Linphone.xcodeproj/project.pbxproj +++ b/Linphone.xcodeproj/project.pbxproj @@ -880,6 +880,7 @@ 660AAF772B839271004C0FA6 /* Sources */, 660AAF782B839271004C0FA6 /* Frameworks */, 660AAF792B839271004C0FA6 /* Resources */, + 6677CE082C73D71A0020FD0E /* Crashlytics */, ); buildRules = ( ); @@ -983,6 +984,29 @@ /* End PBXResourcesBuildPhase section */ /* Begin PBXShellScriptBuildPhase section */ + 6677CE082C73D71A0020FD0E /* Crashlytics */ = { + isa = PBXShellScriptBuildPhase; + 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; buildActionMask = 2147483647; @@ -1204,7 +1228,7 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = Z2V957B3D6; - ENABLE_USER_SCRIPT_SANDBOXING = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; GCC_C_LANGUAGE_STANDARD = gnu17; GCC_PREPROCESSOR_DEFINITIONS = ( "$(inherited)", @@ -1246,7 +1270,7 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = Z2V957B3D6; - ENABLE_USER_SCRIPT_SANDBOXING = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; GCC_C_LANGUAGE_STANDARD = gnu17; GCC_PREPROCESSOR_DEFINITIONS = "$(inherited)"; GENERATE_INFOPLIST_FILE = YES; @@ -1398,7 +1422,7 @@ ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = NO; CODE_SIGN_ENTITLEMENTS = Linphone/Linphone.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 27; + CURRENT_PROJECT_VERSION = 32; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_ASSET_PATHS = "\"Linphone/Preview Content\""; @@ -1454,7 +1478,7 @@ ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = NO; CODE_SIGN_ENTITLEMENTS = Linphone/Linphone.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 27; + CURRENT_PROJECT_VERSION = 32; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_ASSET_PATHS = "\"Linphone/Preview Content\""; DEVELOPMENT_TEAM = Z2V957B3D6; From c821b960edab96f5b9731a5bd806c00edd04a78b Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Thu, 22 Aug 2024 12:12:58 +0200 Subject: [PATCH 364/486] Set deliver_imdn to false --- Linphone/Core/CoreContext.swift | 2 +- .../Main/Conversations/ViewModel/ConversationViewModel.swift | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Linphone/Core/CoreContext.swift b/Linphone/Core/CoreContext.swift index 13e3e3252..231fae30e 100644 --- a/Linphone/Core/CoreContext.swift +++ b/Linphone/Core/CoreContext.swift @@ -127,7 +127,7 @@ final class CoreContext: ObservableObject { 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.mCoreSuscriptions.insert(self.mCore.publisher?.onGlobalStateChanged?.postOnCoreQueue { (cbVal: (core: Core, state: GlobalState, message: String)) in if cbVal.state == GlobalState.On { diff --git a/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift b/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift index 33ae49c0a..f0600c597 100644 --- a/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift +++ b/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift @@ -590,11 +590,11 @@ class ConversationViewModel: ObservableObject { let attachment = Attachment( id: UUID().uuidString, - name: content.name!, + name: content.name ?? "???", url: path!, type: .image ) - attachmentNameList += ", \(content.name!)" + attachmentNameList += ", \(content.name ?? "???")" attachmentList.append(attachment) } } else if content.name != nil && !content.name!.isEmpty { From e01a27f5386bfa4551a7c384f14bf1f19de167d1 Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Thu, 22 Aug 2024 15:53:19 +0200 Subject: [PATCH 365/486] Fix chat room switching in landscape mode --- .../Fragments/ConversationsListFragment.swift | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/Linphone/UI/Main/Conversations/Fragments/ConversationsListFragment.swift b/Linphone/UI/Main/Conversations/Fragments/ConversationsListFragment.swift index 4b2c27d74..ef7ff73dd 100644 --- a/Linphone/UI/Main/Conversations/Fragments/ConversationsListFragment.swift +++ b/Linphone/UI/Main/Conversations/Fragments/ConversationsListFragment.swift @@ -137,13 +137,16 @@ struct ConversationsListFragment: View { .background(.white) .onTapGesture { if index < conversationsListViewModel.conversationsList.count { - if conversationViewModel.displayedConversation != nil { + if conversationViewModel.displayedConversation != nil { conversationViewModel.displayedConversation = nil - conversationViewModel.selectedMessage = nil - conversationViewModel.resetMessage() - conversationViewModel.changeDisplayedChatRoom(conversationModel: conversationsListViewModel.conversationsList[index]) - - conversationViewModel.getMessages() + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + conversationViewModel.selectedMessage = nil + conversationViewModel.resetMessage() + withAnimation { + conversationViewModel.changeDisplayedChatRoom(conversationModel: conversationsListViewModel.conversationsList[index]) + } + conversationViewModel.getMessages() + } } else { conversationViewModel.selectedMessage = nil withAnimation { From e792810c3c7f43385ca9eaa4ea3510d79d6a9d5c Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Thu, 22 Aug 2024 16:11:40 +0200 Subject: [PATCH 366/486] Change imdn icon --- Linphone/UI/Main/Conversations/Fragments/ChatBubbleView.swift | 3 ++- Linphone/UI/Main/Conversations/Model/Message.swift | 4 ++-- .../Main/Conversations/ViewModel/ConversationViewModel.swift | 4 +++- 3 files changed, 7 insertions(+), 4 deletions(-) diff --git a/Linphone/UI/Main/Conversations/Fragments/ChatBubbleView.swift b/Linphone/UI/Main/Conversations/Fragments/ChatBubbleView.swift index d89e39460..d7fed15c1 100644 --- a/Linphone/UI/Main/Conversations/Fragments/ChatBubbleView.swift +++ b/Linphone/UI/Main/Conversations/Fragments/ChatBubbleView.swift @@ -132,8 +132,9 @@ struct ChatBubbleView: View { if (conversationViewModel.displayedConversation != nil && conversationViewModel.displayedConversation!.isGroup) || message.isOutgoing { if message.status == .sending { ProgressView() + .controlSize(.mini) .progressViewStyle(CircularProgressViewStyle(tint: .orangeMain500)) - .frame(width: 15, height: 15) + .frame(width: 10, height: 10) .padding(.top, 1) } else if message.status != nil { Image(conversationViewModel.getImageIMDN(status: message.status!)) diff --git a/Linphone/UI/Main/Conversations/Model/Message.swift b/Linphone/UI/Main/Conversations/Model/Message.swift index 067621a79..a351c04b9 100644 --- a/Linphone/UI/Main/Conversations/Model/Message.swift +++ b/Linphone/UI/Main/Conversations/Model/Message.swift @@ -26,7 +26,7 @@ public struct Message: Identifiable, Hashable { case sent case received case read - case error(DraftMessage) + case error public func hash(into hasher: inout Hasher) { switch self { @@ -53,7 +53,7 @@ public struct Message: Identifiable, Hashable { return true case (.read, .read): return true - case ( .error(_), .error(_)): + case ( .error, .error): return true default: return false diff --git a/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift b/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift index f0600c597..6d267ac39 100644 --- a/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift +++ b/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift @@ -294,6 +294,8 @@ class ConversationViewModel: ObservableObject { statusTmp = .received case .Displayed: statusTmp = .read + case .NotDelivered: + statusTmp = .error default: statusTmp = .sending } @@ -1255,7 +1257,7 @@ class ConversationViewModel: ObservableObject { case .read: return "checks" case .error: - return "" + return "warning-circle" } } From ace392528b29f40d4975adba0b175a526d209c3a Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Mon, 26 Aug 2024 10:52:15 +0200 Subject: [PATCH 367/486] Add eventLog to ui message object for message list --- Linphone.xcodeproj/project.pbxproj | 12 +- .../Fragments/ChatBubbleView.swift | 132 ++++---- .../Fragments/ConversationFragment.swift | 46 +-- .../Main/Conversations/Fragments/UIList.swift | 54 ++- .../UI/Main/Conversations/Model/Message.swift | 3 + .../ViewModel/ConversationViewModel.swift | 318 ++++++++++-------- 6 files changed, 311 insertions(+), 254 deletions(-) diff --git a/Linphone.xcodeproj/project.pbxproj b/Linphone.xcodeproj/project.pbxproj index 6c4eeda84..b0c478b0d 100644 --- a/Linphone.xcodeproj/project.pbxproj +++ b/Linphone.xcodeproj/project.pbxproj @@ -7,7 +7,7 @@ objects = { /* Begin PBXBuildFile section */ - 4ED1F0A881A9ACB5977A8987 /* BuildFile in Frameworks */ = {isa = PBXBuildFile; }; + 4ED1F0A881A9ACB5977A8987 /* (null) 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 */; }; @@ -364,7 +364,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - 4ED1F0A881A9ACB5977A8987 /* BuildFile in Frameworks */, + 4ED1F0A881A9ACB5977A8987 /* (null) in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -1238,7 +1238,7 @@ INFOPLIST_FILE = msgNotificationService/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = msgNotificationService; INFOPLIST_KEY_NSHumanReadableCopyright = ""; - IPHONEOS_DEPLOYMENT_TARGET = 17.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -1277,7 +1277,7 @@ INFOPLIST_FILE = msgNotificationService/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = msgNotificationService; INFOPLIST_KEY_NSHumanReadableCopyright = ""; - IPHONEOS_DEPLOYMENT_TARGET = 17.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -1422,7 +1422,7 @@ ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = NO; CODE_SIGN_ENTITLEMENTS = Linphone/Linphone.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 32; + CURRENT_PROJECT_VERSION = 36; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_ASSET_PATHS = "\"Linphone/Preview Content\""; @@ -1478,7 +1478,7 @@ ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = NO; CODE_SIGN_ENTITLEMENTS = Linphone/Linphone.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 32; + CURRENT_PROJECT_VERSION = 36; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_ASSET_PATHS = "\"Linphone/Preview Content\""; DEVELOPMENT_TEAM = Z2V957B3D6; diff --git a/Linphone/UI/Main/Conversations/Fragments/ChatBubbleView.swift b/Linphone/UI/Main/Conversations/Fragments/ChatBubbleView.swift index d7fed15c1..24f5b4e58 100644 --- a/Linphone/UI/Main/Conversations/Fragments/ChatBubbleView.swift +++ b/Linphone/UI/Main/Conversations/Fragments/ChatBubbleView.swift @@ -24,7 +24,7 @@ struct ChatBubbleView: View { @ObservedObject var conversationViewModel: ConversationViewModel - let message: Message + let eventLogMessage: EventLogMessage let geometryProxy: GeometryProxy @@ -35,50 +35,50 @@ struct ChatBubbleView: View { var body: some View { HStack { VStack { - if !message.text.isEmpty || !message.attachments.isEmpty { + if !eventLogMessage.message.text.isEmpty || !eventLogMessage.message.attachments.isEmpty { HStack(alignment: .top, content: { - if message.isOutgoing { + if eventLogMessage.message.isOutgoing { Spacer() } - if conversationViewModel.displayedConversation != nil && conversationViewModel.displayedConversation!.isGroup && !message.isOutgoing && message.isFirstMessage { + if conversationViewModel.displayedConversation != nil && conversationViewModel.displayedConversation!.isGroup && !eventLogMessage.message.isOutgoing && eventLogMessage.message.isFirstMessage { VStack { Avatar( - contactAvatarModel: conversationViewModel.participantConversationModel.first(where: {$0.address == message.address}) ?? + 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 && !message.isOutgoing { + } 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 && !message.isOutgoing && message.isFirstMessage { - Text(conversationViewModel.participantConversationModel.first(where: {$0.address == message.address})?.name ?? "") + 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, 10) .padding(.bottom, 2) } - if message.replyMessage != nil { + if eventLogMessage.message.replyMessage != nil { HStack { - if message.isOutgoing { + if eventLogMessage.message.isOutgoing { Spacer() } - VStack(alignment: message.isOutgoing ? .trailing : .leading) { - VStack(alignment: message.isOutgoing ? .trailing : .leading) { - if !message.replyMessage!.text.isEmpty { - Text(message.replyMessage!.text) + VStack(alignment: eventLogMessage.message.isOutgoing ? .trailing : .leading) { + 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: 16) .lineLimit(/*@START_MENU_TOKEN@*/2/*@END_MENU_TOKEN@*/) - } else if !message.replyMessage!.attachmentsNames.isEmpty { - Text(message.replyMessage!.attachmentsNames) + } else if !eventLogMessage.message.replyMessage!.attachmentsNames.isEmpty { + Text(eventLogMessage.message.replyMessage!.attachmentsNames) .foregroundStyle(Color.grayMain2c700) .default_text_style(styleSize: 16) .lineLimit(/*@START_MENU_TOKEN@*/2/*@END_MENU_TOKEN@*/) @@ -90,14 +90,14 @@ struct ChatBubbleView: View { .clipShape(RoundedRectangle(cornerRadius: 1)) .roundedCorner( 16, - corners: message.isOutgoing ? [.topLeft, .topRight, .bottomLeft] : [.topLeft, .topRight, .bottomRight] + corners: eventLogMessage.message.isOutgoing ? [.topLeft, .topRight, .bottomLeft] : [.topLeft, .topRight, .bottomRight] ) } .onTapGesture { - conversationViewModel.scrollToMessage(message: message) + conversationViewModel.scrollToMessage(message: eventLogMessage.message) } - if !message.isOutgoing { + if !eventLogMessage.message.isOutgoing { Spacer() } } @@ -107,37 +107,37 @@ struct ChatBubbleView: View { ZStack { HStack { - if message.isOutgoing { + if eventLogMessage.message.isOutgoing { Spacer() } - VStack(alignment: message.isOutgoing ? .trailing : .leading) { - VStack(alignment: message.isOutgoing ? .trailing : .leading) { - if !message.attachments.isEmpty { + VStack(alignment: eventLogMessage.message.isOutgoing ? .trailing : .leading) { + VStack(alignment: eventLogMessage.message.isOutgoing ? .trailing : .leading) { + if !eventLogMessage.message.attachments.isEmpty { messageAttachments() } - if !message.text.isEmpty { - Text(message.text) + if !eventLogMessage.message.text.isEmpty { + Text(eventLogMessage.message.text) .foregroundStyle(Color.grayMain2c700) .default_text_style(styleSize: 16) } HStack(alignment: .center) { - Text(conversationViewModel.getMessageTime(startDate: message.dateReceived)) + Text(conversationViewModel.getMessageTime(startDate: eventLogMessage.message.dateReceived)) .foregroundStyle(Color.grayMain2c500) .default_text_style_300(styleSize: 14) .padding(.top, 1) - if (conversationViewModel.displayedConversation != nil && conversationViewModel.displayedConversation!.isGroup) || message.isOutgoing { - if message.status == .sending { + 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 message.status != nil { - Image(conversationViewModel.getImageIMDN(status: message.status!)) + } else if eventLogMessage.message.status != nil { + Image(conversationViewModel.getImageIMDN(status: eventLogMessage.message.status!)) .renderingMode(.template) .resizable() .foregroundStyle(Color.orangeMain500) @@ -149,49 +149,49 @@ struct ChatBubbleView: View { .padding(.top, -4) } .padding(.all, 15) - .background(message.isOutgoing ? Color.orangeMain100 : Color.grayMain2c100) + .background(eventLogMessage.message.isOutgoing ? Color.orangeMain100 : Color.grayMain2c100) .clipShape(RoundedRectangle(cornerRadius: 3)) .roundedCorner( 16, - corners: message.isOutgoing && message.isFirstMessage ? [.topLeft, .topRight, .bottomLeft] : - (!message.isOutgoing && message.isFirstMessage ? [.topRight, .bottomRight, .bottomLeft] : [.allCorners])) + corners: eventLogMessage.message.isOutgoing && eventLogMessage.message.isFirstMessage ? [.topLeft, .topRight, .bottomLeft] : + (!eventLogMessage.message.isOutgoing && eventLogMessage.message.isFirstMessage ? [.topRight, .bottomRight, .bottomLeft] : [.allCorners])) - if !message.reactions.isEmpty { + if !eventLogMessage.message.reactions.isEmpty { HStack { - ForEach(0.. some View { - if message.attachments.count == 1 { - if message.attachments.first!.type == .image || message.attachments.first!.type == .gif || message.attachments.first!.type == .video { - let result = imageDimensions(url: message.attachments.first!.thumbnail.absoluteString) + 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)) @@ -268,9 +268,9 @@ struct ChatBubbleView: View { ) } - if message.attachments.first!.type == .image || message.attachments.first!.type == .video { + if eventLogMessage.message.attachments.first!.type == .image || eventLogMessage.message.attachments.first!.type == .video { if #available(iOS 16.0, *) { - AsyncImage(url: message.attachments.first!.thumbnail) { phase in + AsyncImage(url: eventLogMessage.message.attachments.first!.thumbnail) { phase in switch phase { case .empty: ProgressView() @@ -281,7 +281,7 @@ struct ChatBubbleView: View { .interpolation(.medium) .aspectRatio(contentMode: .fill) - if message.attachments.first!.type == .video { + if eventLogMessage.message.attachments.first!.type == .video { Image("play-fill") .renderingMode(.template) .resizable() @@ -298,7 +298,7 @@ struct ChatBubbleView: View { .layoutPriority(-1) .clipShape(RoundedRectangle(cornerRadius: 4)) } else { - AsyncImage(url: message.attachments.first!.thumbnail) { phase in + AsyncImage(url: eventLogMessage.message.attachments.first!.thumbnail) { phase in switch phase { case .empty: ProgressView() @@ -309,7 +309,7 @@ struct ChatBubbleView: View { .interpolation(.medium) .aspectRatio(contentMode: .fill) - if message.attachments.first!.type == .video { + if eventLogMessage.message.attachments.first!.type == .video { Image("play-fill") .renderingMode(.template) .resizable() @@ -327,13 +327,13 @@ struct ChatBubbleView: View { .clipShape(RoundedRectangle(cornerRadius: 4)) .id(UUID()) } - } else if message.attachments.first!.type == .gif { + } else if eventLogMessage.message.attachments.first!.type == .gif { if #available(iOS 16.0, *) { - GifImageView(message.attachments.first!.thumbnail) + GifImageView(eventLogMessage.message.attachments.first!.thumbnail) .layoutPriority(-1) .clipShape(RoundedRectangle(cornerRadius: 4)) } else { - GifImageView(message.attachments.first!.thumbnail) + GifImageView(eventLogMessage.message.attachments.first!.thumbnail) .id(UUID()) .layoutPriority(-1) .clipShape(RoundedRectangle(cornerRadius: 4)) @@ -343,12 +343,12 @@ struct ChatBubbleView: View { .clipShape(RoundedRectangle(cornerRadius: 4)) .clipped() } - } else if message.attachments.count > 1 { + } 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(message.attachments) { attachment in + ForEach(eventLogMessage.message.attachments) { attachment in ZStack { Rectangle() .fill(Color(.white)) @@ -402,9 +402,9 @@ struct ChatBubbleView: View { } } .frame( - width: geometryProxy.size.width > 0 && CGFloat(122 * message.attachments.count) > geometryProxy.size.width - 110 - (isGroup ? 40 : 0) + 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 * message.attachments.count) + : CGFloat(122 * eventLogMessage.message.attachments.count) ) } } diff --git a/Linphone/UI/Main/Conversations/Fragments/ConversationFragment.swift b/Linphone/UI/Main/Conversations/Fragments/ConversationFragment.swift index 1be552038..9cd784708 100644 --- a/Linphone/UI/Main/Conversations/Fragments/ConversationFragment.swift +++ b/Linphone/UI/Main/Conversations/Fragments/ConversationFragment.swift @@ -235,8 +235,8 @@ struct ConversationFragment: View { if conversationViewModel.conversationMessagesSection.first != nil { let counter = conversationViewModel.conversationMessagesSection.first!.rows.count ForEach(0.. 50 ? 50 : iconSize) } .padding(.horizontal, 8) - .background(conversationViewModel.selectedMessage?.ownReaction == "👍" ? Color.gray200 : .white) + .background(conversationViewModel.selectedMessage?.message.ownReaction == "👍" ? Color.gray200 : .white) .cornerRadius(10) Button { @@ -608,7 +614,7 @@ struct ConversationFragment: View { .default_text_style(styleSize: iconSize > 50 ? 50 : iconSize) } .padding(.horizontal, 8) - .background(conversationViewModel.selectedMessage?.ownReaction == "❤️" ? Color.gray200 : .white) + .background(conversationViewModel.selectedMessage?.message.ownReaction == "❤️" ? Color.gray200 : .white) .cornerRadius(10) Button { @@ -618,7 +624,7 @@ struct ConversationFragment: View { .default_text_style(styleSize: iconSize > 50 ? 50 : iconSize) } .padding(.horizontal, 8) - .background(conversationViewModel.selectedMessage?.ownReaction == "😂" ? Color.gray200 : .white) + .background(conversationViewModel.selectedMessage?.message.ownReaction == "😂" ? Color.gray200 : .white) .cornerRadius(10) Button { @@ -628,7 +634,7 @@ struct ConversationFragment: View { .default_text_style(styleSize: iconSize > 50 ? 50 : iconSize) } .padding(.horizontal, 8) - .background(conversationViewModel.selectedMessage?.ownReaction == "😮" ? Color.gray200 : .white) + .background(conversationViewModel.selectedMessage?.message.ownReaction == "😮" ? Color.gray200 : .white) .cornerRadius(10) Button { @@ -638,7 +644,7 @@ struct ConversationFragment: View { .default_text_style(styleSize: iconSize > 50 ? 50 : iconSize) } .padding(.horizontal, 8) - .background(conversationViewModel.selectedMessage?.ownReaction == "😢" ? Color.gray200 : .white) + .background(conversationViewModel.selectedMessage?.message.ownReaction == "😢" ? Color.gray200 : .white) .cornerRadius(10) Button { @@ -656,7 +662,7 @@ struct ConversationFragment: View { .background(.white) .cornerRadius(20) - if !conversationViewModel.selectedMessage!.isOutgoing { + if !conversationViewModel.selectedMessage!.message.isOutgoing { Spacer() } } @@ -665,19 +671,19 @@ struct ConversationFragment: View { .padding(.leading, conversationViewModel.displayedConversation!.isGroup ? 43 : 0) .shadow(color: .black.opacity(0.1), radius: 10) - ChatBubbleView(conversationViewModel: conversationViewModel, message: conversationViewModel.selectedMessage!, geometryProxy: geometry) + 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!.isOutgoing { + if conversationViewModel.selectedMessage!.message.isOutgoing { Spacer() } VStack { Button { - let indexMessage = conversationViewModel.conversationMessagesSection[0].rows.firstIndex(where: {$0.id == conversationViewModel.selectedMessage!.id}) + let indexMessage = conversationViewModel.conversationMessagesSection[0].rows.firstIndex(where: {$0.message.id == conversationViewModel.selectedMessage!.message.id}) conversationViewModel.selectedMessage = nil conversationViewModel.replyToMessage(index: indexMessage ?? 0) } label: { @@ -695,10 +701,10 @@ struct ConversationFragment: View { Divider() - if !conversationViewModel.selectedMessage!.text.isEmpty { + if !conversationViewModel.selectedMessage!.message.text.isEmpty { Button { UIPasteboard.general.setValue( - conversationViewModel.selectedMessage!.text, + conversationViewModel.selectedMessage!.message.text, forPasteboardType: UTType.plainText.identifier ) @@ -760,7 +766,7 @@ struct ConversationFragment: View { .background(.white) .cornerRadius(20) - if !conversationViewModel.selectedMessage!.isOutgoing { + if !conversationViewModel.selectedMessage!.message.isOutgoing { Spacer() } } diff --git a/Linphone/UI/Main/Conversations/Fragments/UIList.swift b/Linphone/UI/Main/Conversations/Fragments/UIList.swift index db7870bbd..ee8d2f376 100644 --- a/Linphone/UI/Main/Conversations/Fragments/UIList.swift +++ b/Linphone/UI/Main/Conversations/Fragments/UIList.swift @@ -19,6 +19,7 @@ // swiftlint:disable large_tuple import SwiftUI +import linphonesw public extension Notification.Name { static let onScrollToBottom = Notification.Name("onScrollToBottom") @@ -64,7 +65,7 @@ struct UIList: UIViewRepresentable { && conversationViewModel.displayedConversation != nil && context.coordinator.sections.first!.chatRoomID == conversationViewModel.displayedConversation!.id && context.coordinator.sections.first!.rows.count == conversationViewModel.conversationMessagesSection.first!.rows.count { - tableView.scrollToRow(at: IndexPath(row: 0, section: 0), at: .bottom, animated: true) + tableView.scrollToRow(at: IndexPath(row: 0, section: 0), at: .top, animated: true) } else { NotificationCenter.default.removeObserver(self, name: .onScrollToBottom, object: nil) } @@ -209,24 +210,26 @@ struct UIList: UIViewRepresentable { var oldRows = appliedDeletes[oldIndex].rows var newRows = appliedDeletesSwapsAndEdits[newIndex].rows - let oldRowIDs = Set(oldRows.map { $0.id }) - let newRowIDs = Set(newRows.map { $0.id }) + 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.id == rowId }) { + 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.id == rowId }) { + 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.id == rowId }) { + if let index = newRows.firstIndex(where: { $0.message.id == rowId }) { newRows.remove(at: index) } } @@ -234,8 +237,8 @@ struct UIList: UIViewRepresentable { for row in 0..= scrollView.contentSize.height - scrollView.frame.height - 200 { self.conversationViewModel.getOldMessages() } - isScrolledToTop = scrollView.contentOffset.y >= scrollView.contentSize.height - scrollView.frame.height - 200 + + DispatchQueue.main.async { + self.isScrolledToTop = scrollView.contentOffset.y >= scrollView.contentSize.height - scrollView.frame.height - 200 + } } func tableView(_ tableView: UITableView, leadingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? { @@ -390,7 +396,7 @@ struct MessagesSection: Equatable { let date: Date let chatRoomID: String - var rows: [Message] + var rows: [EventLogMessage] static var formatter = { let formatter = DateFormatter() @@ -398,7 +404,7 @@ struct MessagesSection: Equatable { return formatter }() - init(date: Date, chatRoomID: String, rows: [Message]) { + init(date: Date, chatRoomID: String, rows: [EventLogMessage]) { self.date = date self.chatRoomID = chatRoomID self.rows = rows @@ -414,6 +420,28 @@ struct MessagesSection: Equatable { } +struct EventLogMessage: Equatable { + + let eventLog: EventLog + var message: Message + + static var formatter = { + let formatter = DateFormatter() + formatter.dateFormat = "EEEE, MMMM d" + return formatter + }() + + init(eventLog: EventLog, message: Message) { + self.eventLog = eventLog + self.message = message + } + + static func == (lhs: EventLogMessage, rhs: EventLogMessage) -> Bool { + lhs.message == rhs.message + } + +} + final class PaginationState: ObservableObject { var onEvent: ChatPaginationClosure? var offset: Int diff --git a/Linphone/UI/Main/Conversations/Model/Message.swift b/Linphone/UI/Main/Conversations/Model/Message.swift index a351c04b9..051074283 100644 --- a/Linphone/UI/Main/Conversations/Model/Message.swift +++ b/Linphone/UI/Main/Conversations/Model/Message.swift @@ -62,6 +62,7 @@ public struct Message: Identifiable, Hashable { } public var id: String + public var appData: String public var status: Status? public var createdAt: Date public var isOutgoing: Bool @@ -79,6 +80,7 @@ public struct Message: Identifiable, Hashable { public init( id: String, + appData: String = "", status: Status? = nil, createdAt: Date = Date(), isOutgoing: Bool, @@ -94,6 +96,7 @@ public struct Message: Identifiable, Hashable { reactions: [String] = [] ) { self.id = id + self.appData = appData self.status = status self.createdAt = createdAt self.isOutgoing = isOutgoing diff --git a/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift b/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift index 6d267ac39..72131d663 100644 --- a/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift +++ b/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift @@ -45,8 +45,8 @@ class ConversationViewModel: ObservableObject { var oldMessageReceived = false - @Published var selectedMessage: Message? - @Published var messageToReply: Message? + @Published var selectedMessage: EventLogMessage? + @Published var messageToReply: EventLogMessage? init() {} @@ -79,22 +79,24 @@ class ConversationViewModel: ObservableObject { statusTmp = .received case .Displayed: statusTmp = .read + case .NotDelivered: + statusTmp = .error default: statusTmp = .sending } - let indexMessage = self.conversationMessagesSection[0].rows.firstIndex(where: {$0.id == message.messageId}) + var indexMessage = self.conversationMessagesSection[0].rows.firstIndex(where: {$0.eventLog.chatMessage?.messageId == message.messageId}) DispatchQueue.main.async { if indexMessage != nil { self.objectWillChange.send() - self.conversationMessagesSection[0].rows[indexMessage!].status = statusTmp + self.conversationMessagesSection[0].rows[indexMessage!].message.status = statusTmp } } }) self.chatMessageSuscriptions.insert(message.publisher?.onNewMessageReaction?.postOnCoreQueue {(cbValue: (message: ChatMessage, reaction: ChatMessageReaction)) in - let indexMessage = self.conversationMessagesSection[0].rows.firstIndex(where: {$0.id == message.messageId}) + let indexMessage = self.conversationMessagesSection[0].rows.firstIndex(where: {$0.eventLog.chatMessage?.messageId == message.messageId}) var reactionsTmp: [String] = [] cbValue.message.reactions.forEach({ chatMessageReaction in reactionsTmp.append(chatMessageReaction.body) @@ -103,13 +105,13 @@ class ConversationViewModel: ObservableObject { DispatchQueue.main.async { if indexMessage != nil { self.objectWillChange.send() - self.conversationMessagesSection[0].rows[indexMessage!].reactions = reactionsTmp + self.conversationMessagesSection[0].rows[indexMessage!].message.reactions = reactionsTmp } } }) self.chatMessageSuscriptions.insert(message.publisher?.onReactionRemoved?.postOnCoreQueue {(cbValue: (message: ChatMessage, address: Address)) in - let indexMessage = self.conversationMessagesSection[0].rows.firstIndex(where: {$0.id == message.messageId}) + let indexMessage = self.conversationMessagesSection[0].rows.firstIndex(where: {$0.eventLog.chatMessage?.messageId == message.messageId}) var reactionsTmp: [String] = [] cbValue.message.reactions.forEach({ chatMessageReaction in reactionsTmp.append(chatMessageReaction.body) @@ -118,7 +120,7 @@ class ConversationViewModel: ObservableObject { DispatchQueue.main.async { if indexMessage != nil { self.objectWillChange.send() - self.conversationMessagesSection[0].rows[indexMessage!].reactions = reactionsTmp + self.conversationMessagesSection[0].rows[indexMessage!].message.reactions = reactionsTmp } } }) @@ -156,13 +158,15 @@ class ConversationViewModel: ObservableObject { func markAsRead() { coreContext.doOnCoreQueue { _ in - let unreadMessagesCount = self.displayedConversation!.chatRoom.unreadMessagesCount - - if unreadMessagesCount > 0 { - self.displayedConversation!.chatRoom.markAsRead() + if self.displayedConversation != nil { + let unreadMessagesCount = self.displayedConversation!.chatRoom.unreadMessagesCount - DispatchQueue.main.async { - self.displayedConversationUnreadMessagesCount = 0 + if unreadMessagesCount > 0 { + self.displayedConversation!.chatRoom.markAsRead() + + DispatchQueue.main.async { + self.displayedConversationUnreadMessagesCount = 0 + } } } } @@ -206,7 +210,7 @@ class ConversationViewModel: ObservableObject { if self.displayedConversation != nil { let historyEvents = self.displayedConversation!.chatRoom.getHistoryRangeEvents(begin: 0, end: 30) - var conversationMessage: [Message] = [] + var conversationMessage: [EventLogMessage] = [] historyEvents.enumerated().forEach { index, eventLog in var attachmentNameList: String = "" @@ -342,36 +346,42 @@ class ConversationViewModel: ObservableObject { if eventLog.chatMessage != nil { conversationMessage.append( - Message( - id: 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, - ownReaction: eventLog.chatMessage?.ownReaction?.body ?? "", - reactions: reactionsTmp + EventLogMessage( + 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, + ownReaction: eventLog.chatMessage?.ownReaction?.body ?? "", + reactions: reactionsTmp + ) ) ) self.addChatMessageDelegate(message: eventLog.chatMessage!) } else { conversationMessage.insert( - Message( - id: UUID().uuidString, - status: nil, - isOutgoing: false, - dateReceived: 0, - address: "", - isFirstMessage: false, - text: "", - attachments: [], - ownReaction: "", - reactions: [] + EventLogMessage( + eventLog: eventLog, + message: Message( + id: UUID().uuidString, + status: nil, + isOutgoing: false, + dateReceived: 0, + address: "", + isFirstMessage: false, + text: "", + attachments: [], + ownReaction: "", + reactions: [] + ) ), at: 0 ) } @@ -391,7 +401,7 @@ class ConversationViewModel: ObservableObject { if self.displayedConversation != nil && 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: [Message] = [] + var conversationMessagesTmp: [EventLogMessage] = [] historyEvents.enumerated().reversed().forEach { index, eventLog in var attachmentNameList: String = "" @@ -479,6 +489,8 @@ class ConversationViewModel: ObservableObject { statusTmp = .received case .Displayed: statusTmp = .read + case .NotDelivered: + statusTmp = .error default: statusTmp = .sending } @@ -525,36 +537,42 @@ class ConversationViewModel: ObservableObject { if eventLog.chatMessage != nil { conversationMessagesTmp.insert( - Message( - id: 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, - ownReaction: eventLog.chatMessage?.ownReaction?.body ?? "", - reactions: reactionsTmp + EventLogMessage( + 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, + ownReaction: eventLog.chatMessage?.ownReaction?.body ?? "", + reactions: reactionsTmp + ) ), at: 0 ) self.addChatMessageDelegate(message: eventLog.chatMessage!) } else { conversationMessagesTmp.insert( - Message( - id: UUID().uuidString, - status: nil, - isOutgoing: false, - dateReceived: 0, - address: "", - isFirstMessage: false, - text: "", - attachments: [], - ownReaction: "", - reactions: [] + EventLogMessage( + eventLog: eventLog, + message: Message( + id: UUID().uuidString, + status: nil, + isOutgoing: false, + dateReceived: 0, + address: "", + isFirstMessage: false, + text: "", + attachments: [], + ownReaction: "", + reactions: [] + ) ), at: 0 ) } @@ -562,8 +580,8 @@ class ConversationViewModel: ObservableObject { if !conversationMessagesTmp.isEmpty { DispatchQueue.main.async { - if self.conversationMessagesSection[0].rows.last?.address == conversationMessagesTmp.last?.address { - self.conversationMessagesSection[0].rows[self.conversationMessagesSection[0].rows.count - 1].isFirstMessage = false + 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 @@ -650,7 +668,7 @@ class ConversationViewModel: ObservableObject { : ( self.conversationMessagesSection.isEmpty || self.conversationMessagesSection[0].rows.isEmpty ? true - : self.conversationMessagesSection[0].rows[0].address != addressCleaned?.asStringUriOnly() + : self.conversationMessagesSection[0].rows[0].message.address != addressCleaned?.asStringUriOnly() ) let isFirstMessageOutgoingTmp = index <= eventLogs.count - 2 @@ -658,7 +676,7 @@ class ConversationViewModel: ObservableObject { : ( self.conversationMessagesSection.isEmpty || self.conversationMessagesSection[0].rows.isEmpty ? true - : !self.conversationMessagesSection[0].rows[0].isOutgoing || self.conversationMessagesSection[0].rows[0].address == addressCleaned?.asStringUriOnly() + : !self.conversationMessagesSection[0].rows[0].message.isOutgoing || self.conversationMessagesSection[0].rows[0].message.address == addressCleaned?.asStringUriOnly() ) let isFirstMessageTmp = (eventLog.chatMessage?.isOutgoing ?? false) ? isFirstMessageOutgoingTmp : isFirstMessageIncomingTmp @@ -675,6 +693,8 @@ class ConversationViewModel: ObservableObject { statusTmp = .received case .Displayed: statusTmp = .read + case .NotDelivered: + statusTmp = .error default: statusTmp = .sending } @@ -720,19 +740,23 @@ class ConversationViewModel: ObservableObject { } if eventLog.chatMessage != nil { - let message = Message( - id: 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, - ownReaction: eventLog.chatMessage?.ownReaction?.body ?? "", - reactions: reactionsTmp + let message = EventLogMessage( + 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?.asStringUriOnly() ?? "", + isFirstMessage: isFirstMessageTmp, + text: contentText, + attachmentsNames: attachmentNameList, + attachments: attachmentList, + replyMessage: replyMessageTmp, + ownReaction: eventLog.chatMessage?.ownReaction?.body ?? "", + reactions: reactionsTmp + ) ) self.addChatMessageDelegate(message: eventLog.chatMessage!) @@ -740,9 +764,9 @@ class ConversationViewModel: ObservableObject { DispatchQueue.main.async { if !self.conversationMessagesSection.isEmpty && !self.conversationMessagesSection[0].rows.isEmpty - && self.conversationMessagesSection[0].rows[0].isOutgoing - && (self.conversationMessagesSection[0].rows[0].address == message.address) { - self.conversationMessagesSection[0].rows[0].isFirstMessage = false + && 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 { @@ -751,22 +775,25 @@ class ConversationViewModel: ObservableObject { self.conversationMessagesSection[0].rows.insert(message, at: 0) } - if !message.isOutgoing { + if !message.message.isOutgoing { self.displayedConversationUnreadMessagesCount = unreadMessagesCount } } } else { - let message = Message( - id: UUID().uuidString, - status: nil, - isOutgoing: false, - dateReceived: 0, - address: "", - isFirstMessage: false, - text: "", - attachments: [], - ownReaction: "", - reactions: [] + let message = EventLogMessage( + eventLog: eventLog, + message: Message( + id: UUID().uuidString, + status: nil, + isOutgoing: false, + dateReceived: 0, + address: "", + isFirstMessage: false, + text: "", + attachments: [], + ownReaction: "", + reactions: [] + ) ) DispatchQueue.main.async { @@ -795,10 +822,11 @@ class ConversationViewModel: ObservableObject { } } + // swiftlint:disable cyclomatic_complexity func scrollToMessage(message: Message) { coreContext.doOnCoreQueue { _ in if message.replyMessage != nil { - if let indexMessage = self.conversationMessagesSection[0].rows.firstIndex(where: {$0.id == message.replyMessage!.id}) { + if let indexMessage = self.conversationMessagesSection[0].rows.firstIndex(where: {$0.eventLog.chatMessage?.messageId == 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 { @@ -825,7 +853,7 @@ class ConversationViewModel: ObservableObject { historyEvents.insert(contentsOf: historyEventsAfter, at: 0) - var conversationMessagesTmp: [Message] = [] + var conversationMessagesTmp: [EventLogMessage] = [] historyEvents.enumerated().reversed().forEach { index, eventLog in var attachmentNameList: String = "" @@ -913,6 +941,8 @@ class ConversationViewModel: ObservableObject { statusTmp = .received case .Displayed: statusTmp = .read + case .NotDelivered: + statusTmp = .error default: statusTmp = .sending } @@ -959,36 +989,42 @@ class ConversationViewModel: ObservableObject { if eventLog.chatMessage != nil { conversationMessagesTmp.insert( - Message( - id: 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, - ownReaction: eventLog.chatMessage?.ownReaction?.body ?? "", - reactions: reactionsTmp + EventLogMessage( + 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, + ownReaction: eventLog.chatMessage?.ownReaction?.body ?? "", + reactions: reactionsTmp + ) ), at: 0 ) self.addChatMessageDelegate(message: eventLog.chatMessage!) } else { conversationMessagesTmp.insert( - Message( - id: UUID().uuidString, - status: nil, - isOutgoing: false, - dateReceived: 0, - address: "", - isFirstMessage: false, - text: "", - attachments: [], - ownReaction: "", - reactions: [] + EventLogMessage( + eventLog: eventLog, + message: Message( + id: UUID().uuidString, + status: nil, + isOutgoing: false, + dateReceived: 0, + address: "", + isFirstMessage: false, + text: "", + attachments: [], + ownReaction: "", + reactions: [] + ) ), at: 0 ) } @@ -996,8 +1032,8 @@ class ConversationViewModel: ObservableObject { if !conversationMessagesTmp.isEmpty { DispatchQueue.main.async { - if self.conversationMessagesSection[0].rows.last?.address == conversationMessagesTmp.last?.address { - self.conversationMessagesSection[0].rows[self.conversationMessagesSection[0].rows.count - 1].isFirstMessage = false + 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()) @@ -1013,13 +1049,14 @@ class ConversationViewModel: ObservableObject { } } } + // swiftlint:enable cyclomatic_complexity func sendMessage() { coreContext.doOnCoreQueue { _ in do { var message: ChatMessage? if self.messageToReply != nil { - let chatMessageToReply = self.displayedConversation!.chatRoom.findMessage(messageId: self.messageToReply!.id) + let chatMessageToReply = self.messageToReply!.eventLog.chatMessage if chatMessageToReply != nil { message = try self.displayedConversation!.chatRoom.createReplyMessage(message: chatMessageToReply!) } @@ -1264,8 +1301,8 @@ class ConversationViewModel: ObservableObject { func sendReaction(emoji: String) { coreContext.doOnCoreQueue { _ in if self.selectedMessage != nil { - Log.info("[ConversationViewModel] Sending reaction \(emoji) to message with ID \(self.selectedMessage!.id)") - let messageToSendReaction = self.displayedConversation!.chatRoom.findMessage(messageId: self.selectedMessage!.id) + Log.info("[ConversationViewModel] Sending reaction \(emoji) to message with ID \(self.selectedMessage!.message.id)") + let messageToSendReaction = self.selectedMessage!.eventLog.chatMessage if messageToSendReaction != nil { do { let reaction = try messageToSendReaction!.createReaction(utf8Reaction: messageToSendReaction?.ownReaction?.body == emoji ? "" : emoji) @@ -1275,12 +1312,12 @@ class ConversationViewModel: ObservableObject { DispatchQueue.main.async { if indexMessageSelected != nil { - self.conversationMessagesSection[0].rows[indexMessageSelected!].ownReaction = messageToSendReaction?.ownReaction?.body == emoji ? "" : emoji + 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!.id)") + Log.info("[ConversationViewModel] Error: Can't send reaction \(emoji) to message with ID \(self.selectedMessage!.message.id)") } } } @@ -1289,28 +1326,11 @@ class ConversationViewModel: ObservableObject { func resend() { coreContext.doOnCoreQueue { _ in - if self.selectedMessage != nil { - Log.info("[ConversationViewModel] Re-sending message with ID \(self.selectedMessage!.id)") - let messageToResend = self.displayedConversation!.chatRoom.findMessage(messageId: self.selectedMessage!.id) - if messageToResend != nil { - messageToResend!.send() - } + if self.selectedMessage != nil && self.selectedMessage!.eventLog.chatMessage != nil { + Log.info("[ConversationViewModel] Re-sending message with ID \(self.selectedMessage!.eventLog.chatMessage!)") + self.selectedMessage!.eventLog.chatMessage!.send() } } } } -struct LinphoneCustomEventLog: Hashable { - var id = UUID() - var eventLog: EventLog - - func hash(into hasher: inout Hasher) { - hasher.combine(id) - } -} - -extension LinphoneCustomEventLog { - static func ==(lhs: LinphoneCustomEventLog, rhs: LinphoneCustomEventLog) -> Bool { - return lhs.id == rhs.id - } -} // swiftlint:enable type_body_length From 24435dcb92918a4849e920e132e216ec6b7818eb Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Tue, 27 Aug 2024 17:24:33 +0200 Subject: [PATCH 368/486] Add participant name to reply message bubble --- .../Fragments/ChatBubbleView.swift | 12 ++++- .../ViewModel/ConversationViewModel.swift | 53 +++++++++++++++++-- 2 files changed, 60 insertions(+), 5 deletions(-) diff --git a/Linphone/UI/Main/Conversations/Fragments/ChatBubbleView.swift b/Linphone/UI/Main/Conversations/Fragments/ChatBubbleView.swift index 24f5b4e58..697522b67 100644 --- a/Linphone/UI/Main/Conversations/Fragments/ChatBubbleView.swift +++ b/Linphone/UI/Main/Conversations/Fragments/ChatBubbleView.swift @@ -70,7 +70,17 @@ struct ChatBubbleView: View { Spacer() } - VStack(alignment: eventLogMessage.message.isOutgoing ? .trailing : .leading) { + 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) diff --git a/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift b/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift index 72131d663..cc9aba79d 100644 --- a/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift +++ b/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift @@ -198,6 +198,17 @@ class ConversationViewModel: ObservableObject { } } + func addParticipantConversationModel(address: Address) { + coreContext.doOnCoreQueue { core in + ContactAvatarModel.getAvatarModelFromAddress(address: address) { avatarResult in + let avatarModelTmp = avatarResult + DispatchQueue.main.async { + self.participantConversationModel.append(avatarModelTmp) + } + } + } + } + func getMessages() { self.getHistorySize() self.getUnreadMessagesCount() @@ -283,6 +294,10 @@ class ConversationViewModel: ObservableObject { 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 @@ -316,7 +331,11 @@ class ConversationViewModel: ObservableObject { var replyMessageTmp: ReplyMessage? if eventLog.chatMessage?.replyMessage != nil { let addressReplyCleaned = eventLog.chatMessage?.replyMessage?.fromAddress?.clone() - addressCleaned?.clean() + addressReplyCleaned?.clean() + + if addressReplyCleaned != nil && self.participantConversationModel.first(where: {$0.address == addressReplyCleaned!.asStringUriOnly()}) == nil { + self.addParticipantConversationModel(address: addressReplyCleaned!) + } let contentReplyText = eventLog.chatMessage?.replyMessage?.utf8Text ?? "" @@ -474,6 +493,10 @@ class ConversationViewModel: ObservableObject { 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 @@ -507,7 +530,11 @@ class ConversationViewModel: ObservableObject { var replyMessageTmp: ReplyMessage? if eventLog.chatMessage?.replyMessage != nil { let addressReplyCleaned = eventLog.chatMessage?.replyMessage?.fromAddress?.clone() - addressCleaned?.clean() + addressReplyCleaned?.clean() + + if addressReplyCleaned != nil && self.participantConversationModel.first(where: {$0.address == addressReplyCleaned!.asStringUriOnly()}) == nil { + self.addParticipantConversationModel(address: addressReplyCleaned!) + } let contentReplyText = eventLog.chatMessage?.replyMessage?.utf8Text ?? "" @@ -591,6 +618,7 @@ class ConversationViewModel: ObservableObject { } } + // swiftlint:disable cyclomatic_complexity func getNewMessages(eventLogs: [EventLog]) { eventLogs.enumerated().forEach { index, eventLog in var attachmentNameList: String = "" @@ -663,6 +691,10 @@ class ConversationViewModel: ObservableObject { 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() : ( @@ -711,7 +743,11 @@ class ConversationViewModel: ObservableObject { var replyMessageTmp: ReplyMessage? if eventLog.chatMessage?.replyMessage != nil { let addressReplyCleaned = eventLog.chatMessage?.replyMessage?.fromAddress?.clone() - addressCleaned?.clean() + addressReplyCleaned?.clean() + + if addressReplyCleaned != nil && self.participantConversationModel.first(where: {$0.address == addressReplyCleaned!.asStringUriOnly()}) == nil { + self.addParticipantConversationModel(address: addressReplyCleaned!) + } let contentReplyText = eventLog.chatMessage?.replyMessage?.utf8Text ?? "" @@ -806,6 +842,7 @@ class ConversationViewModel: ObservableObject { } } } + // swiftlint:enable cyclomatic_complexity func resetMessage() { conversationMessagesSection = [] @@ -926,6 +963,10 @@ class ConversationViewModel: ObservableObject { 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 @@ -959,7 +1000,11 @@ class ConversationViewModel: ObservableObject { var replyMessageTmp: ReplyMessage? if eventLog.chatMessage?.replyMessage != nil { let addressReplyCleaned = eventLog.chatMessage?.replyMessage?.fromAddress?.clone() - addressCleaned?.clean() + addressReplyCleaned?.clean() + + if addressReplyCleaned != nil && self.participantConversationModel.first(where: {$0.address == addressReplyCleaned!.asStringUriOnly()}) == nil { + self.addParticipantConversationModel(address: addressReplyCleaned!) + } let contentReplyText = eventLog.chatMessage?.replyMessage?.utf8Text ?? "" From be414f3c144593279e1e9f76a9ed69c7dab9709c Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Thu, 29 Aug 2024 10:20:58 +0200 Subject: [PATCH 369/486] Change of UIList coordinator to a singleton --- .../Fragments/ConversationFragment.swift | 7 +- .../Main/Conversations/Fragments/UIList.swift | 142 ++++++++++-------- .../ViewModel/ConversationViewModel.swift | 5 +- 3 files changed, 83 insertions(+), 71 deletions(-) diff --git a/Linphone/UI/Main/Conversations/Fragments/ConversationFragment.swift b/Linphone/UI/Main/Conversations/Fragments/ConversationFragment.swift index 9cd784708..529fce11e 100644 --- a/Linphone/UI/Main/Conversations/Fragments/ConversationFragment.swift +++ b/Linphone/UI/Main/Conversations/Fragments/ConversationFragment.swift @@ -38,9 +38,6 @@ struct ConversationFragment: View { private let ids: [String] = [] - @State private var isScrolledToBottom: Bool = true - var showMessageMenuOnLongPress: Bool = true - @StateObject private var viewModel = ChatViewModel() @StateObject private var paginationState = PaginationState() @@ -171,13 +168,11 @@ struct ConversationFragment: View { paginationState: paginationState, conversationViewModel: conversationViewModel, conversationsListViewModel: conversationsListViewModel, - isScrolledToBottom: $isScrolledToBottom, - showMessageMenuOnLongPress: showMessageMenuOnLongPress, geometryProxy: geometry, sections: conversationViewModel.conversationMessagesSection ) - if !isScrolledToBottom { + if !conversationViewModel.isScrolledToBottom { Button { NotificationCenter.default.post(name: .onScrollToBottom, object: nil) } label: { diff --git a/Linphone/UI/Main/Conversations/Fragments/UIList.swift b/Linphone/UI/Main/Conversations/Fragments/UIList.swift index ee8d2f376..e0b94ad70 100644 --- a/Linphone/UI/Main/Conversations/Fragments/UIList.swift +++ b/Linphone/UI/Main/Conversations/Fragments/UIList.swift @@ -28,18 +28,18 @@ public extension Notification.Name { struct UIList: UIViewRepresentable { + private static var sharedCoordinator: Coordinator? + @ObservedObject var viewModel: ChatViewModel @ObservedObject var paginationState: PaginationState @ObservedObject var conversationViewModel: ConversationViewModel @ObservedObject var conversationsListViewModel: ConversationsListViewModel - @Binding var isScrolledToBottom: Bool - - let showMessageMenuOnLongPress: Bool let geometryProxy: GeometryProxy let sections: [MessagesSection] @State private var isScrolledToTop = false + @State private var isScrolledToBottom = true func makeUIView(context: Context) -> UITableView { let tableView = UITableView(frame: .zero, style: .grouped) @@ -57,42 +57,11 @@ struct UIList: UIViewRepresentable { tableView.backgroundColor = UIColor(.white) tableView.scrollsToTop = true - NotificationCenter.default.addObserver(forName: .onScrollToBottom, object: nil, queue: nil) { _ in - DispatchQueue.main.async { - if !context.coordinator.sections.isEmpty { - if context.coordinator.sections.first != nil - && conversationViewModel.conversationMessagesSection.first != nil - && conversationViewModel.displayedConversation != nil - && context.coordinator.sections.first!.chatRoomID == conversationViewModel.displayedConversation!.id - && context.coordinator.sections.first!.rows.count == conversationViewModel.conversationMessagesSection.first!.rows.count { - tableView.scrollToRow(at: IndexPath(row: 0, section: 0), at: .top, animated: true) - } else { - NotificationCenter.default.removeObserver(self, name: .onScrollToBottom, object: nil) - } - } - } - } + context.coordinator.tableView = tableView + context.coordinator.geometryProxy = geometryProxy - NotificationCenter.default.addObserver(forName: .onScrollToIndex, object: nil, queue: nil) { notification in - DispatchQueue.main.async { - if !context.coordinator.sections.isEmpty { - if context.coordinator.sections.first != nil - && conversationViewModel.conversationMessagesSection.first != nil - && conversationViewModel.displayedConversation != nil - && context.coordinator.sections.first!.chatRoomID == conversationViewModel.displayedConversation!.id - && context.coordinator.sections.first!.rows.count == 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 { - tableView.scrollToRow(at: IndexPath(row: index, section: 0), at: .bottom, animated: animated) - } - } - } - } else { - NotificationCenter.default.removeObserver(self, name: .onScrollToIndex, object: nil) - } - } - } + DispatchQueue.main.async { + conversationViewModel.isScrolledToBottom = true } return tableView @@ -140,7 +109,7 @@ struct UIList: UIViewRepresentable { tableView.endUpdates() } - if isScrolledToBottom { + if conversationViewModel.isScrolledToBottom && conversationViewModel.displayedConversationUnreadMessagesCount > 0 { conversationViewModel.markAsRead() conversationsListViewModel.computeChatRoomsList(filter: "") } @@ -268,43 +237,85 @@ struct UIList: UIViewRepresentable { // MARK: - Coordinator func makeCoordinator() -> Coordinator { - Coordinator( - conversationViewModel: conversationViewModel, - conversationsListViewModel: conversationsListViewModel, - viewModel: viewModel, - paginationState: paginationState, - isScrolledToBottom: $isScrolledToBottom, - isScrolledToTop: $isScrolledToTop, - showMessageMenuOnLongPress: showMessageMenuOnLongPress, - geometryProxy: geometryProxy, - sections: sections - ) + if UIList.sharedCoordinator == nil { + UIList.sharedCoordinator = Coordinator( + conversationViewModel: conversationViewModel, + conversationsListViewModel: conversationsListViewModel, + viewModel: viewModel, + paginationState: paginationState, + isScrolledToTop: $isScrolledToTop, + isScrolledToBottom: $isScrolledToBottom, + geometryProxy: geometryProxy, + sections: sections + ) + } + return UIList.sharedCoordinator! } class Coordinator: NSObject, UITableViewDataSource, UITableViewDelegate { + var tableView: UITableView? + @ObservedObject var viewModel: ChatViewModel @ObservedObject var paginationState: PaginationState @ObservedObject var conversationViewModel: ConversationViewModel @ObservedObject var conversationsListViewModel: ConversationsListViewModel - @Binding var isScrolledToBottom: Bool @Binding var isScrolledToTop: Bool + @Binding var isScrolledToBottom: Bool - let showMessageMenuOnLongPress: Bool - let geometryProxy: GeometryProxy + var geometryProxy: GeometryProxy var sections: [MessagesSection] - init(conversationViewModel: ConversationViewModel, conversationsListViewModel: ConversationsListViewModel, viewModel: ChatViewModel, paginationState: PaginationState, isScrolledToBottom: Binding, isScrolledToTop: Binding, showMessageMenuOnLongPress: Bool, geometryProxy: GeometryProxy, sections: [MessagesSection]) { + init(conversationViewModel: ConversationViewModel, conversationsListViewModel: ConversationsListViewModel, viewModel: ChatViewModel, paginationState: PaginationState, isScrolledToTop: Binding, isScrolledToBottom: Binding, geometryProxy: GeometryProxy, sections: [MessagesSection]) { self.conversationViewModel = conversationViewModel self.conversationsListViewModel = conversationsListViewModel self.viewModel = viewModel self.paginationState = paginationState - self._isScrolledToBottom = isScrolledToBottom self._isScrolledToTop = isScrolledToTop - self.showMessageMenuOnLongPress = showMessageMenuOnLongPress + self._isScrolledToBottom = isScrolledToBottom 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 + && self.conversationViewModel.conversationMessagesSection.first != nil + && self.conversationViewModel.displayedConversation != nil + && self.sections.first!.chatRoomID == self.conversationViewModel.displayedConversation!.id + && self.sections.first!.rows.count == self.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 + && self.conversationViewModel.conversationMessagesSection.first != nil + && self.conversationViewModel.displayedConversation != nil + && self.sections.first!.chatRoomID == self.conversationViewModel.displayedConversation!.id + && self.sections.first!.rows.count == self.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 { @@ -361,19 +372,22 @@ struct UIList: UIViewRepresentable { } func scrollViewDidScroll(_ scrollView: UIScrollView) { - isScrolledToBottom = scrollView.contentOffset.y <= 10 - if isScrolledToBottom && conversationViewModel.displayedConversationUnreadMessagesCount > 0 { - conversationViewModel.markAsRead() - conversationsListViewModel.computeChatRoomsList(filter: "") + self.isScrolledToBottom = scrollView.contentOffset.y <= 10 + + if self.isScrolledToBottom != self.conversationViewModel.isScrolledToBottom { + self.conversationViewModel.isScrolledToBottom = self.isScrolledToBottom } - if !isScrolledToTop && scrollView.contentOffset.y >= scrollView.contentSize.height - scrollView.frame.height - 200 { + if self.conversationViewModel.isScrolledToBottom && self.conversationViewModel.displayedConversationUnreadMessagesCount > 0 { + self.conversationViewModel.markAsRead() + self.conversationsListViewModel.computeChatRoomsList(filter: "") + } + + if !self.isScrolledToTop && scrollView.contentOffset.y >= scrollView.contentSize.height - scrollView.frame.height - 500 { self.conversationViewModel.getOldMessages() } - DispatchQueue.main.async { - self.isScrolledToTop = scrollView.contentOffset.y >= scrollView.contentSize.height - scrollView.frame.height - 200 - } + self.isScrolledToTop = scrollView.contentOffset.y >= scrollView.contentSize.height - scrollView.frame.height - 500 } func tableView(_ tableView: UITableView, leadingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? { diff --git a/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift b/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift index cc9aba79d..f8202cb9e 100644 --- a/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift +++ b/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift @@ -48,6 +48,8 @@ class ConversationViewModel: ObservableObject { @Published var selectedMessage: EventLogMessage? @Published var messageToReply: EventLogMessage? + @Published var isScrolledToBottom: Bool = true + init() {} func addConversationDelegate() { @@ -417,7 +419,8 @@ class ConversationViewModel: ObservableObject { func getOldMessages() { coreContext.doOnCoreQueue { _ in - if self.displayedConversation != nil && self.displayedConversationHistorySize > self.conversationMessagesSection[0].rows.count && !self.oldMessageReceived { + 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] = [] From 923c290fa0804d98f2d7a1f95b15e7316539d81e Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Fri, 30 Aug 2024 14:56:22 +0200 Subject: [PATCH 370/486] Added the floating button to the UIList (UIKit list) --- .../Fragments/ConversationFragment.swift | 58 +--- .../Main/Conversations/Fragments/UIList.swift | 257 ++++++++++++------ .../ViewModel/ConversationViewModel.swift | 2 - 3 files changed, 178 insertions(+), 139 deletions(-) diff --git a/Linphone/UI/Main/Conversations/Fragments/ConversationFragment.swift b/Linphone/UI/Main/Conversations/Fragments/ConversationFragment.swift index 529fce11e..0657e73e0 100644 --- a/Linphone/UI/Main/Conversations/Fragments/ConversationFragment.swift +++ b/Linphone/UI/Main/Conversations/Fragments/ConversationFragment.swift @@ -164,58 +164,14 @@ struct ConversationFragment: View { if #available(iOS 16.0, *) { ZStack(alignment: .bottomTrailing) { - UIList(viewModel: viewModel, - paginationState: paginationState, - conversationViewModel: conversationViewModel, - conversationsListViewModel: conversationsListViewModel, - geometryProxy: geometry, - sections: conversationViewModel.conversationMessagesSection + UIList( + viewModel: viewModel, + paginationState: paginationState, + conversationViewModel: conversationViewModel, + conversationsListViewModel: conversationsListViewModel, + geometryProxy: geometry, + sections: conversationViewModel.conversationMessagesSection ) - - if !conversationViewModel.isScrolledToBottom { - Button { - NotificationCenter.default.post(name: .onScrollToBottom, object: nil) - } 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() diff --git a/Linphone/UI/Main/Conversations/Fragments/UIList.swift b/Linphone/UI/Main/Conversations/Fragments/UIList.swift index e0b94ad70..d0e4e6eac 100644 --- a/Linphone/UI/Main/Conversations/Fragments/UIList.swift +++ b/Linphone/UI/Main/Conversations/Fragments/UIList.swift @@ -26,6 +26,64 @@ public extension Notification.Name { 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? @@ -41,7 +99,11 @@ struct UIList: UIViewRepresentable { @State private var isScrolledToTop = false @State private var isScrolledToBottom = true - func makeUIView(context: Context) -> UITableView { + 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 @@ -57,49 +119,81 @@ struct UIList: UIViewRepresentable { 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 - - DispatchQueue.main.async { - conversationViewModel.isScrolledToBottom = true - } - - return tableView + + return containerView } - func updateUIView(_ tableView: UITableView, context: Context) { - if context.coordinator.sections == sections { - return - } - if context.coordinator.sections == sections { - return + //func updateUIView(_ tableView: UITableView, context: Context) { + func updateUIView(_ uiView: UIView, context: Context) { + if let button = uiView.viewWithTag(102) as? FloatingButton { + button.unreadMessageCount = conversationViewModel.displayedConversationUnreadMessagesCount } - 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) + if let tableView = uiView.viewWithTag(101) as? UITableView { + if context.coordinator.sections == sections { + return } - } - - 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) + if context.coordinator.sections == sections { + return } - } - - tableView.performBatchUpdates { - context.coordinator.sections = appliedDeletesSwapsAndEdits - for operation in editOperations { - applyOperation(operation, tableView: tableView) + + 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) + } } - } - - if isScrolledToBottom || isScrolledToTop { + + 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() @@ -107,11 +201,11 @@ struct UIList: UIViewRepresentable { applyOperation(operation, tableView: tableView) } tableView.endUpdates() - } - - if conversationViewModel.isScrolledToBottom && conversationViewModel.displayedConversationUnreadMessagesCount > 0 { - conversationViewModel.markAsRead() - conversationsListViewModel.computeChatRoomsList(filter: "") + + if isScrolledToBottom && conversationViewModel.displayedConversationUnreadMessagesCount > 0 { + conversationViewModel.markAsRead() + conversationsListViewModel.computeChatRoomsList(filter: "") + } } } @@ -239,12 +333,7 @@ struct UIList: UIViewRepresentable { func makeCoordinator() -> Coordinator { if UIList.sharedCoordinator == nil { UIList.sharedCoordinator = Coordinator( - conversationViewModel: conversationViewModel, - conversationsListViewModel: conversationsListViewModel, - viewModel: viewModel, - paginationState: paginationState, - isScrolledToTop: $isScrolledToTop, - isScrolledToBottom: $isScrolledToBottom, + parent: self, geometryProxy: geometryProxy, sections: sections ) @@ -254,26 +343,15 @@ struct UIList: UIViewRepresentable { class Coordinator: NSObject, UITableViewDataSource, UITableViewDelegate { + var parent: UIList var tableView: UITableView? - - @ObservedObject var viewModel: ChatViewModel - @ObservedObject var paginationState: PaginationState - @ObservedObject var conversationViewModel: ConversationViewModel - @ObservedObject var conversationsListViewModel: ConversationsListViewModel - - @Binding var isScrolledToTop: Bool - @Binding var isScrolledToBottom: Bool + var floatingButton: FloatingButton? var geometryProxy: GeometryProxy var sections: [MessagesSection] - init(conversationViewModel: ConversationViewModel, conversationsListViewModel: ConversationsListViewModel, viewModel: ChatViewModel, paginationState: PaginationState, isScrolledToTop: Binding, isScrolledToBottom: Binding, geometryProxy: GeometryProxy, sections: [MessagesSection]) { - self.conversationViewModel = conversationViewModel - self.conversationsListViewModel = conversationsListViewModel - self.viewModel = viewModel - self.paginationState = paginationState - self._isScrolledToTop = isScrolledToTop - self._isScrolledToBottom = isScrolledToBottom + init(parent: UIList, geometryProxy: GeometryProxy, sections: [MessagesSection]) { + self.parent = parent self.geometryProxy = geometryProxy self.sections = sections @@ -283,10 +361,10 @@ struct UIList: UIViewRepresentable { DispatchQueue.main.async { if !self.sections.isEmpty { if self.sections.first != nil - && self.conversationViewModel.conversationMessagesSection.first != nil - && self.conversationViewModel.displayedConversation != nil - && self.sections.first!.chatRoomID == self.conversationViewModel.displayedConversation!.id - && self.sections.first!.rows.count == self.conversationViewModel.conversationMessagesSection.first!.rows.count { + && 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) } } @@ -297,10 +375,10 @@ struct UIList: UIViewRepresentable { DispatchQueue.main.async { if !self.sections.isEmpty { if self.sections.first != nil - && self.conversationViewModel.conversationMessagesSection.first != nil - && self.conversationViewModel.displayedConversation != nil - && self.sections.first!.chatRoomID == self.conversationViewModel.displayedConversation!.id - && self.sections.first!.rows.count == self.conversationViewModel.conversationMessagesSection.first!.rows.count { + && 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 { @@ -331,8 +409,8 @@ struct UIList: UIViewRepresentable { } func progressView(_ section: Int) -> UIView? { - if section > conversationViewModel.conversationMessagesSection.count - && conversationViewModel.conversationMessagesSection[section].rows.count < conversationViewModel.displayedConversationHistorySize { + 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) @@ -352,7 +430,7 @@ struct UIList: UIViewRepresentable { let row = sections[indexPath.section].rows[indexPath.row] if #available(iOS 16.0, *) { tableViewCell.contentConfiguration = UIHostingConfiguration { - ChatBubbleView(conversationViewModel: conversationViewModel, eventLogMessage: row, geometryProxy: geometryProxy) + ChatBubbleView(conversationViewModel: parent.conversationViewModel, eventLogMessage: row, geometryProxy: geometryProxy) .padding(.vertical, 2) .padding(.horizontal, 10) .onTapGesture { } @@ -368,32 +446,39 @@ struct UIList: UIViewRepresentable { func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) { let row = sections[indexPath.section].rows[indexPath.row] - paginationState.handle(row.message) + parent.paginationState.handle(row.message) } func scrollViewDidScroll(_ scrollView: UIScrollView) { - self.isScrolledToBottom = scrollView.contentOffset.y <= 10 - - if self.isScrolledToBottom != self.conversationViewModel.isScrolledToBottom { - self.conversationViewModel.isScrolledToBottom = self.isScrolledToBottom + 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 self.conversationViewModel.isScrolledToBottom && self.conversationViewModel.displayedConversationUnreadMessagesCount > 0 { - self.conversationViewModel.markAsRead() - self.conversationsListViewModel.computeChatRoomsList(filter: "") + if !parent.isScrolledToTop && scrollView.contentOffset.y >= scrollView.contentSize.height - scrollView.frame.height - 500 { + parent.conversationViewModel.getOldMessages() } - if !self.isScrolledToTop && scrollView.contentOffset.y >= scrollView.contentSize.height - scrollView.frame.height - 500 { - self.conversationViewModel.getOldMessages() - } - - self.isScrolledToTop = scrollView.contentOffset.y >= scrollView.contentSize.height - scrollView.frame.height - 500 + 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: "") { action, view, completionHandler in - self.conversationViewModel.replyToMessage(index: indexPath.row) + self.parent.conversationViewModel.replyToMessage(index: indexPath.row) completionHandler(true) } diff --git a/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift b/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift index f8202cb9e..bd239df9e 100644 --- a/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift +++ b/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift @@ -48,8 +48,6 @@ class ConversationViewModel: ObservableObject { @Published var selectedMessage: EventLogMessage? @Published var messageToReply: EventLogMessage? - @Published var isScrolledToBottom: Bool = true - init() {} func addConversationDelegate() { From e95045dab4619a0b7388737c01803cb5bc5e7db8 Mon Sep 17 00:00:00 2001 From: QuentinArguillere Date: Sat, 31 Aug 2024 09:01:39 +0200 Subject: [PATCH 371/486] Fix most warnings (remaining: @sendables and deprecated calls) --- Linphone.xcodeproj/project.pbxproj | 18 ++++---- Linphone/Contacts/ContactsManager.swift | 42 +++++++++-------- Linphone/Core/CoreContext.swift | 23 +++++----- .../TelecomManager/ProviderDelegate.swift | 28 ++++++------ Linphone/TelecomManager/TelecomManager.swift | 27 ++++++----- .../Fragments/ProfileModeFragment.swift | 2 +- .../RegisterCodeConfirmationFragment.swift | 2 + .../Fragments/RegisterFragment.swift | 4 ++ .../Viewmodel/RegisterViewModel.swift | 27 +++++------ Linphone/UI/Call/CallView.swift | 45 +++++++++---------- .../Fragments/AudioRouteBottomSheet.swift | 6 ++- .../UI/Call/Fragments/CallsListFragment.swift | 6 ++- .../Fragments/ParticipantsListFragment.swift | 4 +- Linphone/UI/Call/Fragments/ZRTPPopup.swift | 1 + .../UI/Call/MeetingWaitingRoomFragment.swift | 35 ++++++++------- .../Call/Model/CallMediaEncryptionModel.swift | 6 +-- Linphone/UI/Call/Model/CallStatsModel.swift | 8 ++-- .../UI/Call/ViewModel/CallViewModel.swift | 42 +++++++++-------- .../MeetingWaitingRoomViewModel.swift | 8 ++-- .../ContactInnerActionsFragment.swift | 7 +-- .../Fragments/ContactsInnerFragment.swift | 3 +- .../Fragments/ContactsListFragment.swift | 3 +- .../Fragments/EditContactFragment.swift | 5 ++- .../Contacts/ViewModel/ContactViewModel.swift | 8 ++-- Linphone/UI/Main/ContentView.swift | 5 +-- .../Fragments/ChatBubbleView.swift | 31 ++++++++----- .../Fragments/ConversationFragment.swift | 15 ++++--- .../Fragments/ConversationsFragment.swift | 6 ++- .../Fragments/StartConversationFragment.swift | 6 ++- .../Main/Conversations/Fragments/UIList.swift | 28 ++++++------ .../Model/ConversationModel.swift | 6 ++- .../UI/Main/Conversations/Model/Message.swift | 10 +++-- .../ViewModel/ConversationViewModel.swift | 42 +++++++++-------- .../ConversationsListViewModel.swift | 10 +++-- .../StartConversationViewModel.swift | 9 ++-- Linphone/UI/Main/Fragments/ToastView.swift | 4 +- .../History/Fragments/DialerBottomSheet.swift | 10 ++++- .../Fragments/HistoryContactFragment.swift | 2 - .../History/Fragments/StartCallFragment.swift | 6 ++- .../UI/Main/History/Model/HistoryModel.swift | 4 +- .../ViewModel/StartCallViewModel.swift | 13 ++---- .../Fragments/AddParticipantsFragment.swift | 4 +- .../Meetings/Fragments/MeetingFragment.swift | 6 +-- .../Fragments/MeetingsListFragment.swift | 4 -- .../Fragments/ScheduleMeetingFragment.swift | 11 ++--- .../Meetings/ViewModel/MeetingViewModel.swift | 17 +++---- .../ViewModel/MeetingsListViewModel.swift | 6 ++- .../Utils/Extensions/ConfigExtension.swift | 1 - Linphone/Utils/Extensions/IntExtension.swift | 2 + Linphone/Utils/Extensions/URLExtension.swift | 6 +-- Linphone/Utils/FileUtils.swift | 9 ++-- Linphone/Utils/LinphoneUtils.swift | 3 +- Linphone/Utils/MagicSearchSingleton.swift | 2 +- Linphone/Utils/PhotoPicker.swift | 11 ++++- .../NotificationService.swift | 6 +-- 55 files changed, 356 insertions(+), 299 deletions(-) diff --git a/Linphone.xcodeproj/project.pbxproj b/Linphone.xcodeproj/project.pbxproj index b0c478b0d..d2e8b6f10 100644 --- a/Linphone.xcodeproj/project.pbxproj +++ b/Linphone.xcodeproj/project.pbxproj @@ -7,7 +7,7 @@ objects = { /* Begin PBXBuildFile section */ - 4ED1F0A881A9ACB5977A8987 /* (null) in Frameworks */ = {isa = PBXBuildFile; }; + 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 */; }; @@ -364,7 +364,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - 4ED1F0A881A9ACB5977A8987 /* (null) in Frameworks */, + 4ED1F0A881A9ACB5977A8987 /* BuildFile in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -986,6 +986,7 @@ /* Begin PBXShellScriptBuildPhase section */ 6677CE082C73D71A0020FD0E /* Crashlytics */ = { isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; buildActionMask = 2147483647; files = ( ); @@ -1009,6 +1010,7 @@ }; 66BF2D4B2B558A3100A5F2E3 /* Crashlytics */ = { isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; buildActionMask = 2147483647; files = ( ); @@ -1226,7 +1228,7 @@ CODE_SIGN_ENTITLEMENTS = msgNotificationService/msgNotificationService.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 38; DEVELOPMENT_TEAM = Z2V957B3D6; ENABLE_USER_SCRIPT_SANDBOXING = NO; GCC_C_LANGUAGE_STANDARD = gnu17; @@ -1245,7 +1247,7 @@ "@executable_path/../../Frameworks", ); LOCALIZATION_PREFERS_STRING_CATALOGS = YES; - MARKETING_VERSION = 1.0; + MARKETING_VERSION = 6.0.0; OTHER_SWIFT_FLAGS = "$(inherited)"; PRODUCT_BUNDLE_IDENTIFIER = org.linphone.phone.msgNotificationService; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -1268,7 +1270,7 @@ CODE_SIGN_ENTITLEMENTS = msgNotificationService/msgNotificationService.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 38; DEVELOPMENT_TEAM = Z2V957B3D6; ENABLE_USER_SCRIPT_SANDBOXING = NO; GCC_C_LANGUAGE_STANDARD = gnu17; @@ -1284,7 +1286,7 @@ "@executable_path/../../Frameworks", ); LOCALIZATION_PREFERS_STRING_CATALOGS = YES; - MARKETING_VERSION = 1.0; + MARKETING_VERSION = 6.0.0; OTHER_SWIFT_FLAGS = "$(inherited)"; PRODUCT_BUNDLE_IDENTIFIER = org.linphone.phone.msgNotificationService; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -1422,7 +1424,7 @@ ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = NO; CODE_SIGN_ENTITLEMENTS = Linphone/Linphone.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 36; + CURRENT_PROJECT_VERSION = 38; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_ASSET_PATHS = "\"Linphone/Preview Content\""; @@ -1478,7 +1480,7 @@ ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = NO; CODE_SIGN_ENTITLEMENTS = Linphone/Linphone.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 36; + CURRENT_PROJECT_VERSION = 38; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_ASSET_PATHS = "\"Linphone/Preview Content\""; DEVELOPMENT_TEAM = Z2V957B3D6; diff --git a/Linphone/Contacts/ContactsManager.swift b/Linphone/Contacts/ContactsManager.swift index 62090bfd5..c635f5290 100644 --- a/Linphone/Contacts/ContactsManager.swift +++ b/Linphone/Contacts/ContactsManager.swift @@ -17,6 +17,10 @@ * along with this program. If not, see . */ +// swiftlint:disable line_length +// swiftlint:disable large_tuple +// swiftlint:disable function_parameter_count + import linphonesw import Contacts import SwiftUI @@ -135,26 +139,22 @@ final class ContactsManager: ObservableObject { var addedAvatarListModel: [ContactAvatarModel] = [] cbValue.linphoneFriend.phoneNumbers.forEach { phone in - do { - let address = core.interpretUrl(url: phone, applyInternationalPrefix: true) + let address = core.interpretUrl(url: phone, applyInternationalPrefix: true) + + let presence = cbValue.linphoneFriend.getPresenceModelForUriOrTel(uriOrTel: address?.asStringUriOnly() ?? "") + if address != nil && presence != nil { + cbValue.linphoneFriend.edit() + cbValue.linphoneFriend.addAddress(address: address!) + cbValue.linphoneFriend.done() - let presence = cbValue.linphoneFriend.getPresenceModelForUriOrTel(uriOrTel: address?.asStringUriOnly() ?? "") - if address != nil && presence != nil { - cbValue.linphoneFriend.edit() - cbValue.linphoneFriend.addAddress(address: address!) - cbValue.linphoneFriend.done() - - addedAvatarListModel.append( - ContactAvatarModel( - friend: cbValue.linphoneFriend, - name: cbValue.linphoneFriend.name ?? "", - address: cbValue.linphoneFriend.address?.clone()?.asStringUriOnly() ?? "", - withPresence: true - ) + addedAvatarListModel.append( + ContactAvatarModel( + friend: cbValue.linphoneFriend, + name: cbValue.linphoneFriend.name ?? "", + address: cbValue.linphoneFriend.address?.clone()?.asStringUriOnly() ?? "", + withPresence: true ) - } - } catch let error { - print("\(#function) - Failed to create friend phone number for \(phone):", error) + ) } } @@ -361,7 +361,7 @@ final class ContactsManager: ObservableObject { } func getFriendWithAddressInCoreQueue(address: Address?, completion: @escaping (Friend?) -> Void) { - self.coreContext.doOnCoreQueue { core in + self.coreContext.doOnCoreQueue { _ in completion(self.getFriendWithAddress(address: address)) } } @@ -384,3 +384,7 @@ struct Contact: Identifiable { var phoneNumbers: [PhoneNumber] = [] var imageData: String } + +// swiftlint:enable line_length +// swiftlint:enable large_tuple +// swiftlint:enable function_parameter_count diff --git a/Linphone/Core/CoreContext.swift b/Linphone/Core/CoreContext.swift index 231fae30e..9437069ef 100644 --- a/Linphone/Core/CoreContext.swift +++ b/Linphone/Core/CoreContext.swift @@ -19,6 +19,8 @@ // swiftlint:disable large_tuple // 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 @@ -46,14 +48,14 @@ final class CoreContext: ObservableObject { private var mIterateSuscription: AnyCancellable? private var mCoreSuscriptions = Set() - var bearerAuthInfoPendingPasswordUpdate: AuthInfo? = nil + var bearerAuthInfoPendingPasswordUpdate: AuthInfo? let monitor = NWPathMonitor() private var mCorePushIncomingDelegate: CoreDelegate! - private var actionsToPerformOnCoreQueueWhenCoreIsStarted : [((Core)->Void)] = [] - private var callStateCallBacks : [((Call.State)->Void)] = [] - private var configuringStateCallBacks : [((ConfiguringState)->Void)] = [] + private var actionsToPerformOnCoreQueueWhenCoreIsStarted: [((Core) -> Void)] = [] + private var callStateCallBacks: [((Call.State) -> Void)] = [] + private var configuringStateCallBacks: [((ConfiguringState) -> Void)] = [] private init() { do { @@ -128,7 +130,6 @@ final class CoreContext: ObservableObject { 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.mCoreSuscriptions.insert(self.mCore.publisher?.onGlobalStateChanged?.postOnCoreQueue { (cbVal: (core: Core, state: GlobalState, message: String)) in if cbVal.state == GlobalState.On { #if DEBUG @@ -236,7 +237,7 @@ final class CoreContext: ObservableObject { TelecomManager.shared.onCallStateChanged(core: cbVal.core, call: cbVal.call, state: cbVal.state, message: cbVal.message) }) - self.mCorePushIncomingDelegate = CoreDelegateStub(onCallStateChanged: { (core: Core, call: Call, cstate: Call.State, message: String) in + self.mCorePushIncomingDelegate = CoreDelegateStub(onCallStateChanged: { (_, call: Call, cstate: Call.State, _) in if cstate == .PushIncomingReceived { let callLog = call.callLog let callId = callLog?.callId ?? "" @@ -260,9 +261,9 @@ final class CoreContext: ObservableObject { } }) - self.mCoreSuscriptions.insert(self.mCore.publisher?.onTransferStateChanged?.postOnCoreQueue { (cbValue: (_: Core, transfered: Call, callState: Call.State)) in + self.mCoreSuscriptions.insert(self.mCore.publisher?.onTransferStateChanged?.postOnCoreQueue { (cbValue: (_: Core, transferred: Call, callState: Call.State)) in Log.info( - "[CoreContext] Transferred call \(cbValue.transfered.remoteAddress!.asStringUriOnly()) state changed \(cbValue.callState)" + "[CoreContext] Transferred call \(cbValue.transferred.remoteAddress!.asStringUriOnly()) state changed \(cbValue.callState)" ) DispatchQueue.main.async { @@ -362,8 +363,8 @@ final class CoreContext: ObservableObject { fatalError("Crashing app to test crashlytics") } - func performActionOnCoreQueueWhenCoreIsStarted(action: @escaping (_ core: Core)->Void ) { - if (coreIsStarted) { + func performActionOnCoreQueueWhenCoreIsStarted(action: @escaping (_ core: Core) -> Void ) { + if coreIsStarted { CoreContext.shared.doOnCoreQueue { core in action(core) } @@ -387,3 +388,5 @@ final class CoreContext: ObservableObject { // swiftlint:enable large_tuple // swiftlint:enable line_length +// swiftlint:enable cyclomatic_complexity +// swiftlint:enable identifier_name diff --git a/Linphone/TelecomManager/ProviderDelegate.swift b/Linphone/TelecomManager/ProviderDelegate.swift index fbc097cdd..3c3e59499 100644 --- a/Linphone/TelecomManager/ProviderDelegate.swift +++ b/Linphone/TelecomManager/ProviderDelegate.swift @@ -70,21 +70,19 @@ class ProviderDelegate: NSObject { } static var providerConfiguration: CXProviderConfiguration { - get { - 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 - } + 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) { diff --git a/Linphone/TelecomManager/TelecomManager.swift b/Linphone/TelecomManager/TelecomManager.swift index 7ddcf79fe..c43dc3600 100644 --- a/Linphone/TelecomManager/TelecomManager.swift +++ b/Linphone/TelecomManager/TelecomManager.swift @@ -17,6 +17,8 @@ * along with this program. If not, see . */ // swiftlint:disable cyclomatic_complexity +// swiftlint:disable line_length +// swiftlint:disable type_body_length import Foundation import linphonesw @@ -33,7 +35,6 @@ class CallAppData: NSObject { } -// swiftlint:disable type_body_length class TelecomManager: ObservableObject { static let shared = TelecomManager() static var uuidReplacedCall: String? @@ -127,7 +128,7 @@ class TelecomManager: ObservableObject { 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) { + 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) @@ -138,7 +139,7 @@ class TelecomManager: ObservableObject { func setHeld(call: Call, hold: Bool) { #if targetEnvironment(simulator) - if (hold) { + if hold { try?call.pause() } else { try?call.resume() @@ -146,7 +147,7 @@ class TelecomManager: ObservableObject { #else let callid = call.callLog?.callId ?? "" let uuid = providerDelegate.uuids["\(callid)"] - if (uuid == nil) { + if uuid == nil { Log.error("Can not find correspondant call to set held.") return } @@ -352,9 +353,8 @@ class TelecomManager: ObservableObject { providerDelegate.reportIncomingCall(call: call, uuid: uuid, handle: handle, hasVideo: hasVideo, displayName: displayName) } - func incomingDisplayName(call: Call, completion: @escaping (String) -> Void) { - CoreContext.shared.doOnCoreQueue { core in + 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 { @@ -377,8 +377,9 @@ class TelecomManager: ObservableObject { static func callKitEnabled(core: Core) -> Bool { #if !targetEnvironment(simulator) return core.callkitEnabled -#endif +#else return false +#endif } func requestTransaction(_ transaction: CXTransaction, action: String) { @@ -417,7 +418,7 @@ class TelecomManager: ObservableObject { if cstate == .PushIncomingReceived { Log.info("PushIncomingReceived on TelecomManager -- Ignore, should be processed by a the dedicated CoreDelegate for callkit display") } else { - let oldRemoteConfVideo = self.remoteConfVideo + // let oldRemoteConfVideo = self.remoteConfVideo if call.conference != nil { if call.conference!.activeSpeakerParticipantDevice != nil { @@ -595,9 +596,9 @@ class TelecomManager: ObservableObject { } */ let uuid = self.providerDelegate.uuids["\(callId)"] - //if call.replacedCall == nil { + // if call.replacedCall == nil { TelecomManager.uuidReplacedCall = callId - //} + // } if uuid != nil { // Tha app is now registered, updated the call already existed. @@ -692,7 +693,7 @@ class TelecomManager: ObservableObject { // bluetoothEnabled = false } - //if core.callsNb == 0 { + // if core.callsNb == 0 { self.incomingDisplayName(call: call) { displayNameResult in var displayName = "Unknown" if call.dir == .Incoming { @@ -744,7 +745,7 @@ class TelecomManager: ObservableObject { } } } - //} + // } if TelecomManager.callKitEnabled(core: core) { var uuid = providerDelegate.uuids["\(callId)"] @@ -795,5 +796,7 @@ class TelecomManager: ObservableObject { ]) } } + // swiftlint:enable type_body_length // swiftlint:enable cyclomatic_complexity +// swiftlint:enable line_length diff --git a/Linphone/UI/Assistant/Fragments/ProfileModeFragment.swift b/Linphone/UI/Assistant/Fragments/ProfileModeFragment.swift index 9c11671f5..ff100b8cd 100644 --- a/Linphone/UI/Assistant/Fragments/ProfileModeFragment.swift +++ b/Linphone/UI/Assistant/Fragments/ProfileModeFragment.swift @@ -141,7 +141,7 @@ struct ProfileModeFragment: View { } .onAppear { UserDefaults.standard.set(false, forKey: "display_profile_mode") - //Skip this view + // Skip this view sharedMainViewModel.changeHideProfileMode() } diff --git a/Linphone/UI/Assistant/Fragments/RegisterCodeConfirmationFragment.swift b/Linphone/UI/Assistant/Fragments/RegisterCodeConfirmationFragment.swift index ff128d410..374c362c6 100644 --- a/Linphone/UI/Assistant/Fragments/RegisterCodeConfirmationFragment.swift +++ b/Linphone/UI/Assistant/Fragments/RegisterCodeConfirmationFragment.swift @@ -19,6 +19,7 @@ import SwiftUI +// swiftlint:disable line_length struct RegisterCodeConfirmationFragment: View { @ObservedObject private var sharedMainViewModel = SharedMainViewModel.shared @ObservedObject var registerViewModel: RegisterViewModel @@ -196,3 +197,4 @@ struct RegisterCodeConfirmationFragment: View { #Preview { RegisterCodeConfirmationFragment(registerViewModel: RegisterViewModel()) } +// swiftlint:enable line_length diff --git a/Linphone/UI/Assistant/Fragments/RegisterFragment.swift b/Linphone/UI/Assistant/Fragments/RegisterFragment.swift index 97f4f3a8b..f5d1e5f74 100644 --- a/Linphone/UI/Assistant/Fragments/RegisterFragment.swift +++ b/Linphone/UI/Assistant/Fragments/RegisterFragment.swift @@ -17,6 +17,8 @@ * along with this program. If not, see . */ +// swiftlint:disable line_length + import SwiftUI struct RegisterFragment: View { @@ -343,3 +345,5 @@ struct RegisterFragment: View { #Preview { RegisterFragment(registerViewModel: RegisterViewModel()) } + +// swiftlint:enable line_length diff --git a/Linphone/UI/Assistant/Viewmodel/RegisterViewModel.swift b/Linphone/UI/Assistant/Viewmodel/RegisterViewModel.swift index 5ed6af21d..5e6eca950 100644 --- a/Linphone/UI/Assistant/Viewmodel/RegisterViewModel.swift +++ b/Linphone/UI/Assistant/Viewmodel/RegisterViewModel.swift @@ -21,6 +21,8 @@ import Foundation import linphonesw import Combine +// swiftlint:disable line_length +// swiftlint:disable type_body_length class RegisterViewModel: ObservableObject { static let TAG = "[RegisterViewModel]" @@ -119,8 +121,7 @@ class RegisterViewModel: ObservableObject { } func addDelegate(request: AccountManagerServicesRequest) { coreContext.doOnCoreQueue { core in - self.accountManagerServicesSuscriptions.insert(request.publisher?.onRequestSuccessful?.postOnCoreQueue { - (request: AccountManagerServicesRequest, data: String) in + self.accountManagerServicesSuscriptions.insert(request.publisher?.onRequestSuccessful?.postOnCoreQueue { (request: AccountManagerServicesRequest, data: String) in Log.info("\(RegisterViewModel.TAG) Request \(request) was successful, data is \(data)") switch request.type { case .CreateAccountUsingToken: @@ -142,9 +143,7 @@ class RegisterViewModel: ObservableObject { case .LinkPhoneNumberUsingCode: let account = self.accountCreated if account != nil { - Log.info( - "\(RegisterViewModel.TAG) Account \(account?.params?.identityAddress?.asStringUriOnly()) has been created & activated, setting it as default" - ) + 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) @@ -166,8 +165,7 @@ class RegisterViewModel: ObservableObject { } }) - self.accountManagerServicesSuscriptions.insert(request.publisher?.onRequestError?.postOnCoreQueue { - (request: AccountManagerServicesRequest, statusCode: Int, errorMessage: String, parameterErrors: Dictionary?) in + self.accountManagerServicesSuscriptions.insert(request.publisher?.onRequestError?.postOnCoreQueue { (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)" ) @@ -371,9 +369,7 @@ class RegisterViewModel: ObservableObject { 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 ?? "")" - ) + 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!, @@ -404,9 +400,7 @@ class RegisterViewModel: ObservableObject { return } - Log.info( - "\(RegisterViewModel.TAG) Account creation token is \(token ?? "Error token"), creating account with username \(username) and algorithm \(HASHALGORITHM)" - ) + 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( @@ -441,9 +435,7 @@ class RegisterViewModel: ObservableObject { 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")" - ) + Log.info( "\(RegisterViewModel.TAG) Formatted phone number \(number) using dial plan \(dialPlan?.country ?? "Error country") is \(formattedPhoneNumber ?? "Error phone number")") self.normalizedPhoneNumber = formattedPhoneNumber } else { @@ -481,3 +473,6 @@ class RegisterViewModel: ObservableObject { } } } + +// swiftlint:enable line_length +// swiftlint:enable type_body_length diff --git a/Linphone/UI/Call/CallView.swift b/Linphone/UI/Call/CallView.swift index b771b28ed..d44ffa041 100644 --- a/Linphone/UI/Call/CallView.swift +++ b/Linphone/UI/Call/CallView.swift @@ -23,6 +23,7 @@ import AVFAudio import linphonesw import UniformTypeIdentifiers +// swiftlint:disable function_body_length // swiftlint:disable type_body_length // swiftlint:disable line_length // swiftlint:disable file_length @@ -73,28 +74,28 @@ struct CallView: View { 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(), @@ -110,28 +111,28 @@ struct CallView: View { 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(), @@ -237,7 +238,6 @@ struct CallView: View { } @ViewBuilder - // swiftlint:disable:next cyclomatic_complexity func innerView(geometry: GeometryProxy) -> some View { ZStack { VStack { @@ -432,8 +432,7 @@ struct CallView: View { .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 @@ -451,7 +450,7 @@ struct CallView: View { 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) { optionsChangeLayoutValue in + .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 } @@ -461,7 +460,6 @@ struct CallView: View { } } - // swiftlint:disable function_body_length // swiftlint:disable:next cyclomatic_complexity func simpleCallView(geometry: GeometryProxy) -> some View { ZStack { @@ -748,7 +746,6 @@ struct CallView: View { callViewModel.orientationUpdate(orientation: orientation) } } - // swiftlint:enable function_body_length // swiftlint:disable:next cyclomatic_complexity func activeSpeakerMode(geometry: GeometryProxy) -> some View { @@ -1821,7 +1818,7 @@ struct CallView: View { } } - // swiftlint:disable function_body_length + // swiftlint:disable:next cyclomatic_complexity func bottomSheetContent(geo: GeometryProxy) -> some View { GeometryReader { _ in VStack(spacing: 0) { @@ -2689,7 +2686,6 @@ struct CallView: View { .frame(maxHeight: .infinity, alignment: .top) } } - // swiftlint:enable function_body_length func getAudioRouteImage() { if !AVAudioSession.sharedInstance().currentRoute.outputs.filter({ $0.portType.rawValue == "Speaker" }).isEmpty { @@ -2810,4 +2806,5 @@ struct PressedButtonStyle: ButtonStyle { } // 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 index 4b85ee333..73f292ecb 100644 --- a/Linphone/UI/Call/Fragments/AudioRouteBottomSheet.swift +++ b/Linphone/UI/Call/Fragments/AudioRouteBottomSheet.swift @@ -38,7 +38,8 @@ struct AudioRouteBottomSheet: View { do { try AVAudioSession.sharedInstance().overrideOutputAudioPort(.none) if callViewModel.isHeadPhoneAvailable() { - try AVAudioSession.sharedInstance().setPreferredInput(AVAudioSession.sharedInstance().availableInputs?.filter({ $0.portType.rawValue.contains("Receiver") }).first) + try AVAudioSession.sharedInstance().setPreferredInput( + AVAudioSession.sharedInstance().availableInputs?.filter({ $0.portType.rawValue.contains("Receiver") }).first) } else { try AVAudioSession.sharedInstance().setPreferredInput(AVAudioSession.sharedInstance().availableInputs?.first) } @@ -106,7 +107,8 @@ struct AudioRouteBottomSheet: View { do { try AVAudioSession.sharedInstance().overrideOutputAudioPort(.none) - try AVAudioSession.sharedInstance().setPreferredInput(AVAudioSession.sharedInstance().availableInputs?.filter({ $0.portType.rawValue.contains("Bluetooth") }).first) + try AVAudioSession.sharedInstance().setPreferredInput( + AVAudioSession.sharedInstance().availableInputs?.filter({ $0.portType.rawValue.contains("Bluetooth") }).first) } catch _ { } diff --git a/Linphone/UI/Call/Fragments/CallsListFragment.swift b/Linphone/UI/Call/Fragments/CallsListFragment.swift index 4c14a816f..c99b6496a 100644 --- a/Linphone/UI/Call/Fragments/CallsListFragment.swift +++ b/Linphone/UI/Call/Fragments/CallsListFragment.swift @@ -20,6 +20,7 @@ import SwiftUI import linphonesw +// swiftlint:disable type_body_length struct CallsListFragment: View { @ObservedObject private var coreContext = CoreContext.shared @@ -88,10 +89,10 @@ struct CallsListFragment: View { if #available(iOS 16.0, *), idiom != .pad { callsList .sheet(isPresented: $isShowCallsListBottomSheet, onDismiss: { - }) { + }, content: { innerBottomSheet() .presentationDetents([.fraction(0.2)]) - } + }) } else { callsList .halfSheet(showSheet: $isShowCallsListBottomSheet) { @@ -382,3 +383,4 @@ struct CallsListFragment: View { #Preview { CallsListFragment(callViewModel: CallViewModel(), isShowCallsListFragment: .constant(true)) } +// swiftlint:enable type_body_length diff --git a/Linphone/UI/Call/Fragments/ParticipantsListFragment.swift b/Linphone/UI/Call/Fragments/ParticipantsListFragment.swift index e80c723e6..22c3c1ff9 100644 --- a/Linphone/UI/Call/Fragments/ParticipantsListFragment.swift +++ b/Linphone/UI/Call/Fragments/ParticipantsListFragment.swift @@ -265,5 +265,7 @@ struct ParticipantsListFragment: View { } #Preview { - ParticipantsListFragment(callViewModel: CallViewModel(), addParticipantsViewModel: AddParticipantsViewModel(), isShowParticipantsListFragment: .constant(true)) + ParticipantsListFragment(callViewModel: CallViewModel(), + addParticipantsViewModel: AddParticipantsViewModel(), + isShowParticipantsListFragment: .constant(true)) } diff --git a/Linphone/UI/Call/Fragments/ZRTPPopup.swift b/Linphone/UI/Call/Fragments/ZRTPPopup.swift index 3c27f8df4..18f6e6ec6 100644 --- a/Linphone/UI/Call/Fragments/ZRTPPopup.swift +++ b/Linphone/UI/Call/Fragments/ZRTPPopup.swift @@ -20,6 +20,7 @@ import SwiftUI import Foundation +// swiftlint:disable:next type_body_length struct ZRTPPopup: View { @ObservedObject private var telecomManager = TelecomManager.shared diff --git a/Linphone/UI/Call/MeetingWaitingRoomFragment.swift b/Linphone/UI/Call/MeetingWaitingRoomFragment.swift index 33e93806c..1c1be011e 100644 --- a/Linphone/UI/Call/MeetingWaitingRoomFragment.swift +++ b/Linphone/UI/Call/MeetingWaitingRoomFragment.swift @@ -21,6 +21,9 @@ import SwiftUI import linphonesw import AVFAudio +// swiftlint:disable type_body_length +// swiftlint:disable cyclomatic_complexity + struct MeetingWaitingRoomFragment: View { @ObservedObject private var coreContext = CoreContext.shared @@ -44,10 +47,9 @@ struct MeetingWaitingRoomFragment: View { innerView(geometry: geometry) .sheet(isPresented: $audioRouteSheet, onDismiss: { audioRouteSheet = false - }) { - innerBottomSheet() - .presentationDetents([.fraction(0.3)]) - } + }, content: { + innerBottomSheet().presentationDetents([.fraction(0.3)]) + }) .onAppear { meetingWaitingRoomViewModel.enableAVAudioSession() if AVAudioSession.sharedInstance().currentRoute.outputs.filter({ $0.portType.rawValue.contains("Bluetooth") }).isEmpty { @@ -87,6 +89,7 @@ struct MeetingWaitingRoomFragment: View { } @ViewBuilder + // swiftlint:disable:next function_body_length func innerView(geometry: GeometryProxy) -> some View { VStack { if #available(iOS 16.0, *) { @@ -261,7 +264,8 @@ struct MeetingWaitingRoomFragment: View { Spacer() Button { - !meetingWaitingRoomViewModel.videoDisplayed ? meetingWaitingRoomViewModel.enableVideoPreview() : meetingWaitingRoomViewModel.disableVideoPreview() + !meetingWaitingRoomViewModel.videoDisplayed + ? meetingWaitingRoomViewModel.enableVideoPreview() : meetingWaitingRoomViewModel.disableVideoPreview() } label: { HStack { Image(meetingWaitingRoomViewModel.videoDisplayed ? "video-camera" : "video-camera-slash") @@ -302,13 +306,9 @@ struct MeetingWaitingRoomFragment: View { } else { do { try AVAudioSession.sharedInstance().overrideOutputAudioPort( - AVAudioSession.sharedInstance().currentRoute.outputs.filter( - { $0.portType.rawValue == "Speaker" } - ).isEmpty ? .speaker : .none - ) - } catch _ { - - } + AVAudioSession.sharedInstance().currentRoute + .outputs.filter({ $0.portType.rawValue == "Speaker" }).isEmpty ? .speaker : .none) + } catch _ {} } } label: { HStack { @@ -378,13 +378,11 @@ struct MeetingWaitingRoomFragment: View { .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) @@ -411,7 +409,6 @@ struct MeetingWaitingRoomFragment: View { } .background(Color.gray900) .onRotate { newOrientation in - let oldOrientation = orientation orientation = newOrientation if orientation == .portrait || orientation == .portraitUpsideDown { angleDegree = 0 @@ -453,7 +450,8 @@ struct MeetingWaitingRoomFragment: View { do { try AVAudioSession.sharedInstance().overrideOutputAudioPort(.none) if meetingWaitingRoomViewModel.isHeadPhoneAvailable() { - try AVAudioSession.sharedInstance().setPreferredInput(AVAudioSession.sharedInstance().availableInputs?.filter({ $0.portType.rawValue.contains("Receiver") }).first) + try AVAudioSession.sharedInstance().setPreferredInput(AVAudioSession.sharedInstance() + .availableInputs?.filter({ $0.portType.rawValue.contains("Receiver") }).first) } else { try AVAudioSession.sharedInstance().setPreferredInput(AVAudioSession.sharedInstance().availableInputs?.first) } @@ -521,7 +519,8 @@ struct MeetingWaitingRoomFragment: View { do { try AVAudioSession.sharedInstance().overrideOutputAudioPort(.none) - try AVAudioSession.sharedInstance().setPreferredInput(AVAudioSession.sharedInstance().availableInputs?.filter({ $0.portType.rawValue.contains("Bluetooth") }).first) + try AVAudioSession.sharedInstance().setPreferredInput(AVAudioSession.sharedInstance().availableInputs? + .filter({ $0.portType.rawValue.contains("Bluetooth") }).first) } catch _ { } @@ -571,3 +570,5 @@ struct MeetingWaitingRoomFragment: View { #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 index 86174516b..6795591e1 100644 --- a/Linphone/UI/Call/Model/CallMediaEncryptionModel.swift +++ b/Linphone/UI/Call/Model/CallMediaEncryptionModel.swift @@ -33,8 +33,8 @@ class CallMediaEncryptionModel: ObservableObject { @Published var zrtpAuthSas = "" func update(call: Call) { - coreContext.doOnCoreQueue { core in - var stats = call.getStats(type: StreamType.Audio) + 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 { @@ -82,7 +82,7 @@ class CallMediaEncryptionModel: ObservableObject { self.zrtpAuthSas = zrtpAuthSasTmp } } else { - let mediaEncryptionTmp = "Media encryption: " + call.currentParams!.mediaEncryption.rawValue.description //call.currentParams.mediaEncryption + 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 index f5f11dc97..605cb95cb 100644 --- a/Linphone/UI/Call/Model/CallStatsModel.swift +++ b/Linphone/UI/Call/Model/CallStatsModel.swift @@ -33,7 +33,7 @@ class CallStatsModel: ObservableObject { @Published var videoFps = "" func update(call: Call, stats: CallStats) { - coreContext.doOnCoreQueue { core in + coreContext.doOnCoreQueue { _ in if call.params != nil { self.isVideoEnabled = call.params!.videoEnabled && call.currentParams != nil && call.currentParams!.videoDirection != .Inactive switch stats.type { @@ -43,7 +43,8 @@ class CallStatsModel: ObservableObject { 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 { + if stats.uploadBandwidth.rounded().isNaN || stats.uploadBandwidth.rounded().isInfinite + || stats.downloadBandwidth.rounded().isNaN || stats.downloadBandwidth.rounded().isInfinite { return } @@ -62,7 +63,8 @@ class CallStatsModel: ObservableObject { 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 { + if stats.uploadBandwidth.rounded().isNaN || stats.uploadBandwidth.rounded().isInfinite + || stats.downloadBandwidth.rounded().isNaN || stats.downloadBandwidth.rounded().isInfinite { return } diff --git a/Linphone/UI/Call/ViewModel/CallViewModel.swift b/Linphone/UI/Call/ViewModel/CallViewModel.swift index 50e57d7a1..d40f26899 100644 --- a/Linphone/UI/Call/ViewModel/CallViewModel.swift +++ b/Linphone/UI/Call/ViewModel/CallViewModel.swift @@ -22,7 +22,10 @@ import linphonesw import AVFAudio import Combine +// swiftlint:disable line_length // swiftlint:disable type_body_length +// swiftlint:disable cyclomatic_complexity +// swiftlint:disable large_tuple class CallViewModel: ObservableObject { static let TAG = "[CallViewModel]" @@ -154,8 +157,6 @@ class CallViewModel: ObservableObject { } } - - DispatchQueue.main.async { self.displayName = displayNameTmp } @@ -230,7 +231,7 @@ class CallViewModel: ObservableObject { } } - self.callSuscriptions.insert(self.currentCall!.publisher?.onEncryptionChanged?.postOnCoreQueue {(cbVal: (call: Call, on: Bool, authenticationToken: String?)) in + self.callSuscriptions.insert(self.currentCall!.publisher?.onEncryptionChanged?.postOnCoreQueue { _ in self.updateEncryption(withToast: false) if self.currentCall != nil { self.callMediaEncryptionModel.update(call: self.currentCall!) @@ -246,7 +247,7 @@ class CallViewModel: ObservableObject { }) self.callSuscriptions.insert( - self.currentCall!.publisher?.onAuthenticationTokenVerified?.postOnCoreQueue {(call: Call, verified: Bool) in + self.currentCall!.publisher?.onAuthenticationTokenVerified?.postOnCoreQueue {(_, verified: Bool) in Log.warn("[CallViewModel][ZRTPPopup] Notified that authentication token is \(verified ? "verified" : "not verified!")") if verified { self.updateEncryption(withToast: true) @@ -293,20 +294,20 @@ class CallViewModel: ObservableObject { } func getConference() { - coreContext.doOnCoreQueue { core in + coreContext.doOnCoreQueue { _ in if self.currentCall?.conference != nil { let conf = self.currentCall!.conference! let displayNameTmp = conf.subject ?? "" - var myParticipantModelTmp: ParticipantModel? = nil + 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? = nil + var activeSpeakerParticipantTmp: ParticipantModel? if conf.activeSpeakerParticipantDevice?.address != nil { activeSpeakerParticipantTmp = ParticipantModel( address: conf.activeSpeakerParticipantDevice!.address!, @@ -385,8 +386,8 @@ class CallViewModel: ObservableObject { func waitingForCreatedStateConference() { self.mConferenceSuscriptions.insert( - self.currentCall?.conference?.publisher?.onStateChanged?.postOnCoreQueue {(cbValue: (conference: Conference, state: Conference.State)) in - if cbValue.state == .Created { + self.currentCall?.conference?.publisher?.onStateChanged?.postOnCoreQueue {(cbValue: (conference: Conference, newState: Conference.State)) in + if cbValue.newState == .Created { DispatchQueue.main.async { self.getConference() } @@ -395,9 +396,8 @@ class CallViewModel: ObservableObject { ) } - // swiftlint:disable:next cyclomatic_complexity func addConferenceCallBacks() { - coreContext.doOnCoreQueue { core in + coreContext.doOnCoreQueue { _ in self.mConferenceSuscriptions.insert( self.currentCall?.conference?.publisher?.onActiveSpeakerParticipantDevice?.postOnCoreQueue {(cbValue: (conference: Conference, participantDevice: ParticipantDevice)) in if cbValue.participantDevice.address != nil { @@ -477,7 +477,7 @@ class CallViewModel: ObservableObject { } }) - var activeSpeakerParticipantTmp: ParticipantModel? = nil + var activeSpeakerParticipantTmp: ParticipantModel? var activeSpeakerNameTmp = "" if self.activeSpeakerParticipant == nil { @@ -755,7 +755,7 @@ class CallViewModel: ObservableObject { func switchCamera() { coreContext.doOnCoreQueue { core in let currentDevice = core.videoDevice - Log.info("[CallViewModel] Current camera device is \(currentDevice)") + Log.info("[CallViewModel] Current camera device is \(currentDevice ?? "nil")") core.videoDevicesList.forEach { camera in if camera != currentDevice && camera != "StaticImage: Static picture" { @@ -945,9 +945,6 @@ class CallViewModel: ObservableObject { } */ - // When Post Quantum is available, ZRTP is Post Quantum - let isZrtpPQTmp = Core.getPostQuantumAvailable - DispatchQueue.main.async { self.isRemoteDeviceTrusted = isRemoteDeviceTrustedTmp self.isMediaEncrypted = true @@ -1072,7 +1069,7 @@ class CallViewModel: ObservableObject { } func toggleAdminParticipant(index: Int) { - coreContext.doOnCoreQueue { core in + 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) @@ -1082,7 +1079,7 @@ class CallViewModel: ObservableObject { } func removeParticipant(index: Int) { - coreContext.doOnCoreQueue { core in + 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 { @@ -1309,8 +1306,7 @@ class CallViewModel: ObservableObject { } func chatRoomAddDelegate(core: Core, chatRoom: ChatRoom) { - self.chatRoomSuscriptions.insert(chatRoom.publisher?.onConferenceJoined?.postOnCoreQueue { - (chatRoom: ChatRoom, eventLog: EventLog) in + self.chatRoomSuscriptions.insert(chatRoom.publisher?.onConferenceJoined?.postOnCoreQueue { (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)") @@ -1345,8 +1341,7 @@ class CallViewModel: ObservableObject { } }) - self.chatRoomSuscriptions.insert(chatRoom.publisher?.onStateChanged?.postOnCoreQueue { - (chatRoom: ChatRoom, state: ChatRoom.State) in + self.chatRoomSuscriptions.insert(chatRoom.publisher?.onStateChanged?.postOnCoreQueue { (chatRoom: ChatRoom, state: ChatRoom.State) in let state = chatRoom.state let id = LinphoneUtils.getChatRoomId(room: chatRoom) if state == ChatRoom.State.CreationFailed { @@ -1362,3 +1357,6 @@ class CallViewModel: ObservableObject { } } // swiftlint:enable type_body_length +// swiftlint:enable line_length +// swiftlint:enable cyclomatic_complexity +// swiftlint:enable large_tuple diff --git a/Linphone/UI/Call/ViewModel/MeetingWaitingRoomViewModel.swift b/Linphone/UI/Call/ViewModel/MeetingWaitingRoomViewModel.swift index ee07b19a0..24d4b1480 100644 --- a/Linphone/UI/Call/ViewModel/MeetingWaitingRoomViewModel.swift +++ b/Linphone/UI/Call/ViewModel/MeetingWaitingRoomViewModel.swift @@ -155,7 +155,7 @@ class MeetingWaitingRoomViewModel: ObservableObject { func switchCamera() { coreContext.doOnCoreQueue { core in let currentDevice = core.videoDevice - Log.info("[CallViewModel] Current camera device is \(currentDevice)") + Log.info("[CallViewModel] Current camera device is \(currentDevice ?? "nil")") core.videoDevicesList.forEach { camera in if camera != currentDevice && camera != "StaticImage: Static picture" { @@ -246,7 +246,8 @@ class MeetingWaitingRoomViewModel: ObservableObject { case "bluetooth": do { try AVAudioSession.sharedInstance().overrideOutputAudioPort(.none) - try AVAudioSession.sharedInstance().setPreferredInput(AVAudioSession.sharedInstance().availableInputs?.filter({ $0.portType.rawValue.contains("Bluetooth") }).first) + try AVAudioSession.sharedInstance().setPreferredInput(AVAudioSession.sharedInstance().availableInputs? + .filter({ $0.portType.rawValue.contains("Bluetooth") }).first) } catch _ { } @@ -260,7 +261,8 @@ class MeetingWaitingRoomViewModel: ObservableObject { do { try AVAudioSession.sharedInstance().overrideOutputAudioPort(.none) if self.isHeadPhoneAvailable() { - try AVAudioSession.sharedInstance().setPreferredInput(AVAudioSession.sharedInstance().availableInputs?.filter({ $0.portType.rawValue.contains("Receiver") }).first) + try AVAudioSession.sharedInstance().setPreferredInput(AVAudioSession.sharedInstance() + .availableInputs?.filter({ $0.portType.rawValue.contains("Receiver") }).first) } else { try AVAudioSession.sharedInstance().setPreferredInput(AVAudioSession.sharedInstance().availableInputs?.first) } diff --git a/Linphone/UI/Main/Contacts/Fragments/ContactInnerActionsFragment.swift b/Linphone/UI/Main/Contacts/Fragments/ContactInnerActionsFragment.swift index 01e0ea646..708d5eec9 100644 --- a/Linphone/UI/Main/Contacts/Fragments/ContactInnerActionsFragment.swift +++ b/Linphone/UI/Main/Contacts/Fragments/ContactInnerActionsFragment.swift @@ -20,6 +20,7 @@ import SwiftUI import linphonesw +// swiftlint:disable type_body_length struct ContactInnerActionsFragment: View { @ObservedObject var contactsManager = ContactsManager.shared @@ -333,9 +334,7 @@ struct ContactInnerActionsFragment: View { .padding(.horizontal) Button { - if contactAvatarModel != nil { - isShowDeletePopup.toggle() - } + isShowDeletePopup.toggle() } label: { HStack { Image("trash-simple") @@ -377,3 +376,5 @@ struct ContactInnerActionsFragment: View { actionEditButton: {} ) } + +// swiftlint:enable type_body_length diff --git a/Linphone/UI/Main/Contacts/Fragments/ContactsInnerFragment.swift b/Linphone/UI/Main/Contacts/Fragments/ContactsInnerFragment.swift index 8fe87a939..8e59f3d47 100644 --- a/Linphone/UI/Main/Contacts/Fragments/ContactsInnerFragment.swift +++ b/Linphone/UI/Main/Contacts/Fragments/ContactsInnerFragment.swift @@ -77,7 +77,8 @@ struct ContactsInnerFragment: View { VStack { List { - ContactsListFragment(contactViewModel: contactViewModel, contactsListViewModel: ContactsListViewModel(), showingSheet: $showingSheet, startCallFunc: {addr in })} + ContactsListFragment(contactViewModel: contactViewModel, contactsListViewModel: ContactsListViewModel(), + showingSheet: $showingSheet, startCallFunc: {_ in })} .listStyle(.plain) .overlay( VStack { diff --git a/Linphone/UI/Main/Contacts/Fragments/ContactsListFragment.swift b/Linphone/UI/Main/Contacts/Fragments/ContactsListFragment.swift index dec374cfc..1d6bcbf2b 100644 --- a/Linphone/UI/Main/Contacts/Fragments/ContactsListFragment.swift +++ b/Linphone/UI/Main/Contacts/Fragments/ContactsListFragment.swift @@ -88,7 +88,8 @@ struct ContactsListFragment: View { contactViewModel.indexDisplayedFriend = index } - if index < contactsManager.lastSearch.count && contactsManager.lastSearch[index].friend != nil && contactsManager.lastSearch[index].friend!.address != nil { + if index < contactsManager.lastSearch.count && contactsManager.lastSearch[index].friend != nil + && contactsManager.lastSearch[index].friend!.address != nil { startCallFunc(contactsManager.lastSearch[index].friend!.address!) } } diff --git a/Linphone/UI/Main/Contacts/Fragments/EditContactFragment.swift b/Linphone/UI/Main/Contacts/Fragments/EditContactFragment.swift index e912466fe..79e4e1732 100644 --- a/Linphone/UI/Main/Contacts/Fragments/EditContactFragment.swift +++ b/Linphone/UI/Main/Contacts/Fragments/EditContactFragment.swift @@ -20,6 +20,7 @@ import SwiftUI import linphonesw +// swiftlint:disable type_body_length struct EditContactFragment: View { @ObservedObject var editContactViewModel: EditContactViewModel @@ -531,7 +532,8 @@ struct EditContactFragment: View { contact: newContact, linphoneFriend: true, existingFriend: editContactViewModel.selectedEditFriend) { MagicSearchSingleton.shared.searchForContacts(sourceFlags: MagicSearch.Source.Friends.rawValue | MagicSearch.Source.LdapServers.rawValue) - if editContactViewModel.selectedEditFriend != nil && editContactViewModel.selectedEditFriend!.name != editContactViewModel.firstName + " " + editContactViewModel.lastName { + if editContactViewModel.selectedEditFriend != nil + && editContactViewModel.selectedEditFriend!.name != editContactViewModel.firstName + " " + editContactViewModel.lastName { DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { let result = ContactsManager.shared.lastSearch.firstIndex(where: { $0.friend!.name == newContact.firstName + " " + newContact.lastName @@ -562,3 +564,4 @@ struct EditContactFragment: View { isShowDismissPopup: .constant(false) ) } +// swiftlint:enable type_body_length diff --git a/Linphone/UI/Main/Contacts/ViewModel/ContactViewModel.swift b/Linphone/UI/Main/Contacts/ViewModel/ContactViewModel.swift index be749b46e..283f3df41 100644 --- a/Linphone/UI/Main/Contacts/ViewModel/ContactViewModel.swift +++ b/Linphone/UI/Main/Contacts/ViewModel/ContactViewModel.swift @@ -20,6 +20,7 @@ import linphonesw import Combine +// swiftlint:disable line_length class ContactViewModel: ObservableObject { @Published var indexDisplayedFriend: Int? @@ -177,8 +178,7 @@ class ContactViewModel: ObservableObject { } func chatRoomAddDelegate(core: Core, chatRoom: ChatRoom) { - self.chatRoomSuscriptions.insert(chatRoom.publisher?.onConferenceJoined?.postOnCoreQueue { - (chatRoom: ChatRoom, eventLog: EventLog) in + self.chatRoomSuscriptions.insert(chatRoom.publisher?.onConferenceJoined?.postOnCoreQueue { (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)") @@ -213,8 +213,7 @@ class ContactViewModel: ObservableObject { } }) - self.chatRoomSuscriptions.insert(chatRoom.publisher?.onStateChanged?.postOnCoreQueue { - (chatRoom: ChatRoom, state: ChatRoom.State) in + self.chatRoomSuscriptions.insert(chatRoom.publisher?.onStateChanged?.postOnCoreQueue { (chatRoom: ChatRoom, state: ChatRoom.State) in let state = chatRoom.state let id = LinphoneUtils.getChatRoomId(room: chatRoom) if state == ChatRoom.State.CreationFailed { @@ -229,3 +228,4 @@ class ContactViewModel: ObservableObject { }) } } +// swiftlint:enable line_length diff --git a/Linphone/UI/Main/ContentView.swift b/Linphone/UI/Main/ContentView.swift index 3e0245555..147db9421 100644 --- a/Linphone/UI/Main/ContentView.swift +++ b/Linphone/UI/Main/ContentView.swift @@ -64,7 +64,7 @@ struct ContentView: View { @State var isShowDismissPopup = false @State var isShowSendCancelMeetingNotificationPopup = false @State var isShowSipAddressesPopup = false - @State var isShowSipAddressesPopupType = 0 //0 to call, 1 to message, 2 to video call + @State var isShowSipAddressesPopupType = 0 // 0 to call, 1 to message, 2 to video call @State var isShowConversationFragment = false @State var fullscreenVideo = false @@ -760,8 +760,7 @@ struct ContentView: View { } if contactViewModel.indexDisplayedFriend != nil || historyViewModel.displayedCall != nil || conversationViewModel.displayedConversation != nil || - meetingViewModel.displayedMeeting != nil - { + meetingViewModel.displayedMeeting != nil { HStack(spacing: 0) { Spacer() .frame(maxWidth: diff --git a/Linphone/UI/Main/Conversations/Fragments/ChatBubbleView.swift b/Linphone/UI/Main/Conversations/Fragments/ChatBubbleView.swift index 697522b67..e12e8e29b 100644 --- a/Linphone/UI/Main/Conversations/Fragments/ChatBubbleView.swift +++ b/Linphone/UI/Main/Conversations/Fragments/ChatBubbleView.swift @@ -20,6 +20,8 @@ import SwiftUI import WebKit +// swiftlint:disable type_body_length +// swiftlint:disable cyclomatic_complexity struct ChatBubbleView: View { @ObservedObject var conversationViewModel: ConversationViewModel @@ -40,8 +42,8 @@ struct ChatBubbleView: View { if eventLogMessage.message.isOutgoing { Spacer() } - - if conversationViewModel.displayedConversation != nil && conversationViewModel.displayedConversation!.isGroup && !eventLogMessage.message.isOutgoing && eventLogMessage.message.isFirstMessage { + 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}) ?? @@ -50,14 +52,16 @@ struct ChatBubbleView: View { ) .padding(.top, 30) } - } else if conversationViewModel.displayedConversation != nil && conversationViewModel.displayedConversation!.isGroup && !eventLogMessage.message.isOutgoing { + } 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 { + 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, 10) @@ -76,7 +80,8 @@ struct ChatBubbleView: View { .resizable() .frame(width: 15, height: 15, alignment: .leading) - Text(conversationViewModel.participantConversationModel.first(where: {$0.address == eventLogMessage.message.replyMessage!.address})?.name ?? "") + Text(conversationViewModel.participantConversationModel.first( + where: {$0.address == eventLogMessage.message.replyMessage!.address})?.name ?? "") .default_text_style(styleSize: 12) } .padding(.bottom, 2) @@ -139,7 +144,8 @@ struct ChatBubbleView: View { .default_text_style_300(styleSize: 14) .padding(.top, 1) - if (conversationViewModel.displayedConversation != nil && conversationViewModel.displayedConversation!.isGroup) || eventLogMessage.message.isOutgoing { + if (conversationViewModel.displayedConversation != nil && conversationViewModel.displayedConversation!.isGroup) + || eventLogMessage.message.isOutgoing { if eventLogMessage.message.status == .sending { ProgressView() .controlSize(.mini) @@ -253,7 +259,8 @@ struct ChatBubbleView: View { @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 { + 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() @@ -265,7 +272,8 @@ struct ChatBubbleView: View { .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 + .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) @@ -411,8 +419,8 @@ struct ChatBubbleView: View { .contentShape(Rectangle()) } } - .frame( - width: geometryProxy.size.width > 0 && CGFloat(122 * eventLogMessage.message.attachments.count) > geometryProxy.size.width - 110 - (isGroup ? 40 : 0) + .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) ) @@ -516,3 +524,6 @@ extension View { ChatBubbleView(conversationViewModel: ConversationViewModel(), index: 0) } */ + +// swiftlint:enable type_body_length +// swiftlint:enable cyclomatic_complexity diff --git a/Linphone/UI/Main/Conversations/Fragments/ConversationFragment.swift b/Linphone/UI/Main/Conversations/Fragments/ConversationFragment.swift index 0657e73e0..79b44df5c 100644 --- a/Linphone/UI/Main/Conversations/Fragments/ConversationFragment.swift +++ b/Linphone/UI/Main/Conversations/Fragments/ConversationFragment.swift @@ -20,6 +20,7 @@ import SwiftUI import UniformTypeIdentifiers +// swiftlint:disable line_length // swiftlint:disable type_body_length struct ConversationFragment: View { @@ -780,7 +781,6 @@ struct ConversationFragment: View { .navigationViewStyle(.stack) } } -// swiftlint:enable type_body_length struct ScrollOffsetPreferenceKey: PreferenceKey { static var defaultValue: CGPoint = .zero @@ -809,9 +809,9 @@ struct ImagePicker: UIViewControllerRepresentable { let image = info[UIImagePickerController.InfoKey.originalImage] as? UIImage let date = Date() - let df = DateFormatter() - df.dateFormat = "yyyy-MM-dd-HHmmss" - let dateString = df.string(from: date) + let dformater = DateFormatter() + dformater.dateFormat = "yyyy-MM-dd-HHmmss" + let dateString = dformater.string(from: date) let path = FileManager.default.temporaryDirectory.appendingPathComponent((dateString.addingPercentEncoding(withAllowedCharacters: .urlHostAllowed) ?? "") + ".jpeg") @@ -819,7 +819,7 @@ struct ImagePicker: UIViewControllerRepresentable { let data = image!.jpegData(compressionQuality: 1) if data != nil { do { - let decodedData: () = try data!.write(to: path) + _ = try data!.write(to: path) let attachment = Attachment(id: UUID().uuidString, name: (dateString.addingPercentEncoding(withAllowedCharacters: .urlHostAllowed) ?? "") + ".jpeg", url: path, type: .image) parent.selectedMedia.append(attachment) } catch { @@ -846,7 +846,7 @@ struct ImagePicker: UIViewControllerRepresentable { } } default: - Log.info("Mismatched type: \(mediaType)") + Log.info("Mismatched type: \(mediaType ?? "mediaType is nil")") } parent.presentationMode.wrappedValue.dismiss() @@ -876,3 +876,6 @@ struct ImagePicker: UIViewControllerRepresentable { 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 index 2509fa12c..b91bf86e0 100644 --- a/Linphone/UI/Main/Conversations/Fragments/ConversationsFragment.swift +++ b/Linphone/UI/Main/Conversations/Fragments/ConversationsFragment.swift @@ -32,7 +32,8 @@ struct ConversationsFragment: View { var body: some View { ZStack { if #available(iOS 16.0, *), idiom != .pad { - ConversationsListFragment(conversationViewModel: conversationViewModel, conversationsListViewModel: conversationsListViewModel, showingSheet: $showingSheet, text: $text) + ConversationsListFragment(conversationViewModel: conversationViewModel, + conversationsListViewModel: conversationsListViewModel, showingSheet: $showingSheet, text: $text) .sheet(isPresented: $showingSheet) { ConversationsListBottomSheet( conversationsListViewModel: conversationsListViewModel, @@ -41,7 +42,8 @@ struct ConversationsFragment: View { .presentationDetents([.fraction(0.4)]) } } else { - ConversationsListFragment(conversationViewModel: conversationViewModel, conversationsListViewModel: conversationsListViewModel, showingSheet: $showingSheet, text: $text) + ConversationsListFragment(conversationViewModel: conversationViewModel, + conversationsListViewModel: conversationsListViewModel, showingSheet: $showingSheet, text: $text) .halfSheet(showSheet: $showingSheet) { ConversationsListBottomSheet( conversationsListViewModel: conversationsListViewModel, diff --git a/Linphone/UI/Main/Conversations/Fragments/StartConversationFragment.swift b/Linphone/UI/Main/Conversations/Fragments/StartConversationFragment.swift index 30db791b5..2983054b6 100644 --- a/Linphone/UI/Main/Conversations/Fragments/StartConversationFragment.swift +++ b/Linphone/UI/Main/Conversations/Fragments/StartConversationFragment.swift @@ -20,6 +20,7 @@ import SwiftUI import linphonesw +// swiftlint:disable type_body_length struct StartConversationFragment: View { @ObservedObject private var sharedMainViewModel = SharedMainViewModel.shared @@ -184,7 +185,8 @@ struct StartConversationFragment: View { .padding(.horizontal, 16) } - ContactsListFragment(contactViewModel: ContactViewModel(), contactsListViewModel: ContactsListViewModel(), showingSheet: .constant(false), startCallFunc: { addr in + ContactsListFragment(contactViewModel: ContactViewModel(), contactsListViewModel: ContactsListViewModel(), showingSheet: .constant(false) + , startCallFunc: { addr in withAnimation { startConversationViewModel.createOneToOneChatRoomWith(remote: addr) } @@ -389,3 +391,5 @@ struct StartConversationFragment: View { isShowStartConversationFragment: .constant(true) ) } + +// swiftlint:enable type_body_length diff --git a/Linphone/UI/Main/Conversations/Fragments/UIList.swift b/Linphone/UI/Main/Conversations/Fragments/UIList.swift index d0e4e6eac..05dfa1be4 100644 --- a/Linphone/UI/Main/Conversations/Fragments/UIList.swift +++ b/Linphone/UI/Main/Conversations/Fragments/UIList.swift @@ -18,6 +18,9 @@ */ // swiftlint:disable large_tuple +// swiftlint:disable line_length +// swiftlint:disable cyclomatic_complexity +// swiftlint:disable type_body_length import SwiftUI import linphonesw @@ -156,7 +159,7 @@ struct UIList: UIViewRepresentable { return containerView } - //func updateUIView(_ tableView: UITableView, context: Context) { + // func updateUIView(_ tableView: UITableView, context: Context) { func updateUIView(_ uiView: UIView, context: Context) { if let button = uiView.viewWithTag(102) as? FloatingButton { button.unreadMessageCount = conversationViewModel.displayedConversationUnreadMessagesCount @@ -253,17 +256,17 @@ struct UIList: UIViewRepresentable { 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 } ) + 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 } ) { + 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 } ) { + if let operationIndex = oldSections.firstIndex(where: { $0.date == date }) { appliedDeletes.remove(at: oldIndex) deleteOperations.append(.deleteSection(operationIndex)) } @@ -476,8 +479,8 @@ struct UIList: UIViewRepresentable { } func tableView(_ tableView: UITableView, leadingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? { - - let archiveAction = UIContextualAction(style: .normal, title: "") { action, view, completionHandler in + + let archiveAction = UIContextualAction(style: .normal, title: "") { _, _, completionHandler in self.parent.conversationViewModel.replyToMessage(index: indexPath.row) completionHandler(true) } @@ -530,11 +533,6 @@ struct EventLogMessage: Equatable { return formatter }() - init(eventLog: EventLog, message: Message) { - self.eventLog = eventLog - self.message = message - } - static func == (lhs: EventLogMessage, rhs: EventLogMessage) -> Bool { lhs.message == rhs.message } @@ -565,7 +563,7 @@ public typealias ChatPaginationClosure = (Message) -> Void final class ChatViewModel: ObservableObject { - @Published private(set) var fullscreenAttachmentItem: Optional = nil + @Published private(set) var fullscreenAttachmentItem: Attachment? @Published var fullscreenAttachmentPresented = false @Published var messageMenuRow: Message? @@ -586,5 +584,7 @@ final class ChatViewModel: ObservableObject { 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/ConversationModel.swift b/Linphone/UI/Main/Conversations/Model/ConversationModel.swift index b222a532c..6171cecf8 100644 --- a/Linphone/UI/Main/Conversations/Model/ConversationModel.swift +++ b/Linphone/UI/Main/Conversations/Model/ConversationModel.swift @@ -21,6 +21,7 @@ import Foundation import linphonesw import Combine +// swiftlint:disable line_length class ConversationModel: ObservableObject { private var coreContext = CoreContext.shared @@ -236,7 +237,9 @@ class ConversationModel: ObservableObject { func downloadContent(chatMessage: ChatMessage, content: Content) { coreContext.doOnCoreQueue { _ in - let result = chatMessage.downloadContent(content: content) + if !chatMessage.downloadContent(content: content) { + Log.error("\(ConversationModel.TAG) An error occured when downloading content of chat message. MessageID=\(chatMessage.messageId)") + } } } @@ -246,3 +249,4 @@ class ConversationModel: ObservableObject { } } } +// swiftlint:enable line_length diff --git a/Linphone/UI/Main/Conversations/Model/Message.swift b/Linphone/UI/Main/Conversations/Model/Message.swift index 051074283..3fedf5713 100644 --- a/Linphone/UI/Main/Conversations/Model/Message.swift +++ b/Linphone/UI/Main/Conversations/Model/Message.swift @@ -19,6 +19,8 @@ import SwiftUI +// swiftlint:disable line_length +// swiftlint:disable vertical_parameter_alignment public struct Message: Identifiable, Hashable { public enum Status: Equatable, Hashable { @@ -236,7 +238,7 @@ public struct DraftMessage { public let createdAt: Date public let ownReaction: String public let reactions: [String] - + public init(id: String? = nil, isOutgoing: Bool, dateReceived: time_t, @@ -324,8 +326,8 @@ extension Sequence { var values = [T]() for element in self { - if let el = try await transform(element) { - values.append(el) + if let elmt = try await transform(element) { + values.append(elmt) } } @@ -354,3 +356,5 @@ extension DateFormatter { return String(format: "%02i:%02i", minute, second) } } +// swiftlint:enable line_length +// swiftlint:enable vertical_parameter_alignment diff --git a/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift b/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift index bd239df9e..471779b47 100644 --- a/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift +++ b/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift @@ -23,7 +23,10 @@ import Combine 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 @@ -85,7 +88,7 @@ class ConversationViewModel: ObservableObject { statusTmp = .sending } - var indexMessage = self.conversationMessagesSection[0].rows.firstIndex(where: {$0.eventLog.chatMessage?.messageId == message.messageId}) + let indexMessage = self.conversationMessagesSection[0].rows.firstIndex(where: {$0.eventLog.chatMessage?.messageId == message.messageId}) DispatchQueue.main.async { if indexMessage != nil { @@ -199,7 +202,7 @@ class ConversationViewModel: ObservableObject { } func addParticipantConversationModel(address: Address) { - coreContext.doOnCoreQueue { core in + coreContext.doOnCoreQueue { _ in ContactAvatarModel.getAvatarModelFromAddress(address: address) { avatarResult in let avatarModelTmp = avatarResult DispatchQueue.main.async { @@ -234,7 +237,7 @@ class ConversationViewModel: ObservableObject { 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) + // self.downloadContent(chatMessage: eventLog.chatMessage!, content: content) let path = URL(string: self.getNewFilePath(name: content.name ?? "")) if path != nil { @@ -434,7 +437,7 @@ class ConversationViewModel: ObservableObject { 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) + // self.downloadContent(chatMessage: eventLog.chatMessage!, content: content) let path = URL(string: self.getNewFilePath(name: content.name ?? "")) if path != nil { @@ -619,7 +622,6 @@ class ConversationViewModel: ObservableObject { } } - // swiftlint:disable cyclomatic_complexity func getNewMessages(eventLogs: [EventLog]) { eventLogs.enumerated().forEach { index, eventLog in var attachmentNameList: String = "" @@ -632,7 +634,7 @@ class ConversationViewModel: ObservableObject { contentText = content.utf8Text ?? "" } else { if content.filePath == nil || content.filePath!.isEmpty { - //self.downloadContent(chatMessage: eventLog.chatMessage!, content: content) + // self.downloadContent(chatMessage: eventLog.chatMessage!, content: content) let path = URL(string: self.getNewFilePath(name: content.name ?? "")) if path != nil { @@ -843,7 +845,6 @@ class ConversationViewModel: ObservableObject { } } } - // swiftlint:enable cyclomatic_complexity func resetMessage() { conversationMessagesSection = [] @@ -860,7 +861,6 @@ class ConversationViewModel: ObservableObject { } } - // swiftlint:disable cyclomatic_complexity func scrollToMessage(message: Message) { coreContext.doOnCoreQueue { _ in if message.replyMessage != nil { @@ -904,7 +904,7 @@ class ConversationViewModel: ObservableObject { 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) + // self.downloadContent(chatMessage: eventLog.chatMessage!, content: content) let path = URL(string: self.getNewFilePath(name: content.name ?? "")) if path != nil { @@ -1095,7 +1095,6 @@ class ConversationViewModel: ObservableObject { } } } - // swiftlint:enable cyclomatic_complexity func sendMessage() { coreContext.doOnCoreQueue { _ in @@ -1155,7 +1154,7 @@ class ConversationViewModel: ObservableObject { content.type = "file" } - //content.subtype = attachment.type == .plainText ? "plain" : FileUtils.getExtensionFromFileName(attachment.fileName) + // content.subtype = attachment.type == .plainText ? "plain" : FileUtils.getExtensionFromFileName(attachment.fileName) content.subtype = attachment.full.pathExtension content.name = attachment.full.lastPathComponent @@ -1186,7 +1185,7 @@ class ConversationViewModel: ObservableObject { } catch { } } - //} + // } if message != nil && !message!.contents.isEmpty { Log.info("[ConversationViewModel] Sending message") @@ -1250,17 +1249,15 @@ class ConversationViewModel: ObservableObject { } func downloadContent(chatMessage: ChatMessage, content: Content) { - //Log.debug("[ConversationViewModel] Starting downloading content for file \(model.fileName)") + // Log.debug("[ConversationViewModel] Starting downloading content for file \(model.fileName)") if !chatMessage.isFileTransferInProgress && (content.filePath == nil || content.filePath!.isEmpty) { - let contentName = content.name - if contentName != nil { - let isImage = FileUtil.isExtensionImage(path: contentName!) - let groupName = "group.\(Bundle.main.bundleIdentifier ?? "").linphoneExtension" - let file = FileUtil.sharedContainerUrl().appendingPathComponent("Library/Images").absoluteString + (contentName!.addingPercentEncoding(withAllowedCharacters: .urlHostAllowed) ?? "") - //let file = FileUtil.getFileStoragePath(fileName: contentName ?? "", isImage: isImage) + 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)" + "[ConversationViewModel] File \(contentName) will be downloaded at \(content.filePath ?? "NIL")" ) self.displayedConversation?.downloadContent(chatMessage: chatMessage, content: content) } else { @@ -1270,7 +1267,6 @@ class ConversationViewModel: ObservableObject { } func getNewFilePath(name: String) -> String { - let groupName = "group.\(Bundle.main.bundleIdentifier ?? "").linphoneExtension" return FileUtil.sharedContainerUrl().appendingPathComponent("Library/Images").absoluteString + (name.addingPercentEncoding(withAllowedCharacters: .urlHostAllowed) ?? "") } @@ -1299,7 +1295,7 @@ class ConversationViewModel: ObservableObject { : pathThumbnail!.appendingPathComponent("preview_" + (name.addingPercentEncoding(withAllowedCharacters: .urlHostAllowed) ?? "") + ".png") if urlName != nil { - let decodedData: () = try data.write(to: urlName!) + _ = try data.write(to: urlName!) } return urlName!.absoluteString @@ -1379,4 +1375,6 @@ class ConversationViewModel: ObservableObject { } } } +// swiftlint:enable line_length // swiftlint:enable type_body_length +// swiftlint:enable cyclomatic_complexity diff --git a/Linphone/UI/Main/Conversations/ViewModel/ConversationsListViewModel.swift b/Linphone/UI/Main/Conversations/ViewModel/ConversationsListViewModel.swift index f5caace40..2bb72aebd 100644 --- a/Linphone/UI/Main/Conversations/ViewModel/ConversationsListViewModel.swift +++ b/Linphone/UI/Main/Conversations/ViewModel/ConversationsListViewModel.swift @@ -21,6 +21,8 @@ import Foundation import linphonesw import Combine +// swiftlint:disable line_length +// swiftlint:disable large_tuple class ConversationsListViewModel: ObservableObject { private var coreContext = CoreContext.shared @@ -68,7 +70,7 @@ class ConversationsListViewModel: ObservableObject { let chatRoomsCounter = account?.chatRooms != nil ? account!.chatRooms.count : core.chatRooms.count var counter = 0 self.mCoreSuscriptions.insert(core.publisher?.onChatRoomStateChanged?.postOnCoreQueue { (cbValue: (core: Core, chatRoom: ChatRoom, state: ChatRoom.State)) in - //Log.info("[ConversationsListViewModel] Conversation [${LinphoneUtils.getChatRoomId(chatRoom)}] state changed [$state]") + // Log.info("[ConversationsListViewModel] Conversation [${LinphoneUtils.getChatRoomId(chatRoom)}] state changed [$state]") switch cbValue.state { case ChatRoom.State.Created: if !(cbValue.chatRoom.isEmpty && cbValue.chatRoom.hasCapability(mask: ChatRoom.Capabilities.OneToOne.rawValue)) { @@ -81,8 +83,8 @@ class ConversationsListViewModel: ObservableObject { } case ChatRoom.State.Deleted: self.computeChatRoomsList(filter: "") - //ToastViewModel.shared.toastMessage = "toast_conversation_deleted" - //ToastViewModel.shared.displayToast = true + // ToastViewModel.shared.toastMessage = "toast_conversation_deleted" + // ToastViewModel.shared.displayToast = true default: break } @@ -214,3 +216,5 @@ class ConversationsListViewModel: ObservableObject { conversationsList = conversationsListTmp } } +// swiftlint:enable line_length +// swiftlint:enable large_tuple diff --git a/Linphone/UI/Main/Conversations/ViewModel/StartConversationViewModel.swift b/Linphone/UI/Main/Conversations/ViewModel/StartConversationViewModel.swift index fec47193a..7ba93ca7d 100644 --- a/Linphone/UI/Main/Conversations/ViewModel/StartConversationViewModel.swift +++ b/Linphone/UI/Main/Conversations/ViewModel/StartConversationViewModel.swift @@ -20,6 +20,7 @@ import linphonesw import Combine +// swiftlint:disable line_length class StartConversationViewModel: ObservableObject { static let TAG = "[StartConversationViewModel]" @@ -301,8 +302,7 @@ class StartConversationViewModel: ObservableObject { } func chatRoomAddDelegate(core: Core, chatRoom: ChatRoom) { - self.chatRoomSuscriptions.insert(chatRoom.publisher?.onConferenceJoined?.postOnCoreQueue { - (chatRoom: ChatRoom, eventLog: EventLog) in + self.chatRoomSuscriptions.insert(chatRoom.publisher?.onConferenceJoined?.postOnCoreQueue { (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)") @@ -337,8 +337,7 @@ class StartConversationViewModel: ObservableObject { } }) - self.chatRoomSuscriptions.insert(chatRoom.publisher?.onStateChanged?.postOnCoreQueue { - (chatRoom: ChatRoom, state: ChatRoom.State) in + self.chatRoomSuscriptions.insert(chatRoom.publisher?.onStateChanged?.postOnCoreQueue { (chatRoom: ChatRoom, state: ChatRoom.State) in let state = chatRoom.state let id = LinphoneUtils.getChatRoomId(room: chatRoom) if state == ChatRoom.State.CreationFailed { @@ -357,3 +356,5 @@ class StartConversationViewModel: ObservableObject { return false // TODO: Will be done later in SDK } } + +// swiftlint:enable line_length diff --git a/Linphone/UI/Main/Fragments/ToastView.swift b/Linphone/UI/Main/Fragments/ToastView.swift index de0bbf2b9..89f27f445 100644 --- a/Linphone/UI/Main/Fragments/ToastView.swift +++ b/Linphone/UI/Main/Fragments/ToastView.swift @@ -248,7 +248,9 @@ struct ToastView: View { .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) + .stroke(toastViewModel.toastMessage.contains("Success") + ? Color.greenSuccess500 : (toastViewModel.toastMessage.contains("Info_") + ? Color.blueInfo500 : Color.redDanger500), lineWidth: 1) ) .onTapGesture { if !toastViewModel.toastMessage.contains("is recording") { diff --git a/Linphone/UI/Main/History/Fragments/DialerBottomSheet.swift b/Linphone/UI/Main/History/Fragments/DialerBottomSheet.swift index 8328dca8b..169a96f7d 100644 --- a/Linphone/UI/Main/History/Fragments/DialerBottomSheet.swift +++ b/Linphone/UI/Main/History/Fragments/DialerBottomSheet.swift @@ -21,6 +21,7 @@ import SwiftUI import UniformTypeIdentifiers import linphonesw +// swiftlint:disable type_body_length struct DialerBottomSheet: View { @Environment(\.dismiss) var dismiss @@ -544,6 +545,11 @@ struct DialerBottomSheet: View { #Preview { DialerBottomSheet( - startCallViewModel: StartCallViewModel(), callViewModel: CallViewModel(), isShowStartCallFragment: .constant(false), showingDialer: .constant(false), currentCall: nil - ) + 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 index 4aa868f20..505477d01 100644 --- a/Linphone/UI/Main/History/Fragments/HistoryContactFragment.swift +++ b/Linphone/UI/Main/History/Fragments/HistoryContactFragment.swift @@ -21,7 +21,6 @@ import SwiftUI import UniformTypeIdentifiers import linphonesw -// swiftlint:disable line_length // swiftlint:disable type_body_length struct HistoryContactFragment: View { @@ -462,5 +461,4 @@ struct HistoryContactFragment: View { indexPage: .constant(1) ) } -// swiftlint:enable line_length // swiftlint:enable type_body_length diff --git a/Linphone/UI/Main/History/Fragments/StartCallFragment.swift b/Linphone/UI/Main/History/Fragments/StartCallFragment.swift index 1f11924e0..89d501e18 100644 --- a/Linphone/UI/Main/History/Fragments/StartCallFragment.swift +++ b/Linphone/UI/Main/History/Fragments/StartCallFragment.swift @@ -20,6 +20,7 @@ import SwiftUI import linphonesw +// swiftlint:disable type_body_length struct StartCallFragment: View { @ObservedObject private var sharedMainViewModel = SharedMainViewModel.shared @@ -217,7 +218,8 @@ struct StartCallFragment: View { .padding(.horizontal, 16) } - ContactsListFragment(contactViewModel: ContactViewModel(), contactsListViewModel: ContactsListViewModel(), showingSheet: .constant(false), startCallFunc: { addr in + ContactsListFragment(contactViewModel: ContactViewModel(), contactsListViewModel: ContactsListViewModel(), showingSheet: .constant(false) + , startCallFunc: { addr in if callViewModel.isTransferInsteadCall { showingDialer = false @@ -482,3 +484,5 @@ struct StartCallFragment: View { resetCallView: {} ) } + +// swiftlint:enable type_body_length diff --git a/Linphone/UI/Main/History/Model/HistoryModel.swift b/Linphone/UI/Main/History/Model/HistoryModel.swift index 4a996b190..92cd1a3eb 100644 --- a/Linphone/UI/Main/History/Model/HistoryModel.swift +++ b/Linphone/UI/Main/History/Model/HistoryModel.swift @@ -112,7 +112,9 @@ class HistoryModel: ObservableObject { func refreshAvatarModel() { coreContext.doOnCoreQueue { _ in - let addressFriendTmp = ContactsManager.shared.getFriendWithAddress(address: self.callLog.dir == .Outgoing ? self.callLog.toAddress! : self.callLog.fromAddress!) + let addressFriendTmp = ContactsManager.shared.getFriendWithAddress( + address: self.callLog.dir == .Outgoing ? self.callLog.toAddress! : self.callLog.fromAddress! + ) if addressFriendTmp != nil { self.addressFriend = addressFriendTmp diff --git a/Linphone/UI/Main/History/ViewModel/StartCallViewModel.swift b/Linphone/UI/Main/History/ViewModel/StartCallViewModel.swift index b5540b777..b132748b9 100644 --- a/Linphone/UI/Main/History/ViewModel/StartCallViewModel.swift +++ b/Linphone/UI/Main/History/ViewModel/StartCallViewModel.swift @@ -20,6 +20,7 @@ import linphonesw import Combine +// swiftlint:disable line_length class StartCallViewModel: ObservableObject { static let TAG = "[StartCallViewModel]" @@ -115,8 +116,7 @@ class StartCallViewModel: ObservableObject { } func conferenceAddDelegate(core: Core, conferenceScheduler: ConferenceScheduler) { - self.conferenceSuscriptions.insert(conferenceScheduler.publisher?.onStateChanged?.postOnCoreQueue { - (conferenceScheduler: ConferenceScheduler, state: ConferenceScheduler.State) in + self.conferenceSuscriptions.insert(conferenceScheduler.publisher?.onStateChanged?.postOnCoreQueue { (conferenceScheduler: ConferenceScheduler, state: ConferenceScheduler.State) in Log.info("\(StartCallViewModel.TAG) Conference scheduler state is \(state)") if state == ConferenceScheduler.State.Ready { self.conferenceSuscriptions.removeAll() @@ -153,13 +153,7 @@ class StartCallViewModel: ObservableObject { } func startVideoCall(core: Core, conferenceAddress: Address) { - do { - TelecomManager.shared.doCallWithCore(addr: conferenceAddress, isVideo: true, isConference: true) - } catch let error { - Log.error( - "\(StartCallViewModel.TAG) StartVideoCall: \(error)" - ) - } + TelecomManager.shared.doCallWithCore(addr: conferenceAddress, isVideo: true, isConference: true) } func interpretAndStartCall() { @@ -171,3 +165,4 @@ class StartCallViewModel: ObservableObject { } } } +// swiftlint:enable line_length diff --git a/Linphone/UI/Main/Meetings/Fragments/AddParticipantsFragment.swift b/Linphone/UI/Main/Meetings/Fragments/AddParticipantsFragment.swift index c49227ce3..6d45dc1cc 100644 --- a/Linphone/UI/Main/Meetings/Fragments/AddParticipantsFragment.swift +++ b/Linphone/UI/Main/Meetings/Fragments/AddParticipantsFragment.swift @@ -89,7 +89,7 @@ struct AddParticipantsFragment: View { Text(addParticipantsViewModel.participantsToAdd[index].avatarModel.name) .default_text_style(styleSize: 12) - .frame(minWidth: 60, maxWidth:80) + .frame(minWidth: 60, maxWidth: 80) } Image("x-circle") .renderingMode(.template) @@ -293,5 +293,5 @@ struct AddParticipantsFragment: View { } #Preview { - AddParticipantsFragment(addParticipantsViewModel: AddParticipantsViewModel(), confirmAddParticipantsFunc: {_ in } ) + AddParticipantsFragment(addParticipantsViewModel: AddParticipantsViewModel(), confirmAddParticipantsFunc: { _ in }) } diff --git a/Linphone/UI/Main/Meetings/Fragments/MeetingFragment.swift b/Linphone/UI/Main/Meetings/Fragments/MeetingFragment.swift index cf4b7d793..6f39dca49 100644 --- a/Linphone/UI/Main/Meetings/Fragments/MeetingFragment.swift +++ b/Linphone/UI/Main/Meetings/Fragments/MeetingFragment.swift @@ -17,7 +17,6 @@ * along with this program. If not, see . */ -// swiftlint:disable line_length import SwiftUI import linphonesw import UniformTypeIdentifiers @@ -247,8 +246,7 @@ struct MeetingFragment: View { .frame(height: 1) .background(Color.gray200) } - - + HStack(alignment: .top, spacing: 10) { Image("users") .renderingMode(.template) @@ -310,5 +308,3 @@ struct MeetingFragment: View { , isShowScheduleMeetingFragment: .constant(true) , isShowSendCancelMeetingNotificationPopup: .constant(false)) } - -// swiftlint:enable line_length diff --git a/Linphone/UI/Main/Meetings/Fragments/MeetingsListFragment.swift b/Linphone/UI/Main/Meetings/Fragments/MeetingsListFragment.swift index c9e6a473e..b52060053 100644 --- a/Linphone/UI/Main/Meetings/Fragments/MeetingsListFragment.swift +++ b/Linphone/UI/Main/Meetings/Fragments/MeetingsListFragment.swift @@ -17,8 +17,6 @@ * along with this program. If not, see . */ -// swiftlint:disable line_length - import SwiftUI import linphonesw @@ -38,5 +36,3 @@ struct MeetingsListFragment: View { #Preview { MeetingsListFragment(meetingsListViewModel: MeetingsListViewModel(), showingSheet: .constant(false)) } - -// swiftlint:enable line_length diff --git a/Linphone/UI/Main/Meetings/Fragments/ScheduleMeetingFragment.swift b/Linphone/UI/Main/Meetings/Fragments/ScheduleMeetingFragment.swift index 0f198a9dc..23c78ca96 100644 --- a/Linphone/UI/Main/Meetings/Fragments/ScheduleMeetingFragment.swift +++ b/Linphone/UI/Main/Meetings/Fragments/ScheduleMeetingFragment.swift @@ -17,10 +17,9 @@ * along with this program. If not, see . */ -// swiftlint:disable line_length - import SwiftUI +// swiftlint:disable type_body_length struct ScheduleMeetingFragment: View { @Environment(\.dismiss) var dismiss @@ -214,7 +213,6 @@ struct ScheduleMeetingFragment: View { .frame(width: 24, height: 24) .padding(.leading, 15) //Picker(selection:, label:(")) - Text("TODO : repeat") .fontWeight(.bold) .padding(.leading, 5) .default_text_style_500(styleSize: 16) @@ -355,7 +353,6 @@ struct ScheduleMeetingFragment: View { } .padding() - if meetingViewModel.operationInProgress { HStack { Spacer() @@ -491,7 +488,7 @@ struct ScheduleMeetingFragment: View { .padding(.horizontal) .frame(maxHeight: .infinity) .shadow(color: Color.orangeMain500, radius: 0, x: 0, y: 2) - //.frame(maxWidth: sharedMainViewModel.maxWidth) + // .frame(maxWidth: sharedMainViewModel.maxWidth) .position(x: geometry.size.width / 2, y: geometry.size.height / 2) } .background(.black.opacity(0.65)) @@ -509,7 +506,7 @@ struct ScheduleMeetingFragment: View { 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) { + 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)! @@ -539,4 +536,4 @@ struct ScheduleMeetingFragment: View { , isShowScheduleMeetingFragment: .constant(true)) } -// swiftlint:enable line_length +// swiftlint:enable type_body_length diff --git a/Linphone/UI/Main/Meetings/ViewModel/MeetingViewModel.swift b/Linphone/UI/Main/Meetings/ViewModel/MeetingViewModel.swift index 3512dc846..2d3927caa 100644 --- a/Linphone/UI/Main/Meetings/ViewModel/MeetingViewModel.swift +++ b/Linphone/UI/Main/Meetings/ViewModel/MeetingViewModel.swift @@ -21,6 +21,7 @@ import Foundation import linphonesw import Combine +// swiftlint:disable line_length class MeetingViewModel: ObservableObject { static let TAG = "[MeetingViewModel]" @@ -41,7 +42,7 @@ class MeetingViewModel: ObservableObject { @Published var selectedTimezoneIdx = 0 var selectedTimezone = TimeZone.current - var knownTimezones : [String] = [] + var knownTimezones: [String] = [] var conferenceScheduler: ConferenceScheduler? private var mSchedulerSubscriptions = Set() @@ -272,8 +273,7 @@ class MeetingViewModel: ObservableObject { self.fromDate = meeting.meetingDate self.toDate = meeting.endDate self.participants = [] - - + CoreContext.shared.doOnCoreQueue { core in let organizer = meeting.confInfo.organizer var organizerFound = false @@ -294,7 +294,7 @@ class MeetingViewModel: ObservableObject { organizerFound = organizerFound || isOrganizer ContactAvatarModel.getAvatarModelFromAddress(address: addr) { avatarResult in DispatchQueue.main.async { - self.participants.append(SelectedAddressModel(addr: addr, avModel: avatarResult, isOrg:isOrganizer)) + self.participants.append(SelectedAddressModel(addr: addr, avModel: avatarResult, isOrg: isOrganizer)) } } } @@ -322,12 +322,12 @@ class MeetingViewModel: ObservableObject { if let conferenceInfo = core.findConferenceInformationFromUri(uri: conferenceAddress) { self.conferenceInfoToEdit = conferenceInfo - Log.info("\(MeetingViewModel.TAG) Found conference info matching URI \(conferenceInfo.uri?.asString()) with subject \(conferenceInfo.subject)") + Log.info("\(MeetingViewModel.TAG) Found conference info matching URI \(conferenceInfo.uri?.asString() ?? "NIL") with subject \(conferenceInfo.subject ?? "NIL")") self.fromDate = Date(timeIntervalSince1970: TimeInterval(conferenceInfo.dateTime)) self.toDate = Calendar.current.date(byAdding: .minute, value: Int(conferenceInfo.duration), to: self.fromDate)! - var list: [SelectedAddressModel] = [] + let list: [SelectedAddressModel] = [] for partInfo in conferenceInfo.participantInfos { if let addr = partInfo.address { ContactAvatarModel.getAvatarModelFromAddress(address: addr) { avatarResult in @@ -342,10 +342,9 @@ class MeetingViewModel: ObservableObject { DispatchQueue.main.async { self.subject = conferenceInfo.subject ?? "" self.description = conferenceInfo.description ?? "" - self.isBroadcastSelected = false // TODO FIXME + self.isBroadcastSelected = false self.computeDateLabels() self.computeTimeLabels() - //self.participants = list } } else { @@ -365,3 +364,5 @@ class MeetingViewModel: ObservableObject { } } } + +// swiftlint:enable line_length diff --git a/Linphone/UI/Main/Meetings/ViewModel/MeetingsListViewModel.swift b/Linphone/UI/Main/Meetings/ViewModel/MeetingsListViewModel.swift index dad4aff90..06eb9fa46 100644 --- a/Linphone/UI/Main/Meetings/ViewModel/MeetingsListViewModel.swift +++ b/Linphone/UI/Main/Meetings/ViewModel/MeetingsListViewModel.swift @@ -36,7 +36,7 @@ class MeetingsListViewModel: ObservableObject { init() { coreContext.doOnCoreQueue { core in self.mCoreSuscriptions.insert(core.publisher?.onConferenceInfoReceived?.postOnCoreQueue { (cbVal: (core: Core, conferenceInfo: ConferenceInfo)) in - Log.info("\(MeetingsListViewModel.TAG) Conference info received [\(cbVal.conferenceInfo.uri?.asStringUriOnly())") + Log.info("\(MeetingsListViewModel.TAG) Conference info received [\(cbVal.conferenceInfo.uri?.asStringUriOnly() ?? "NIL")") self.computeMeetingsList() }) } @@ -68,7 +68,9 @@ class MeetingsListViewModel: ObservableObject { 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 + let participantsCheck = confInfo.participantInfos.first( + where: {$0.address?.asStringUriOnly().range(of: filter, options: .caseInsensitive) != nil} + ) != nil add = organizerCheck || subjectCheck || descriptionCheck || participantsCheck } diff --git a/Linphone/Utils/Extensions/ConfigExtension.swift b/Linphone/Utils/Extensions/ConfigExtension.swift index b7b308c0a..51bd40ed7 100644 --- a/Linphone/Utils/Extensions/ConfigExtension.swift +++ b/Linphone/Utils/Extensions/ConfigExtension.swift @@ -37,7 +37,6 @@ extension Config { public static func get() -> Config { if _instance == nil { let factoryPath = FileUtil.bundleFilePath("linphonerc-factory")! - let configDir = Factory.Instance.getConfigDir(context: nil) _instance = Config.newForSharedCore(appGroupId: Config.appGroupName, configFilename: "linphonerc", factoryConfigFilename: factoryPath)! } return _instance! diff --git a/Linphone/Utils/Extensions/IntExtension.swift b/Linphone/Utils/Extensions/IntExtension.swift index 0f457700a..b4dce52f2 100644 --- a/Linphone/Utils/Extensions/IntExtension.swift +++ b/Linphone/Utils/Extensions/IntExtension.swift @@ -19,6 +19,7 @@ import Foundation +// swiftlint:disable large_tuple extension Int { public func hmsFrom() -> (Int, Int, Int) { @@ -65,3 +66,4 @@ extension Int { return "\(second)" } } +// swiftlint:enable large_tuple diff --git a/Linphone/Utils/Extensions/URLExtension.swift b/Linphone/Utils/Extensions/URLExtension.swift index 10fb96e93..8a548a0b3 100644 --- a/Linphone/Utils/Extensions/URLExtension.swift +++ b/Linphone/Utils/Extensions/URLExtension.swift @@ -26,9 +26,7 @@ extension URL { return components?.url } var resourceSpecifier: String { - get { - let nrl : NSURL = self as NSURL - return nrl.resourceSpecifier ?? self.absoluteString - } + let nrl: NSURL = self as NSURL + return nrl.resourceSpecifier ?? self.absoluteString } } diff --git a/Linphone/Utils/FileUtils.swift b/Linphone/Utils/FileUtils.swift index a7f8323d8..d81046753 100644 --- a/Linphone/Utils/FileUtils.swift +++ b/Linphone/Utils/FileUtils.swift @@ -195,8 +195,7 @@ extension NSURL { if let pathExt = self.pathExtension, let mimeType = UTType(filenameExtension: pathExt)?.preferredMIMEType { return mimeType - } - else { + } else { return "application/octet-stream" } } @@ -206,8 +205,7 @@ extension URL { public func mimeType() -> String { if let mimeType = UTType(filenameExtension: self.pathExtension)?.preferredMIMEType { return mimeType - } - else { + } else { return "application/octet-stream" } } @@ -217,8 +215,7 @@ extension NSString { public func mimeType() -> String { if let mimeType = UTType(filenameExtension: self.pathExtension)?.preferredMIMEType { return mimeType - } - else { + } else { return "application/octet-stream" } } diff --git a/Linphone/Utils/LinphoneUtils.swift b/Linphone/Utils/LinphoneUtils.swift index d959bbf0e..98fc32bdc 100644 --- a/Linphone/Utils/LinphoneUtils.swift +++ b/Linphone/Utils/LinphoneUtils.swift @@ -61,7 +61,8 @@ class LinphoneUtils: NSObject { } public class func applyInternationalPrefix(core: Core, account: Account? = nil) -> Bool { - return account?.params?.useInternationalPrefixForCallsAndChats == true || core.defaultAccount?.params?.useInternationalPrefixForCallsAndChats == true + return account?.params?.useInternationalPrefixForCallsAndChats == true + || core.defaultAccount?.params?.useInternationalPrefixForCallsAndChats == true } public class func isEndToEndEncryptedChatAvailable(core: Core) -> Bool { diff --git a/Linphone/Utils/MagicSearchSingleton.swift b/Linphone/Utils/MagicSearchSingleton.swift index 0307c5bc9..223a9378f 100644 --- a/Linphone/Utils/MagicSearchSingleton.swift +++ b/Linphone/Utils/MagicSearchSingleton.swift @@ -76,7 +76,7 @@ final class MagicSearchSingleton: ObservableObject { $1.friend!.name!.lowercased().folding(options: .diacriticInsensitive, locale: .current) }) - var addedAvatarListModel : [ContactAvatarModel] = [] + var addedAvatarListModel: [ContactAvatarModel] = [] sortedLastSearch.forEach { searchResult in if searchResult.friend != nil { addedAvatarListModel.append( diff --git a/Linphone/Utils/PhotoPicker.swift b/Linphone/Utils/PhotoPicker.swift index 5bc4befb9..16a309adf 100644 --- a/Linphone/Utils/PhotoPicker.swift +++ b/Linphone/Utils/PhotoPicker.swift @@ -20,6 +20,7 @@ import SwiftUI import PhotosUI +// swiftlint:disable line_length struct PhotoPicker: UIViewControllerRepresentable { typealias UIViewControllerType = PHPickerViewController @@ -85,6 +86,8 @@ struct PhotoPicker: UIViewControllerRepresentable { } catch { } + } else { + Log.error("Could not load file representation: \(error?.localizedDescription ?? "unknown error")") } dispatchGroup.leave() @@ -104,6 +107,8 @@ struct PhotoPicker: UIViewControllerRepresentable { } catch { } + } else { + Log.error("Could not load file representation: \(error?.localizedDescription ?? "unknown error")") } dispatchGroup.leave() } @@ -119,7 +124,7 @@ struct PhotoPicker: UIViewControllerRepresentable { do { let path = FileManager.default.temporaryDirectory.appendingPathComponent((name.addingPercentEncoding(withAllowedCharacters: .urlHostAllowed) ?? "")) - let decodedData: () = try data.write(to: path) + _ = try data.write(to: path) if type == .video { let asset = AVURLAsset(url: path, options: nil) @@ -134,7 +139,7 @@ struct PhotoPicker: UIViewControllerRepresentable { let urlName = FileManager.default.temporaryDirectory.appendingPathComponent("preview_" + (name.addingPercentEncoding(withAllowedCharacters: .urlHostAllowed) ?? "") + ".png") - let decodedData: () = try data.write(to: urlName) + _ = try data.write(to: urlName) } return path @@ -168,3 +173,5 @@ struct PhotoPicker: UIViewControllerRepresentable { } } } + +// swiftlint:enable line_length diff --git a/msgNotificationService/NotificationService.swift b/msgNotificationService/NotificationService.swift index 7a65dbadc..cce7555a8 100644 --- a/msgNotificationService/NotificationService.swift +++ b/msgNotificationService/NotificationService.swift @@ -98,7 +98,7 @@ class NotificationService: UNNotificationServiceExtension { if let bestAttemptContent = bestAttemptContent { createCore() - //if !lc!.config!.getBool(section: "app", key: "disable_chat_feature", defaultValue: false) { + // if !lc!.config!.getBool(section: "app", key: "disable_chat_feature", defaultValue: false) { Log.info("received push payload : \(bestAttemptContent.userInfo.debugDescription)") /* @@ -178,7 +178,7 @@ class NotificationService: UNNotificationServiceExtension { Log.info("Message not found for callid ["+callId+"]") } } - //} + // } serviceExtensionTimeWillExpire() } @@ -196,7 +196,7 @@ class NotificationService: UNNotificationServiceExtension { 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")) // TODO : temporary fix, to be removed after flexisip release + bestAttemptContent.sound = UNNotificationSound(named: UNNotificationSoundName("msg.caf")) } else { bestAttemptContent.title = NSLocalizedString("Message received", comment: "") bestAttemptContent.body = NSLocalizedString("IM_MSG", comment: "") From b75c756991b1811439062b9b1c445d1317a2feae Mon Sep 17 00:00:00 2001 From: QuentinArguillere Date: Mon, 19 Aug 2024 12:39:04 +0200 Subject: [PATCH 372/486] Add NSCalendarsUsageDescription and NSCalendarsWriteOnlyAccessUsageDescriptions to info.plist --- Linphone/Info.plist | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Linphone/Info.plist b/Linphone/Info.plist index c374ee2fe..fdbc8ca20 100644 --- a/Linphone/Info.plist +++ b/Linphone/Info.plist @@ -130,5 +130,9 @@ UIImageName linphone
+ NSCalendarsUsageDescription + Deprecated - Prior to iOS 17 full calendar access is required + NSCalendarsWriteOnlyAccessUsageDescription + From 2b371db0feb20efa6b6c5b8907fcfdce36fa0228 Mon Sep 17 00:00:00 2001 From: QuentinArguillere Date: Mon, 19 Aug 2024 12:39:56 +0200 Subject: [PATCH 373/486] Remove unused function loadExistingConferenceInfoFromUri --- .../Meetings/ViewModel/MeetingViewModel.swift | 41 ------------------- 1 file changed, 41 deletions(-) diff --git a/Linphone/UI/Main/Meetings/ViewModel/MeetingViewModel.swift b/Linphone/UI/Main/Meetings/ViewModel/MeetingViewModel.swift index 2d3927caa..eacf5b25b 100644 --- a/Linphone/UI/Main/Meetings/ViewModel/MeetingViewModel.swift +++ b/Linphone/UI/Main/Meetings/ViewModel/MeetingViewModel.swift @@ -316,47 +316,6 @@ class MeetingViewModel: ObservableObject { self.displayedMeeting = meeting } - func loadExistingConferenceInfoFromUri(conferenceUri: String) { - CoreContext.shared.doOnCoreQueue { core in - if let conferenceAddress = core.interpretUrl(url: conferenceUri, applyInternationalPrefix: false) { - if let conferenceInfo = core.findConferenceInformationFromUri(uri: conferenceAddress) { - - self.conferenceInfoToEdit = conferenceInfo - Log.info("\(MeetingViewModel.TAG) Found conference info matching URI \(conferenceInfo.uri?.asString() ?? "NIL") with subject \(conferenceInfo.subject ?? "NIL")") - - self.fromDate = Date(timeIntervalSince1970: TimeInterval(conferenceInfo.dateTime)) - self.toDate = Calendar.current.date(byAdding: .minute, value: Int(conferenceInfo.duration), to: self.fromDate)! - - let list: [SelectedAddressModel] = [] - for partInfo in conferenceInfo.participantInfos { - if let addr = partInfo.address { - ContactAvatarModel.getAvatarModelFromAddress(address: addr) { avatarResult in - let avatarModel = avatarResult - self.participants.append(SelectedAddressModel(addr: addr, avModel: avatarModel)) - Log.info("\(MeetingViewModel.TAG) Loaded participant \(addr.asStringUriOnly())") - } - } - } - Log.info("\(MeetingViewModel.TAG) \(list.count) participants loaded from found conference info") - - DispatchQueue.main.async { - self.subject = conferenceInfo.subject ?? "" - self.description = conferenceInfo.description ?? "" - self.isBroadcastSelected = false - self.computeDateLabels() - self.computeTimeLabels() - } - - } else { - Log.error("\(MeetingViewModel.TAG) Failed to find a conference info matching URI [${conferenceAddress.asString()}], abort") - } - } else { - Log.error("\(MeetingViewModel.TAG) Failed to parse conference URI [$conferenceUri], abort") - } - - } - } - func sendMeetingCancelledNotifications(meeting: MeetingModel) { CoreContext.shared.doOnCoreQueue { core in self.conferenceScheduler = try? core.createConferenceScheduler() From 356803051f6d6df971c2f3a8b8673192bd68cb57 Mon Sep 17 00:00:00 2001 From: QuentinArguillere Date: Mon, 19 Aug 2024 12:40:51 +0200 Subject: [PATCH 374/486] WIP - add meeting to calendar --- .../Meetings/ViewModel/MeetingViewModel.swift | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/Linphone/UI/Main/Meetings/ViewModel/MeetingViewModel.swift b/Linphone/UI/Main/Meetings/ViewModel/MeetingViewModel.swift index eacf5b25b..3ca21adf5 100644 --- a/Linphone/UI/Main/Meetings/ViewModel/MeetingViewModel.swift +++ b/Linphone/UI/Main/Meetings/ViewModel/MeetingViewModel.swift @@ -20,10 +20,12 @@ import Foundation import linphonesw import Combine +import EventKit // swiftlint:disable line_length class MeetingViewModel: ObservableObject { static let TAG = "[MeetingViewModel]" + let eventStore : EKEventStore = EKEventStore() @Published var isBroadcastSelected: Bool = false @Published var showBroadcastHelp: Bool = false @@ -322,6 +324,36 @@ class MeetingViewModel: ObservableObject { self.conferenceScheduler?.cancelConference(conferenceInfo: meeting.confInfo) } } + + func addMeetingToCalendar() { + if #available(iOS 17.0, *) { + eventStore.requestAccess(to: .event) { granted, error in + guard let meeting = self.displayedMeeting else { + Log.error("\(MeetingViewModel.TAG) Failed to add meeting to calendar: no meeting selected") + } + if !granted { + Log.error("\(MeetingViewModel.TAG) Failed to add meeting to calendar: access not granted") + } else if error == nil { + + let event:EKEvent = EKEvent(eventStore: self.eventStore) + event.title = self.subject + event.startDate = self.fromDate + event.endDate = self.toDate + event.notes = self.description + event.calendar = self.eventStore.defaultCalendarForNewEvents + do { + try self.eventStore.save(event, span: .thisEvent) + } catch let error as NSError { + Log.error("\(MeetingViewModel.TAG) Failed to add meeting to calendar: \(error)") + } + Log.info("\(MeetingViewModel.TAG) Meeting '\(meeting.subject)': \(error ?? "")") + } else { + Log.error("\(MeetingViewModel.TAG) Failed to add meeting to calendar: \(error ?? "")") + } + } + } + } + // eventStore.requestAccess(to: EKEntityType.event) { granted, error in } // swiftlint:enable line_length From 96d8a879b8a80b6c65d2b3eeb5d3ecb3ba19e21b Mon Sep 17 00:00:00 2001 From: QuentinArguillere Date: Mon, 19 Aug 2024 17:49:19 +0200 Subject: [PATCH 375/486] Fix build --- Linphone/UI/Main/Fragments/ToastView.swift | 5 ++ .../Meetings/ViewModel/MeetingViewModel.swift | 57 +++++++++++-------- 2 files changed, 38 insertions(+), 24 deletions(-) diff --git a/Linphone/UI/Main/Fragments/ToastView.swift b/Linphone/UI/Main/Fragments/ToastView.swift index 89f27f445..b696d2228 100644 --- a/Linphone/UI/Main/Fragments/ToastView.swift +++ b/Linphone/UI/Main/Fragments/ToastView.swift @@ -231,6 +231,11 @@ struct ToastView: View { 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) diff --git a/Linphone/UI/Main/Meetings/ViewModel/MeetingViewModel.swift b/Linphone/UI/Main/Meetings/ViewModel/MeetingViewModel.swift index 3ca21adf5..b14a3ce62 100644 --- a/Linphone/UI/Main/Meetings/ViewModel/MeetingViewModel.swift +++ b/Linphone/UI/Main/Meetings/ViewModel/MeetingViewModel.swift @@ -326,31 +326,40 @@ class MeetingViewModel: ObservableObject { } func addMeetingToCalendar() { - if #available(iOS 17.0, *) { - eventStore.requestAccess(to: .event) { granted, error in - guard let meeting = self.displayedMeeting else { - Log.error("\(MeetingViewModel.TAG) Failed to add meeting to calendar: no meeting selected") - } - if !granted { - Log.error("\(MeetingViewModel.TAG) Failed to add meeting to calendar: access not granted") - } else if error == nil { - - let event:EKEvent = EKEvent(eventStore: self.eventStore) - event.title = self.subject - event.startDate = self.fromDate - event.endDate = self.toDate - event.notes = self.description - event.calendar = self.eventStore.defaultCalendarForNewEvents - do { - try self.eventStore.save(event, span: .thisEvent) - } catch let error as NSError { - Log.error("\(MeetingViewModel.TAG) Failed to add meeting to calendar: \(error)") - } - Log.info("\(MeetingViewModel.TAG) Meeting '\(meeting.subject)': \(error ?? "")") - } else { - Log.error("\(MeetingViewModel.TAG) Failed to add meeting to calendar: \(error ?? "")") - } + + let addToCalendar = { (granted: Bool, error: (any Error)?) in + guard let meeting = self.displayedMeeting else { + Log.error("\(MeetingViewModel.TAG) Failed to add meeting to calendar: no meeting selected") + return } + if !granted { + Log.error("\(MeetingViewModel.TAG) Failed to add meeting to calendar: access not granted") + } else if error == nil { + let event: EKEvent = EKEvent(eventStore: self.eventStore) + event.title = self.subject + event.startDate = self.fromDate + event.endDate = self.toDate + event.notes = self.description + event.calendar = self.eventStore.defaultCalendarForNewEvents + do { + try self.eventStore.save(event, span: .thisEvent) + Log.info("\(MeetingViewModel.TAG) Meeting '\(meeting.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 ?? "")") + } + } + + if #available(iOS 17.0, *) { + eventStore.requestWriteOnlyAccessToEvents(completion: addToCalendar) + } else { + eventStore.requestAccess(to: .event, completion: addToCalendar) } } // eventStore.requestAccess(to: EKEntityType.event) { granted, error in From 8059dd4470f5e0d6e1bd5452cf486d3d810c97de Mon Sep 17 00:00:00 2001 From: QuentinArguillere Date: Sun, 1 Sep 2024 12:08:18 +0200 Subject: [PATCH 376/486] Add NSCalendarsUsageDescription and NSCalendarsWriteOnlyAccessUsageDescriptions to info.plist --- Linphone.xcodeproj/project.pbxproj | 4 ++ Linphone/Localizable.xcstrings | 8 ++- .../Meetings/Fragments/MeetingFragment.swift | 24 ++++++++- .../ViewModel/EventEditViewController.swift | 54 +++++++++++++++++++ .../Meetings/ViewModel/MeetingViewModel.swift | 38 ++++++------- 5 files changed, 104 insertions(+), 24 deletions(-) create mode 100644 Linphone/UI/Main/Meetings/ViewModel/EventEditViewController.swift diff --git a/Linphone.xcodeproj/project.pbxproj b/Linphone.xcodeproj/project.pbxproj index d2e8b6f10..e1fb6d0ba 100644 --- a/Linphone.xcodeproj/project.pbxproj +++ b/Linphone.xcodeproj/project.pbxproj @@ -37,6 +37,7 @@ 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 */; }; @@ -218,6 +219,7 @@ 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 = ""; }; @@ -439,6 +441,7 @@ children = ( 66E56BC82BA4A6D7006CE56F /* MeetingsListViewModel.swift */, 6613A0B32BAEBE3F008923A4 /* MeetingViewModel.swift */, + 66FDB7802C7C689A00561566 /* EventEditViewController.swift */, ); path = ViewModel; sourceTree = ""; @@ -1082,6 +1085,7 @@ 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 */, diff --git a/Linphone/Localizable.xcstrings b/Linphone/Localizable.xcstrings index c4ead7d27..acc2a4b2a 100644 --- a/Linphone/Localizable.xcstrings +++ b/Linphone/Localizable.xcstrings @@ -198,6 +198,9 @@ }, "Add the contact" : { + }, + "Add to calendar" : { + }, "Add to contacts" : { @@ -1483,6 +1486,9 @@ }, "Marquer comme non lu" : { + }, + "Meeting added to iPhone calendar" : { + }, "Meetings" : { @@ -1906,7 +1912,7 @@ "This contact will be deleted definitively." : { }, - "Time Zone:" : { + "Time Zone: %@" : { }, "Title" : { diff --git a/Linphone/UI/Main/Meetings/Fragments/MeetingFragment.swift b/Linphone/UI/Main/Meetings/Fragments/MeetingFragment.swift index 6f39dca49..05cfc0b30 100644 --- a/Linphone/UI/Main/Meetings/Fragments/MeetingFragment.swift +++ b/Linphone/UI/Main/Meetings/Fragments/MeetingFragment.swift @@ -33,6 +33,7 @@ struct MeetingFragment: View { @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 @@ -107,6 +108,25 @@ struct MeetingFragment: View { } 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 @@ -292,7 +312,9 @@ struct MeetingFragment: View { .padding(.leading, 15) .padding(.trailing, 15) }) - } + }.sheet(isPresented: $showEventEditView, content: { // $showEventEditView only edited on iOS17+ + EventEditViewController(meetingViewModel: self.meetingViewModel) + }) } .navigationViewStyle(StackNavigationViewStyle()) } 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 index b14a3ce62..ad6390925 100644 --- a/Linphone/UI/Main/Meetings/ViewModel/MeetingViewModel.swift +++ b/Linphone/UI/Main/Meetings/ViewModel/MeetingViewModel.swift @@ -25,7 +25,7 @@ import EventKit // swiftlint:disable line_length class MeetingViewModel: ObservableObject { static let TAG = "[MeetingViewModel]" - let eventStore : EKEventStore = EKEventStore() + let eventStore: EKEventStore = EKEventStore() @Published var isBroadcastSelected: Bool = false @Published var showBroadcastHelp: Bool = false @@ -325,25 +325,26 @@ class MeetingViewModel: ObservableObject { } } + 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() { - - let addToCalendar = { (granted: Bool, error: (any Error)?) in - guard let meeting = self.displayedMeeting else { - Log.error("\(MeetingViewModel.TAG) Failed to add meeting to calendar: no meeting selected") - return - } + 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: EKEvent = EKEvent(eventStore: self.eventStore) - event.title = self.subject - event.startDate = self.fromDate - event.endDate = self.toDate - event.notes = self.description - event.calendar = self.eventStore.defaultCalendarForNewEvents + let event = self.createMeetingEKEvent() do { try self.eventStore.save(event, span: .thisEvent) - Log.info("\(MeetingViewModel.TAG) Meeting '\(meeting.subject)' added to calendar") + 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 { @@ -354,15 +355,8 @@ class MeetingViewModel: ObservableObject { } else { Log.error("\(MeetingViewModel.TAG) Failed to add meeting to calendar: \(error?.localizedDescription ?? "")") } - } - - if #available(iOS 17.0, *) { - eventStore.requestWriteOnlyAccessToEvents(completion: addToCalendar) - } else { - eventStore.requestAccess(to: .event, completion: addToCalendar) - } + }) } - // eventStore.requestAccess(to: EKEntityType.event) { granted, error in } // swiftlint:enable line_length From 22d37cfce9d5e75fbe8dffc38b7e15ea99e55744 Mon Sep 17 00:00:00 2001 From: QuentinArguillere Date: Tue, 27 Aug 2024 16:08:23 +0200 Subject: [PATCH 377/486] Update and cancel meeting : now properly send ICS through chat --- Linphone/UI/Main/ContentView.swift | 3 +- .../Fragments/ScheduleMeetingFragment.swift | 2 ++ .../Meetings/ViewModel/MeetingViewModel.swift | 29 ++++++++++++------- .../ViewModel/MeetingsListViewModel.swift | 19 ++++++------ 4 files changed, 30 insertions(+), 23 deletions(-) diff --git a/Linphone/UI/Main/ContentView.swift b/Linphone/UI/Main/ContentView.swift index 147db9421..d4858dc7e 100644 --- a/Linphone/UI/Main/ContentView.swift +++ b/Linphone/UI/Main/ContentView.swift @@ -1085,9 +1085,8 @@ struct ContentView: View { actionSecondButton: { meetingViewModel.displayedMeeting = nil if let meetingToDelete = self.meetingsListViewModel.selectedMeetingToDelete { + self.meetingViewModel.cancelMeetingWithNotifications(meeting: meetingToDelete) meetingsListViewModel.deleteSelectedMeeting() - // We're in the meeting list view - self.meetingViewModel.sendMeetingCancelledNotifications(meeting: meetingToDelete) self.isShowSendCancelMeetingNotificationPopup.toggle() } }) diff --git a/Linphone/UI/Main/Meetings/Fragments/ScheduleMeetingFragment.swift b/Linphone/UI/Main/Meetings/Fragments/ScheduleMeetingFragment.swift index 23c78ca96..4b9bf8e31 100644 --- a/Linphone/UI/Main/Meetings/Fragments/ScheduleMeetingFragment.swift +++ b/Linphone/UI/Main/Meetings/Fragments/ScheduleMeetingFragment.swift @@ -401,6 +401,8 @@ struct ScheduleMeetingFragment: View { .onAppear { proxyReader.scrollTo(meetingViewModel.selectedTimezoneIdx) } + .background(.white) + .cornerRadius(20) .listStyle(.plain) } .frame(width: geometry.size.width, height: geometry.size.height) diff --git a/Linphone/UI/Main/Meetings/ViewModel/MeetingViewModel.swift b/Linphone/UI/Main/Meetings/ViewModel/MeetingViewModel.swift index ad6390925..e5d31b496 100644 --- a/Linphone/UI/Main/Meetings/ViewModel/MeetingViewModel.swift +++ b/Linphone/UI/Main/Meetings/ViewModel/MeetingViewModel.swift @@ -161,6 +161,18 @@ class MeetingViewModel: ObservableObject { 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.mSchedulerSubscriptions.removeAll() self.conferenceScheduler = try? core.createConferenceScheduler() @@ -185,15 +197,7 @@ class MeetingViewModel: ObservableObject { if self.sendInvitations { Log.info("\(MeetingViewModel.TAG) User asked for invitations to be sent, let's do it") - if let chatRoomParams = try? core.createDefaultChatRoomParams() { - chatRoomParams.groupEnabled = false - chatRoomParams.backend = ChatRoom.Backend.FlexisipChat - chatRoomParams.encryptionEnabled = true - chatRoomParams.subject = "Meeting invitation" // Won't be used - self.conferenceScheduler?.sendInvitations(chatRoomParams: chatRoomParams) - } else { - Log.error("\(MeetingViewModel.TAG) Failed to create default chatroom parameters. This should not happen") - } + self.sendIcsInvitation(core: core) } else { Log.info("\(MeetingViewModel.TAG) User didn't asked for invitations to be sent") DispatchQueue.main.async { @@ -201,6 +205,8 @@ class MeetingViewModel: ObservableObject { self.conferenceCreatedEvent = true } } + } else if cbVal.state == ConferenceScheduler.State.Updating { + self.sendIcsInvitation(core: core) } }) @@ -318,9 +324,10 @@ class MeetingViewModel: ObservableObject { self.displayedMeeting = meeting } - func sendMeetingCancelledNotifications(meeting: MeetingModel) { + func cancelMeetingWithNotifications(meeting: MeetingModel) { CoreContext.shared.doOnCoreQueue { core in - self.conferenceScheduler = try? core.createConferenceScheduler() + Log.info("debugtrace - core media encryption = \(core.mediaEncryption)") + self.resetConferenceSchedulerAndListeners(core: core) self.conferenceScheduler?.cancelConference(conferenceInfo: meeting.confInfo) } } diff --git a/Linphone/UI/Main/Meetings/ViewModel/MeetingsListViewModel.swift b/Linphone/UI/Main/Meetings/ViewModel/MeetingsListViewModel.swift index 06eb9fa46..e08fdd3c7 100644 --- a/Linphone/UI/Main/Meetings/ViewModel/MeetingsListViewModel.swift +++ b/Linphone/UI/Main/Meetings/ViewModel/MeetingsListViewModel.swift @@ -122,17 +122,16 @@ class MeetingsListViewModel: ObservableObject { coreContext.doOnCoreQueue { core in core.deleteConferenceInformation(conferenceInfo: meetingToDelete.confInfo) - DispatchQueue.main.async { - 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) - ToastViewModel.shared.toastMessage = "Success_toast_meeting_deleted" - ToastViewModel.shared.displayToast = true - } + } + + 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) + ToastViewModel.shared.toastMessage = "Success_toast_meeting_deleted" + ToastViewModel.shared.displayToast = true } } } From 8261e8d5c10fd1ecb34ba3af97095b8d0d1e5ca1 Mon Sep 17 00:00:00 2001 From: QuentinArguillere Date: Sun, 1 Sep 2024 12:22:29 +0200 Subject: [PATCH 378/486] If a meeting has been cancelled, then mark it as such in the meetings list --- .../UI/Main/Meetings/Fragments/MeetingsFragment.swift | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/Linphone/UI/Main/Meetings/Fragments/MeetingsFragment.swift b/Linphone/UI/Main/Meetings/Fragments/MeetingsFragment.swift index faf2fa9ae..0619e54cb 100644 --- a/Linphone/UI/Main/Meetings/Fragments/MeetingsFragment.swift +++ b/Linphone/UI/Main/Meetings/Fragments/MeetingsFragment.swift @@ -66,8 +66,14 @@ struct MeetingsFragment: View { .padding(.top, 5) .default_text_style_500(styleSize: 15) } - Text(model.model!.time) // this time string is formatted for the current timezone, we use the selected timezone only when displaying details - .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) From c2f9f34ba851076b35e3f89bda1d9f11d0f9368c Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Mon, 2 Sep 2024 12:04:27 +0200 Subject: [PATCH 379/486] Add forwarded message --- Linphone.xcodeproj/project.pbxproj | 28 +- Linphone/LinphoneApp.swift | 8 +- Linphone/Localizable.xcstrings | 71 ++++ Linphone/UI/Call/CallView.swift | 27 +- Linphone/UI/Main/ContentView.swift | 45 ++- .../Fragments/ChatBubbleView.swift | 25 ++ .../ConversationForwardMessageFragment.swift | 329 ++++++++++++++++++ .../Fragments/ConversationFragment.swift | 20 ++ .../UI/Main/Conversations/Model/Message.swift | 3 + .../ConversationForwardMessageViewModel.swift | 300 ++++++++++++++++ .../ViewModel/ConversationViewModel.swift | 31 ++ 11 files changed, 854 insertions(+), 33 deletions(-) create mode 100644 Linphone/UI/Main/Conversations/Fragments/ConversationForwardMessageFragment.swift create mode 100644 Linphone/UI/Main/Conversations/ViewModel/ConversationForwardMessageViewModel.swift diff --git a/Linphone.xcodeproj/project.pbxproj b/Linphone.xcodeproj/project.pbxproj index e1fb6d0ba..64853448b 100644 --- a/Linphone.xcodeproj/project.pbxproj +++ b/Linphone.xcodeproj/project.pbxproj @@ -58,6 +58,8 @@ 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 */; }; @@ -240,6 +242,8 @@ 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 = ""; }; @@ -826,6 +830,7 @@ D7CEE0372B7A214F00FD79B7 /* ConversationsListViewModel.swift */, D70A26EF2B7D02E6006CC8FC /* ConversationViewModel.swift */, D759CB652C3FBE1D00AC35E8 /* StartConversationViewModel.swift */, + D70C82A62C85F5910087F43F /* ConversationForwardMessageViewModel.swift */, ); path = ViewModel; sourceTree = ""; @@ -841,6 +846,7 @@ D72A9A042B9750A1000DC093 /* UIList.swift */, D759CB632C3FBD4200AC35E8 /* StartConversationFragment.swift */, D7A0ACBA2C415D630043AE79 /* StartGroupConversationFragment.swift */, + D70C82A42C85EDC90087F43F /* ConversationForwardMessageFragment.swift */, ); path = Fragments; sourceTree = ""; @@ -1155,6 +1161,7 @@ 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 */, @@ -1190,6 +1197,7 @@ 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 */, @@ -1239,6 +1247,7 @@ GCC_PREPROCESSOR_DEFINITIONS = ( "$(inherited)", "DEBUG=1", + "USE_CRASHLYTICS=1", ); GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = msgNotificationService/Info.plist; @@ -1252,7 +1261,7 @@ ); LOCALIZATION_PREFERS_STRING_CATALOGS = YES; MARKETING_VERSION = 6.0.0; - OTHER_SWIFT_FLAGS = "$(inherited)"; + OTHER_SWIFT_FLAGS = "$(inherited) -DUSE_CRASHLYTICS"; PRODUCT_BUNDLE_IDENTIFIER = org.linphone.phone.msgNotificationService; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -1278,7 +1287,10 @@ DEVELOPMENT_TEAM = Z2V957B3D6; ENABLE_USER_SCRIPT_SANDBOXING = NO; GCC_C_LANGUAGE_STANDARD = gnu17; - GCC_PREPROCESSOR_DEFINITIONS = "$(inherited)"; + GCC_PREPROCESSOR_DEFINITIONS = ( + "$(inherited)", + "USE_CRASHLYTICS=1", + ); GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = msgNotificationService/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = msgNotificationService; @@ -1291,7 +1303,7 @@ ); LOCALIZATION_PREFERS_STRING_CATALOGS = YES; MARKETING_VERSION = 6.0.0; - OTHER_SWIFT_FLAGS = "$(inherited)"; + OTHER_SWIFT_FLAGS = "$(inherited) -DUSE_CRASHLYTICS"; PRODUCT_BUNDLE_IDENTIFIER = org.linphone.phone.msgNotificationService; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -1438,6 +1450,7 @@ GCC_PREPROCESSOR_DEFINITIONS = ( "$(inherited)", "DEBUG=1", + "USE_CRASHLYTICS=1", ); GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = Linphone/Info.plist; @@ -1464,7 +1477,7 @@ "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; MACOSX_DEPLOYMENT_TARGET = 13.3; MARKETING_VERSION = 6.0.0; - OTHER_SWIFT_FLAGS = "$(inherited)"; + OTHER_SWIFT_FLAGS = "$(inherited) -DUSE_CRASHLYTICS"; PRODUCT_BUNDLE_IDENTIFIER = org.linphone.phone; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; @@ -1490,7 +1503,10 @@ DEVELOPMENT_TEAM = Z2V957B3D6; ENABLE_HARDENED_RUNTIME = YES; ENABLE_PREVIEWS = YES; - GCC_PREPROCESSOR_DEFINITIONS = "$(inherited)"; + 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"; @@ -1516,7 +1532,7 @@ "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; MACOSX_DEPLOYMENT_TARGET = 13.3; MARKETING_VERSION = 6.0.0; - OTHER_SWIFT_FLAGS = "$(inherited)"; + OTHER_SWIFT_FLAGS = "$(inherited) -DUSE_CRASHLYTICS"; PRODUCT_BUNDLE_IDENTIFIER = org.linphone.phone; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; diff --git a/Linphone/LinphoneApp.swift b/Linphone/LinphoneApp.swift index c67122247..dce056b89 100644 --- a/Linphone/LinphoneApp.swift +++ b/Linphone/LinphoneApp.swift @@ -82,6 +82,7 @@ struct LinphoneApp: App { @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 { @@ -113,7 +114,8 @@ struct LinphoneApp: App { && conversationsListViewModel != nil && conversationViewModel != nil && meetingsListViewModel != nil - && meetingViewModel != nil { + && meetingViewModel != nil + && conversationForwardMessageViewModel != nil { ContentView( contactViewModel: contactViewModel!, editContactViewModel: editContactViewModel!, @@ -126,7 +128,8 @@ struct LinphoneApp: App { conversationsListViewModel: conversationsListViewModel!, conversationViewModel: conversationViewModel!, meetingsListViewModel: meetingsListViewModel!, - meetingViewModel: meetingViewModel! + meetingViewModel: meetingViewModel!, + conversationForwardMessageViewModel: conversationForwardMessageViewModel! ).onOpenURL { url in URIHandler.handleURL(url: url) } @@ -150,6 +153,7 @@ struct LinphoneApp: App { conversationViewModel = ConversationViewModel() meetingsListViewModel = MeetingsListViewModel() meetingViewModel = MeetingViewModel() + conversationForwardMessageViewModel = ConversationForwardMessageViewModel() }.onOpenURL { url in URIHandler.handleURL(url: url) } diff --git a/Linphone/Localizable.xcstrings b/Linphone/Localizable.xcstrings index acc2a4b2a..2adeced6a 100644 --- a/Linphone/Localizable.xcstrings +++ b/Linphone/Localizable.xcstrings @@ -787,6 +787,9 @@ }, "Cancel for me only" : { + }, + "Cancelled" : { + }, "Ce mode vous permet d’être interopérable avec d’autres services SIP.\nVos communications seront chiffrées de point à point. " : { @@ -958,6 +961,23 @@ } } }, + "conversation_forward_message_title" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Forward message to…" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Transférer à…" + } + } + } + }, "conversation_invalid_participant_due_to_security_mode_toast" : { "extractionState" : "manual", "localizations" : { @@ -975,6 +995,40 @@ } } }, + "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_reply_to_message_title" : { "extractionState" : "manual", "localizations" : { @@ -1599,6 +1653,23 @@ }, "Message copied into clipboard" : { + }, + "message_forwarded_label" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Forwarded" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Transféré" + } + } + } }, "Messages" : { diff --git a/Linphone/UI/Call/CallView.swift b/Linphone/UI/Call/CallView.swift index d44ffa041..69def5681 100644 --- a/Linphone/UI/Call/CallView.swift +++ b/Linphone/UI/Call/CallView.swift @@ -36,6 +36,7 @@ struct CallView: View { @ObservedObject var callViewModel: CallViewModel @ObservedObject var conversationViewModel: ConversationViewModel @ObservedObject var conversationsListViewModel: ConversationsListViewModel + @ObservedObject var conversationForwardMessageViewModel: ConversationForwardMessageViewModel @State private var addParticipantsViewModel: AddParticipantsViewModel? @@ -192,16 +193,21 @@ struct CallView: View { } if isShowConversationFragment && conversationViewModel.displayedConversation != nil { - ConversationFragment(conversationViewModel: conversationViewModel, conversationsListViewModel: conversationsListViewModel, isShowConversationFragment: $isShowConversationFragment) - .frame(maxWidth: .infinity) - .background(Color.gray100) - .ignoresSafeArea(.keyboard) - .zIndex(4) - .transition(.move(edge: .bottom)) - .onDisappear { - conversationViewModel.displayedConversation = nil - isShowConversationFragment = false - } + ConversationFragment( + conversationViewModel: conversationViewModel, + conversationsListViewModel: conversationsListViewModel, + conversationForwardMessageViewModel: conversationForwardMessageViewModel, + isShowConversationFragment: $isShowConversationFragment + ) + .frame(maxWidth: .infinity) + .background(Color.gray100) + .ignoresSafeArea(.keyboard) + .zIndex(4) + .transition(.move(edge: .bottom)) + .onDisappear { + conversationViewModel.displayedConversation = nil + isShowConversationFragment = false + } } if callViewModel.zrtpPopupDisplayed == true { @@ -2799,6 +2805,7 @@ struct PressedButtonStyle: ButtonStyle { callViewModel: CallViewModel(), conversationViewModel: ConversationViewModel(), conversationsListViewModel: ConversationsListViewModel(), + conversationForwardMessageViewModel: ConversationForwardMessageViewModel(), fullscreenVideo: .constant(false), isShowStartCallFragment: .constant(false), isShowConversationFragment: .constant(false) diff --git a/Linphone/UI/Main/ContentView.swift b/Linphone/UI/Main/ContentView.swift index d4858dc7e..e20188a02 100644 --- a/Linphone/UI/Main/ContentView.swift +++ b/Linphone/UI/Main/ContentView.swift @@ -46,6 +46,7 @@ struct ContentView: View { @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 @@ -800,7 +801,12 @@ struct ContentView: View { .ignoresSafeArea(.keyboard) } } else if self.index == 2 { - ConversationFragment(conversationViewModel: conversationViewModel, conversationsListViewModel: conversationsListViewModel, isShowConversationFragment: $isShowConversationFragment) + ConversationFragment( + conversationViewModel: conversationViewModel, + conversationsListViewModel: conversationsListViewModel, + conversationForwardMessageViewModel: conversationForwardMessageViewModel, + isShowConversationFragment: $isShowConversationFragment + ) .frame(maxWidth: .infinity) .background(Color.gray100) .ignoresSafeArea(.keyboard) @@ -1107,21 +1113,29 @@ struct ContentView: View { } if telecomManager.callDisplayed && ((telecomManager.callInProgress && telecomManager.outgoingCallStarted) || telecomManager.callConnected) && !telecomManager.meetingWaitingRoomDisplayed { - CallView(callViewModel: callViewModel, conversationViewModel: conversationViewModel, conversationsListViewModel: conversationsListViewModel, fullscreenVideo: $fullscreenVideo, isShowStartCallFragment: $isShowStartCallFragment, isShowConversationFragment: $isShowConversationFragment) - .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() - } + CallView( + callViewModel: callViewModel, + conversationViewModel: conversationViewModel, + conversationsListViewModel: conversationsListViewModel, + conversationForwardMessageViewModel: conversationForwardMessageViewModel, + fullscreenVideo: $fullscreenVideo, + isShowStartCallFragment: $isShowStartCallFragment, + isShowConversationFragment: $isShowConversationFragment + ) + .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 - } + } + .onDisappear { + UIApplication.shared.isIdleTimerDisabled = false + } } ToastView() @@ -1176,7 +1190,8 @@ struct ContentView: View { conversationsListViewModel: ConversationsListViewModel(), conversationViewModel: ConversationViewModel(), meetingsListViewModel: MeetingsListViewModel(), - meetingViewModel: MeetingViewModel() + meetingViewModel: MeetingViewModel(), + conversationForwardMessageViewModel: ConversationForwardMessageViewModel() ) } // swiftlint:enable type_body_length diff --git a/Linphone/UI/Main/Conversations/Fragments/ChatBubbleView.swift b/Linphone/UI/Main/Conversations/Fragments/ChatBubbleView.swift index e12e8e29b..c7b36fd29 100644 --- a/Linphone/UI/Main/Conversations/Fragments/ChatBubbleView.swift +++ b/Linphone/UI/Main/Conversations/Fragments/ChatBubbleView.swift @@ -68,6 +68,31 @@ struct ChatBubbleView: View { .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 { diff --git a/Linphone/UI/Main/Conversations/Fragments/ConversationForwardMessageFragment.swift b/Linphone/UI/Main/Conversations/Fragments/ConversationForwardMessageFragment.swift new file mode 100644 index 000000000..d21972d96 --- /dev/null +++ b/Linphone/UI/Main/Conversations/Fragments/ConversationForwardMessageFragment.swift @@ -0,0 +1,329 @@ +/* + * 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.2) { + 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 { + conversationViewModel.displayedConversation = nil + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + conversationViewModel.selectedMessage = nil + conversationViewModel.resetMessage() + withAnimation { + self.conversationViewModel.changeDisplayedChatRoom(conversationModel: conversationForwardMessageViewModel.displayedConversation!) + } + conversationViewModel.getMessages() + } + } else { + conversationViewModel.selectedMessage = nil + withAnimation { + 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 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 chatRoomSuscriptions = Set() + + 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.chatRoomSuscriptions.insert(chatRoom.publisher?.onConferenceJoined?.postOnCoreQueue { (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") + self.chatRoomSuscriptions.removeAll() + + 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!") + self.chatRoomSuscriptions.removeAll() + DispatchQueue.main.async { + self.operationInProgress = false + ToastViewModel.shared.toastMessage = "Failed_to_create_conversation_error" + ToastViewModel.shared.displayToast = true + } + } + }) + + self.chatRoomSuscriptions.insert(chatRoom.publisher?.onStateChanged?.postOnCoreQueue { (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!") + self.chatRoomSuscriptions.removeAll() + DispatchQueue.main.async { + self.operationInProgress = false + ToastViewModel.shared.toastMessage = "Failed_to_create_conversation_error" + ToastViewModel.shared.displayToast = true + } + } + }) + } + + func forwardMessage() { + CoreContext.shared.doOnCoreQueue { _ in + if self.displayedConversation != nil && self.selectedMessage != nil { + if let messageToForward = self.selectedMessage!.eventLog.chatMessage { + 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 index 471779b47..621b93e29 100644 --- a/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift +++ b/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift @@ -70,6 +70,33 @@ class ConversationViewModel: ObservableObject { 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 indexMessage = self.conversationMessagesSection[0].rows.firstIndex(where: {$0.eventLog.chatMessage?.messageId == message.messageId}) + + if self.conversationMessagesSection[0].rows[indexMessage!].message.status != statusTmp { + DispatchQueue.main.async { + if indexMessage != nil { + self.objectWillChange.send() + self.conversationMessagesSection[0].rows[indexMessage!].message.status = statusTmp + } + } + } + self.coreContext.doOnCoreQueue { _ in self.chatMessageSuscriptions.insert(message.publisher?.onMsgStateChanged?.postOnCoreQueue {(cbValue: (message: ChatMessage, state: ChatMessage.State)) in var statusTmp: Message.Status? = .sending @@ -381,6 +408,7 @@ class ConversationViewModel: ObservableObject { attachmentsNames: attachmentNameList, attachments: attachmentList, replyMessage: replyMessageTmp, + isForward: eventLog.chatMessage?.isForward ?? false, ownReaction: eventLog.chatMessage?.ownReaction?.body ?? "", reactions: reactionsTmp ) @@ -581,6 +609,7 @@ class ConversationViewModel: ObservableObject { attachmentsNames: attachmentNameList, attachments: attachmentList, replyMessage: replyMessageTmp, + isForward: eventLog.chatMessage?.isForward ?? false, ownReaction: eventLog.chatMessage?.ownReaction?.body ?? "", reactions: reactionsTmp ) @@ -793,6 +822,7 @@ class ConversationViewModel: ObservableObject { attachmentsNames: attachmentNameList, attachments: attachmentList, replyMessage: replyMessageTmp, + isForward: eventLog.chatMessage?.isForward ?? false, ownReaction: eventLog.chatMessage?.ownReaction?.body ?? "", reactions: reactionsTmp ) @@ -1048,6 +1078,7 @@ class ConversationViewModel: ObservableObject { attachmentsNames: attachmentNameList, attachments: attachmentList, replyMessage: replyMessageTmp, + isForward: eventLog.chatMessage?.isForward ?? false, ownReaction: eventLog.chatMessage?.ownReaction?.body ?? "", reactions: reactionsTmp ) From 5ed0fc1f76972271970eb8d72faa8e72b3a224cb Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Wed, 4 Sep 2024 17:26:29 +0200 Subject: [PATCH 380/486] Add a bottom sheet to display delivery status --- Linphone/Localizable.xcstrings | 71 + .../Fragments/ChatBubbleView.swift | 4 + .../Fragments/ConversationFragment.swift | 1442 +++++++++-------- .../ViewModel/ConversationViewModel.swift | 77 + 4 files changed, 920 insertions(+), 674 deletions(-) diff --git a/Linphone/Localizable.xcstrings b/Linphone/Localizable.xcstrings index 2adeced6a..63dbe5d6f 100644 --- a/Linphone/Localizable.xcstrings +++ b/Linphone/Localizable.xcstrings @@ -790,6 +790,9 @@ }, "Cancelled" : { + }, + "Categories" : { + }, "Ce mode vous permet d’être interopérable avec d’autres services SIP.\nVos communications seront chiffrées de point à point. " : { @@ -1653,6 +1656,74 @@ }, "Message copied into clipboard" : { + }, + "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", diff --git a/Linphone/UI/Main/Conversations/Fragments/ChatBubbleView.swift b/Linphone/UI/Main/Conversations/Fragments/ChatBubbleView.swift index c7b36fd29..ccff001b5 100644 --- a/Linphone/UI/Main/Conversations/Fragments/ChatBubbleView.swift +++ b/Linphone/UI/Main/Conversations/Fragments/ChatBubbleView.swift @@ -187,6 +187,10 @@ struct ChatBubbleView: View { } } } + .onTapGesture { + conversationViewModel.selectedMessageToDisplayDetails = eventLogMessage + conversationViewModel.prepareBottomSheetForDeliveryStatus() + } .padding(.top, -4) } .padding(.all, 15) diff --git a/Linphone/UI/Main/Conversations/Fragments/ConversationFragment.swift b/Linphone/UI/Main/Conversations/Fragments/ConversationFragment.swift index 7027ad705..16619df4e 100644 --- a/Linphone/UI/Main/Conversations/Fragments/ConversationFragment.swift +++ b/Linphone/UI/Main/Conversations/Fragments/ConversationFragment.swift @@ -25,6 +25,7 @@ import UniformTypeIdentifiers struct ConversationFragment: View { @State private var orientation = UIDevice.current.orientation + private var idiom: UIUserInterfaceIdiom { UIDevice.current.userInterfaceIdiom } @ObservedObject var contactsManager = ContactsManager.shared @@ -54,374 +55,537 @@ struct ConversationFragment: View { @Binding var isShowConversationFragment: Bool + @State private var selectedCategoryIndex = 0 + var body: some View { NavigationView { GeometryReader { geometry in - 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) + if #available(iOS 16.0, *), idiom != .pad { + innerView(geometry: geometry) + .background(.white) + .navigationBarHidden(true) + .onRotate { newOrientation in + orientation = newOrientation + } + .onAppear { + conversationViewModel.addConversationDelegate() + } + .onDisappear { + conversationViewModel.removeConversationDelegate() + } + .sheet(isPresented: $conversationViewModel.isShowSelectedMessageToDisplayDetailsBottomSheet, onDismiss: { + conversationViewModel.isShowSelectedMessageToDisplayDetailsBottomSheet = false + }, content: { + imdnSheet() + .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) + } + } else { + innerView(geometry: geometry) + .background(.white) + .navigationBarHidden(true) + .onRotate { newOrientation in + orientation = newOrientation + } + .onAppear { + conversationViewModel.addConversationDelegate() + } + .onDisappear { + conversationViewModel.removeConversationDelegate() + } + .halfSheet(showSheet: $conversationViewModel.isShowSelectedMessageToDisplayDetailsBottomSheet) { + imdnSheet() + } onDismiss: { + conversationViewModel.isShowSelectedMessageToDisplayDetailsBottomSheet = 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) + } + } + } + } + .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 { + } 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("See contact") + Spacer() + Image("user-circle") .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 + } + } + + Button { + isMenuOpen = false + } 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 + } 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.grayMain2c500) + .frame(width: 25, height: 25, alignment: .leading) + .padding(.all, 10) + .padding(.top, 4) + } + .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.2) { + conversationViewModel.getOldMessages() + } + } + + if index == 0 { + displayFloatingButton = false + conversationViewModel.markAsRead() + conversationsListViewModel.computeChatRoomsList(filter: "") + } } - conversationViewModel.displayedConversation = nil + .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() } - - Avatar(contactAvatarModel: conversationViewModel.displayedConversation!.avatarModel, avatarSize: 50) - .padding(.top, 4) - - Text(conversationViewModel.displayedConversation!.subject) - .default_text_style(styleSize: 16) + } + .onAppear { + conversationViewModel.getMessages() + } + .onDisappear { + conversationViewModel.resetMessage() + } + } + } + + 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(.top, 4) + .padding(.bottom, 1) .lineLimit(1) - - Spacer() - - Button { - } 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("See contact") - Spacer() - Image("user-circle") - .resizable() - .frame(width: 25, height: 25, alignment: .leading) - .padding(.all, 10) - } - } - Button { - isMenuOpen = false - } label: { - HStack { - Text("Copy SIP address") - Spacer() - Image("copy") - .resizable() - .frame(width: 25, height: 25, alignment: .leading) - .padding(.all, 10) - } + 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) } - - Button(role: .destructive) { - isMenuOpen = false - } 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.grayMain2c500) - .frame(width: 25, height: 25, alignment: .leading) - .padding(.all, 10) - .padding(.top, 4) - } - .onTapGesture { - isMenuOpen = true } } .frame(maxWidth: .infinity) - .frame(height: 50) - .padding(.horizontal) - .padding(.bottom, 4) - .background(.white) + .padding(.all, 20) + .background(Color.gray100) - 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.2) { - 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() - } + HStack { + Spacer() + + Button(action: { + withAnimation { + conversationViewModel.messageToReply = nil } - .onAppear { - conversationViewModel.getMessages() - } - .onDisappear { - conversationViewModel.resetMessage() - } - } + }, label: { + Image("x") + .resizable() + .frame(width: 30, height: 30, alignment: .leading) + .padding(.all, 10) + }) } - - 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) - + } + .transition(.move(edge: .bottom)) + } + + if !conversationViewModel.mediasToSend.isEmpty || mediasIsLoading { + ZStack(alignment: .top) { + HStack { + if mediasIsLoading { 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) - } + ProgressView() - if !mediasIsLoading { - LazyVGrid(columns: [ - GridItem(.adaptive(minimum: 100), spacing: 1) - ], spacing: 3) { - ForEach(conversationViewModel.mediasToSend, id: \.id) { attachment in + 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 { - Rectangle() - .fill(Color(.white)) - .frame(width: 100, height: 100) + image + .resizable() + .interpolation(.medium) + .aspectRatio(contentMode: .fill) - 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) - } + if attachment.type == .video { + Image("play-fill") + .renderingMode(.template) + .resizable() + .foregroundStyle(.white) + .frame(width: 40, height: 40, alignment: .leading) } } - .clipShape(RoundedRectangle(cornerRadius: 4)) - .contentShape(Rectangle()) + } 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) + } } } - .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) - ) + .clipShape(RoundedRectangle(cornerRadius: 4)) + .contentShape(Rectangle()) } } - .frame(maxWidth: .infinity) - .padding(.all, conversationViewModel.mediasToSend.isEmpty ? 0 : 10) - .background(Color.gray100) + .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() - if !mediasIsLoading { - HStack { - Spacer() - - Button(action: { - withAnimation { - conversationViewModel.mediasToSend.removeAll() - } - }, label: { - Image("x") - .resizable() - .frame(width: 30, height: 30, alignment: .leading) - .padding(.all, 10) - }) + 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) { + 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) + } 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) + + if conversationViewModel.messageText.isEmpty { + Text("Say something...") + .padding(.leading, 4) + .opacity(conversationViewModel.messageText.isEmpty ? 1 : 0) + .foregroundStyle(Color.gray300) + .default_text_style(styleSize: 15) } } - .transition(.move(edge: .bottom)) + .onTapGesture { + isMessageTextFocused = true + } } - HStack(spacing: 0) { + if conversationViewModel.messageText.isEmpty && conversationViewModel.mediasToSend.isEmpty { Button { } label: { - Image("smiley") + Image("microphone") .renderingMode(.template) .resizable() .foregroundStyle(Color.grayMain2c500) @@ -429,383 +593,313 @@ struct ConversationFragment: View { .padding(.all, 6) .padding(.top, 4) } - .padding(.horizontal, isMessageTextFocused ? 0 : 2) - + } else { Button { - self.isShowPhotoLibrary = true - self.mediasIsLoading = true + if conversationViewModel.displayedConversationHistorySize > 0 { + NotificationCenter.default.post(name: .onScrollToBottom, object: nil) + } + conversationViewModel.sendMessage() } label: { - Image("paperclip") + Image("paper-plane-tilt") .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) + .foregroundStyle(Color.orangeMain500) + .frame(width: 28, height: 28, alignment: .leading) + .padding(.all, 6) .padding(.top, 4) - .disabled(conversationViewModel.maxMediaCount <= conversationViewModel.mediasToSend.count || mediasIsLoading) + .rotationEffect(.degrees(45)) } - .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) - } 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) - - if conversationViewModel.messageText.isEmpty { - Text("Say something...") - .padding(.leading, 4) - .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 { - } 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) + .padding(.trailing, 4) } - .frame(maxWidth: .infinity, minHeight: 60) - .padding(.top, 12) - .padding(.bottom, geometry.safeAreaInsets.bottom > 0 ? (isMessageTextFocused ? 12 : 0) : 12) - .padding(.horizontal, 10) - .background(Color.gray100) } + .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) } - .blur(radius: conversationViewModel.selectedMessage != nil ? 8 : 0) + .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() - 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() + } - 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() - } + HStack { + Button { + conversationViewModel.sendReaction(emoji: "👍") + } label: { + Text("👍") + .default_text_style(styleSize: iconSize > 50 ? 50 : iconSize) } - .frame(maxWidth: .infinity) - .padding(.horizontal, 10) - .padding(.leading, conversationViewModel.displayedConversation!.isGroup ? 43 : 0) - .shadow(color: .black.opacity(0.1), radius: 10) + .padding(.horizontal, 8) + .background(conversationViewModel.selectedMessage?.message.ownReaction == "👍" ? Color.gray200 : .white) + .cornerRadius(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, - 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() - } + Button { + conversationViewModel.sendReaction(emoji: "❤️") + } label: { + Text("❤️") + .default_text_style(styleSize: iconSize > 50 ? 50 : iconSize) } - .frame(maxWidth: .infinity) - .padding(.horizontal, 10) - .padding(.leading, conversationViewModel.displayedConversation!.isGroup ? 43 : 0) - .shadow(color: .black.opacity(0.1), radius: 10) + .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) - .background(.gray.opacity(0.1)) - .onTapGesture { - withAnimation { - conversationViewModel.selectedMessage = nil + .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, + 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() } } - .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)) + .frame(maxWidth: .infinity) + .padding(.horizontal, 10) + .padding(.leading, conversationViewModel.displayedConversation!.isGroup ? 43 : 0) + .shadow(color: .black.opacity(0.1), radius: 10) } } - .background(.white) - .navigationBarHidden(true) - .onRotate { newOrientation in - orientation = newOrientation + .frame(maxWidth: .infinity) + .background(.gray.opacity(0.1)) + .onTapGesture { + withAnimation { + conversationViewModel.selectedMessage = nil + } } .onAppear { - conversationViewModel.addConversationDelegate() + touchFeedback() } .onDisappear { - conversationViewModel.removeConversationDelegate() - } - .sheet(isPresented: $isShowPhotoLibrary) { - 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 - } + if conversationViewModel.selectedMessage != nil { + conversationViewModel.selectedMessage = nil } - .edgesIgnoringSafeArea(.all) - } - .fullScreenCover(isPresented: $isShowCamera) { - ImagePicker(conversationViewModel: conversationViewModel, selectedMedia: self.$conversationViewModel.mediasToSend) - .edgesIgnoringSafeArea(.all) } } + + if isShowConversationForwardMessageFragment { + ConversationForwardMessageFragment( + conversationViewModel: conversationViewModel, + conversationsListViewModel: conversationsListViewModel, + conversationForwardMessageViewModel: conversationForwardMessageViewModel, + isShowConversationForwardMessageFragment: $isShowConversationForwardMessageFragment + ) + .zIndex(5) + .transition(.move(edge: .trailing)) + } } - .navigationViewStyle(.stack) } -} - -struct ScrollOffsetPreferenceKey: PreferenceKey { - static var defaultValue: CGPoint = .zero + //swiftlint:enable cyclomatic_complexity + //swiftlint:enable function_body_length - static func reduce(value: inout CGPoint, nextValue: () -> CGPoint) { + @ViewBuilder + func imdnSheet() -> some View { + VStack { + Picker("Categories", selection: $selectedCategoryIndex) { + ForEach(0.. Date: Thu, 5 Sep 2024 17:33:34 +0200 Subject: [PATCH 381/486] Add a bottom sheet to display reactions --- Linphone/Localizable.xcstrings | 34 ++++++ .../Fragments/ChatBubbleView.swift | 19 ++-- .../Fragments/ConversationFragment.swift | 105 +++++++++++------- .../ViewModel/ConversationViewModel.swift | 97 +++++++++++++++- 4 files changed, 202 insertions(+), 53 deletions(-) diff --git a/Linphone/Localizable.xcstrings b/Linphone/Localizable.xcstrings index 63dbe5d6f..05408cf40 100644 --- a/Linphone/Localizable.xcstrings +++ b/Linphone/Localizable.xcstrings @@ -1742,6 +1742,40 @@ } } }, + "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" : { }, diff --git a/Linphone/UI/Main/Conversations/Fragments/ChatBubbleView.swift b/Linphone/UI/Main/Conversations/Fragments/ChatBubbleView.swift index ccff001b5..2da7dab3e 100644 --- a/Linphone/UI/Main/Conversations/Fragments/ChatBubbleView.swift +++ b/Linphone/UI/Main/Conversations/Fragments/ChatBubbleView.swift @@ -191,6 +191,7 @@ struct ChatBubbleView: View { conversationViewModel.selectedMessageToDisplayDetails = eventLogMessage conversationViewModel.prepareBottomSheetForDeliveryStatus() } + .disabled(conversationViewModel.selectedMessage != nil) .padding(.top, -4) } .padding(.all, 15) @@ -211,18 +212,17 @@ struct ChatBubbleView: View { } } - if ( - (eventLogMessage.message.reactions.contains("👍") ? 1 : 0) + - (eventLogMessage.message.reactions.contains("❤️") ? 1 : 0) + - (eventLogMessage.message.reactions.contains("😂") ? 1 : 0) + - (eventLogMessage.message.reactions.contains("😮") ? 1 : 0) + - (eventLogMessage.message.reactions.contains("😢") ? 1 : 0) - ) != eventLogMessage.message.reactions.count { + if containsDuplicates(strings: eventLogMessage.message.reactions) { Text("\(eventLogMessage.message.reactions.count)") .default_text_style(styleSize: 14) .padding(.horizontal, -2) } } + .onTapGesture { + conversationViewModel.selectedMessageToDisplayDetails = eventLogMessage + conversationViewModel.prepareBottomSheetForReactions() + } + .disabled(conversationViewModel.selectedMessage != nil) .padding(.vertical, 6) .padding(.horizontal, 10) .background(eventLogMessage.message.isOutgoing ? Color.orangeMain100 : Color.grayMain2c100) @@ -285,6 +285,11 @@ struct ChatBubbleView: View { } } + func containsDuplicates(strings: [String]) -> Bool { + let uniqueStrings = Set(strings) + return uniqueStrings.count != strings.count + } + @ViewBuilder func messageAttachments() -> some View { if eventLogMessage.message.attachments.count == 1 { diff --git a/Linphone/UI/Main/Conversations/Fragments/ConversationFragment.swift b/Linphone/UI/Main/Conversations/Fragments/ConversationFragment.swift index 16619df4e..41f8073ac 100644 --- a/Linphone/UI/Main/Conversations/Fragments/ConversationFragment.swift +++ b/Linphone/UI/Main/Conversations/Fragments/ConversationFragment.swift @@ -73,10 +73,10 @@ struct ConversationFragment: View { .onDisappear { conversationViewModel.removeConversationDelegate() } - .sheet(isPresented: $conversationViewModel.isShowSelectedMessageToDisplayDetailsBottomSheet, onDismiss: { - conversationViewModel.isShowSelectedMessageToDisplayDetailsBottomSheet = false + .sheet(isPresented: $conversationViewModel.isShowSelectedMessageToDisplayDetails, onDismiss: { + conversationViewModel.isShowSelectedMessageToDisplayDetails = false }, content: { - imdnSheet() + imdnOrReactionsSheet() .presentationDetents([.medium]) .presentationDragIndicator(.visible) }) @@ -115,10 +115,10 @@ struct ConversationFragment: View { .onDisappear { conversationViewModel.removeConversationDelegate() } - .halfSheet(showSheet: $conversationViewModel.isShowSelectedMessageToDisplayDetailsBottomSheet) { - imdnSheet() + .halfSheet(showSheet: $conversationViewModel.isShowSelectedMessageToDisplayDetails) { + imdnOrReactionsSheet() } onDismiss: { - conversationViewModel.isShowSelectedMessageToDisplayDetailsBottomSheet = false + conversationViewModel.isShowSelectedMessageToDisplayDetails = false } .sheet(isPresented: $isShowPhotoLibrary, onDismiss: { isShowPhotoLibrary = false @@ -864,42 +864,67 @@ struct ConversationFragment: View { //swiftlint:enable function_body_length @ViewBuilder - func imdnSheet() -> some View { - VStack { - Picker("Categories", selection: $selectedCategoryIndex) { - ForEach(0.. some View { + VStack { + Picker("Categories", selection: $selectedCategoryIndex) { + ForEach(0.. Date: Mon, 9 Sep 2024 09:42:18 +0200 Subject: [PATCH 382/486] Fix unread message counter --- Linphone/UI/Main/ContentView.swift | 2 +- .../Conversations/ViewModel/ConversationsListViewModel.swift | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/Linphone/UI/Main/ContentView.swift b/Linphone/UI/Main/ContentView.swift index e20188a02..76921f46f 100644 --- a/Linphone/UI/Main/ContentView.swift +++ b/Linphone/UI/Main/ContentView.swift @@ -1146,7 +1146,7 @@ struct ContentView: View { MagicSearchSingleton.shared.searchForContacts(sourceFlags: MagicSearch.Source.Friends.rawValue | MagicSearch.Source.LdapServers.rawValue) } .onReceive(pub) { _ in - conversationsListViewModel.refreshContactAvatarModel() + conversationsListViewModel.computeChatRoomsList(filter: "") historyListViewModel.refreshHistoryAvatarModel() } } diff --git a/Linphone/UI/Main/Conversations/ViewModel/ConversationsListViewModel.swift b/Linphone/UI/Main/Conversations/ViewModel/ConversationsListViewModel.swift index 2bb72aebd..6ae4f155c 100644 --- a/Linphone/UI/Main/Conversations/ViewModel/ConversationsListViewModel.swift +++ b/Linphone/UI/Main/Conversations/ViewModel/ConversationsListViewModel.swift @@ -90,6 +90,10 @@ class ConversationsListViewModel: ObservableObject { } }) + self.mCoreSuscriptions.insert(core.publisher?.onChatRoomRead?.postOnCoreQueue { _ in + self.computeChatRoomsList(filter: "") + }) + self.mCoreSuscriptions.insert(core.publisher?.onMessageSent?.postOnCoreQueue { _ in self.computeChatRoomsList(filter: "") }) From 1674a4127b2a7fe8cc62297b550c324cf6620b72 Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Mon, 9 Sep 2024 10:12:56 +0200 Subject: [PATCH 383/486] Fix getAvatarModelFromAddress crash --- Linphone/UI/Main/Contacts/Model/ContactAvatarModel.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Linphone/UI/Main/Contacts/Model/ContactAvatarModel.swift b/Linphone/UI/Main/Contacts/Model/ContactAvatarModel.swift index 1ce1df3f4..46b2f0381 100644 --- a/Linphone/UI/Main/Contacts/Model/ContactAvatarModel.swift +++ b/Linphone/UI/Main/Contacts/Model/ContactAvatarModel.swift @@ -139,7 +139,7 @@ class ContactAvatarModel: ObservableObject { if let addressFriend = resultFriend { if addressFriend.address != nil { var avatarModel = ContactsManager.shared.avatarListModel.first(where: { - $0.friend!.name == addressFriend.name + $0.friend != nil && $0.friend!.name == addressFriend.name && $0.friend!.address != nil && $0.friend!.address!.asStringUriOnly() == addressFriend.address!.asStringUriOnly() }) From cec5d999154501258b516f104c5f3f9d0ecb491c Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Mon, 9 Sep 2024 10:28:05 +0200 Subject: [PATCH 384/486] Changed asyncAfter delays from 0.2 to 0.3 --- Linphone/UI/Call/Fragments/CallsListFragment.swift | 4 ++-- .../ConversationForwardMessageFragment.swift | 2 +- .../Fragments/ConversationFragment.swift | 2 +- .../Fragments/StartConversationFragment.swift | 4 ++-- .../Main/History/Fragments/DialerBottomSheet.swift | 4 ++-- .../Main/History/Fragments/StartCallFragment.swift | 14 +++++++------- 6 files changed, 15 insertions(+), 15 deletions(-) diff --git a/Linphone/UI/Call/Fragments/CallsListFragment.swift b/Linphone/UI/Call/Fragments/CallsListFragment.swift index c99b6496a..dfc84b328 100644 --- a/Linphone/UI/Call/Fragments/CallsListFragment.swift +++ b/Linphone/UI/Call/Fragments/CallsListFragment.swift @@ -162,7 +162,7 @@ struct CallsListFragment: View { } TelecomManager.shared.setHeld(call: callViewModel.selectedCall!, hold: false) - DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { callViewModel.resetCallView() } } @@ -345,7 +345,7 @@ struct CallsListFragment: View { } TelecomManager.shared.setHeld(call: callViewModel.calls[index], hold: false) - DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { callViewModel.resetCallView() } } diff --git a/Linphone/UI/Main/Conversations/Fragments/ConversationForwardMessageFragment.swift b/Linphone/UI/Main/Conversations/Fragments/ConversationForwardMessageFragment.swift index d21972d96..88ecd8cf2 100644 --- a/Linphone/UI/Main/Conversations/Fragments/ConversationForwardMessageFragment.swift +++ b/Linphone/UI/Main/Conversations/Fragments/ConversationForwardMessageFragment.swift @@ -196,7 +196,7 @@ struct ConversationForwardMessageFragment: View { PopupLoadingView() .background(.black.opacity(0.65)) .onDisappear { - DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { magicSearch.searchForContacts( sourceFlags: MagicSearch.Source.Friends.rawValue | MagicSearch.Source.LdapServers.rawValue ) diff --git a/Linphone/UI/Main/Conversations/Fragments/ConversationFragment.swift b/Linphone/UI/Main/Conversations/Fragments/ConversationFragment.swift index 41f8073ac..5a79c70d3 100644 --- a/Linphone/UI/Main/Conversations/Fragments/ConversationFragment.swift +++ b/Linphone/UI/Main/Conversations/Fragments/ConversationFragment.swift @@ -292,7 +292,7 @@ struct ConversationFragment: View { .onAppear { if index == counter - 1 && conversationViewModel.displayedConversationHistorySize > conversationViewModel.conversationMessagesSection.first!.rows.count { - DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { conversationViewModel.getOldMessages() } } diff --git a/Linphone/UI/Main/Conversations/Fragments/StartConversationFragment.swift b/Linphone/UI/Main/Conversations/Fragments/StartConversationFragment.swift index 2983054b6..b25e8cec3 100644 --- a/Linphone/UI/Main/Conversations/Fragments/StartConversationFragment.swift +++ b/Linphone/UI/Main/Conversations/Fragments/StartConversationFragment.swift @@ -60,7 +60,7 @@ struct StartConversationFragment: View { .padding(.top, 2) .padding(.leading, -10) .onTapGesture { - DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { magicSearch.searchForContacts( sourceFlags: MagicSearch.Source.Friends.rawValue | MagicSearch.Source.LdapServers.rawValue) } @@ -225,7 +225,7 @@ struct StartConversationFragment: View { PopupLoadingView() .background(.black.opacity(0.65)) .onDisappear { - DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { magicSearch.searchForContacts( sourceFlags: MagicSearch.Source.Friends.rawValue | MagicSearch.Source.LdapServers.rawValue ) diff --git a/Linphone/UI/Main/History/Fragments/DialerBottomSheet.swift b/Linphone/UI/Main/History/Fragments/DialerBottomSheet.swift index 169a96f7d..08d04377d 100644 --- a/Linphone/UI/Main/History/Fragments/DialerBottomSheet.swift +++ b/Linphone/UI/Main/History/Fragments/DialerBottomSheet.swift @@ -456,7 +456,7 @@ struct DialerBottomSheet: View { if callViewModel.isTransferInsteadCall { showingDialer = false - DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { magicSearch.searchForContacts( sourceFlags: MagicSearch.Source.Friends.rawValue | MagicSearch.Source.LdapServers.rawValue) @@ -478,7 +478,7 @@ struct DialerBottomSheet: View { } else { showingDialer = false - DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { magicSearch.searchForContacts( sourceFlags: MagicSearch.Source.Friends.rawValue | MagicSearch.Source.LdapServers.rawValue) diff --git a/Linphone/UI/Main/History/Fragments/StartCallFragment.swift b/Linphone/UI/Main/History/Fragments/StartCallFragment.swift index 89d501e18..e15c6f520 100644 --- a/Linphone/UI/Main/History/Fragments/StartCallFragment.swift +++ b/Linphone/UI/Main/History/Fragments/StartCallFragment.swift @@ -63,7 +63,7 @@ struct StartCallFragment: View { .padding(.top, 2) .padding(.leading, -10) .onTapGesture { - DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { magicSearch.searchForContacts( sourceFlags: MagicSearch.Source.Friends.rawValue | MagicSearch.Source.LdapServers.rawValue) @@ -127,13 +127,13 @@ struct StartCallFragment: View { if !showingDialer { isSearchFieldFocused = false - DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { showingDialer = true } } else { showingDialer = false - DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { isSearchFieldFocused = true } } @@ -223,7 +223,7 @@ struct StartCallFragment: View { if callViewModel.isTransferInsteadCall { showingDialer = false - DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { magicSearch.searchForContacts( sourceFlags: MagicSearch.Source.Friends.rawValue | MagicSearch.Source.LdapServers.rawValue) @@ -245,7 +245,7 @@ struct StartCallFragment: View { } else { showingDialer = false - DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { magicSearch.searchForContacts( sourceFlags: MagicSearch.Source.Friends.rawValue | MagicSearch.Source.LdapServers.rawValue) @@ -326,7 +326,7 @@ struct StartCallFragment: View { if callViewModel.isTransferInsteadCall { showingDialer = false - DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { magicSearch.searchForContacts( sourceFlags: MagicSearch.Source.Friends.rawValue | MagicSearch.Source.LdapServers.rawValue) @@ -350,7 +350,7 @@ struct StartCallFragment: View { } else { showingDialer = false - DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { magicSearch.searchForContacts( sourceFlags: MagicSearch.Source.Friends.rawValue | MagicSearch.Source.LdapServers.rawValue) From 26cbad1e46b93058bcd4732c2e6b1df9aa5b4026 Mon Sep 17 00:00:00 2001 From: QuentinArguillere Date: Fri, 6 Sep 2024 12:32:37 +0200 Subject: [PATCH 385/486] Rework logout : do not delete authinfo before the registration is in Cleared state --- Linphone/Core/CoreContext.swift | 17 ++++++++++++++++- Linphone/UI/Main/Fragments/HelpView.swift | 19 +++---------------- Linphone/UI/Main/Fragments/ToastView.swift | 9 ++++++++- 3 files changed, 27 insertions(+), 18 deletions(-) diff --git a/Linphone/Core/CoreContext.swift b/Linphone/Core/CoreContext.swift index 9437069ef..3b9ca411b 100644 --- a/Linphone/Core/CoreContext.swift +++ b/Linphone/Core/CoreContext.swift @@ -201,6 +201,14 @@ final class CoreContext: ObservableObject { if self.mCore.consolidatedPresence != ConsolidatedPresence.Online { self.updatePresence(core: self.mCore, presence: ConsolidatedPresence.Online) } + } else if cbVal.state == .Cleared { + Log.info("[onAccountRegistrationStateChanged] Account \(cbVal.account.displayName()) registration was cleared. Looking for auth info") + if let authInfo = cbVal.account.findAuthInfo() { + Log.info("[onAccountRegistrationStateChanged] Found auth info for account, removing it") + cbVal.core.removeAuthInfo(info: authInfo) + } else { + Log.warn("[onAccountRegistrationStateChanged] Failed to find matching auth info for account") + } } else if cbVal.state != .Ok && cbVal.state != .Progress { // If registration failed, remove account from core self.monitor.pathUpdateHandler = { path in @@ -216,6 +224,7 @@ final class CoreContext: ObservableObject { } } } + TelecomManager.shared.onAccountRegistrationStateChanged(core: cbVal.core, account: cbVal.account, state: cbVal.state, message: cbVal.message) DispatchQueue.main.async { @@ -224,10 +233,16 @@ final class CoreContext: ObservableObject { self.loggedIn = true } else if cbVal.state == .Progress || cbVal.state == .Refreshing { self.loggingInProgress = true + } else if cbVal.state == .Cleared { + self.loggingInProgress = false + self.loggedIn = false + self.hasDefaultAccount = false + ToastViewModel.shared.toastMessage = "Success_account_logged_out" + ToastViewModel.shared.displayToast = true } else { self.loggingInProgress = false self.loggedIn = false - ToastViewModel.shared.toastMessage = "Registration failed" + ToastViewModel.shared.toastMessage = "Registration_failed" ToastViewModel.shared.displayToast = true } } diff --git a/Linphone/UI/Main/Fragments/HelpView.swift b/Linphone/UI/Main/Fragments/HelpView.swift index af227f297..17cf680bf 100644 --- a/Linphone/UI/Main/Fragments/HelpView.swift +++ b/Linphone/UI/Main/Fragments/HelpView.swift @@ -40,22 +40,9 @@ class HelpView { // TODO (basic debug moved here until halp view is implemented) static func logout() { CoreContext.shared.doOnCoreQueue { core in - if core.defaultAccount != nil { - let authInfo = core.defaultAccount!.findAuthInfo() - if authInfo != nil { - Log.info("$TAG Found auth info for account, removing it") - core.removeAuthInfo(info: authInfo!) - } else { - Log.warn("$TAG Failed to find matching auth info for account") - } - - core.removeAccount(account: core.defaultAccount!) - Log.info("$TAG Account has been removed") - - DispatchQueue.main.async { - CoreContext.shared.hasDefaultAccount = false - CoreContext.shared.loggedIn = false - } + 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/ToastView.swift b/Linphone/UI/Main/Fragments/ToastView.swift index b696d2228..7ef3d9d63 100644 --- a/Linphone/UI/Main/Fragments/ToastView.swift +++ b/Linphone/UI/Main/Fragments/ToastView.swift @@ -122,13 +122,20 @@ struct ToastView: View { .default_text_style(styleSize: 15) .padding(8) - case "Registration failed": + case "Registration_failed": Text("The user name or password is incorrects") .multilineTextAlignment(.center) .foregroundStyle(Color.redDanger500) .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) From 321e1065ac94e9726e7cbf7ad8ed5a33dd101b1e Mon Sep 17 00:00:00 2001 From: QuentinArguillere Date: Fri, 6 Sep 2024 16:42:45 +0200 Subject: [PATCH 386/486] Network management: add variable networkStatusIsConnected in CoreContext. Use this new variable to proceed to account removal when failed to register while network is --- Linphone/Core/CoreContext.swift | 49 +++++++++++++++++++++++---------- 1 file changed, 34 insertions(+), 15 deletions(-) diff --git a/Linphone/Core/CoreContext.swift b/Linphone/Core/CoreContext.swift index 3b9ca411b..d42c48c51 100644 --- a/Linphone/Core/CoreContext.swift +++ b/Linphone/Core/CoreContext.swift @@ -21,6 +21,7 @@ // swiftlint:disable line_length // swiftlint:disable cyclomatic_complexity // swiftlint:disable identifier_name +// swiftlint:disable type_body_length import linphonesw import linphone // needed for unwrapped function linphone_core_set_push_and_app_delegate_dispatch_queue @@ -51,6 +52,7 @@ final class CoreContext: ObservableObject { var bearerAuthInfoPendingPasswordUpdate: AuthInfo? let monitor = NWPathMonitor() + var networkStatusIsConnected: Bool = true // updated on core queue private var mCorePushIncomingDelegate: CoreDelegate! private var actionsToPerformOnCoreQueueWhenCoreIsStarted: [((Core) -> Void)] = [] @@ -85,6 +87,20 @@ final class CoreContext: ObservableObject { #if USE_CRASHLYTICS FirebaseApp.configure() #endif + monitor.pathUpdateHandler = { path in + let isConnected = path.status == .satisfied + if self.networkStatusIsConnected != isConnected { + if isConnected { + Log.info("Network is now satisfied") + } else { + Log.error("Network is now \(path.status)") + } + self.networkStatusIsConnected = isConnected + } + + } + monitor.start(queue: coreQueue) + coreQueue.async { LoggingService.Instance.logLevel = LogLevel.Debug Factory.Instance.logCollectionPath = Factory.Instance.getConfigDir(context: nil) @@ -148,6 +164,7 @@ final class CoreContext: ObservableObject { self.actionsToPerformOnCoreQueueWhenCoreIsStarted.removeAll() let hasDefaultAccount = self.mCore.defaultAccount != nil ? true : false + Log.info("debugtrace onGlobalStateChanged -- core accounts: \(self.mCore.accountList.count), hasDefaultAccount: \(self.mCore.defaultAccount != nil ? "yes" : "no")") var accountModels: [AccountModel] = [] for account in self.mCore.accountList { accountModels.append(AccountModel(account: account, corePublisher: self.mCore.publisher)) @@ -164,6 +181,7 @@ final class CoreContext: ObservableObject { // In this case, we want to know about the account registration status self.mCoreSuscriptions.insert(self.mCore.publisher?.onConfiguringStatus?.postOnCoreQueue { (cbVal: (core: Core, status: ConfiguringState, message: String)) in Log.info("New configuration state is \(cbVal.status) = \(cbVal.message)\n") + Log.info("debugtrace onConfiguringStatus -- core accounts: \(self.mCore.accountList.count), hasDefaultAccount: \(self.mCore.defaultAccount != nil ? "yes" : "no")") var accountModels: [AccountModel] = [] for account in self.mCore.accountList { accountModels.append(AccountModel(account: account, corePublisher: self.mCore.publisher)) @@ -189,8 +207,10 @@ final class CoreContext: ObservableObject { // Otherwise, we will be Failed. Log.info("New registration state is \(cbVal.state) for user id " + "\( String(describing: cbVal.account.params?.identityAddress?.asString())) = \(cbVal.message)\n") + Log.info("debugtrace onAccountRegistrationStateChanged -- core accounts: \(self.mCore.accountList.count), hasDefaultAccount: \(self.mCore.defaultAccount != nil ? "yes" : "no")") - if cbVal.state == .Ok { + switch(cbVal.state) { + case .Ok: let newParams = cbVal.account.params?.clone() newParams?.internationalPrefix = "33" newParams?.internationalPrefixIsoCountryCode = "FRA" @@ -201,7 +221,7 @@ final class CoreContext: ObservableObject { if self.mCore.consolidatedPresence != ConsolidatedPresence.Online { self.updatePresence(core: self.mCore, presence: ConsolidatedPresence.Online) } - } else if cbVal.state == .Cleared { + case .Cleared: Log.info("[onAccountRegistrationStateChanged] Account \(cbVal.account.displayName()) registration was cleared. Looking for auth info") if let authInfo = cbVal.account.findAuthInfo() { Log.info("[onAccountRegistrationStateChanged] Found auth info for account, removing it") @@ -209,20 +229,18 @@ final class CoreContext: ObservableObject { } else { Log.warn("[onAccountRegistrationStateChanged] Failed to find matching auth info for account") } - } else if cbVal.state != .Ok && cbVal.state != .Progress { // If registration failed, remove account from core - - self.monitor.pathUpdateHandler = { path in - if path.status == .satisfied { - let params = cbVal.account.params - let clonedParams = params?.clone() - clonedParams?.registerEnabled = false - cbVal.account.params = clonedParams - - cbVal.core.removeAccount(account: cbVal.account) - cbVal.core.clearAccounts() - cbVal.core.clearAllAuthInfo() - } + case .Failed: // If registration failed, remove account from core + if self.networkStatusIsConnected { + let params = cbVal.account.params + let clonedParams = params?.clone() + clonedParams?.registerEnabled = false + cbVal.account.params = clonedParams + + Log.warn("Registration failed for account \(cbVal.account.displayName()), deleting it from core") + cbVal.core.removeAccount(account: cbVal.account) } + default: + break } TelecomManager.shared.onAccountRegistrationStateChanged(core: cbVal.core, account: cbVal.account, state: cbVal.state, message: cbVal.message) @@ -405,3 +423,4 @@ final class CoreContext: ObservableObject { // swiftlint:enable line_length // swiftlint:enable cyclomatic_complexity // swiftlint:enable identifier_name +// swiftlint:enable type_body_length From df8f51560154f2ef3c1d15ac921d436309329f89 Mon Sep 17 00:00:00 2001 From: QuentinArguillere Date: Fri, 6 Sep 2024 16:43:18 +0200 Subject: [PATCH 387/486] Display error toast instead of spinning forever when trying to login for the first time with no network connected --- .../UI/Assistant/Viewmodel/AccountLoginViewModel.swift | 8 ++++++++ Linphone/UI/Main/Fragments/ToastView.swift | 7 +++++++ 2 files changed, 15 insertions(+) diff --git a/Linphone/UI/Assistant/Viewmodel/AccountLoginViewModel.swift b/Linphone/UI/Assistant/Viewmodel/AccountLoginViewModel.swift index 8172c9bfb..7b0e9a1e3 100644 --- a/Linphone/UI/Assistant/Viewmodel/AccountLoginViewModel.swift +++ b/Linphone/UI/Assistant/Viewmodel/AccountLoginViewModel.swift @@ -34,6 +34,14 @@ class AccountLoginViewModel: ObservableObject { func login() { coreContext.doOnCoreQueue { core in + guard self.coreContext.networkStatusIsConnected else { + DispatchQueue.main.async { + self.coreContext.loggingInProgress = false + ToastViewModel.shared.toastMessage = "Registration_failed_no_network" + ToastViewModel.shared.displayToast = true + } + return + } do { let usernameWithDomain = self.username.split(separator: "@") diff --git a/Linphone/UI/Main/Fragments/ToastView.swift b/Linphone/UI/Main/Fragments/ToastView.swift index 7ef3d9d63..24a0c551b 100644 --- a/Linphone/UI/Main/Fragments/ToastView.swift +++ b/Linphone/UI/Main/Fragments/ToastView.swift @@ -129,6 +129,13 @@ struct ToastView: View { .default_text_style(styleSize: 15) .padding(8) + case "Registration_failed_no_network": + Text("Could not reach network") + .multilineTextAlignment(.center) + .foregroundStyle(Color.redDanger500) + .default_text_style(styleSize: 15) + .padding(8) + case "Success_account_logged_out": Text("Account successfully logged out") .multilineTextAlignment(.center) From 66500e42b59107f4c211aea552c2b8f00bf50637 Mon Sep 17 00:00:00 2001 From: QuentinArguillere Date: Fri, 6 Sep 2024 17:55:22 +0200 Subject: [PATCH 388/486] Error management for meeting scheduling: unreachable network, missing subject/participant, fail to send some or all ICS invitations --- .../Viewmodel/AccountLoginViewModel.swift | 2 +- Linphone/UI/Main/Fragments/ToastView.swift | 16 +++++++- .../Meetings/ViewModel/MeetingViewModel.swift | 39 ++++++++++++++----- 3 files changed, 46 insertions(+), 11 deletions(-) diff --git a/Linphone/UI/Assistant/Viewmodel/AccountLoginViewModel.swift b/Linphone/UI/Assistant/Viewmodel/AccountLoginViewModel.swift index 7b0e9a1e3..822236ef1 100644 --- a/Linphone/UI/Assistant/Viewmodel/AccountLoginViewModel.swift +++ b/Linphone/UI/Assistant/Viewmodel/AccountLoginViewModel.swift @@ -37,7 +37,7 @@ class AccountLoginViewModel: ObservableObject { guard self.coreContext.networkStatusIsConnected else { DispatchQueue.main.async { self.coreContext.loggingInProgress = false - ToastViewModel.shared.toastMessage = "Registration_failed_no_network" + ToastViewModel.shared.toastMessage = "Unavailable_network" ToastViewModel.shared.displayToast = true } return diff --git a/Linphone/UI/Main/Fragments/ToastView.swift b/Linphone/UI/Main/Fragments/ToastView.swift index 24a0c551b..de4886ef6 100644 --- a/Linphone/UI/Main/Fragments/ToastView.swift +++ b/Linphone/UI/Main/Fragments/ToastView.swift @@ -129,7 +129,7 @@ struct ToastView: View { .default_text_style(styleSize: 15) .padding(8) - case "Registration_failed_no_network": + case "Unavailable_network": Text("Could not reach network") .multilineTextAlignment(.center) .foregroundStyle(Color.redDanger500) @@ -253,6 +253,20 @@ struct ToastView: View { .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) diff --git a/Linphone/UI/Main/Meetings/ViewModel/MeetingViewModel.swift b/Linphone/UI/Main/Meetings/ViewModel/MeetingViewModel.swift index e5d31b496..41ba92152 100644 --- a/Linphone/UI/Main/Meetings/ViewModel/MeetingViewModel.swift +++ b/Linphone/UI/Main/Meetings/ViewModel/MeetingViewModel.swift @@ -216,13 +216,23 @@ class MeetingViewModel: ObservableObject { Log.info("\(MeetingViewModel.TAG) All invitations have been sent") } else if cbVal.failedInvitations.count == self.participants.count { Log.error("\(MeetingViewModel.TAG) No invitation sent!") - // TODO: show error toast - } else { - Log.warn("\(MeetingViewModel.TAG) \(cbVal.failedInvitations.count) invitations couldn't have been sent for:") - for failInv in cbVal.failedInvitations { - Log.warn(failInv.asStringUriOnly()) + DispatchQueue.main.async { + ToastViewModel.shared.toastMessage = "Failed_meeting_invitations_not_sent" + ToastViewModel.shared.displayToast = true + } + } else { + var failInvList = "" + for failInv in cbVal.failedInvitations { + if !failInvList.isEmpty { + failInvList += ", " + } + failInvList.append(failInv.asStringUriOnly()) + } + Log.warn("\(MeetingViewModel.TAG) \(cbVal.failedInvitations.count) invitations couldn't have been sent to: \(failInvList)") + DispatchQueue.main.async { + ToastViewModel.shared.toastMessage = "Error: \(cbVal.failedInvitations.count) invitations couldn't be sent to \(failInvList)" + ToastViewModel.shared.displayToast = true } - // TODO: show error toast } DispatchQueue.main.async { @@ -233,13 +243,24 @@ class MeetingViewModel: ObservableObject { } func schedule() { - if subject.isEmpty || participants.isEmpty { + guard !subject.isEmpty && participants.isEmpty else { Log.error("\(MeetingViewModel.TAG) Either no subject was set or no participant was selected, can't schedule meeting.") - // TODO: show red toast + DispatchQueue.main.async { + ToastViewModel.shared.toastMessage = "Failed_no_subject_or_participant" + ToastViewModel.shared.displayToast = true + } return } - operationInProgress = true + 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")") From 64763565da1df3960b9d2e6e589e343245b18d0d Mon Sep 17 00:00:00 2001 From: QuentinArguillere Date: Thu, 5 Sep 2024 15:46:03 +0200 Subject: [PATCH 389/486] When remote video is enabled, switch to fullscreen --- Linphone/TelecomManager/TelecomManager.swift | 60 ++++++-------------- Linphone/UI/Call/CallView.swift | 5 ++ 2 files changed, 21 insertions(+), 44 deletions(-) diff --git a/Linphone/TelecomManager/TelecomManager.swift b/Linphone/TelecomManager/TelecomManager.swift index c43dc3600..5e709ce51 100644 --- a/Linphone/TelecomManager/TelecomManager.swift +++ b/Linphone/TelecomManager/TelecomManager.swift @@ -412,6 +412,15 @@ class TelecomManager: ObservableObject { } } + 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 ?? "" @@ -423,64 +432,27 @@ class TelecomManager: ObservableObject { if call.conference != nil { if call.conference!.activeSpeakerParticipantDevice != nil { let direction = call.conference?.activeSpeakerParticipantDevice!.getStreamCapability(streamType: StreamType.Video) - - DispatchQueue.main.async { - self.remoteConfVideo = false - DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { - self.remoteConfVideo = direction == .SendRecv || direction == .SendOnly - } - } + 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) - - DispatchQueue.main.async { - self.remoteConfVideo = false - DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { - self.remoteConfVideo = direction == .SendRecv || direction == .SendOnly - } - } + 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) - - DispatchQueue.main.async { - self.remoteConfVideo = false - DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { - self.remoteConfVideo = direction == .SendRecv || direction == .SendOnly - } - } + updateRemoteConfVideo(remConfVideoEnabled: direction == .SendRecv || direction == .SendOnly) } else { - DispatchQueue.main.async { - self.remoteConfVideo = false - } + updateRemoteConfVideo(remConfVideoEnabled: false) } } else { + var remConfVideoEnabled = false if call.currentParams != nil { - let remoteConfVideoTmp = call.currentParams!.videoEnabled && call.currentParams!.videoDirection == .SendRecv || call.currentParams!.videoDirection == .RecvOnly - - DispatchQueue.main.async { - self.remoteConfVideo = false - DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { - self.remoteConfVideo = remoteConfVideoTmp - } - } - } else { - DispatchQueue.main.async { - self.remoteConfVideo = false - } + remConfVideoEnabled = call.currentParams!.videoEnabled && call.currentParams!.videoDirection == .SendRecv || call.currentParams!.videoDirection == .RecvOnly } + updateRemoteConfVideo(remConfVideoEnabled: remConfVideoEnabled) } - /* - if self.remoteConfVideo && self.remoteConfVideo != oldRemoteConfVideo { - do { - try AVAudioSession.sharedInstance().overrideOutputAudioPort(.speaker) - } catch _ { - } - } - */ if self.remoteConfVideo { Log.info("[Call] Remote video is activated") diff --git a/Linphone/UI/Call/CallView.swift b/Linphone/UI/Call/CallView.swift index 69def5681..7d12df34a 100644 --- a/Linphone/UI/Call/CallView.swift +++ b/Linphone/UI/Call/CallView.swift @@ -751,6 +751,11 @@ struct CallView: View { callViewModel.orientationUpdate(orientation: orientation) } + .onReceive(telecomManager.$remoteConfVideo, perform: { videoOn in + if videoOn { + fullscreenVideo = videoOn + } + }) } // swiftlint:disable:next cyclomatic_complexity From b2a7a11dba3d2f84fd1bebade5e9eaf29ed98931 Mon Sep 17 00:00:00 2001 From: QuentinArguillere Date: Mon, 9 Sep 2024 10:56:15 +0200 Subject: [PATCH 390/486] Remove comments and light refactor. Update version from 38 to 40 --- Linphone.xcodeproj/project.pbxproj | 8 +- Linphone/TelecomManager/TelecomManager.swift | 78 ++------------------ 2 files changed, 10 insertions(+), 76 deletions(-) diff --git a/Linphone.xcodeproj/project.pbxproj b/Linphone.xcodeproj/project.pbxproj index 64853448b..ec63d1ae4 100644 --- a/Linphone.xcodeproj/project.pbxproj +++ b/Linphone.xcodeproj/project.pbxproj @@ -1240,7 +1240,7 @@ CODE_SIGN_ENTITLEMENTS = msgNotificationService/msgNotificationService.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 38; + CURRENT_PROJECT_VERSION = 40; DEVELOPMENT_TEAM = Z2V957B3D6; ENABLE_USER_SCRIPT_SANDBOXING = NO; GCC_C_LANGUAGE_STANDARD = gnu17; @@ -1283,7 +1283,7 @@ CODE_SIGN_ENTITLEMENTS = msgNotificationService/msgNotificationService.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 38; + CURRENT_PROJECT_VERSION = 40; DEVELOPMENT_TEAM = Z2V957B3D6; ENABLE_USER_SCRIPT_SANDBOXING = NO; GCC_C_LANGUAGE_STANDARD = gnu17; @@ -1440,7 +1440,7 @@ ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = NO; CODE_SIGN_ENTITLEMENTS = Linphone/Linphone.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 38; + CURRENT_PROJECT_VERSION = 40; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_ASSET_PATHS = "\"Linphone/Preview Content\""; @@ -1497,7 +1497,7 @@ ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = NO; CODE_SIGN_ENTITLEMENTS = Linphone/Linphone.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 38; + CURRENT_PROJECT_VERSION = 40; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_ASSET_PATHS = "\"Linphone/Preview Content\""; DEVELOPMENT_TEAM = Z2V957B3D6; diff --git a/Linphone/TelecomManager/TelecomManager.swift b/Linphone/TelecomManager/TelecomManager.swift index 5e709ce51..752d82055 100644 --- a/Linphone/TelecomManager/TelecomManager.swift +++ b/Linphone/TelecomManager/TelecomManager.swift @@ -492,12 +492,11 @@ class TelecomManager: ObservableObject { Log.info("[Call] Recording is stopped by \(call.remoteAddress!.asStringUriOnly())") } - switch call.state { - case Call.State.PausedByRemote: + if cstate == Call.State.PausedByRemote { DispatchQueue.main.async { self.isPausedByRemote = true } - default: + } else { DispatchQueue.main.async { self.isPausedByRemote = false } @@ -515,13 +514,7 @@ class TelecomManager: ObservableObject { let appData = CallAppData() TelecomManager.setAppData(sCall: call, appData: appData) } - /* - if let conference = call.conference, ConferenceViewModel.shared.conference.value == nil { - Log.info("[Call] Found conference attached to call and no conference in dedicated view model, init & configure it") - ConferenceViewModel.shared.initConference(conference) - ConferenceViewModel.shared.configureConference(conference) - } - */ + switch cstate { case .IncomingReceived: let addr = call.remoteAddress @@ -539,38 +532,9 @@ class TelecomManager: ObservableObject { } } #endif - /* - if call.replacedCall != nil { - self.endCallKitReplacedCall = false - - let uuid = self.providerDelegate.uuids["\(TelecomManager.uuidReplacedCall ?? "")"] - let callInfo = self.providerDelegate.callInfos[uuid!] - callInfo!.callId = self.referedToCall ?? "" - if callInfo != nil && uuid != nil && addr != nil { - self.providerDelegate.callInfos.updateValue(callInfo!, forKey: uuid!) - self.providerDelegate.uuids.removeValue(forKey: callId) - self.providerDelegate.uuids.updateValue(uuid!, forKey: callInfo!.callId) - self.providerDelegate.updateCall(uuid: uuid!, handle: addr!.asStringUriOnly(), hasVideo: self.remoteConfVideo, displayName: displayName) - } - } else */ if TelecomManager.callKitEnabled(core: core) { - /* - let isConference = isConferenceCall(call: call) - let isEarlyConference = isConference && CallsViewModel.shared.currentCallData.value??.isConferenceCall.value != true // Conference info not be received yet. - if (isEarlyConference) { - CallsViewModel.shared.currentCallData.readCurrentAndObserve { _ in - let uuid = providerDelegate.uuids["\(callId)"] - if (uuid != nil) { - displayName = "\(VoipTexts.conference_incoming_title): \(CallsViewModel.shared.currentCallData.value??.remoteConferenceSubject.value ?? "") (\(CallsViewModel.shared.currentCallData.value??.conferenceParticipantsCountLabel.value ?? ""))" - providerDelegate.updateCall(uuid: uuid!, handle: addr!.asStringUriOnly(), hasVideo: video, displayName: displayName) - } - } - } - */ let uuid = self.providerDelegate.uuids["\(callId)"] - // if call.replacedCall == nil { - TelecomManager.uuidReplacedCall = callId - // } + TelecomManager.uuidReplacedCall = callId if uuid != nil { // Tha app is now registered, updated the call already existed. @@ -581,17 +545,7 @@ class TelecomManager: ObservableObject { let videoDir = call.remoteParams?.videoDirection != MediaDirection.Inactive self.displayIncomingCall(call: call, handle: addr!.asStringUriOnly(), hasVideo: videoEnabled && videoDir && !isConference, callId: callId, displayName: displayName) } - } /* else if UIApplication.shared.applicationState != .active { - // not support callkit , use notif - let content = UNMutableNotificationContent() - content.title = NSLocalizedString("Incoming call", comment: "") - content.body = displayName - content.sound = UNNotificationSound.init(named: UNNotificationSoundName.init("notes_of_the_optimistic.caf")) - content.categoryIdentifier = "call_cat" - content.userInfo = ["CallId": callId] - let req = UNNotificationRequest.init(identifier: "call_request", content: content, trigger: nil) - UNUserNotificationCenter.current().add(req, withCompletionHandler: nil) - } */ + } } case .StreamsRunning: if TelecomManager.callKitEnabled(core: core) { @@ -612,13 +566,6 @@ class TelecomManager: ObservableObject { } } - /* - if speakerBeforePause { - speakerBeforePause = false - AudioRouteUtils.routeAudioToSpeaker(core: core) - } - */ - actionToFulFill?.fulfill() actionToFulFill = nil case .Paused: @@ -641,16 +588,7 @@ class TelecomManager: ObservableObject { Log.info("CallKit: outgoing call started connecting with uuid \(uuid!) and callId \(callId)") providerDelegate.reportOutgoingCallStartedConnecting(uuid: uuid!) } else { - if false { /* isConferenceCall(call: call) { - let uuid = UUID() - let callInfo = CallInfo.newOutgoingCallInfo(addr: call.remoteAddress!, isSas: call.params?.mediaEncryption == .ZRTP, displayName: VoipTexts.conference_default_title, isVideo: call.params?.videoEnabled == true, isConference:true) - providerDelegate.callInfos.updateValue(callInfo, forKey: uuid) - providerDelegate.uuids.updateValue(uuid, forKey: "") - providerDelegate.reportOutgoingCallStartedConnecting(uuid: uuid) - Core.get().activateAudioSession(actived: true) */ - } else { - referedToCall = callId - } + referedToCall = callId } } case .End, @@ -659,10 +597,6 @@ class TelecomManager: ObservableObject { UIDevice.current.isProximityMonitoringEnabled = false if core.callsNb == 0 { core.outputAudioDevice = core.defaultOutputAudioDevice - // disable this because I don't find anygood reason for it: _bluetoothAvailable = FALSE; - // furthermore it introduces a bug when calling multiple times since route may not be - // reconfigured between cause leading to bluetooth being disabled while it should not - // bluetoothEnabled = false } // if core.callsNb == 0 { From 6478fbf03e4a8aaeb9d2fa024f8d0396962a8359 Mon Sep 17 00:00:00 2001 From: QuentinArguillere Date: Mon, 9 Sep 2024 17:18:05 +0200 Subject: [PATCH 391/486] Avoid using sync when dispatching to main queue (risk deadlock when entering foreground) --- Linphone/Contacts/ContactsManager.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Linphone/Contacts/ContactsManager.swift b/Linphone/Contacts/ContactsManager.swift index c635f5290..ad3c8c43a 100644 --- a/Linphone/Contacts/ContactsManager.swift +++ b/Linphone/Contacts/ContactsManager.swift @@ -106,7 +106,7 @@ final class ContactsManager: ObservableObject { do { var contactCounter = 0 try store.enumerateContacts(with: request, usingBlock: { (contact, _) in - DispatchQueue.main.sync { + DispatchQueue.main.async { let newContact = Contact( identifier: contact.identifier, firstName: contact.givenName, From ae4dcda49bad88375dfa125f2387c6009b9680e3 Mon Sep 17 00:00:00 2001 From: QuentinArguillere Date: Mon, 9 Sep 2024 17:19:49 +0200 Subject: [PATCH 392/486] Remove international prexif updates when registering --- Linphone/Core/CoreContext.swift | 6 ------ 1 file changed, 6 deletions(-) diff --git a/Linphone/Core/CoreContext.swift b/Linphone/Core/CoreContext.swift index d42c48c51..3d1379fe8 100644 --- a/Linphone/Core/CoreContext.swift +++ b/Linphone/Core/CoreContext.swift @@ -211,12 +211,6 @@ final class CoreContext: ObservableObject { switch(cbVal.state) { case .Ok: - let newParams = cbVal.account.params?.clone() - newParams?.internationalPrefix = "33" - newParams?.internationalPrefixIsoCountryCode = "FRA" - newParams?.useInternationalPrefixForCallsAndChats = true - cbVal.account.params = newParams - ContactsManager.shared.fetchContacts() if self.mCore.consolidatedPresence != ConsolidatedPresence.Online { self.updatePresence(core: self.mCore, presence: ConsolidatedPresence.Online) From 473c486ccfddcc10789a3074f6f94deeb15fdf71 Mon Sep 17 00:00:00 2001 From: QuentinArguillere Date: Mon, 9 Sep 2024 17:21:22 +0200 Subject: [PATCH 393/486] Remove debugtraces. Update version to (41) --- Linphone.xcodeproj/project.pbxproj | 6 +++--- Linphone/Core/CoreContext.swift | 5 +---- Linphone/UI/Main/Meetings/ViewModel/MeetingViewModel.swift | 1 - 3 files changed, 4 insertions(+), 8 deletions(-) diff --git a/Linphone.xcodeproj/project.pbxproj b/Linphone.xcodeproj/project.pbxproj index ec63d1ae4..8687d2df4 100644 --- a/Linphone.xcodeproj/project.pbxproj +++ b/Linphone.xcodeproj/project.pbxproj @@ -1283,7 +1283,7 @@ CODE_SIGN_ENTITLEMENTS = msgNotificationService/msgNotificationService.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 40; + CURRENT_PROJECT_VERSION = 41; DEVELOPMENT_TEAM = Z2V957B3D6; ENABLE_USER_SCRIPT_SANDBOXING = NO; GCC_C_LANGUAGE_STANDARD = gnu17; @@ -1440,7 +1440,7 @@ ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = NO; CODE_SIGN_ENTITLEMENTS = Linphone/Linphone.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 40; + CURRENT_PROJECT_VERSION = 41; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_ASSET_PATHS = "\"Linphone/Preview Content\""; @@ -1497,7 +1497,7 @@ ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = NO; CODE_SIGN_ENTITLEMENTS = Linphone/Linphone.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 40; + CURRENT_PROJECT_VERSION = 41; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_ASSET_PATHS = "\"Linphone/Preview Content\""; DEVELOPMENT_TEAM = Z2V957B3D6; diff --git a/Linphone/Core/CoreContext.swift b/Linphone/Core/CoreContext.swift index 3d1379fe8..431797df3 100644 --- a/Linphone/Core/CoreContext.swift +++ b/Linphone/Core/CoreContext.swift @@ -164,7 +164,6 @@ final class CoreContext: ObservableObject { self.actionsToPerformOnCoreQueueWhenCoreIsStarted.removeAll() let hasDefaultAccount = self.mCore.defaultAccount != nil ? true : false - Log.info("debugtrace onGlobalStateChanged -- core accounts: \(self.mCore.accountList.count), hasDefaultAccount: \(self.mCore.defaultAccount != nil ? "yes" : "no")") var accountModels: [AccountModel] = [] for account in self.mCore.accountList { accountModels.append(AccountModel(account: account, corePublisher: self.mCore.publisher)) @@ -181,7 +180,6 @@ final class CoreContext: ObservableObject { // In this case, we want to know about the account registration status self.mCoreSuscriptions.insert(self.mCore.publisher?.onConfiguringStatus?.postOnCoreQueue { (cbVal: (core: Core, status: ConfiguringState, message: String)) in Log.info("New configuration state is \(cbVal.status) = \(cbVal.message)\n") - Log.info("debugtrace onConfiguringStatus -- core accounts: \(self.mCore.accountList.count), hasDefaultAccount: \(self.mCore.defaultAccount != nil ? "yes" : "no")") var accountModels: [AccountModel] = [] for account in self.mCore.accountList { accountModels.append(AccountModel(account: account, corePublisher: self.mCore.publisher)) @@ -207,9 +205,8 @@ final class CoreContext: ObservableObject { // Otherwise, we will be Failed. Log.info("New registration state is \(cbVal.state) for user id " + "\( String(describing: cbVal.account.params?.identityAddress?.asString())) = \(cbVal.message)\n") - Log.info("debugtrace onAccountRegistrationStateChanged -- core accounts: \(self.mCore.accountList.count), hasDefaultAccount: \(self.mCore.defaultAccount != nil ? "yes" : "no")") - switch(cbVal.state) { + switch cbVal.state { case .Ok: ContactsManager.shared.fetchContacts() if self.mCore.consolidatedPresence != ConsolidatedPresence.Online { diff --git a/Linphone/UI/Main/Meetings/ViewModel/MeetingViewModel.swift b/Linphone/UI/Main/Meetings/ViewModel/MeetingViewModel.swift index 41ba92152..7a012a95b 100644 --- a/Linphone/UI/Main/Meetings/ViewModel/MeetingViewModel.swift +++ b/Linphone/UI/Main/Meetings/ViewModel/MeetingViewModel.swift @@ -347,7 +347,6 @@ class MeetingViewModel: ObservableObject { func cancelMeetingWithNotifications(meeting: MeetingModel) { CoreContext.shared.doOnCoreQueue { core in - Log.info("debugtrace - core media encryption = \(core.mediaEncryption)") self.resetConferenceSchedulerAndListeners(core: core) self.conferenceScheduler?.cancelConference(conferenceInfo: meeting.confInfo) } From 69242599f8c027a8f6c551c5451cdc0f9e36d480 Mon Sep 17 00:00:00 2001 From: QuentinArguillere Date: Tue, 10 Sep 2024 14:20:29 +0200 Subject: [PATCH 394/486] In corecontext, replace postOnCoreQueue by CoreDelegate. This allows us to have callbacks triggered immediately and not at at later time asynchronously --- Linphone/Core/CoreContext.swift | 193 +++++++------------ Linphone/TelecomManager/TelecomManager.swift | 3 +- 2 files changed, 74 insertions(+), 122 deletions(-) diff --git a/Linphone/Core/CoreContext.swift b/Linphone/Core/CoreContext.swift index 431797df3..54863a9d3 100644 --- a/Linphone/Core/CoreContext.swift +++ b/Linphone/Core/CoreContext.swift @@ -17,11 +17,9 @@ * along with this program. If not, see . */ -// swiftlint:disable large_tuple // swiftlint:disable line_length // swiftlint:disable cyclomatic_complexity // swiftlint:disable identifier_name -// swiftlint:disable type_body_length import linphonesw import linphone // needed for unwrapped function linphone_core_set_push_and_app_delegate_dispatch_queue @@ -47,14 +45,13 @@ final class CoreContext: ObservableObject { private var mCore: Core! private var mIterateSuscription: AnyCancellable? - private var mCoreSuscriptions = Set() var bearerAuthInfoPendingPasswordUpdate: AuthInfo? let monitor = NWPathMonitor() var networkStatusIsConnected: Bool = true // updated on core queue - private var mCorePushIncomingDelegate: CoreDelegate! + private var mCoreDelegate: CoreDelegate! private var actionsToPerformOnCoreQueueWhenCoreIsStarted: [((Core) -> Void)] = [] private var callStateCallBacks: [((Call.State) -> Void)] = [] private var configuringStateCallBacks: [((ConfiguringState) -> Void)] = [] @@ -137,7 +134,6 @@ final class CoreContext: ObservableObject { 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 @@ -146,21 +142,22 @@ final class CoreContext: ObservableObject { 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.mCoreSuscriptions.insert(self.mCore.publisher?.onGlobalStateChanged?.postOnCoreQueue { (cbVal: (core: Core, state: GlobalState, message: String)) in - if cbVal.state == GlobalState.On { + + self.mCoreDelegate = CoreDelegateStub(onGlobalStateChanged: { (core: Core, state: GlobalState, message: String) in + if state == GlobalState.On { #if DEBUG let pushEnvironment = ".dev" #else let pushEnvironment = "" #endif - for account in cbVal.core.accountList where account.params?.pushNotificationConfig?.provider != ("apns" + pushEnvironment) { + 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(cbVal.core) } + self.actionsToPerformOnCoreQueueWhenCoreIsStarted.forEach { $0(core) } self.actionsToPerformOnCoreQueueWhenCoreIsStarted.removeAll() let hasDefaultAccount = self.mCore.defaultAccount != nil ? true : false @@ -174,75 +171,96 @@ final class CoreContext: ObservableObject { self.accounts = accountModels } } - }) - - // Create a Core listener to listen for the callback we need - // In this case, we want to know about the account registration status - self.mCoreSuscriptions.insert(self.mCore.publisher?.onConfiguringStatus?.postOnCoreQueue { (cbVal: (core: Core, status: ConfiguringState, message: String)) in - Log.info("New configuration state is \(cbVal.status) = \(cbVal.message)\n") + }, 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: 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, corePublisher: self.mCore.publisher)) } DispatchQueue.main.async { - if cbVal.status == ConfiguringState.Successful { + if status == ConfiguringState.Successful { ToastViewModel.shared.toastMessage = "Successful" ToastViewModel.shared.displayToast = true self.accounts = accountModels } } - /* - else { - ToastViewModel.shared.toastMessage = "Failed" - ToastViewModel.shared.displayToast = true - } - */ - }) - - self.mCoreSuscriptions.insert(self.mCore.publisher?.onAccountRegistrationStateChanged?.postOnCoreQueue { (cbVal: (core: Core, account: Account, state: RegistrationState, message: String)) in - + }, 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 \(cbVal.state) for user id " + - "\( String(describing: cbVal.account.params?.identityAddress?.asString())) = \(cbVal.message)\n") + Log.info("New registration state is \(state) for user id " + + "\( String(describing: account.params?.identityAddress?.asString())) = \(message)\n") - switch cbVal.state { + 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 \(cbVal.account.displayName()) registration was cleared. Looking for auth info") - if let authInfo = cbVal.account.findAuthInfo() { + 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") - cbVal.core.removeAuthInfo(info: authInfo) + 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 = cbVal.account.params + let params = account.params let clonedParams = params?.clone() clonedParams?.registerEnabled = false - cbVal.account.params = clonedParams + account.params = clonedParams - Log.warn("Registration failed for account \(cbVal.account.displayName()), deleting it from core") - cbVal.core.removeAccount(account: cbVal.account) + Log.warn("Registration failed for account \(account.displayName()), deleting it from core") + core.removeAccount(account: account) } default: break } - TelecomManager.shared.onAccountRegistrationStateChanged(core: cbVal.core, account: cbVal.account, state: cbVal.state, message: cbVal.message) + TelecomManager.shared.onAccountRegistrationStateChanged(core: core, account: account, state: state, message: message) DispatchQueue.main.async { - if cbVal.state == .Ok { + if state == .Ok { self.loggingInProgress = false self.loggedIn = true - } else if cbVal.state == .Progress || cbVal.state == .Refreshing { + } else if state == .Progress || state == .Refreshing { self.loggingInProgress = true - } else if cbVal.state == .Cleared { + } else if state == .Cleared { self.loggingInProgress = false self.loggedIn = false self.hasDefaultAccount = false @@ -255,89 +273,24 @@ final class CoreContext: ObservableObject { ToastViewModel.shared.displayToast = true } } - }) - - self.mCoreSuscriptions.insert(self.mCore.publisher?.onCallStateChanged?.postOnCoreQueue { (cbVal: (core: Core, call: Call, state: Call.State, message: String)) in - TelecomManager.shared.onCallStateChanged(core: cbVal.core, call: cbVal.call, state: cbVal.state, message: cbVal.message) - }) - - self.mCorePushIncomingDelegate = CoreDelegateStub(onCallStateChanged: { (_, call: Call, cstate: Call.State, _) in - if cstate == .PushIncomingReceived { - let callLog = call.callLog - let callId = callLog?.callId ?? "" - Log.info("PushIncomingReceived in core delegate, display callkit call") - TelecomManager.shared.displayIncomingCall(call: call, handle: "Calling", hasVideo: false, callId: callId, displayName: "Calling") + }, onAccountAdded: { (_: Core, _: Account) in + var accountModels: [AccountModel] = [] + for account in self.mCore.accountList { + accountModels.append(AccountModel(account: account, corePublisher: self.mCore.publisher)) } - }) - self.mCore.addDelegate(delegate: self.mCorePushIncomingDelegate) - - self.mCoreSuscriptions.insert(self.mCore.publisher?.onLogCollectionUploadStateChanged?.postOnCoreQueue { (cbValue: (_: Core, _: Core.LogCollectionUploadState, info: String)) in - - if cbValue.info.starts(with: "https") { - DispatchQueue.main.async { - UIPasteboard.general.setValue( - cbValue.info, - forPasteboardType: UTType.plainText.identifier - ) - ToastViewModel.shared.toastMessage = "Success_send_logs" - ToastViewModel.shared.displayToast = true - } - } - }) - - self.mCoreSuscriptions.insert(self.mCore.publisher?.onTransferStateChanged?.postOnCoreQueue { (cbValue: (_: Core, transferred: Call, callState: Call.State)) in - Log.info( - "[CoreContext] Transferred call \(cbValue.transferred.remoteAddress!.asStringUriOnly()) state changed \(cbValue.callState)" - ) - DispatchQueue.main.async { - if cbValue.callState == Call.State.Connected { - ToastViewModel.shared.toastMessage = "Success_toast_call_transfer_successful" - ToastViewModel.shared.displayToast = true - } else if cbValue.callState == Call.State.OutgoingProgress { - ToastViewModel.shared.toastMessage = "Success_toast_call_transfer_in_progress" - ToastViewModel.shared.displayToast = true - } else if cbValue.callState == Call.State.End || cbValue.callState == Call.State.Error { - ToastViewModel.shared.toastMessage = "Failed_toast_call_transfer_failed" - ToastViewModel.shared.displayToast = true - } + self.accounts = accountModels + } + }, onAccountRemoved: { (_: Core, _: Account) in + var accountModels: [AccountModel] = [] + for account in self.mCore.accountList { + accountModels.append(AccountModel(account: account, corePublisher: self.mCore.publisher)) + } + DispatchQueue.main.async { + self.accounts = accountModels } }) - - self.mCoreSuscriptions.insert(self.mCore.publisher?.onAuthenticationRequested?.postOnCoreQueue { (cbValue: (_: Core, authInfo: AuthInfo, method: AuthMethod)) in - let authInfo = cbValue.authInfo - 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 cbValue.method == .Bearer { - Log.info("Authentication requested method is Bearer, starting Single Sign On activity with server URL \(server) and username \(username)") - self.bearerAuthInfoPendingPasswordUpdate = cbValue.authInfo - SingleSignOnManager.shared.setUp(ssoUrl: server, user: username) - } - }) - - self.mCoreSuscriptions.insert(self.mCore.publisher?.onAccountAdded? - .postOnCoreQueue { _ in - var accountModels: [AccountModel] = [] - for account in self.mCore.accountList { - accountModels.append(AccountModel(account: account, corePublisher: self.mCore.publisher)) - } - DispatchQueue.main.async { - self.accounts = accountModels - } - }) - - self.mCoreSuscriptions.insert(self.mCore.publisher?.onAccountRemoved? - .postOnCoreQueue { _ in - var accountModels: [AccountModel] = [] - for account in self.mCore.accountList { - accountModels.append(AccountModel(account: account, corePublisher: self.mCore.publisher)) - } - DispatchQueue.main.async { - self.accounts = accountModels - } - }) + self.mCore.addDelegate(delegate: self.mCoreDelegate) self.mIterateSuscription = Timer.publish(every: 0.02, on: .main, in: .common) .autoconnect() @@ -410,8 +363,6 @@ final class CoreContext: ObservableObject { } -// swiftlint:enable large_tuple // swiftlint:enable line_length // swiftlint:enable cyclomatic_complexity // swiftlint:enable identifier_name -// swiftlint:enable type_body_length diff --git a/Linphone/TelecomManager/TelecomManager.swift b/Linphone/TelecomManager/TelecomManager.swift index 752d82055..6149bb186 100644 --- a/Linphone/TelecomManager/TelecomManager.swift +++ b/Linphone/TelecomManager/TelecomManager.swift @@ -425,7 +425,8 @@ class TelecomManager: ObservableObject { let callLog = call.callLog let callId = callLog?.callId ?? "" if cstate == .PushIncomingReceived { - Log.info("PushIncomingReceived on TelecomManager -- Ignore, should be processed by a the dedicated CoreDelegate for callkit display") + 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 From b56912b72923609bc76f35865bfcda5b6bbf4af0 Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Tue, 10 Sep 2024 10:38:36 +0200 Subject: [PATCH 395/486] Fix addChatMessageDelegate crash Check if index is smaller than the size of the list --- .../ViewModel/ConversationViewModel.swift | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift b/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift index 237221cca..6d1adbedb 100644 --- a/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift +++ b/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift @@ -103,13 +103,11 @@ class ConversationViewModel: ObservableObject { statusTmp = .sending } - let indexMessage = self.conversationMessagesSection[0].rows.firstIndex(where: {$0.eventLog.chatMessage?.messageId == message.messageId}) - - if self.conversationMessagesSection[0].rows[indexMessage!].message.status != statusTmp { - DispatchQueue.main.async { - if indexMessage != nil { + if let indexMessage = self.conversationMessagesSection[0].rows.firstIndex(where: {$0.eventLog.chatMessage?.messageId == message.messageId}) { + if indexMessage < self.conversationMessagesSection[0].rows.count && self.conversationMessagesSection[0].rows[indexMessage].message.status != statusTmp { + DispatchQueue.main.async { self.objectWillChange.send() - self.conversationMessagesSection[0].rows[indexMessage!].message.status = statusTmp + self.conversationMessagesSection[0].rows[indexMessage].message.status = statusTmp } } } From 29d377028037267c051c44befedd719294f5a40d Mon Sep 17 00:00:00 2001 From: QuentinArguillere Date: Tue, 10 Sep 2024 15:12:03 +0200 Subject: [PATCH 396/486] Fix build --- Linphone/Utils/AudioRouteUtils.swift | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/Linphone/Utils/AudioRouteUtils.swift b/Linphone/Utils/AudioRouteUtils.swift index b5812a0e8..01c418383 100644 --- a/Linphone/Utils/AudioRouteUtils.swift +++ b/Linphone/Utils/AudioRouteUtils.swift @@ -126,8 +126,7 @@ class AudioRouteUtils { print("[Audio Route Helper] No call found, setting audio route on Core") } - let conference = core.conference - let audioDevice = conference != nil && conference?.isIn == true ? conference!.outputAudioDevice : currentCall != nil ? currentCall!.outputAudioDevice : core.outputAudioDevice + 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 } @@ -138,9 +137,7 @@ class AudioRouteUtils { return false } let currentCall = call != nil ? call : core.currentCall != nil ? core.currentCall : core.calls[0] - let conference = core.conference - - let audioDevice = conference != nil && conference?.isIn == true ? conference!.outputAudioDevice : currentCall?.outputAudioDevice + 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 } From d6293be80f7c2f5c0bcec4c89a21756d374c743f Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Tue, 10 Sep 2024 17:48:17 +0200 Subject: [PATCH 397/486] Message bubbles for different file types --- .../download-simple.imageset/Contents.json | 21 ++++ .../download-simple.svg | 1 + .../file-audio.imageset/Contents.json | 21 ++++ .../file-audio.imageset/file-audio.svg | 1 + .../file-pdf.imageset/Contents.json | 21 ++++ .../file-pdf.imageset/file-pdf.svg | 1 + .../file-text.imageset/file-text.svg | 2 +- .../file.imageset/Contents.json | 21 ++++ .../Assets.xcassets/file.imageset/file.svg | 1 + .../Fragments/ChatBubbleView.swift | 56 +++++++++ .../Main/Conversations/Model/Attachment.swift | 40 +++++- .../UI/Main/Conversations/Model/Message.swift | 13 +- .../ViewModel/ConversationViewModel.swift | 114 ++++++++++++++++-- 13 files changed, 300 insertions(+), 13 deletions(-) create mode 100644 Linphone/Assets.xcassets/download-simple.imageset/Contents.json create mode 100644 Linphone/Assets.xcassets/download-simple.imageset/download-simple.svg create mode 100644 Linphone/Assets.xcassets/file-audio.imageset/Contents.json create mode 100644 Linphone/Assets.xcassets/file-audio.imageset/file-audio.svg create mode 100644 Linphone/Assets.xcassets/file-pdf.imageset/Contents.json create mode 100644 Linphone/Assets.xcassets/file-pdf.imageset/file-pdf.svg create mode 100644 Linphone/Assets.xcassets/file.imageset/Contents.json create mode 100644 Linphone/Assets.xcassets/file.imageset/file.svg 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/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/file-text.svg b/Linphone/Assets.xcassets/file-text.imageset/file-text.svg index 0b256101e..05971352c 100644 --- a/Linphone/Assets.xcassets/file-text.imageset/file-text.svg +++ b/Linphone/Assets.xcassets/file-text.imageset/file-text.svg @@ -1 +1 @@ - \ No newline at end of file + \ 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/UI/Main/Conversations/Fragments/ChatBubbleView.swift b/Linphone/UI/Main/Conversations/Fragments/ChatBubbleView.swift index 2da7dab3e..3cfb8e069 100644 --- a/Linphone/UI/Main/Conversations/Fragments/ChatBubbleView.swift +++ b/Linphone/UI/Main/Conversations/Fragments/ChatBubbleView.swift @@ -33,6 +33,7 @@ struct ChatBubbleView: View { @State private var ticker = Ticker() @State private var isPressed: Bool = false @State private var timePassed: TimeInterval? + @State private var sliderValue: Double = 0.5 var body: some View { HStack { @@ -394,6 +395,47 @@ struct ChatBubbleView: View { } .clipShape(RoundedRectangle(cornerRadius: 4)) .clipped() + } else if eventLogMessage.message.attachments.first!.type == .voiceRecording { + CustomSlider( + value: $sliderValue, + range: 0...1, + thumbColor: .blue, + trackColor: .gray, + trackHeight: 8, + cornerRadius: 10 + ) + .padding() + } 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: 16) + .truncationMode(.middle) + .frame(maxWidth: .infinity, alignment: .leading) + .lineLimit(1) + + Text("2,2 Mo") + .default_text_style_300(styleSize: 16) + .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 @@ -472,6 +514,20 @@ struct ChatBubbleView: View { } 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" + } + } } enum URLType { diff --git a/Linphone/UI/Main/Conversations/Model/Attachment.swift b/Linphone/UI/Main/Conversations/Model/Attachment.swift index 0e84668f1..6b57fd927 100644 --- a/Linphone/UI/Main/Conversations/Model/Attachment.swift +++ b/Linphone/UI/Main/Conversations/Model/Attachment.swift @@ -21,15 +21,35 @@ import Foundation public enum AttachmentType: String, Codable { case image - case video 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" - default: + 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" } } @@ -37,8 +57,22 @@ public enum AttachmentType: String, Codable { switch mediaType { case .image: self = .image - default: + 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 } } } diff --git a/Linphone/UI/Main/Conversations/Model/Message.swift b/Linphone/UI/Main/Conversations/Model/Message.swift index 4e53d314b..94644eaae 100644 --- a/Linphone/UI/Main/Conversations/Model/Message.swift +++ b/Linphone/UI/Main/Conversations/Model/Message.swift @@ -125,7 +125,7 @@ public struct Message: Identifiable, Hashable { guard let thumbnailURL = await media.getThumbnailURL() else { return nil } - + switch media.type { case .image: return Attachment(id: UUID().uuidString, name: "", url: thumbnailURL, type: .image) @@ -134,6 +134,10 @@ public struct Message: Identifiable, Hashable { 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) } } @@ -272,7 +276,14 @@ public struct DraftMessage { 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 { diff --git a/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift b/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift index 6d1adbedb..8c17d611b 100644 --- a/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift +++ b/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift @@ -288,7 +288,7 @@ class ConversationViewModel: ObservableObject { id: UUID().uuidString, name: content.name!, url: path!, - type: .image + type: .fileTransfer ) attachmentNameList += ", \(content.name!)" attachmentList.append(attachment) @@ -296,6 +296,20 @@ class ConversationViewModel: ObservableObject { } 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 = @@ -303,7 +317,7 @@ class ConversationViewModel: ObservableObject { id: UUID().uuidString, name: content.name!, url: path!, - type: (content.name?.lowercased().hasSuffix("gif"))! ? .gif : .image + type: typeTmp ) attachmentNameList += ", \(content.name!)" attachmentList.append(attachment) @@ -489,7 +503,7 @@ class ConversationViewModel: ObservableObject { id: UUID().uuidString, name: content.name!, url: path!, - type: .image + type: .fileTransfer ) attachmentNameList += ", \(content.name!)" attachmentList.append(attachment) @@ -497,6 +511,20 @@ class ConversationViewModel: ObservableObject { } 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 = @@ -504,7 +532,7 @@ class ConversationViewModel: ObservableObject { id: UUID().uuidString, name: content.name!, url: path!, - type: (content.name?.lowercased().hasSuffix("gif"))! ? .gif : .image + type: typeTmp ) attachmentNameList += ", \(content.name!)" attachmentList.append(attachment) @@ -687,7 +715,7 @@ class ConversationViewModel: ObservableObject { id: UUID().uuidString, name: content.name ?? "???", url: path!, - type: .image + type: .fileTransfer ) attachmentNameList += ", \(content.name ?? "???")" attachmentList.append(attachment) @@ -695,6 +723,20 @@ class ConversationViewModel: ObservableObject { } 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 = @@ -702,7 +744,7 @@ class ConversationViewModel: ObservableObject { id: UUID().uuidString, name: content.name!, url: path!, - type: (content.name?.lowercased().hasSuffix("gif"))! ? .gif : .image + type: typeTmp ) attachmentNameList += ", \(content.name!)" attachmentList.append(attachment) @@ -958,7 +1000,7 @@ class ConversationViewModel: ObservableObject { id: UUID().uuidString, name: content.name!, url: path!, - type: .image + type: .fileTransfer ) attachmentNameList += ", \(content.name!)" attachmentList.append(attachment) @@ -966,6 +1008,20 @@ class ConversationViewModel: ObservableObject { } 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 = @@ -973,7 +1029,7 @@ class ConversationViewModel: ObservableObject { id: UUID().uuidString, name: content.name!, url: path!, - type: (content.name?.lowercased().hasSuffix("gif"))! ? .gif : .image + type: typeTmp ) attachmentNameList += ", \(content.name!)" attachmentList.append(attachment) @@ -1566,6 +1622,48 @@ class ConversationViewModel: ObservableObject { } } } + +struct CustomSlider: View { + @Binding var value: Double + var range: ClosedRange + var thumbColor: Color + var trackColor: Color + var trackHeight: CGFloat + var cornerRadius: CGFloat + + var body: some View { + VStack { + ZStack { + // Slider track with rounded corners + Rectangle() + .fill(trackColor) + .frame(height: trackHeight) + .cornerRadius(cornerRadius) + + // Progress track to show the current value + Rectangle() + .fill(thumbColor.opacity(0.5)) + .frame(width: CGFloat((value - range.lowerBound) / (range.upperBound - range.lowerBound)) * UIScreen.main.bounds.width, height: trackHeight) + .cornerRadius(cornerRadius) + + // Thumb (handle) with rounded appearance + Circle() + .fill(thumbColor) + .frame(width: 30, height: 30) + .offset(x: CGFloat((value - range.lowerBound) / (range.upperBound - range.lowerBound)) * UIScreen.main.bounds.width - 20) + .gesture(DragGesture(minimumDistance: 0) + .onChanged { gesture in + let sliderWidth = UIScreen.main.bounds.width + let dragX = gesture.location.x + let newValue = range.lowerBound + Double(dragX / sliderWidth) * (range.upperBound - range.lowerBound) + value = min(max(newValue, range.lowerBound), range.upperBound) + } + ) + } + } + .padding(.horizontal, 20) + } +} // swiftlint:enable line_length // swiftlint:enable type_body_length // swiftlint:enable cyclomatic_complexity From 68f740658b5b21b1d60d4f0d93f840b30b572472 Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Thu, 12 Sep 2024 11:27:13 +0200 Subject: [PATCH 398/486] Added back round corners to main lists in portrait mode (keep round top bar in landscape) --- .../Fragments/ContactsInnerFragment.swift | 4 + Linphone/UI/Main/ContentView.swift | 582 ++++++++++-------- .../Fragments/ConversationsListFragment.swift | 4 + .../Fragments/HistoryListFragment.swift | 4 + .../Meetings/Fragments/MeetingsFragment.swift | 4 + 5 files changed, 334 insertions(+), 264 deletions(-) diff --git a/Linphone/UI/Main/Contacts/Fragments/ContactsInnerFragment.swift b/Linphone/UI/Main/Contacts/Fragments/ContactsInnerFragment.swift index 8e59f3d47..bb71fef4c 100644 --- a/Linphone/UI/Main/Contacts/Fragments/ContactsInnerFragment.swift +++ b/Linphone/UI/Main/Contacts/Fragments/ContactsInnerFragment.swift @@ -79,6 +79,10 @@ struct ContactsInnerFragment: View { List { ContactsListFragment(contactViewModel: contactViewModel, contactsListViewModel: ContactsListViewModel(), showingSheet: $showingSheet, startCallFunc: {_ in })} + .safeAreaInset(edge: .top, content: { + Spacer() + .frame(height: 14) + }) .listStyle(.plain) .overlay( VStack { diff --git a/Linphone/UI/Main/ContentView.swift b/Linphone/UI/Main/ContentView.swift index 76921f46f..72f6cd6e9 100644 --- a/Linphone/UI/Main/ContentView.swift +++ b/Linphone/UI/Main/ContentView.swift @@ -284,293 +284,347 @@ struct ContentView: View { .edgesIgnoringSafeArea(.top) .frame(height: 1) - 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) + 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() - - 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) + } + + VStack(spacing: 0) { + if searchIsActive == false { + HStack { + Image("profile-image-example") .resizable() - .foregroundStyle(.white) - .frame(width: 25, height: 25, alignment: .leading) - .padding(.all, 10) - } - .padding(.trailing, 10) - } else if index != 2 { - Menu { - if index == 0 { + .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 { - contactViewModel.indexDisplayedFriend = nil - isMenuOpen = false - magicSearch.allContact = true - MagicSearchSingleton.shared.searchForContacts( - sourceFlags: MagicSearch.Source.Friends.rawValue | MagicSearch.Source.LdapServers.rawValue) + NotificationCenter.default.post(name: MeetingsListViewModel.ScrollToTodayNotification, object: nil) } label: { - HStack { - Text("See all") - Spacer() - if magicSearch.allContact { - Image("green-check") - .resizable() - .frame(width: 25, height: 25, alignment: .leading) - .padding(.all, 10) + 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() } - Button { - contactViewModel.indexDisplayedFriend = nil - isMenuOpen = false - magicSearch.allContact = false + text = "" + + if index == 0 { + magicSearch.currentFilter = "" 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 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 { - 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) + 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() } } } - } label: { - Image(index == 0 ? "funnel" : "dots-three-vertical") - .renderingMode(.template) - .resizable() - .foregroundStyle(.white) - .frame(width: 25, height: 25, alignment: .leading) - .padding(.all, 10) + + Button { + text = "" + } label: { + Image("x") + .renderingMode(.template) + .resizable() + .foregroundStyle(.white) + .frame(width: 25, height: 25, alignment: .leading) + .padding(.all, 10) + } + .padding(.leading) } - .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) + .frame(maxWidth: .infinity) + .frame(height: 50) + .padding(.horizontal) + .padding(.bottom, 5) + .background(Color.orangeMain500) + .roundedCorner(10, corners: [.bottomRight, .bottomLeft]) } - 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() - } - } + 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 + ) } - - 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 - ) - } else if self.index == 1 { - HistoryView( - historyListViewModel: historyListViewModel, - historyViewModel: historyViewModel, - contactViewModel: contactViewModel, - editContactViewModel: editContactViewModel, - index: $index, - isShowStartCallFragment: $isShowStartCallFragment, - isShowEditContactFragment: $isShowEditContactFragment, - text: $text - ) - } else if self.index == 2 { - ConversationsView( - conversationViewModel: conversationViewModel, - conversationsListViewModel: conversationsListViewModel, - text: $text, - isShowStartConversationFragment: $isShowStartConversationFragment - ) - } else if self.index == 3 { - MeetingsView( - meetingsListViewModel: meetingsListViewModel, - meetingViewModel: meetingViewModel, - isShowScheduleMeetingFragment: $isShowScheduleMeetingFragment, - isShowSendCancelMeetingNotificationPopup: $isShowSendCancelMeetingNotificationPopup, - text: $text - ) } } .frame(maxWidth: diff --git a/Linphone/UI/Main/Conversations/Fragments/ConversationsListFragment.swift b/Linphone/UI/Main/Conversations/Fragments/ConversationsListFragment.swift index ef7ff73dd..acf2aa2c4 100644 --- a/Linphone/UI/Main/Conversations/Fragments/ConversationsListFragment.swift +++ b/Linphone/UI/Main/Conversations/Fragments/ConversationsListFragment.swift @@ -164,6 +164,10 @@ struct ConversationsListFragment: View { } } } + .safeAreaInset(edge: .top, content: { + Spacer() + .frame(height: 14) + }) .listStyle(.plain) .overlay( VStack { diff --git a/Linphone/UI/Main/History/Fragments/HistoryListFragment.swift b/Linphone/UI/Main/History/Fragments/HistoryListFragment.swift index 620d7211e..1b1d87e5e 100644 --- a/Linphone/UI/Main/History/Fragments/HistoryListFragment.swift +++ b/Linphone/UI/Main/History/Fragments/HistoryListFragment.swift @@ -142,6 +142,10 @@ struct HistoryListFragment: View { } } } + .safeAreaInset(edge: .top, content: { + Spacer() + .frame(height: 14) + }) .listStyle(.plain) .overlay( VStack { diff --git a/Linphone/UI/Main/Meetings/Fragments/MeetingsFragment.swift b/Linphone/UI/Main/Meetings/Fragments/MeetingsFragment.swift index 0619e54cb..55a769af4 100644 --- a/Linphone/UI/Main/Meetings/Fragments/MeetingsFragment.swift +++ b/Linphone/UI/Main/Meetings/Fragments/MeetingsFragment.swift @@ -160,6 +160,10 @@ struct MeetingsFragment: View { proxyReader.scrollTo(meetingsListViewModel.todayIdx) } } + .safeAreaInset(edge: .top, content: { + Spacer() + .frame(height: 14) + }) .listStyle(.plain) .overlay( VStack { From 1693c21e2e2433864bda5c174499d917be8be83a Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Thu, 12 Sep 2024 17:56:45 +0200 Subject: [PATCH 399/486] Add voice recording feature --- .../pause-fill.imageset/Contents.json | 21 + .../pause-fill.imageset/pause-fill.svg | 1 + .../stop-fill.imageset/Contents.json | 21 + .../stop-fill.imageset/stop-fill.svg | 1 + .../Fragments/ContactsInnerFragment.swift | 2 +- .../Fragments/ChatBubbleView.swift | 131 ++++- .../Fragments/ConversationFragment.swift | 394 +++++++++---- .../Fragments/ConversationsListFragment.swift | 2 +- .../Main/Conversations/Fragments/UIList.swift | 1 + .../Main/Conversations/Model/Attachment.swift | 8 +- .../ViewModel/ConversationViewModel.swift | 523 ++++++++++++++---- .../Fragments/HistoryListFragment.swift | 2 +- .../Meetings/Fragments/MeetingsFragment.swift | 2 +- .../Utils/Extensions/ConfigExtension.swift | 2 + .../Utils/Extensions/StringExtension.swift | 14 + 15 files changed, 892 insertions(+), 233 deletions(-) create mode 100644 Linphone/Assets.xcassets/pause-fill.imageset/Contents.json create mode 100644 Linphone/Assets.xcassets/pause-fill.imageset/pause-fill.svg create mode 100644 Linphone/Assets.xcassets/stop-fill.imageset/Contents.json create mode 100644 Linphone/Assets.xcassets/stop-fill.imageset/stop-fill.svg 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/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/UI/Main/Contacts/Fragments/ContactsInnerFragment.swift b/Linphone/UI/Main/Contacts/Fragments/ContactsInnerFragment.swift index bb71fef4c..580b37bbb 100644 --- a/Linphone/UI/Main/Contacts/Fragments/ContactsInnerFragment.swift +++ b/Linphone/UI/Main/Contacts/Fragments/ContactsInnerFragment.swift @@ -81,7 +81,7 @@ struct ContactsInnerFragment: View { showingSheet: $showingSheet, startCallFunc: {_ in })} .safeAreaInset(edge: .top, content: { Spacer() - .frame(height: 14) + .frame(height: 12) }) .listStyle(.plain) .overlay( diff --git a/Linphone/UI/Main/Conversations/Fragments/ChatBubbleView.swift b/Linphone/UI/Main/Conversations/Fragments/ChatBubbleView.swift index 3cfb8e069..138615747 100644 --- a/Linphone/UI/Main/Conversations/Fragments/ChatBubbleView.swift +++ b/Linphone/UI/Main/Conversations/Fragments/ChatBubbleView.swift @@ -33,7 +33,6 @@ struct ChatBubbleView: View { @State private var ticker = Ticker() @State private var isPressed: Bool = false @State private var timePassed: TimeInterval? - @State private var sliderValue: Double = 0.5 var body: some View { HStack { @@ -160,8 +159,8 @@ struct ChatBubbleView: View { if !eventLogMessage.message.text.isEmpty { Text(eventLogMessage.message.text) - .foregroundStyle(Color.grayMain2c700) - .default_text_style(styleSize: 16) + .foregroundStyle(Color.grayMain2c700) + .default_text_style(styleSize: 16) } HStack(alignment: .center) { @@ -397,14 +396,10 @@ struct ChatBubbleView: View { .clipped() } else if eventLogMessage.message.attachments.first!.type == .voiceRecording { CustomSlider( - value: $sliderValue, - range: 0...1, - thumbColor: .blue, - trackColor: .gray, - trackHeight: 8, - cornerRadius: 10 + conversationViewModel: conversationViewModel, + eventLogMessage: eventLogMessage ) - .padding() + .frame(width: geometryProxy.size.width - 160, height: 50) } else { HStack { VStack { @@ -609,6 +604,122 @@ extension View { } } +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) diff --git a/Linphone/UI/Main/Conversations/Fragments/ConversationFragment.swift b/Linphone/UI/Main/Conversations/Fragments/ConversationFragment.swift index 5a79c70d3..e8a3e663e 100644 --- a/Linphone/UI/Main/Conversations/Fragments/ConversationFragment.swift +++ b/Linphone/UI/Main/Conversations/Fragments/ConversationFragment.swift @@ -50,6 +50,7 @@ struct ConversationFragment: View { @State private var isShowCamera = false @State private var mediasIsLoading = false + @State private var voiceRecordingInProgress = false @State private var isShowConversationForwardMessageFragment = false @@ -102,6 +103,7 @@ struct ConversationFragment: View { ImagePicker(conversationViewModel: conversationViewModel, selectedMedia: self.$conversationViewModel.mediasToSend) .edgesIgnoringSafeArea(.all) } + .background(Color.gray100.ignoresSafeArea(.keyboard)) } else { innerView(geometry: geometry) .background(.white) @@ -141,6 +143,7 @@ struct ConversationFragment: View { .fullScreenCover(isPresented: $isShowCamera) { ImagePicker(conversationViewModel: conversationViewModel, selectedMedia: self.$conversationViewModel.mediasToSend) } + .background(Color.gray100.ignoresSafeArea(.keyboard)) } } } @@ -513,117 +516,123 @@ struct ConversationFragment: View { } HStack(spacing: 0) { - 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) - } else { - ZStack(alignment: .leading) { - TextEditor(text: $conversationViewModel.messageText) - .multilineTextAlignment(.leading) - .frame(maxHeight: 160) - .fixedSize(horizontal: false, vertical: true) + 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) - - if conversationViewModel.messageText.isEmpty { - Text("Say something...") - .padding(.leading, 4) - .opacity(conversationViewModel.messageText.isEmpty ? 1 : 0) - .foregroundStyle(Color.gray300) + .padding(.vertical, 5) + } 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) + + if conversationViewModel.messageText.isEmpty { + Text("Say something...") + .padding(.leading, 4) + .opacity(conversationViewModel.messageText.isEmpty ? 1 : 0) + .foregroundStyle(Color.gray300) + .default_text_style(styleSize: 15) + } + } + .onTapGesture { + isMessageTextFocused = true } } - .onTapGesture { - isMessageTextFocused = true - } - } - - if conversationViewModel.messageText.isEmpty && conversationViewModel.mediasToSend.isEmpty { - Button { - } 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) + + 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) } - 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)) + } 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(.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) } - .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) } .frame(maxWidth: .infinity, minHeight: 60) .padding(.top, 12) @@ -1010,6 +1019,187 @@ struct ImagePicker: UIViewControllerRepresentable { } } +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: [""]) diff --git a/Linphone/UI/Main/Conversations/Fragments/ConversationsListFragment.swift b/Linphone/UI/Main/Conversations/Fragments/ConversationsListFragment.swift index acf2aa2c4..bbc9dc750 100644 --- a/Linphone/UI/Main/Conversations/Fragments/ConversationsListFragment.swift +++ b/Linphone/UI/Main/Conversations/Fragments/ConversationsListFragment.swift @@ -166,7 +166,7 @@ struct ConversationsListFragment: View { } .safeAreaInset(edge: .top, content: { Spacer() - .frame(height: 14) + .frame(height: 12) }) .listStyle(.plain) .overlay( diff --git a/Linphone/UI/Main/Conversations/Fragments/UIList.swift b/Linphone/UI/Main/Conversations/Fragments/UIList.swift index 05dfa1be4..a155afe45 100644 --- a/Linphone/UI/Main/Conversations/Fragments/UIList.swift +++ b/Linphone/UI/Main/Conversations/Fragments/UIList.swift @@ -119,6 +119,7 @@ struct UIList: UIViewRepresentable { tableView.showsVerticalScrollIndicator = true tableView.estimatedSectionHeaderHeight = 1 tableView.estimatedSectionFooterHeight = UITableView.automaticDimension + tableView.keyboardDismissMode = .interactive tableView.backgroundColor = UIColor(.white) tableView.scrollsToTop = true diff --git a/Linphone/UI/Main/Conversations/Model/Attachment.swift b/Linphone/UI/Main/Conversations/Model/Attachment.swift index 6b57fd927..8d2d83c5a 100644 --- a/Linphone/UI/Main/Conversations/Model/Attachment.swift +++ b/Linphone/UI/Main/Conversations/Model/Attachment.swift @@ -83,16 +83,18 @@ public struct Attachment: Codable, Identifiable, Hashable { public let thumbnail: URL public let full: URL public let type: AttachmentType + public let duration: Int - public init(id: String, name: String, thumbnail: URL, full: URL, type: AttachmentType) { + public init(id: String, name: String, thumbnail: URL, full: URL, type: AttachmentType, duration: Int = 0) { self.id = id self.name = name self.thumbnail = thumbnail self.full = full self.type = type + self.duration = duration } - public init(id: String, name: String, url: URL, type: AttachmentType) { - self.init(id: id, name: name, thumbnail: url, full: url, type: type) + public init(id: String, name: String, url: URL, type: AttachmentType, duration: Int = 0) { + self.init(id: id, name: name, thumbnail: url, full: url, type: type, duration: duration) } } diff --git a/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift b/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift index 8c17d611b..34309dc91 100644 --- a/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift +++ b/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift @@ -26,7 +26,6 @@ import AVFoundation // swiftlint:disable line_length // swiftlint:disable type_body_length // swiftlint:disable cyclomatic_complexity - class ConversationViewModel: ObservableObject { private var coreContext = CoreContext.shared @@ -50,9 +49,16 @@ class ConversationViewModel: ObservableObject { @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 @@ -66,8 +72,6 @@ class ConversationViewModel: ObservableObject { var isMe: Bool = false } - @Published var sheetCategories: [SheetCategory] = [] - init() {} func addConversationDelegate() { @@ -103,11 +107,13 @@ class ConversationViewModel: ObservableObject { statusTmp = .sending } - if let indexMessage = self.conversationMessagesSection[0].rows.firstIndex(where: {$0.eventLog.chatMessage?.messageId == message.messageId}) { - if indexMessage < self.conversationMessagesSection[0].rows.count && self.conversationMessagesSection[0].rows[indexMessage].message.status != statusTmp { - DispatchQueue.main.async { - self.objectWillChange.send() - self.conversationMessagesSection[0].rows[indexMessage].message.status = statusTmp + if !self.conversationMessagesSection.isEmpty && !self.conversationMessagesSection[0].rows.isEmpty { + if let indexMessage = self.conversationMessagesSection[0].rows.firstIndex(where: {$0.eventLog.chatMessage?.messageId == message.messageId}) { + if indexMessage < self.conversationMessagesSection[0].rows.count && self.conversationMessagesSection[0].rows[indexMessage].message.status != statusTmp { + DispatchQueue.main.async { + self.objectWillChange.send() + self.conversationMessagesSection[0].rows[indexMessage].message.status = statusTmp + } } } } @@ -317,7 +323,8 @@ class ConversationViewModel: ObservableObject { id: UUID().uuidString, name: content.name!, url: path!, - type: typeTmp + type: typeTmp, + duration: typeTmp == . voiceRecording ? content.fileDuration : 0 ) attachmentNameList += ", \(content.name!)" attachmentList.append(attachment) @@ -532,7 +539,8 @@ class ConversationViewModel: ObservableObject { id: UUID().uuidString, name: content.name!, url: path!, - type: typeTmp + type: typeTmp, + duration: typeTmp == . voiceRecording ? content.fileDuration : 0 ) attachmentNameList += ", \(content.name!)" attachmentList.append(attachment) @@ -744,7 +752,8 @@ class ConversationViewModel: ObservableObject { id: UUID().uuidString, name: content.name!, url: path!, - type: typeTmp + type: typeTmp, + duration: typeTmp == . voiceRecording ? content.fileDuration : 0 ) attachmentNameList += ", \(content.name!)" attachmentList.append(attachment) @@ -1029,7 +1038,8 @@ class ConversationViewModel: ObservableObject { id: UUID().uuidString, name: content.name!, url: path!, - type: typeTmp + type: typeTmp, + duration: typeTmp == . voiceRecording ? content.fileDuration : 0 ) attachmentNameList += ", \(content.name!)" attachmentList.append(attachment) @@ -1198,7 +1208,7 @@ class ConversationViewModel: ObservableObject { } } - func sendMessage() { + func sendMessage(audioRecorder: AudioRecorder? = nil) { coreContext.doOnCoreQueue { _ in do { var message: ChatMessage? @@ -1219,75 +1229,74 @@ class ConversationViewModel: ObservableObject { } } - /* - if (isVoiceRecording.value == true && voiceMessageRecorder.file != null) { - stopVoiceRecorder() - val content = voiceMessageRecorder.createContent() - if (content != null) { - Log.i( - "$TAG Voice recording content created, file name is ${content.name} and duration is ${content.fileDuration}" - ) - message.addContent(content) - } else { - Log.e("$TAG Voice recording content couldn't be created!") - } - } else { - */ - self.mediasToSend.forEach { attachment in + if audioRecorder != nil { 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 + 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 { - - 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) - } + 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 { } - } catch { } } - // } if message != nil && !message!.contents.isEmpty { Log.info("[ConversationViewModel] Sending message") @@ -1621,49 +1630,335 @@ class ConversationViewModel: ObservableObject { } } } -} - -struct CustomSlider: View { - @Binding var value: Double - var range: ClosedRange - var thumbColor: Color - var trackColor: Color - var trackHeight: CGFloat - var cornerRadius: CGFloat - var body: some View { - VStack { - ZStack { - // Slider track with rounded corners - Rectangle() - .fill(trackColor) - .frame(height: trackHeight) - .cornerRadius(cornerRadius) - - // Progress track to show the current value - Rectangle() - .fill(thumbColor.opacity(0.5)) - .frame(width: CGFloat((value - range.lowerBound) / (range.upperBound - range.lowerBound)) * UIScreen.main.bounds.width, height: trackHeight) - .cornerRadius(cornerRadius) - - // Thumb (handle) with rounded appearance - Circle() - .fill(thumbColor) - .frame(width: 30, height: 30) - .offset(x: CGFloat((value - range.lowerBound) / (range.upperBound - range.lowerBound)) * UIScreen.main.bounds.width - 20) - .gesture(DragGesture(minimumDistance: 0) - .onChanged { gesture in - let sliderWidth = UIScreen.main.bounds.width - let dragX = gesture.location.x - let newValue = range.lowerBound + Double(dragX / sliderWidth) * (range.upperBound - range.lowerBound) - value = min(max(newValue, range.lowerBound), range.upperBound) - } - ) + 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() } } - .padding(.horizontal, 20) } } // 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/History/Fragments/HistoryListFragment.swift b/Linphone/UI/Main/History/Fragments/HistoryListFragment.swift index 1b1d87e5e..753489b06 100644 --- a/Linphone/UI/Main/History/Fragments/HistoryListFragment.swift +++ b/Linphone/UI/Main/History/Fragments/HistoryListFragment.swift @@ -144,7 +144,7 @@ struct HistoryListFragment: View { } .safeAreaInset(edge: .top, content: { Spacer() - .frame(height: 14) + .frame(height: 12) }) .listStyle(.plain) .overlay( diff --git a/Linphone/UI/Main/Meetings/Fragments/MeetingsFragment.swift b/Linphone/UI/Main/Meetings/Fragments/MeetingsFragment.swift index 55a769af4..80319a5d7 100644 --- a/Linphone/UI/Main/Meetings/Fragments/MeetingsFragment.swift +++ b/Linphone/UI/Main/Meetings/Fragments/MeetingsFragment.swift @@ -162,7 +162,7 @@ struct MeetingsFragment: View { } .safeAreaInset(edge: .top, content: { Spacer() - .frame(height: 14) + .frame(height: 12) }) .listStyle(.plain) .overlay( diff --git a/Linphone/Utils/Extensions/ConfigExtension.swift b/Linphone/Utils/Extensions/ConfigExtension.swift index 51bd40ed7..697b25221 100644 --- a/Linphone/Utils/Extensions/ConfigExtension.swift +++ b/Linphone/Utils/Extensions/ConfigExtension.swift @@ -57,5 +57,7 @@ extension Config { 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/StringExtension.swift b/Linphone/Utils/Extensions/StringExtension.swift index 47c681f12..c3db23298 100644 --- a/Linphone/Utils/Extensions/StringExtension.swift +++ b/Linphone/Utils/Extensions/StringExtension.swift @@ -24,3 +24,17 @@ extension 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) + } +} From 4fa2d923821779aa6b548d236f1ad052b7c99ec2 Mon Sep 17 00:00:00 2001 From: QuentinArguillere Date: Thu, 19 Sep 2024 14:29:50 +0200 Subject: [PATCH 400/486] Remove "all day meeting" option in meeting scheduling --- .../Fragments/ScheduleMeetingFragment.swift | 78 ++++++------------- .../Meetings/ViewModel/MeetingViewModel.swift | 4 +- 2 files changed, 24 insertions(+), 58 deletions(-) diff --git a/Linphone/UI/Main/Meetings/Fragments/ScheduleMeetingFragment.swift b/Linphone/UI/Main/Meetings/Fragments/ScheduleMeetingFragment.swift index 4b9bf8e31..59e18b074 100644 --- a/Linphone/UI/Main/Meetings/Fragments/ScheduleMeetingFragment.swift +++ b/Linphone/UI/Main/Meetings/Fragments/ScheduleMeetingFragment.swift @@ -132,63 +132,31 @@ struct ScheduleMeetingFragment: View { Spacer() }.padding(.bottom, -5) - if !meetingViewModel.allDayMeeting { - HStack(spacing: 10) { - Text(meetingViewModel.fromTime) - .fontWeight(.bold) - .padding(.leading, 50) - .frame(height: 29, alignment: .leading) - .default_text_style_500(styleSize: 16) - .opacity(meetingViewModel.allDayMeeting ? 0 : 1) - .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) - .opacity(meetingViewModel.allDayMeeting ? 0 : 1) - .onTapGesture { - setFromDate = false - selectedDate = meetingViewModel.toDate - showTimePicker.toggle() - } - Spacer() - Toggle("", isOn: $meetingViewModel.allDayMeeting) - .labelsHidden() - .tint(Color.orangeMain300) - Text("All day") - .default_text_style_500(styleSize: 16) - .padding(.trailing, 15) - } - } else { - HStack(alignment: .center, spacing: 10) { - Image("clock") - .renderingMode(.template) - .resizable() - .foregroundStyle(Color.grayMain2c800) - .frame(width: 24, height: 24) - .padding(.leading, 15) - Text(meetingViewModel.toDateStr) - .fontWeight(.bold) - .default_text_style_500(styleSize: 16) - .onTapGesture { - setFromDate = false - selectedDate = meetingViewModel.toDate - showDatePicker.toggle() - } - Spacer() - Toggle("", isOn: $meetingViewModel.allDayMeeting) - .labelsHidden() - .tint(Color.orangeMain300) - Text("All day") - .default_text_style_500(styleSize: 16) - .padding(.trailing, 15) } + 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) diff --git a/Linphone/UI/Main/Meetings/ViewModel/MeetingViewModel.swift b/Linphone/UI/Main/Meetings/ViewModel/MeetingViewModel.swift index 7a012a95b..a41279957 100644 --- a/Linphone/UI/Main/Meetings/ViewModel/MeetingViewModel.swift +++ b/Linphone/UI/Main/Meetings/ViewModel/MeetingViewModel.swift @@ -31,7 +31,6 @@ class MeetingViewModel: ObservableObject { @Published var showBroadcastHelp: Bool = false @Published var subject: String = "" @Published var description: String = "" - @Published var allDayMeeting: Bool = false @Published var fromDateStr: String = "" @Published var fromTime: String = "" @Published var toDateStr: String = "" @@ -80,7 +79,6 @@ class MeetingViewModel: ObservableObject { showBroadcastHelp = false subject = "" description = "" - allDayMeeting = false sendInvitations = true participants = [] operationInProgress = false @@ -122,7 +120,7 @@ class MeetingViewModel: ObservableObject { func getFullDateString() -> String { let formatter = DateFormatter() formatter.dateFormat = "EEE d MMM yyyy" - return "\(formatter.string(from: fromDate)) | \(allDayMeeting ? "All day" : "\(fromTime) - \(toTime)")" + return "\(formatter.string(from: fromDate)) | \(fromTime) - \(toTime)" } func addParticipants(participantsToAdd: [SelectedAddressModel]) { From 8af6977085f443c2348c4880c9cedf13f93f35f1 Mon Sep 17 00:00:00 2001 From: QuentinArguillere Date: Tue, 24 Sep 2024 17:39:10 +0200 Subject: [PATCH 401/486] Display toast when network change state. Also, remove "hasDefaultAccount" key from coreContext, instead check if accounts is empty. --- Linphone/Core/CoreContext.swift | 25 +++++++++++-------- Linphone/LinphoneApp.swift | 5 ++-- Linphone/Localizable.xcstrings | 6 +++++ .../Viewmodel/AccountLoginViewModel.swift | 6 ----- Linphone/UI/Main/Fragments/ToastView.swift | 9 ++++++- 5 files changed, 31 insertions(+), 20 deletions(-) diff --git a/Linphone/Core/CoreContext.swift b/Linphone/Core/CoreContext.swift index 54863a9d3..1191cc080 100644 --- a/Linphone/Core/CoreContext.swift +++ b/Linphone/Core/CoreContext.swift @@ -39,7 +39,6 @@ final class CoreContext: ObservableObject { var coreVersion: String = Core.getVersion @Published var loggedIn: Bool = false @Published var loggingInProgress: Bool = false - @Published var hasDefaultAccount: Bool = false @Published var coreIsStarted: Bool = false @Published var accounts: [AccountModel] = [] @@ -87,10 +86,15 @@ final class CoreContext: ObservableObject { monitor.pathUpdateHandler = { path in let isConnected = path.status == .satisfied if self.networkStatusIsConnected != isConnected { - if isConnected { - Log.info("Network is now satisfied") - } else { - Log.error("Network is now \(path.status)") + 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 } @@ -160,13 +164,11 @@ final class CoreContext: ObservableObject { self.actionsToPerformOnCoreQueueWhenCoreIsStarted.forEach { $0(core) } self.actionsToPerformOnCoreQueueWhenCoreIsStarted.removeAll() - let hasDefaultAccount = self.mCore.defaultAccount != nil ? true : false var accountModels: [AccountModel] = [] for account in self.mCore.accountList { accountModels.append(AccountModel(account: account, corePublisher: self.mCore.publisher)) } DispatchQueue.main.async { - self.hasDefaultAccount = hasDefaultAccount self.coreIsStarted = true self.accounts = accountModels } @@ -263,14 +265,17 @@ final class CoreContext: ObservableObject { } else if state == .Cleared { self.loggingInProgress = false self.loggedIn = false - self.hasDefaultAccount = false ToastViewModel.shared.toastMessage = "Success_account_logged_out" ToastViewModel.shared.displayToast = true } else { self.loggingInProgress = false self.loggedIn = false - ToastViewModel.shared.toastMessage = "Registration_failed" - ToastViewModel.shared.displayToast = true + 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 diff --git a/Linphone/LinphoneApp.swift b/Linphone/LinphoneApp.swift index dce056b89..636ce5427 100644 --- a/Linphone/LinphoneApp.swift +++ b/Linphone/LinphoneApp.swift @@ -94,15 +94,14 @@ struct LinphoneApp: App { ToastView() .zIndex(3) } - } else if !coreContext.hasDefaultAccount || sharedMainViewModel.displayProfileMode { + } else if coreContext.accounts.isEmpty || sharedMainViewModel.displayProfileMode { ZStack { AssistantView() ToastView() .zIndex(3) } - } else if coreContext.hasDefaultAccount - && coreContext.loggedIn + } else if !coreContext.accounts.isEmpty && contactViewModel != nil && editContactViewModel != nil && historyViewModel != nil diff --git a/Linphone/Localizable.xcstrings b/Linphone/Localizable.xcstrings index 05408cf40..6fdd2ee8b 100644 --- a/Linphone/Localizable.xcstrings +++ b/Linphone/Localizable.xcstrings @@ -1787,6 +1787,12 @@ }, "Mosaïque" : { + }, + "Network is not reachable" : { + + }, + "Network is now reachable again" : { + }, "New call" : { diff --git a/Linphone/UI/Assistant/Viewmodel/AccountLoginViewModel.swift b/Linphone/UI/Assistant/Viewmodel/AccountLoginViewModel.swift index 822236ef1..ebebd6c32 100644 --- a/Linphone/UI/Assistant/Viewmodel/AccountLoginViewModel.swift +++ b/Linphone/UI/Assistant/Viewmodel/AccountLoginViewModel.swift @@ -123,9 +123,6 @@ class AccountLoginViewModel: ObservableObject { // Also set the newly added account as default core.defaultAccount = account - DispatchQueue.main.async { - self.coreContext.hasDefaultAccount = true - } self.domain = "sip.linphone.org" self.transportType = "TLS" @@ -157,9 +154,6 @@ class AccountLoginViewModel: ObservableObject { // To completely remove an Account if let account = core.defaultAccount { core.removeAccount(account: account) - DispatchQueue.main.async { - self.coreContext.hasDefaultAccount = false - } // To remove all accounts use core.clearAccounts() diff --git a/Linphone/UI/Main/Fragments/ToastView.swift b/Linphone/UI/Main/Fragments/ToastView.swift index de4886ef6..279be4ce1 100644 --- a/Linphone/UI/Main/Fragments/ToastView.swift +++ b/Linphone/UI/Main/Fragments/ToastView.swift @@ -130,12 +130,19 @@ struct ToastView: View { .padding(8) case "Unavailable_network": - Text("Could not reach 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) From 17e3633cb6bfee61e681e7cf2941abfc6cf50dfd Mon Sep 17 00:00:00 2001 From: QuentinArguillere Date: Tue, 10 Sep 2024 16:49:00 +0200 Subject: [PATCH 402/486] Replace publisher with delegate in RegisterViewModel --- .../UI/Assistant/Viewmodel/RegisterViewModel.swift | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/Linphone/UI/Assistant/Viewmodel/RegisterViewModel.swift b/Linphone/UI/Assistant/Viewmodel/RegisterViewModel.swift index 5e6eca950..186a980d7 100644 --- a/Linphone/UI/Assistant/Viewmodel/RegisterViewModel.swift +++ b/Linphone/UI/Assistant/Viewmodel/RegisterViewModel.swift @@ -54,8 +54,7 @@ class RegisterViewModel: ObservableObject { private var accountCreated: Account? private var normalizedPhoneNumber: String? - private var accountManagerServicesSuscriptions = Set() - private var mCoreSuscriptions = Set() + private var requestDelegate: AccountManagerServicesRequestDelegate? @Published var isLinkActive: Bool = false @Published var createInProgress: Bool = false @@ -121,7 +120,7 @@ class RegisterViewModel: ObservableObject { } func addDelegate(request: AccountManagerServicesRequest) { coreContext.doOnCoreQueue { core in - self.accountManagerServicesSuscriptions.insert(request.publisher?.onRequestSuccessful?.postOnCoreQueue { (request: AccountManagerServicesRequest, data: String) 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: @@ -156,16 +155,15 @@ class RegisterViewModel: ObservableObject { do { try core.addAccount(account: account!) core.defaultAccount = account - self.accountManagerServicesSuscriptions.removeAll() + request.removeDelegate(delegate: self.requestDelegate!) + self.requestDelegate = nil } catch { } } default: break } - }) - - self.accountManagerServicesSuscriptions.insert(request.publisher?.onRequestError?.postOnCoreQueue { (request: AccountManagerServicesRequest, statusCode: Int, errorMessage: String, parameterErrors: Dictionary?) in + }, 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)" ) @@ -202,6 +200,7 @@ class RegisterViewModel: ObservableObject { self.createInProgress = false } }) + request.addDelegate(delegate: self.requestDelegate!) } } From a25441a4676a0d663b25b7f6614b95c9394f899b Mon Sep 17 00:00:00 2001 From: QuentinArguillere Date: Tue, 10 Sep 2024 16:49:59 +0200 Subject: [PATCH 403/486] Replace publisher with delegate in ConversationListViewModel --- .../ConversationsListViewModel.swift | 30 ++++++++----------- 1 file changed, 12 insertions(+), 18 deletions(-) diff --git a/Linphone/UI/Main/Conversations/ViewModel/ConversationsListViewModel.swift b/Linphone/UI/Main/Conversations/ViewModel/ConversationsListViewModel.swift index 6ae4f155c..16a5ba0a6 100644 --- a/Linphone/UI/Main/Conversations/ViewModel/ConversationsListViewModel.swift +++ b/Linphone/UI/Main/Conversations/ViewModel/ConversationsListViewModel.swift @@ -22,13 +22,12 @@ import linphonesw import Combine // swiftlint:disable line_length -// swiftlint:disable large_tuple class ConversationsListViewModel: ObservableObject { private var coreContext = CoreContext.shared private var contactsManager = ContactsManager.shared - private var mCoreSuscriptions = Set() + private var coreConversationDelegate: CoreDelegate? @Published var conversationsList: [ConversationModel] = [] var conversationsListTmp: [ConversationModel] = [] @@ -69,11 +68,18 @@ class ConversationsListViewModel: ObservableObject { let account = core.defaultAccount let chatRoomsCounter = account?.chatRooms != nil ? account!.chatRooms.count : core.chatRooms.count var counter = 0 - self.mCoreSuscriptions.insert(core.publisher?.onChatRoomStateChanged?.postOnCoreQueue { (cbValue: (core: Core, chatRoom: ChatRoom, state: ChatRoom.State)) in + + 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 cbValue.state { + switch state { case ChatRoom.State.Created: - if !(cbValue.chatRoom.isEmpty && cbValue.chatRoom.hasCapability(mask: ChatRoom.Capabilities.OneToOne.rawValue)) { + if !(chatRoom.isEmpty && chatRoom.hasCapability(mask: ChatRoom.Capabilities.OneToOne.rawValue)) { counter += 1 } @@ -89,18 +95,7 @@ class ConversationsListViewModel: ObservableObject { break } }) - - self.mCoreSuscriptions.insert(core.publisher?.onChatRoomRead?.postOnCoreQueue { _ in - self.computeChatRoomsList(filter: "") - }) - - self.mCoreSuscriptions.insert(core.publisher?.onMessageSent?.postOnCoreQueue { _ in - self.computeChatRoomsList(filter: "") - }) - - self.mCoreSuscriptions.insert(core.publisher?.onMessagesReceived?.postOnCoreQueue { _ in - self.computeChatRoomsList(filter: "") - }) + core.addDelegate(delegate: self.coreConversationDelegate!) } } @@ -221,4 +216,3 @@ class ConversationsListViewModel: ObservableObject { } } // swiftlint:enable line_length -// swiftlint:enable large_tuple From 8d8966407a9abfd02e5dcd79f568d5f2bc0b91c1 Mon Sep 17 00:00:00 2001 From: QuentinArguillere Date: Tue, 10 Sep 2024 17:14:02 +0200 Subject: [PATCH 404/486] Replace publisher with delegate in ContactAvatarModel --- .../Contacts/Model/ContactAvatarModel.swift | 35 ++++++++++--------- Linphone/Utils/MagicSearchSingleton.swift | 2 +- 2 files changed, 19 insertions(+), 18 deletions(-) diff --git a/Linphone/UI/Main/Contacts/Model/ContactAvatarModel.swift b/Linphone/UI/Main/Contacts/Model/ContactAvatarModel.swift index 46b2f0381..4938ae238 100644 --- a/Linphone/UI/Main/Contacts/Model/ContactAvatarModel.swift +++ b/Linphone/UI/Main/Contacts/Model/ContactAvatarModel.swift @@ -39,7 +39,7 @@ class ContactAvatarModel: ObservableObject { @Published var presenceStatus: ConsolidatedPresence - private var friendSuscription: AnyCancellable? + private var friendDelegate: FriendDelegate? init(friend: Friend?, name: String, address: String, withPresence: Bool?) { self.friend = friend @@ -71,27 +71,26 @@ class ContactAvatarModel: ObservableObject { self.lastPresenceInfo = "" } - if self.friendSuscription != nil { - self.friendSuscription = nil + if let delegate = friendDelegate { + self.friend?.removeDelegate(delegate: delegate) + self.friendDelegate = nil } - addSubscription() + addFriendDelegate() } else { self.lastPresenceInfo = "" self.presenceStatus = .Offline } } - func addSubscription() { - friendSuscription = self.friend?.publisher?.onPresenceReceived?.postOnCoreQueue { (cbValue: (Friend)) in - - let latestActivityTimestamp = cbValue.presenceModel?.latestActivityTimestamp ?? -1 - + func addFriendDelegate() { + friendDelegate = FriendDelegateStub(onPresenceReceived: { (friend: Friend) in + let latestActivityTimestamp = friend.presenceModel?.latestActivityTimestamp ?? -1 DispatchQueue.main.async { - self.presenceStatus = cbValue.consolidatedPresence - if cbValue.consolidatedPresence == .Online || cbValue.consolidatedPresence == .Busy { - if cbValue.consolidatedPresence == .Online || latestActivityTimestamp != -1 { - self.lastPresenceInfo = cbValue.consolidatedPresence == .Online ? + 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" @@ -100,13 +99,15 @@ class ContactAvatarModel: ObservableObject { self.lastPresenceInfo = "" } } - } + }) + friend?.addDelegate(delegate: friendDelegate!) } - func removeAllSuscription() { - if friendSuscription != nil { + func removeFriendDelegate() { + if let delegate = friendDelegate { presenceStatus = .Offline - friendSuscription = nil + friend?.removeDelegate(delegate: delegate) + friendDelegate = nil } } diff --git a/Linphone/Utils/MagicSearchSingleton.swift b/Linphone/Utils/MagicSearchSingleton.swift index 223a9378f..2ad37e30e 100644 --- a/Linphone/Utils/MagicSearchSingleton.swift +++ b/Linphone/Utils/MagicSearchSingleton.swift @@ -95,7 +95,7 @@ final class MagicSearchSingleton: ObservableObject { self.contactsManager.lastSearchSuggestions = lastSearchSuggestions self.contactsManager.avatarListModel.forEach { contactAvatarModel in - contactAvatarModel.removeAllSuscription() + contactAvatarModel.removeFriendDelegate() } self.contactsManager.avatarListModel.removeAll() self.contactsManager.avatarListModel += addedAvatarListModel From 70267b6d3bf716c5fb9879cfc82c4d4ef8b43db3 Mon Sep 17 00:00:00 2001 From: QuentinArguillere Date: Tue, 10 Sep 2024 17:24:21 +0200 Subject: [PATCH 405/486] Replace publisher with delegate in ContactViewModel --- .../Contacts/ViewModel/ContactViewModel.swift | 46 +++++++++++-------- 1 file changed, 27 insertions(+), 19 deletions(-) diff --git a/Linphone/UI/Main/Contacts/ViewModel/ContactViewModel.swift b/Linphone/UI/Main/Contacts/ViewModel/ContactViewModel.swift index 283f3df41..38390ac93 100644 --- a/Linphone/UI/Main/Contacts/ViewModel/ContactViewModel.swift +++ b/Linphone/UI/Main/Contacts/ViewModel/ContactViewModel.swift @@ -34,7 +34,7 @@ class ContactViewModel: ObservableObject { @Published var operationInProgress: Bool = false @Published var displayedConversation: ConversationModel? - private var chatRoomSuscriptions = Set() + private var contactChatRoomDelegate: ChatRoomDelegate? init() {} @@ -178,14 +178,31 @@ class ContactViewModel: ObservableObject { } func chatRoomAddDelegate(core: Core, chatRoom: ChatRoom) { - self.chatRoomSuscriptions.insert(chatRoom.publisher?.onConferenceJoined?.postOnCoreQueue { (chatRoom: ChatRoom, _: EventLog) in + 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") - self.chatRoomSuscriptions.removeAll() - + if let delegate = self.contactChatRoomDelegate { + chatRoom.removeDelegate(delegate: delegate) + self.contactChatRoomDelegate = nil + } let model = ConversationModel(chatRoom: chatRoom) if self.operationInProgress == false { DispatchQueue.main.async { @@ -204,21 +221,11 @@ class ContactViewModel: ObservableObject { } } else if state == ChatRoom.State.CreationFailed { Log.error("\(StartConversationViewModel.TAG) Conversation \(id) creation has failed!") - self.chatRoomSuscriptions.removeAll() - DispatchQueue.main.async { - self.operationInProgress = false - ToastViewModel.shared.toastMessage = "Failed_to_create_conversation_error" - ToastViewModel.shared.displayToast = true - } - } - }) - - self.chatRoomSuscriptions.insert(chatRoom.publisher?.onStateChanged?.postOnCoreQueue { (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!") - self.chatRoomSuscriptions.removeAll() + + 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" @@ -226,6 +233,7 @@ class ContactViewModel: ObservableObject { } } }) + chatRoom.addDelegate(delegate: contactChatRoomDelegate!) } } // swiftlint:enable line_length From 3261cebd5f6bc5f2ace329a7fc8fe2afb2d04fd8 Mon Sep 17 00:00:00 2001 From: QuentinArguillere Date: Tue, 10 Sep 2024 17:38:27 +0200 Subject: [PATCH 406/486] Replace publisher with delegate in AccountModel --- Linphone/Core/CoreContext.swift | 8 ++-- Linphone/UI/Main/Viewmodel/AccountModel.swift | 40 ++++++++++++------- 2 files changed, 29 insertions(+), 19 deletions(-) diff --git a/Linphone/Core/CoreContext.swift b/Linphone/Core/CoreContext.swift index 1191cc080..090b8c4be 100644 --- a/Linphone/Core/CoreContext.swift +++ b/Linphone/Core/CoreContext.swift @@ -166,7 +166,7 @@ final class CoreContext: ObservableObject { var accountModels: [AccountModel] = [] for account in self.mCore.accountList { - accountModels.append(AccountModel(account: account, corePublisher: self.mCore.publisher)) + accountModels.append(AccountModel(account: account, core: self.mCore)) } DispatchQueue.main.async { self.coreIsStarted = true @@ -203,7 +203,7 @@ final class CoreContext: ObservableObject { Log.info("New configuration state is \(status) = \(message)\n") var accountModels: [AccountModel] = [] for account in self.mCore.accountList { - accountModels.append(AccountModel(account: account, corePublisher: self.mCore.publisher)) + accountModels.append(AccountModel(account: account, core: self.mCore)) } DispatchQueue.main.async { if status == ConfiguringState.Successful { @@ -281,7 +281,7 @@ final class CoreContext: ObservableObject { }, onAccountAdded: { (_: Core, _: Account) in var accountModels: [AccountModel] = [] for account in self.mCore.accountList { - accountModels.append(AccountModel(account: account, corePublisher: self.mCore.publisher)) + accountModels.append(AccountModel(account: account, core: self.mCore)) } DispatchQueue.main.async { self.accounts = accountModels @@ -289,7 +289,7 @@ final class CoreContext: ObservableObject { }, onAccountRemoved: { (_: Core, _: Account) in var accountModels: [AccountModel] = [] for account in self.mCore.accountList { - accountModels.append(AccountModel(account: account, corePublisher: self.mCore.publisher)) + accountModels.append(AccountModel(account: account, core: self.mCore)) } DispatchQueue.main.async { self.accounts = accountModels diff --git a/Linphone/UI/Main/Viewmodel/AccountModel.swift b/Linphone/UI/Main/Viewmodel/AccountModel.swift index b4c4a7dbd..b11c8933f 100644 --- a/Linphone/UI/Main/Viewmodel/AccountModel.swift +++ b/Linphone/UI/Main/Viewmodel/AccountModel.swift @@ -31,32 +31,42 @@ class AccountModel: ObservableObject { @Published var displayName: String = "" @Published var address: String = "" - private var mSuscriptions = Set() + private var accountDelegate: AccountDelegate? + private var coreDelegate: CoreDelegate? - init(account: Account, corePublisher: CoreDelegatePublisher?) { + init(account: Account, core: Core) { self.account = account - mSuscriptions.insert(account.publisher?.onRegistrationStateChanged?.postOnCoreQueue { _ in + accountDelegate = AccountDelegateStub(onRegistrationStateChanged: { (_: Account, _: RegistrationState, _: String) in self.update() }) - mSuscriptions.insert(corePublisher?.onChatRoomRead?.postOnCoreQueue( - receiveValue: { _ in - self.computeNotificationsCount() - })) - mSuscriptions.insert(corePublisher?.onMessagesReceived?.postOnCoreQueue( - receiveValue: { _ in - self.computeNotificationsCount() - })) - mSuscriptions.insert(corePublisher?.onCallStateChanged?.postOnCoreQueue( - receiveValue: { _ in - self.computeNotificationsCount() - })) + 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 From bcd5792c861e1ebf901200cb899d36681c13863b Mon Sep 17 00:00:00 2001 From: QuentinArguillere Date: Tue, 10 Sep 2024 17:42:38 +0200 Subject: [PATCH 407/486] Replace publisher with delegate in MagicSearchSingleton --- Linphone/Utils/MagicSearchSingleton.swift | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/Linphone/Utils/MagicSearchSingleton.swift b/Linphone/Utils/MagicSearchSingleton.swift index 2ad37e30e..e12596f2b 100644 --- a/Linphone/Utils/MagicSearchSingleton.swift +++ b/Linphone/Utils/MagicSearchSingleton.swift @@ -41,7 +41,7 @@ final class MagicSearchSingleton: ObservableObject { @Published var allContact = false private var domainDefaultAccount = "" - var searchSubscription: AnyCancellable? + var searchDelegate: MagicSearchDelegate? func destroyMagicSearch() { magicSearch = nil @@ -54,7 +54,7 @@ final class MagicSearchSingleton: ObservableObject { self.magicSearch = try? core.createMagicSearch() self.magicSearch.limitedSearch = false - self.searchSubscription = self.magicSearch.publisher?.onSearchResultsReceived?.postOnCoreQueue { (magicSearch: MagicSearch) in + self.searchDelegate = MagicSearchDelegateStub(onSearchResultsReceived: { (magicSearch: MagicSearch) in self.needUpdateLastSearchContacts = true var lastSearchFriend: [SearchResult] = [] @@ -102,7 +102,8 @@ final class MagicSearchSingleton: ObservableObject { NotificationCenter.default.post(name: NSNotification.Name("ContactLoaded"), object: nil) } - } + }) + self.magicSearch.addDelegate(delegate: self.searchDelegate!) } } From 502747d72e6bab2b1a815ac17cb7f2dcc70de674 Mon Sep 17 00:00:00 2001 From: QuentinArguillere Date: Tue, 10 Sep 2024 17:48:52 +0200 Subject: [PATCH 408/486] Replace publisher with delegate in MeetingViewModel --- .../Meetings/ViewModel/MeetingViewModel.swift | 35 +++++++++---------- 1 file changed, 16 insertions(+), 19 deletions(-) diff --git a/Linphone/UI/Main/Meetings/ViewModel/MeetingViewModel.swift b/Linphone/UI/Main/Meetings/ViewModel/MeetingViewModel.swift index a41279957..0cf9422e4 100644 --- a/Linphone/UI/Main/Meetings/ViewModel/MeetingViewModel.swift +++ b/Linphone/UI/Main/Meetings/ViewModel/MeetingViewModel.swift @@ -22,7 +22,7 @@ import linphonesw import Combine import EventKit -// swiftlint:disable line_length +// swiftlint:disable type_body_length class MeetingViewModel: ObservableObject { static let TAG = "[MeetingViewModel]" let eventStore: EKEventStore = EKEventStore() @@ -46,7 +46,7 @@ class MeetingViewModel: ObservableObject { var knownTimezones: [String] = [] var conferenceScheduler: ConferenceScheduler? - private var mSchedulerSubscriptions = Set() + 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? @@ -172,20 +172,18 @@ class MeetingViewModel: ObservableObject { } private func resetConferenceSchedulerAndListeners(core: Core) { - self.mSchedulerSubscriptions.removeAll() + self.mSchedulerDelegate = nil self.conferenceScheduler = try? core.createConferenceScheduler() - self.mSchedulerSubscriptions.insert(self.conferenceScheduler?.publisher?.onStateChanged?.postOnCoreQueue { (cbVal: (conferenceScheduler: ConferenceScheduler, state: ConferenceScheduler.State)) in - - Log.info("\(MeetingViewModel.TAG) Conference state changed \(cbVal.state)") - if cbVal.state == ConferenceScheduler.State.Error { + 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 cbVal.state == ConferenceScheduler.State.Ready { + } 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") @@ -203,16 +201,14 @@ class MeetingViewModel: ObservableObject { self.conferenceCreatedEvent = true } } - } else if cbVal.state == ConferenceScheduler.State.Updating { + } else if state == ConferenceScheduler.State.Updating { self.sendIcsInvitation(core: core) } - }) - - self.mSchedulerSubscriptions.insert(self.conferenceScheduler?.publisher?.onInvitationsSent?.postOnCoreQueue { (cbVal: (conferenceScheduler: ConferenceScheduler, failedInvitations: [Address])) in + }, onInvitationsSent: { (_: ConferenceScheduler, failedInvitations: [Address]) in - if cbVal.failedInvitations.isEmpty { + if failedInvitations.isEmpty { Log.info("\(MeetingViewModel.TAG) All invitations have been sent") - } else if cbVal.failedInvitations.count == self.participants.count { + } 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" @@ -220,15 +216,15 @@ class MeetingViewModel: ObservableObject { } } else { var failInvList = "" - for failInv in cbVal.failedInvitations { + for failInv in failedInvitations { if !failInvList.isEmpty { failInvList += ", " } failInvList.append(failInv.asStringUriOnly()) } - Log.warn("\(MeetingViewModel.TAG) \(cbVal.failedInvitations.count) invitations couldn't have been sent to: \(failInvList)") + Log.warn("\(MeetingViewModel.TAG) \(failedInvitations.count) invitations couldn't have been sent to: \(failInvList)") DispatchQueue.main.async { - ToastViewModel.shared.toastMessage = "Error: \(cbVal.failedInvitations.count) invitations couldn't be sent to \(failInvList)" + ToastViewModel.shared.toastMessage = "Error: \(failedInvitations.count) invitations couldn't be sent to \(failInvList)" ToastViewModel.shared.displayToast = true } } @@ -238,6 +234,7 @@ class MeetingViewModel: ObservableObject { self.conferenceCreatedEvent = true } }) + self.conferenceScheduler?.addDelegate(delegate: self.mSchedulerDelegate!) } func schedule() { @@ -384,4 +381,4 @@ class MeetingViewModel: ObservableObject { } } -// swiftlint:enable line_length +// swiftlint:enable type_body_length From 8bfcb185d7dfa6fdb2c7007fb2e2947e6ee7fd04 Mon Sep 17 00:00:00 2001 From: QuentinArguillere Date: Tue, 10 Sep 2024 17:55:11 +0200 Subject: [PATCH 409/486] Replace publisher with delegate in HistoryListViewModel --- .../UI/Main/History/ViewModel/HistoryListViewModel.swift | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/Linphone/UI/Main/History/ViewModel/HistoryListViewModel.swift b/Linphone/UI/Main/History/ViewModel/HistoryListViewModel.swift index 036365ef2..bc3cd9798 100644 --- a/Linphone/UI/Main/History/ViewModel/HistoryListViewModel.swift +++ b/Linphone/UI/Main/History/ViewModel/HistoryListViewModel.swift @@ -28,7 +28,7 @@ class HistoryListViewModel: ObservableObject { var callLogsTmp: [HistoryModel] = [] var callLogsAddressToDelete = "" - var callLogSubscription: AnyCancellable? + var callLogCoreDelegate: CoreDelegate? @Published var missedCallsCount: Int = 0 @@ -59,7 +59,7 @@ class HistoryListViewModel: ObservableObject { self.callLogsTmp = callLogsTmpBis } - self.callLogSubscription = core.publisher?.onCallLogUpdated?.postOnCoreQueue { (_: (_: Core, _: CallLog)) in + self.callLogCoreDelegate = CoreDelegateStub(onCallLogUpdated: { (_: Core, _: CallLog) in let account = core.defaultAccount let logs = account?.callLogs != nil ? account!.callLogs : core.callLogs @@ -81,7 +81,8 @@ class HistoryListViewModel: ObservableObject { } self.updateMissedCallsCount() - } + }) + core.addDelegate(delegate: self.callLogCoreDelegate!) } } From 72e8ecfd7eaa7f1e14903a9264018bac41aaaa9e Mon Sep 17 00:00:00 2001 From: QuentinArguillere Date: Thu, 12 Sep 2024 15:37:14 +0200 Subject: [PATCH 410/486] Replace publisher with delegate in MeetingsListViewModel --- .../UI/Main/Meetings/ViewModel/MeetingsListViewModel.swift | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/Linphone/UI/Main/Meetings/ViewModel/MeetingsListViewModel.swift b/Linphone/UI/Main/Meetings/ViewModel/MeetingsListViewModel.swift index e08fdd3c7..64312cebf 100644 --- a/Linphone/UI/Main/Meetings/ViewModel/MeetingsListViewModel.swift +++ b/Linphone/UI/Main/Meetings/ViewModel/MeetingsListViewModel.swift @@ -26,7 +26,7 @@ class MeetingsListViewModel: ObservableObject { static let ScrollToTodayNotification = Notification.Name("ScrollToToday") private var coreContext = CoreContext.shared - private var mCoreSuscriptions = Set() + private var mMeetingsListCoreDelegate: CoreDelegate? var selectedMeetingToDelete: MeetingModel? @Published var meetingsList: [MeetingsListItemModel] = [] @@ -35,10 +35,11 @@ class MeetingsListViewModel: ObservableObject { init() { coreContext.doOnCoreQueue { core in - self.mCoreSuscriptions.insert(core.publisher?.onConferenceInfoReceived?.postOnCoreQueue { (cbVal: (core: Core, conferenceInfo: ConferenceInfo)) in - Log.info("\(MeetingsListViewModel.TAG) Conference info received [\(cbVal.conferenceInfo.uri?.asStringUriOnly() ?? "NIL")") + 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() } From 57d4e3cc1fba64caa0ceb7e127b215a78a41cce4 Mon Sep 17 00:00:00 2001 From: QuentinArguillere Date: Thu, 12 Sep 2024 16:37:11 +0200 Subject: [PATCH 411/486] Replace publisher with delegate in CallViewModel --- .../UI/Call/ViewModel/CallViewModel.swift | 550 +++++++++--------- 1 file changed, 266 insertions(+), 284 deletions(-) diff --git a/Linphone/UI/Call/ViewModel/CallViewModel.swift b/Linphone/UI/Call/ViewModel/CallViewModel.swift index d40f26899..aa38da837 100644 --- a/Linphone/UI/Call/ViewModel/CallViewModel.swift +++ b/Linphone/UI/Call/ViewModel/CallViewModel.swift @@ -25,7 +25,6 @@ import Combine // swiftlint:disable line_length // swiftlint:disable type_body_length // swiftlint:disable cyclomatic_complexity -// swiftlint:disable large_tuple class CallViewModel: ObservableObject { static let TAG = "[CallViewModel]" @@ -67,7 +66,8 @@ class CallViewModel: ObservableObject { @Published var qualityValue: Float = 0.0 @Published var qualityIcon = "cell-signal-full" - private var mConferenceSuscriptions = Set() + private var conferenceDelegate: ConferenceDelegate? + private var waitingForConferenceDelegate: ConferenceDelegate? @Published var calls: [Call] = [] @Published var callsCounter: Int = 0 @@ -77,7 +77,7 @@ class CallViewModel: ObservableObject { var currentCall: Call? - private var callSuscriptions = Set() + private var callDelegate: CallDelegate? @Published var letters1: String = "AA" @Published var letters2: String = "BB" @@ -87,7 +87,7 @@ class CallViewModel: ObservableObject { @Published var operationInProgress: Bool = false @Published var displayedConversation: ConversationModel? - private var chatRoomSuscriptions = Set() + private var chatRoomDelegate: ChatRoomDelegate? init() { do { @@ -100,10 +100,19 @@ class CallViewModel: ObservableObject { func resetCallView() { 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 - self.callSuscriptions.removeAll() - self.mConferenceSuscriptions.removeAll() - let callsCounterTmp = core.calls.count var videoDisplayedTmp = false @@ -231,47 +240,41 @@ class CallViewModel: ObservableObject { } } - self.callSuscriptions.insert(self.currentCall!.publisher?.onEncryptionChanged?.postOnCoreQueue { _ in + self.callDelegate = CallDelegateStub(onEncryptionChanged: { (_: Call, _: Bool, _: String)in self.updateEncryption(withToast: false) if self.currentCall != nil { self.callMediaEncryptionModel.update(call: self.currentCall!) } - }) - - self.callSuscriptions.insert(self.currentCall!.publisher?.onStatsUpdated?.postOnCoreQueue {(cbVal: (call: Call, stats: CallStats)) in + }, 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: cbVal.stats) + self.callStatsModel.update(call: self.currentCall!, stats: stats) } } }) - - self.callSuscriptions.insert( - self.currentCall!.publisher?.onAuthenticationTokenVerified?.postOnCoreQueue {(_, 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 - } - } - } - } - ) - + self.currentCall!.addDelegate(delegate: self.callDelegate!) self.updateCallQualityIcon() } } @@ -385,263 +388,241 @@ class CallViewModel: ObservableObject { } func waitingForCreatedStateConference() { - self.mConferenceSuscriptions.insert( - self.currentCall?.conference?.publisher?.onStateChanged?.postOnCoreQueue {(cbValue: (conference: Conference, newState: Conference.State)) in - if cbValue.newState == .Created { + 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 - self.mConferenceSuscriptions.insert( - self.currentCall?.conference?.publisher?.onActiveSpeakerParticipantDevice?.postOnCoreQueue {(cbValue: (conference: Conference, participantDevice: ParticipantDevice)) in - if cbValue.participantDevice.address != nil { - let activeSpeakerParticipantBis = self.activeSpeakerParticipant - - let activeSpeakerParticipantTmp = ParticipantModel( - address: cbValue.participantDevice.address!, - isJoining: false, - onPause: cbValue.participantDevice.state == .OnHold, - isMuted: cbValue.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! + 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 participantListTmp: [ParticipantModel] = [] - if (activeSpeakerParticipantBis != nil && !activeSpeakerParticipantBis!.address.equal(address2: activeSpeakerParticipantTmp.address)) - || ( activeSpeakerParticipantBis == nil) { - - cbValue.conference.participantDeviceList.forEach({ participantDevice in - if participantDevice.address != nil && !cbValue.conference.isMe(uri: participantDevice.address!.clone()!) { - if !cbValue.conference.isMe(uri: participantDevice.address!.clone()!) { - let isAdmin = cbValue.conference.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 - ) - ) - } - } - }) + }) + + 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 + ) } - DispatchQueue.main.async { + 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 - if (activeSpeakerParticipantBis != nil && !activeSpeakerParticipantBis!.address.equal(address2: activeSpeakerParticipantTmp.address)) - || ( activeSpeakerParticipantBis == nil) { - self.participantList = participantListTmp - } } + self.participantList = participantListTmp } } - ) - - self.mConferenceSuscriptions.insert( - self.currentCall?.conference?.publisher?.onParticipantDeviceAdded?.postOnCoreQueue {(cbValue: (conference: Conference, participantDevice: ParticipantDevice)) in - if cbValue.participantDevice.address != nil { - var participantListTmp: [ParticipantModel] = [] - cbValue.conference.participantDeviceList.forEach({ participantDevice in - if participantDevice.address != nil && !cbValue.conference.isMe(uri: participantDevice.address!.clone()!) { - if !cbValue.conference.isMe(uri: participantDevice.address!.clone()!) { - let isAdmin = cbValue.conference.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 - ) + }, 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 ) - } - } - }) - - var activeSpeakerParticipantTmp: ParticipantModel? - var activeSpeakerNameTmp = "" - - if self.activeSpeakerParticipant == nil { - if cbValue.conference.activeSpeakerParticipantDevice?.address != nil { - activeSpeakerParticipantTmp = ParticipantModel( - address: cbValue.conference.activeSpeakerParticipantDevice!.address!, - isJoining: false, - onPause: cbValue.conference.activeSpeakerParticipantDevice!.state == .OnHold, - isMuted: cbValue.conference.activeSpeakerParticipantDevice!.isMuted ) - } else if cbValue.conference.participantList.first?.address != nil && cbValue.conference.participantList.first!.address!.clone()!.equal(address2: (cbValue.conference.me?.address)!) { - activeSpeakerParticipantTmp = ParticipantModel( - address: cbValue.conference.participantDeviceList.first!.address!, - isJoining: false, - onPause: cbValue.conference.participantDeviceList.first!.state == .OnHold, - isMuted: cbValue.conference.participantDeviceList.first!.isMuted - ) - } else if cbValue.conference.participantList.last?.address != nil { - activeSpeakerParticipantTmp = ParticipantModel( - address: cbValue.conference.participantDeviceList.last!.address!, - isJoining: false, - onPause: cbValue.conference.participantDeviceList.last!.state == .OnHold, - isMuted: cbValue.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 - } - } - } - ) - - self.mConferenceSuscriptions.insert( - self.currentCall?.conference?.publisher?.onParticipantDeviceRemoved?.postOnCoreQueue {(cbValue: (conference: Conference, participantDevice: ParticipantDevice)) in - if cbValue.participantDevice.address != nil { - var participantListTmp: [ParticipantModel] = [] - cbValue.conference.participantDeviceList.forEach({ participantDevice in - if participantDevice.address != nil && !cbValue.conference.isMe(uri: participantDevice.address!.clone()!) { - if !cbValue.conference.isMe(uri: participantDevice.address!.clone()!) { - let isAdmin = cbValue.conference.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 - ) - ) - } - } - }) - - let participantDeviceListCount = cbValue.conference.participantDeviceList.count - - DispatchQueue.main.async { - self.participantList = participantListTmp - - if participantDeviceListCount == 1 { - self.activeSpeakerParticipant = nil - } - } - } - } - ) - - self.mConferenceSuscriptions.insert( - self.currentCall?.conference?.publisher?.onParticipantDeviceIsMuted?.postOnCoreQueue {(cbValue: (conference: Conference, participantDevice: ParticipantDevice, isMuted: Bool)) in - if self.activeSpeakerParticipant != nil && self.activeSpeakerParticipant!.address.equal(address2: cbValue.participantDevice.address!) { - DispatchQueue.main.async { - self.activeSpeakerParticipant!.isMuted = cbValue.isMuted - } - } - self.participantList.forEach({ participantDevice in - if participantDevice.address.equal(address2: cbValue.participantDevice.address!) { - DispatchQueue.main.async { - participantDevice.isMuted = cbValue.isMuted } } }) + + let participantDeviceListCount = conference.participantDeviceList.count + + DispatchQueue.main.async { + self.participantList = participantListTmp + + if participantDeviceListCount == 1 { + self.activeSpeakerParticipant = nil + } + } } - ) - - self.mConferenceSuscriptions.insert( - self.currentCall?.conference?.publisher?.onParticipantDeviceStateChanged?.postOnCoreQueue {(cbValue: (conference: Conference, device: ParticipantDevice, state: ParticipantDevice.State)) in - Log.info( - "[CallViewModel] Participant device \(cbValue.device.address!.asStringUriOnly()) state changed \(cbValue.state)" + }, 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 ) - if self.activeSpeakerParticipant != nil && self.activeSpeakerParticipant!.address.equal(address2: cbValue.device.address!) { - DispatchQueue.main.async { - self.activeSpeakerParticipant!.onPause = cbValue.state == .OnHold - self.activeSpeakerParticipant!.isJoining = cbValue.state == .Joining || cbValue.state == .Alerting + + 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! } } - self.participantList.forEach({ participantDevice in - if participantDevice.address.equal(address2: cbValue.device.address!) { - DispatchQueue.main.async { - participantDevice.onPause = cbValue.state == .OnHold - participantDevice.isJoining = cbValue.state == .Joining || cbValue.state == .Alerting + + 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 + ) + ) + } } - } - }) - } - ) - - self.mConferenceSuscriptions.insert( - self.currentCall?.conference?.publisher?.onParticipantAdminStatusChanged?.postOnCoreQueue {(cbValue: (conference: Conference, participant: Participant)) in - let isAdmin = cbValue.participant.isAdmin - if self.myParticipantModel != nil && self.myParticipantModel!.address.clone()!.equal(address2: cbValue.participant.address!) { - DispatchQueue.main.async { - self.myParticipantModel!.isAdmin = isAdmin + }) + } + + DispatchQueue.main.async { + self.activeSpeakerParticipant = activeSpeakerParticipantTmp + self.activeSpeakerName = activeSpeakerNameTmp + if (activeSpeakerParticipantBis != nil && !activeSpeakerParticipantBis!.address.equal(address2: activeSpeakerParticipantTmp.address)) + || ( activeSpeakerParticipantBis == nil) { + self.participantList = participantListTmp } } - self.participantList.forEach({ participantDevice in - if participantDevice.address.clone()!.equal(address2: cbValue.participant.address!) { - DispatchQueue.main.async { - participantDevice.isAdmin = isAdmin - } - } - }) } - ) - - self.mConferenceSuscriptions.insert( - self.currentCall?.conference?.publisher?.onParticipantDeviceIsSpeakingChanged?.postOnCoreQueue {(cbValue: (conference: Conference, participantDevice: ParticipantDevice, isSpeaking: Bool)) in - let isSpeaking = cbValue.participantDevice.isSpeaking - if self.myParticipantModel != nil && self.myParticipantModel!.address.clone()!.equal(address2: cbValue.participantDevice.address!) { - DispatchQueue.main.async { - self.myParticipantModel!.isSpeaking = isSpeaking - } - } - self.participantList.forEach({ participantDeviceList in - if participantDeviceList.address.clone()!.equal(address2: cbValue.participantDevice.address!) { - DispatchQueue.main.async { - participantDeviceList.isSpeaking = isSpeaking - } - } - }) - } - ) + }) + conference.addDelegate(delegate: self.conferenceDelegate!) } } @@ -1306,13 +1287,27 @@ class CallViewModel: ObservableObject { } func chatRoomAddDelegate(core: Core, chatRoom: ChatRoom) { - self.chatRoomSuscriptions.insert(chatRoom.publisher?.onConferenceJoined?.postOnCoreQueue { (chatRoom: ChatRoom, _: EventLog) in + 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") - self.chatRoomSuscriptions.removeAll() + chatRoom.removeDelegate(delegate: self.chatRoomDelegate!) + self.chatRoomDelegate = nil let model = ConversationModel(chatRoom: chatRoom) if self.operationInProgress == false { @@ -1332,21 +1327,8 @@ class CallViewModel: ObservableObject { } } else if state == ChatRoom.State.CreationFailed { Log.error("\(StartConversationViewModel.TAG) Conversation \(id) creation has failed!") - self.chatRoomSuscriptions.removeAll() - DispatchQueue.main.async { - self.operationInProgress = false - ToastViewModel.shared.toastMessage = "Failed_to_create_conversation_error" - ToastViewModel.shared.displayToast = true - } - } - }) - - self.chatRoomSuscriptions.insert(chatRoom.publisher?.onStateChanged?.postOnCoreQueue { (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!") - self.chatRoomSuscriptions.removeAll() + chatRoom.removeDelegate(delegate: self.chatRoomDelegate!) + self.chatRoomDelegate = nil DispatchQueue.main.async { self.operationInProgress = false ToastViewModel.shared.toastMessage = "Failed_to_create_conversation_error" @@ -1354,9 +1336,9 @@ class CallViewModel: ObservableObject { } } }) + chatRoom.addDelegate(delegate: self.chatRoomDelegate!) } } // swiftlint:enable type_body_length // swiftlint:enable line_length // swiftlint:enable cyclomatic_complexity -// swiftlint:enable large_tuple From 62aaf57b3f477bace75a0a3b84b921532b4c7654 Mon Sep 17 00:00:00 2001 From: QuentinArguillere Date: Thu, 12 Sep 2024 16:37:25 +0200 Subject: [PATCH 412/486] Replace publisher with delegate in StartCallViewModel --- .../Main/History/ViewModel/StartCallViewModel.swift | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/Linphone/UI/Main/History/ViewModel/StartCallViewModel.swift b/Linphone/UI/Main/History/ViewModel/StartCallViewModel.swift index b132748b9..03fcb657e 100644 --- a/Linphone/UI/Main/History/ViewModel/StartCallViewModel.swift +++ b/Linphone/UI/Main/History/ViewModel/StartCallViewModel.swift @@ -37,7 +37,7 @@ class StartCallViewModel: ObservableObject { @Published var operationInProgress: Bool = false - private var conferenceSuscriptions = Set() + private var conferenceSchedulerDelegate: ConferenceSchedulerDelegate? init() { coreContext.doOnCoreQueue { core in @@ -116,10 +116,11 @@ class StartCallViewModel: ObservableObject { } func conferenceAddDelegate(core: Core, conferenceScheduler: ConferenceScheduler) { - self.conferenceSuscriptions.insert(conferenceScheduler.publisher?.onStateChanged?.postOnCoreQueue { (conferenceScheduler: ConferenceScheduler, state: ConferenceScheduler.State) in + self.conferenceSchedulerDelegate = ConferenceSchedulerDelegateStub(onStateChanged: { (conferenceScheduler: ConferenceScheduler, state: ConferenceScheduler.State) in Log.info("\(StartCallViewModel.TAG) Conference scheduler state is \(state)") if state == ConferenceScheduler.State.Ready { - self.conferenceSuscriptions.removeAll() + conferenceScheduler.removeDelegate(delegate: self.conferenceSchedulerDelegate!) + self.conferenceSchedulerDelegate = nil let conferenceAddress = conferenceScheduler.info?.uri if conferenceAddress != nil { @@ -139,7 +140,8 @@ class StartCallViewModel: ObservableObject { self.operationInProgress = false } } else if state == ConferenceScheduler.State.Error { - self.conferenceSuscriptions.removeAll() + 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" @@ -150,6 +152,7 @@ class StartCallViewModel: ObservableObject { } } }) + conferenceScheduler.addDelegate(delegate: self.conferenceSchedulerDelegate!) } func startVideoCall(core: Core, conferenceAddress: Address) { From 9f7c4e7304d0b0517759f991019cef687a872cc0 Mon Sep 17 00:00:00 2001 From: QuentinArguillere Date: Thu, 12 Sep 2024 16:37:46 +0200 Subject: [PATCH 413/486] Fix various warnings --- Linphone/Core/CoreContext.swift | 6 +++--- Linphone/TelecomManager/TelecomManager.swift | 3 +-- .../Conversations/Fragments/ConversationFragment.swift | 8 ++++---- .../UI/Main/Meetings/Fragments/MeetingsFragment.swift | 3 ++- Linphone/UI/Main/Viewmodel/AccountModel.swift | 2 +- 5 files changed, 11 insertions(+), 11 deletions(-) diff --git a/Linphone/Core/CoreContext.swift b/Linphone/Core/CoreContext.swift index 090b8c4be..b2a95fdd0 100644 --- a/Linphone/Core/CoreContext.swift +++ b/Linphone/Core/CoreContext.swift @@ -147,7 +147,7 @@ final class CoreContext: ObservableObject { 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, message: String) in + self.mCoreDelegate = CoreDelegateStub(onGlobalStateChanged: { (core: Core, state: GlobalState, _: String) in if state == GlobalState.On { #if DEBUG let pushEnvironment = ".dev" @@ -199,7 +199,7 @@ final class CoreContext: ObservableObject { ToastViewModel.shared.displayToast = true } } - }, onConfiguringStatus: { (core: Core, status: ConfiguringState, message: String) in + }, 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 { @@ -215,7 +215,7 @@ final class CoreContext: ObservableObject { }, onLogCollectionUploadStateChanged: { (_: Core, _: Core.LogCollectionUploadState, info: String) in if info.starts(with: "https") { DispatchQueue.main.async { - UIPasteboard.general.setValue(info, forPasteboardType: UTType.plainText.identifier) + UIPasteboard.general.setValue(info, forPasteboardType: UTType.plainText.identifier) ToastViewModel.shared.toastMessage = "Success_send_logs" ToastViewModel.shared.displayToast = true } diff --git a/Linphone/TelecomManager/TelecomManager.swift b/Linphone/TelecomManager/TelecomManager.swift index 6149bb186..16ce445f9 100644 --- a/Linphone/TelecomManager/TelecomManager.swift +++ b/Linphone/TelecomManager/TelecomManager.swift @@ -453,8 +453,7 @@ class TelecomManager: ObservableObject { } updateRemoteConfVideo(remConfVideoEnabled: remConfVideoEnabled) } - - + if self.remoteConfVideo { Log.info("[Call] Remote video is activated") } diff --git a/Linphone/UI/Main/Conversations/Fragments/ConversationFragment.swift b/Linphone/UI/Main/Conversations/Fragments/ConversationFragment.swift index e8a3e663e..e0fb3c32f 100644 --- a/Linphone/UI/Main/Conversations/Fragments/ConversationFragment.swift +++ b/Linphone/UI/Main/Conversations/Fragments/ConversationFragment.swift @@ -150,8 +150,8 @@ struct ConversationFragment: View { .navigationViewStyle(.stack) } - //swiftlint:disable cyclomatic_complexity - //swiftlint:disable function_body_length + // swiftlint:disable cyclomatic_complexity + // swiftlint:disable function_body_length @ViewBuilder func innerView(geometry: GeometryProxy) -> some View { ZStack { @@ -869,8 +869,8 @@ struct ConversationFragment: View { } } } - //swiftlint:enable cyclomatic_complexity - //swiftlint:enable function_body_length + // swiftlint:enable cyclomatic_complexity + // swiftlint:enable function_body_length @ViewBuilder func imdnOrReactionsSheet() -> some View { diff --git a/Linphone/UI/Main/Meetings/Fragments/MeetingsFragment.swift b/Linphone/UI/Main/Meetings/Fragments/MeetingsFragment.swift index 80319a5d7..28e883319 100644 --- a/Linphone/UI/Main/Meetings/Fragments/MeetingsFragment.swift +++ b/Linphone/UI/Main/Meetings/Fragments/MeetingsFragment.swift @@ -67,7 +67,8 @@ struct MeetingsFragment: View { .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 + 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") diff --git a/Linphone/UI/Main/Viewmodel/AccountModel.swift b/Linphone/UI/Main/Viewmodel/AccountModel.swift index b11c8933f..a920fecfc 100644 --- a/Linphone/UI/Main/Viewmodel/AccountModel.swift +++ b/Linphone/UI/Main/Viewmodel/AccountModel.swift @@ -44,7 +44,7 @@ class AccountModel: ObservableObject { coreDelegate = CoreDelegateStub(onCallStateChanged: { (_: Core, _: Call, _: Call.State, _: String) in self.computeNotificationsCount() - }, onMessagesReceived: { (_: Core, _: ChatRoom, _:[ChatMessage]) in + }, onMessagesReceived: { (_: Core, _: ChatRoom, _: [ChatMessage]) in self.computeNotificationsCount() }, onChatRoomRead: { (_: Core, _: ChatRoom) in self.computeNotificationsCount() From 62ab791cd0d27e8f5fe6b5225edcaccd1b49058b Mon Sep 17 00:00:00 2001 From: QuentinArguillere Date: Thu, 26 Sep 2024 15:44:23 +0200 Subject: [PATCH 414/486] Replace publisher with delegate in ContactsManager --- Linphone/Contacts/ContactsManager.swift | 29 ++++++++++++++----------- 1 file changed, 16 insertions(+), 13 deletions(-) diff --git a/Linphone/Contacts/ContactsManager.swift b/Linphone/Contacts/ContactsManager.swift index ad3c8c43a..5b882a94e 100644 --- a/Linphone/Contacts/ContactsManager.swift +++ b/Linphone/Contacts/ContactsManager.swift @@ -18,7 +18,6 @@ */ // swiftlint:disable line_length -// swiftlint:disable large_tuple // swiftlint:disable function_parameter_count import linphonesw @@ -43,7 +42,7 @@ final class ContactsManager: ObservableObject { @Published var lastSearchSuggestions: [SearchResult] = [] @Published var avatarListModel: [ContactAvatarModel] = [] - private var friendListSuscription: AnyCancellable? + private var friendListDelegate: FriendListDelegate? private init() {} @@ -132,26 +131,30 @@ final class ContactsManager: ObservableObject { 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() - self.friendListSuscription = self.friendList?.publisher?.onNewSipAddressDiscovered?.postOnCoreQueue { (cbValue: (friendList: FriendList, linphoneFriend: Friend, sipUri: String)) in + if let friendListDelegate = self.friendListDelegate { + self.friendList?.removeDelegate(delegate: friendListDelegate) + } + self.friendListDelegate = FriendListDelegateStub(onNewSipAddressDiscovered: { (_: FriendList, linphoneFriend: Friend, sipUri: String) in var addedAvatarListModel: [ContactAvatarModel] = [] - cbValue.linphoneFriend.phoneNumbers.forEach { phone in + linphoneFriend.phoneNumbers.forEach { phone in let address = core.interpretUrl(url: phone, applyInternationalPrefix: true) - let presence = cbValue.linphoneFriend.getPresenceModelForUriOrTel(uriOrTel: address?.asStringUriOnly() ?? "") + let presence = linphoneFriend.getPresenceModelForUriOrTel(uriOrTel: address?.asStringUriOnly() ?? "") if address != nil && presence != nil { - cbValue.linphoneFriend.edit() - cbValue.linphoneFriend.addAddress(address: address!) - cbValue.linphoneFriend.done() + linphoneFriend.edit() + linphoneFriend.addAddress(address: address!) + linphoneFriend.done() addedAvatarListModel.append( ContactAvatarModel( - friend: cbValue.linphoneFriend, - name: cbValue.linphoneFriend.name ?? "", - address: cbValue.linphoneFriend.address?.clone()?.asStringUriOnly() ?? "", + friend: linphoneFriend, + name: linphoneFriend.name ?? "", + address: linphoneFriend.address?.clone()?.asStringUriOnly() ?? "", withPresence: true ) ) @@ -163,7 +166,8 @@ final class ContactsManager: ObservableObject { } MagicSearchSingleton.shared.searchForContacts(sourceFlags: MagicSearch.Source.Friends.rawValue | MagicSearch.Source.LdapServers.rawValue) - } + }) + self.friendList?.addDelegate(delegate: self.friendListDelegate!) } } } @@ -386,5 +390,4 @@ struct Contact: Identifiable { } // swiftlint:enable line_length -// swiftlint:enable large_tuple // swiftlint:enable function_parameter_count From cb58b50e84ba1255d18abf1639a8c92e9438dd94 Mon Sep 17 00:00:00 2001 From: QuentinArguillere Date: Thu, 26 Sep 2024 16:20:16 +0200 Subject: [PATCH 415/486] Replace publisher with delegate in StartConversationViewModel --- .../StartConversationViewModel.swift | 44 +++++++++++-------- 1 file changed, 26 insertions(+), 18 deletions(-) diff --git a/Linphone/UI/Main/Conversations/ViewModel/StartConversationViewModel.swift b/Linphone/UI/Main/Conversations/ViewModel/StartConversationViewModel.swift index 7ba93ca7d..6a97f2282 100644 --- a/Linphone/UI/Main/Conversations/ViewModel/StartConversationViewModel.swift +++ b/Linphone/UI/Main/Conversations/ViewModel/StartConversationViewModel.swift @@ -38,7 +38,7 @@ class StartConversationViewModel: ObservableObject { @Published var operationInProgress: Bool = false @Published var displayedConversation: ConversationModel? - private var chatRoomSuscriptions = Set() + private var chatRoomDelegate: ChatRoomDelegate? init() { coreContext.doOnCoreQueue { core in @@ -302,13 +302,31 @@ class StartConversationViewModel: ObservableObject { } func chatRoomAddDelegate(core: Core, chatRoom: ChatRoom) { - self.chatRoomSuscriptions.insert(chatRoom.publisher?.onConferenceJoined?.postOnCoreQueue { (chatRoom: ChatRoom, _: EventLog) in + 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") - self.chatRoomSuscriptions.removeAll() + if let chatRoomDelegate = self.chatRoomDelegate { + chatRoom.removeDelegate(delegate: chatRoomDelegate) + } + self.chatRoomDelegate = nil let model = ConversationModel(chatRoom: chatRoom) if self.operationInProgress == false { @@ -328,21 +346,10 @@ class StartConversationViewModel: ObservableObject { } } else if state == ChatRoom.State.CreationFailed { Log.error("\(StartConversationViewModel.TAG) Conversation \(id) creation has failed!") - self.chatRoomSuscriptions.removeAll() - DispatchQueue.main.async { - self.operationInProgress = false - ToastViewModel.shared.toastMessage = "Failed_to_create_conversation_error" - ToastViewModel.shared.displayToast = true - } - } - }) - - self.chatRoomSuscriptions.insert(chatRoom.publisher?.onStateChanged?.postOnCoreQueue { (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!") - self.chatRoomSuscriptions.removeAll() + 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" @@ -350,6 +357,7 @@ class StartConversationViewModel: ObservableObject { } } }) + chatRoom.addDelegate(delegate: self.chatRoomDelegate!) } public static func isEndToEndEncryptionMandatory() -> Bool { From 035f74f59ee27b7fa52c060b3923e958922da422 Mon Sep 17 00:00:00 2001 From: QuentinArguillere Date: Thu, 26 Sep 2024 16:27:36 +0200 Subject: [PATCH 416/486] Replace publisher with delegate in ConversationForwardMessageViewModel --- .../ConversationForwardMessageViewModel.swift | 44 +++++++++++-------- 1 file changed, 26 insertions(+), 18 deletions(-) diff --git a/Linphone/UI/Main/Conversations/ViewModel/ConversationForwardMessageViewModel.swift b/Linphone/UI/Main/Conversations/ViewModel/ConversationForwardMessageViewModel.swift index dfa950999..6217bc9c1 100644 --- a/Linphone/UI/Main/Conversations/ViewModel/ConversationForwardMessageViewModel.swift +++ b/Linphone/UI/Main/Conversations/ViewModel/ConversationForwardMessageViewModel.swift @@ -36,7 +36,7 @@ class ConversationForwardMessageViewModel: ObservableObject { @Published var displayedConversation: ConversationModel? - private var chatRoomSuscriptions = Set() + private var chatRoomDelegate: ChatRoomDelegate? init() {} @@ -219,13 +219,31 @@ class ConversationForwardMessageViewModel: ObservableObject { } func chatRoomAddDelegate(core: Core, chatRoom: ChatRoom) { - self.chatRoomSuscriptions.insert(chatRoom.publisher?.onConferenceJoined?.postOnCoreQueue { (chatRoom: ChatRoom, _: EventLog) in + 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") - self.chatRoomSuscriptions.removeAll() + if let chatRoomDelegate = self.chatRoomDelegate { + chatRoom.removeDelegate(delegate: chatRoomDelegate) + } + self.chatRoomDelegate = nil let model = ConversationModel(chatRoom: chatRoom) if self.operationInProgress == false { @@ -245,21 +263,10 @@ class ConversationForwardMessageViewModel: ObservableObject { } } else if state == ChatRoom.State.CreationFailed { Log.error("\(StartConversationViewModel.TAG) Conversation \(id) creation has failed!") - self.chatRoomSuscriptions.removeAll() - DispatchQueue.main.async { - self.operationInProgress = false - ToastViewModel.shared.toastMessage = "Failed_to_create_conversation_error" - ToastViewModel.shared.displayToast = true - } - } - }) - - self.chatRoomSuscriptions.insert(chatRoom.publisher?.onStateChanged?.postOnCoreQueue { (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!") - self.chatRoomSuscriptions.removeAll() + 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" @@ -267,6 +274,7 @@ class ConversationForwardMessageViewModel: ObservableObject { } } }) + chatRoom.addDelegate(delegate: self.chatRoomDelegate!) } func forwardMessage() { From 6034d41a10229696fab21c8bdb4d9c48aa57399e Mon Sep 17 00:00:00 2001 From: QuentinArguillere Date: Fri, 27 Sep 2024 10:37:45 +0200 Subject: [PATCH 417/486] Replace chatRoomSuscriptions with delegates in ConversationViewModel --- .../ViewModel/ConversationViewModel.swift | 31 +++++++++---------- 1 file changed, 15 insertions(+), 16 deletions(-) diff --git a/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift b/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift index 34309dc91..30f0dec23 100644 --- a/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift +++ b/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift @@ -36,7 +36,7 @@ class ConversationViewModel: ObservableObject { @Published var messageText: String = "" - private var chatRoomSuscriptions = Set() + private var chatRoomDelegate: ChatRoomDelegate? private var chatMessageSuscriptions = Set() @Published var conversationMessagesSection: [MessagesSection] = [] @@ -77,13 +77,12 @@ class ConversationViewModel: ObservableObject { func addConversationDelegate() { coreContext.doOnCoreQueue { _ in if self.displayedConversation != nil { - self.chatRoomSuscriptions.insert(self.displayedConversation?.chatRoom.publisher?.onChatMessageSending?.postOnCoreQueue { (cbValue: (chatRoom: ChatRoom, eventLog: EventLog)) in - self.getNewMessages(eventLogs: [cbValue.eventLog]) - }) - - self.chatRoomSuscriptions.insert(self.displayedConversation?.chatRoom.publisher?.onChatMessagesReceived?.postOnCoreQueue { (cbValue: (chatRoom: ChatRoom, eventLogs: [EventLog])) in - self.getNewMessages(eventLogs: cbValue.eventLogs) + self.chatRoomDelegate = ChatRoomDelegateStub(onChatMessagesReceived: { (_: ChatRoom, eventLogs: [EventLog]) in + self.getNewMessages(eventLogs: eventLogs) + }, onChatMessageSending: { (_: ChatRoom, eventLog: EventLog) in + self.getNewMessages(eventLogs: [eventLog]) }) + self.displayedConversation?.chatRoom.addDelegate(delegate: self.chatRoomDelegate!) } } } @@ -181,7 +180,10 @@ class ConversationViewModel: ObservableObject { } func removeConversationDelegate() { - self.chatRoomSuscriptions.removeAll() + if let crDelegate = self.chatRoomDelegate { + self.displayedConversation?.chatRoom.removeDelegate(delegate: crDelegate) + } + self.chatRoomDelegate = nil self.chatMessageSuscriptions.removeAll() } @@ -1341,19 +1343,16 @@ class ConversationViewModel: ObservableObject { func resetDisplayedChatRoom(conversationsList: [ConversationModel]) { removeConversationDelegate() - if self.displayedConversation != nil { conversationsList.forEach { conversation in if conversation.id == self.displayedConversation!.id { self.displayedConversation = conversation - - self.chatRoomSuscriptions.insert(conversation.chatRoom.publisher?.onChatMessageSending?.postOnCoreQueue { (cbValue: (chatRoom: ChatRoom, eventLog: EventLog)) in - self.getNewMessages(eventLogs: [cbValue.eventLog]) - }) - - self.chatRoomSuscriptions.insert(conversation.chatRoom.publisher?.onChatMessagesReceived?.postOnCoreQueue { (cbValue: (chatRoom: ChatRoom, eventLogs: [EventLog])) in - self.getNewMessages(eventLogs: cbValue.eventLogs) + self.chatRoomDelegate = ChatRoomDelegateStub(onChatMessagesReceived: { (_: ChatRoom, eventLogs: [EventLog]) in + self.getNewMessages(eventLogs: eventLogs) + }, onChatMessageSending: { (_: ChatRoom, eventLog: EventLog) in + self.getNewMessages(eventLogs: [eventLog]) }) + self.displayedConversation?.chatRoom.addDelegate(delegate: self.chatRoomDelegate!) } } } From e380431767974565fb1e0e79963bbcc8b8cf8102 Mon Sep 17 00:00:00 2001 From: QuentinArguillere Date: Fri, 27 Sep 2024 10:42:46 +0200 Subject: [PATCH 418/486] Replace chatMessageSuscriptions with delegates in ConversationViewModel --- .../ViewModel/ConversationViewModel.swift | 64 ++++++++++++------- 1 file changed, 40 insertions(+), 24 deletions(-) diff --git a/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift b/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift index 30f0dec23..2073502ec 100644 --- a/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift +++ b/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift @@ -19,13 +19,13 @@ import Foundation import linphonesw -import Combine 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 @@ -37,7 +37,25 @@ class ConversationViewModel: ObservableObject { @Published var messageText: String = "" private var chatRoomDelegate: ChatRoomDelegate? - private var chatMessageSuscriptions = Set() + + // 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) + chatMessage = message + chatMessageDelegate = delegate + } + + deinit { + chatMessage.removeDelegate(delegate: chatMessageDelegate) + } + } + + private var chatMessageDelegateHolders: [ChatMessageDelegateHolder] = [] @Published var conversationMessagesSection: [MessagesSection] = [] @Published var participantConversationModel: [ContactAvatarModel] = [] @@ -118,9 +136,9 @@ class ConversationViewModel: ObservableObject { } self.coreContext.doOnCoreQueue { _ in - self.chatMessageSuscriptions.insert(message.publisher?.onMsgStateChanged?.postOnCoreQueue {(cbValue: (message: ChatMessage, state: ChatMessage.State)) in + let chatMessageDelegate = ChatMessageDelegateStub(onMsgStateChanged: { (message: ChatMessage, _: ChatMessage.State) in var statusTmp: Message.Status? = .sending - switch cbValue.message.state { + switch message.state { case .InProgress: statusTmp = .sending case .Delivered: @@ -143,27 +161,23 @@ class ConversationViewModel: ObservableObject { self.conversationMessagesSection[0].rows[indexMessage!].message.status = statusTmp } } - }) - - self.chatMessageSuscriptions.insert(message.publisher?.onNewMessageReaction?.postOnCoreQueue {(cbValue: (message: ChatMessage, reaction: ChatMessageReaction)) in + }, onNewMessageReaction: { (message: ChatMessage, _: ChatMessageReaction) in let indexMessage = self.conversationMessagesSection[0].rows.firstIndex(where: {$0.eventLog.chatMessage?.messageId == message.messageId}) var reactionsTmp: [String] = [] - cbValue.message.reactions.forEach({ chatMessageReaction in - reactionsTmp.append(chatMessageReaction.body) - }) - - DispatchQueue.main.async { - if indexMessage != nil { - self.objectWillChange.send() - self.conversationMessagesSection[0].rows[indexMessage!].message.reactions = reactionsTmp - } - } - }) - - self.chatMessageSuscriptions.insert(message.publisher?.onReactionRemoved?.postOnCoreQueue {(cbValue: (message: ChatMessage, address: Address)) in - let indexMessage = self.conversationMessagesSection[0].rows.firstIndex(where: {$0.eventLog.chatMessage?.messageId == message.messageId}) - var reactionsTmp: [String] = [] - cbValue.message.reactions.forEach({ chatMessageReaction in + message.reactions.forEach({ chatMessageReaction in + reactionsTmp.append(chatMessageReaction.body) + }) + + DispatchQueue.main.async { + if indexMessage != nil { + self.objectWillChange.send() + self.conversationMessagesSection[0].rows[indexMessage!].message.reactions = reactionsTmp + } + } + }, onReactionRemoved: { (message: ChatMessage, _: Address) in + let indexMessage = self.conversationMessagesSection[0].rows.firstIndex(where: {$0.eventLog.chatMessage?.messageId == message.messageId}) + var reactionsTmp: [String] = [] + message.reactions.forEach({ chatMessageReaction in reactionsTmp.append(chatMessageReaction.body) }) @@ -174,6 +188,8 @@ class ConversationViewModel: ObservableObject { } } }) + message.addDelegate(delegate: chatMessageDelegate) + self.chatMessageDelegateHolders.append(ChatMessageDelegateHolder(message: message, delegate: chatMessageDelegate)) } } } @@ -184,7 +200,7 @@ class ConversationViewModel: ObservableObject { self.displayedConversation?.chatRoom.removeDelegate(delegate: crDelegate) } self.chatRoomDelegate = nil - self.chatMessageSuscriptions.removeAll() + self.chatMessageDelegateHolders.removeAll() } func getHistorySize() { From 9308c4d104ed798eb286e9e8bb46866c26998fd7 Mon Sep 17 00:00:00 2001 From: QuentinArguillere Date: Mon, 30 Sep 2024 16:23:58 +0200 Subject: [PATCH 419/486] Add usernotifications filtering entitlement to app extension --- msgNotificationService/msgNotificationService.entitlements | 2 ++ 1 file changed, 2 insertions(+) diff --git a/msgNotificationService/msgNotificationService.entitlements b/msgNotificationService/msgNotificationService.entitlements index b63a67474..31f88ecaf 100644 --- a/msgNotificationService/msgNotificationService.entitlements +++ b/msgNotificationService/msgNotificationService.entitlements @@ -2,6 +2,8 @@ + com.apple.developer.usernotifications.filtering + com.apple.security.application-groups group.org.linphone.phone.msgNotification From a1b580b78af579f841a738508032404e66bd9858 Mon Sep 17 00:00:00 2001 From: QuentinArguillere Date: Mon, 30 Sep 2024 16:26:25 +0200 Subject: [PATCH 420/486] Fix reaction in app extension for 1-1 chatrooms --- .../NotificationService.swift | 19 ++++++------------- 1 file changed, 6 insertions(+), 13 deletions(-) diff --git a/msgNotificationService/NotificationService.swift b/msgNotificationService/NotificationService.swift index cce7555a8..98b2b210e 100644 --- a/msgNotificationService/NotificationService.swift +++ b/msgNotificationService/NotificationService.swift @@ -150,7 +150,6 @@ class NotificationService: UNNotificationServiceExtension { } stopCore() - bestAttemptContent.sound = UNNotificationSound(named: UNNotificationSoundName(rawValue: "msg.caf")) bestAttemptContent.title = NSLocalizedString("Message received", comment: "") if let subtitle = msgData?.subtitle { @@ -166,7 +165,6 @@ class NotificationService: UNNotificationServiceExtension { 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 { @@ -233,20 +231,15 @@ class NotificationService: UNNotificationServiceExtension { 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 { - if let subject = message.subject as String?, !subject.isEmpty { - msgData.subtitle = subject - if reactionContent == nil { - msgData.body = from + " : " + content - } else { - msgData.body = from + NSLocalizedString(" has reacted by ", comment: "") + reactionContent! + NSLocalizedString(" to: ", comment: "") + content - } + msgData.subtitle = message.subject ?? from + if reactionContent == nil { + msgData.body = (message.subject != nil ? "\(from): " : "") + content } else { - msgData.subtitle = from - msgData.body = content + msgData.body = from + NSLocalizedString(" has reacted by ", comment: "") + reactionContent! + NSLocalizedString(" to: ", comment: "") + content } } else { - if let subject = message.subject as String?, !subject.isEmpty { - msgData.body = subject + " : " + from + if let subject = message.subject { + msgData.body = subject + ": " + from } else { msgData.body = from } From ce9021df3b3899a637749e15d81dfea881244c4e Mon Sep 17 00:00:00 2001 From: QuentinArguillere Date: Mon, 30 Sep 2024 17:44:21 +0200 Subject: [PATCH 421/486] Add Localizable file to sources for msgNotificationService --- Linphone.xcodeproj/project.pbxproj | 1 + 1 file changed, 1 insertion(+) diff --git a/Linphone.xcodeproj/project.pbxproj b/Linphone.xcodeproj/project.pbxproj index 8687d2df4..c03f9c76e 100644 --- a/Linphone.xcodeproj/project.pbxproj +++ b/Linphone.xcodeproj/project.pbxproj @@ -17,6 +17,7 @@ 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 */; }; + 663E07E42CAAFD3B0010029D /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = D70C93DD2AC2D0F60063CA3B /* Localizable.xcstrings */; }; 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 */; }; From e0e0970481a600627a3902b1115a1873be8fc275 Mon Sep 17 00:00:00 2001 From: QuentinArguillere Date: Mon, 30 Sep 2024 17:46:59 +0200 Subject: [PATCH 422/486] Update version to (45) --- Linphone.xcodeproj/project.pbxproj | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Linphone.xcodeproj/project.pbxproj b/Linphone.xcodeproj/project.pbxproj index c03f9c76e..284e9cf84 100644 --- a/Linphone.xcodeproj/project.pbxproj +++ b/Linphone.xcodeproj/project.pbxproj @@ -1241,7 +1241,7 @@ CODE_SIGN_ENTITLEMENTS = msgNotificationService/msgNotificationService.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 40; + CURRENT_PROJECT_VERSION = 45; DEVELOPMENT_TEAM = Z2V957B3D6; ENABLE_USER_SCRIPT_SANDBOXING = NO; GCC_C_LANGUAGE_STANDARD = gnu17; @@ -1284,7 +1284,7 @@ CODE_SIGN_ENTITLEMENTS = msgNotificationService/msgNotificationService.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 41; + CURRENT_PROJECT_VERSION = 45; DEVELOPMENT_TEAM = Z2V957B3D6; ENABLE_USER_SCRIPT_SANDBOXING = NO; GCC_C_LANGUAGE_STANDARD = gnu17; @@ -1441,7 +1441,7 @@ ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = NO; CODE_SIGN_ENTITLEMENTS = Linphone/Linphone.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 41; + CURRENT_PROJECT_VERSION = 45; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_ASSET_PATHS = "\"Linphone/Preview Content\""; @@ -1498,7 +1498,7 @@ ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = NO; CODE_SIGN_ENTITLEMENTS = Linphone/Linphone.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 41; + CURRENT_PROJECT_VERSION = 45; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_ASSET_PATHS = "\"Linphone/Preview Content\""; DEVELOPMENT_TEAM = Z2V957B3D6; From b715cf1cfdb330ba0a091d477d2c2744d1b39550 Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Tue, 24 Sep 2024 15:56:46 +0200 Subject: [PATCH 423/486] Add function to format bytes as readable string in KB, MB or GB --- .../Fragments/ChatBubbleView.swift | 2 +- .../Main/Conversations/Model/Attachment.swift | 8 +++-- .../ViewModel/ConversationViewModel.swift | 36 ++++++++++++------- Linphone/Utils/Extensions/IntExtension.swift | 8 +++++ 4 files changed, 38 insertions(+), 16 deletions(-) diff --git a/Linphone/UI/Main/Conversations/Fragments/ChatBubbleView.swift b/Linphone/UI/Main/Conversations/Fragments/ChatBubbleView.swift index 138615747..fd37145de 100644 --- a/Linphone/UI/Main/Conversations/Fragments/ChatBubbleView.swift +++ b/Linphone/UI/Main/Conversations/Fragments/ChatBubbleView.swift @@ -420,7 +420,7 @@ struct ChatBubbleView: View { .frame(maxWidth: .infinity, alignment: .leading) .lineLimit(1) - Text("2,2 Mo") + Text(eventLogMessage.message.attachments.first!.size.formatBytes()) .default_text_style_300(styleSize: 16) .frame(maxWidth: .infinity, alignment: .leading) .lineLimit(1) diff --git a/Linphone/UI/Main/Conversations/Model/Attachment.swift b/Linphone/UI/Main/Conversations/Model/Attachment.swift index 8d2d83c5a..58b0536bb 100644 --- a/Linphone/UI/Main/Conversations/Model/Attachment.swift +++ b/Linphone/UI/Main/Conversations/Model/Attachment.swift @@ -84,17 +84,19 @@ public struct Attachment: Codable, Identifiable, Hashable { 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) { + 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) { - self.init(id: id, name: name, thumbnail: url, full: url, type: type, duration: duration) + 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/ViewModel/ConversationViewModel.swift b/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift index 2073502ec..f4ea9e59a 100644 --- a/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift +++ b/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift @@ -312,7 +312,8 @@ class ConversationViewModel: ObservableObject { id: UUID().uuidString, name: content.name!, url: path!, - type: .fileTransfer + type: .fileTransfer, + size: content.fileSize ) attachmentNameList += ", \(content.name!)" attachmentList.append(attachment) @@ -342,7 +343,8 @@ class ConversationViewModel: ObservableObject { name: content.name!, url: path!, type: typeTmp, - duration: typeTmp == . voiceRecording ? content.fileDuration : 0 + duration: typeTmp == .voiceRecording ? content.fileDuration : 0, + size: content.fileSize ) attachmentNameList += ", \(content.name!)" attachmentList.append(attachment) @@ -358,7 +360,8 @@ class ConversationViewModel: ObservableObject { name: content.name!, thumbnail: pathThumbnail!, full: path!, - type: .video + type: .video, + size: content.fileSize ) attachmentNameList += ", \(content.name!)" attachmentList.append(attachment) @@ -528,7 +531,8 @@ class ConversationViewModel: ObservableObject { id: UUID().uuidString, name: content.name!, url: path!, - type: .fileTransfer + type: .fileTransfer, + size: content.fileSize ) attachmentNameList += ", \(content.name!)" attachmentList.append(attachment) @@ -558,7 +562,8 @@ class ConversationViewModel: ObservableObject { name: content.name!, url: path!, type: typeTmp, - duration: typeTmp == . voiceRecording ? content.fileDuration : 0 + duration: typeTmp == . voiceRecording ? content.fileDuration : 0, + size: content.fileSize ) attachmentNameList += ", \(content.name!)" attachmentList.append(attachment) @@ -574,7 +579,8 @@ class ConversationViewModel: ObservableObject { name: content.name!, thumbnail: pathThumbnail!, full: path!, - type: .video + type: .video, + size: content.fileSize ) attachmentNameList += ", \(content.name!)" attachmentList.append(attachment) @@ -741,7 +747,8 @@ class ConversationViewModel: ObservableObject { id: UUID().uuidString, name: content.name ?? "???", url: path!, - type: .fileTransfer + type: .fileTransfer, + size: content.fileSize ) attachmentNameList += ", \(content.name ?? "???")" attachmentList.append(attachment) @@ -771,7 +778,8 @@ class ConversationViewModel: ObservableObject { name: content.name!, url: path!, type: typeTmp, - duration: typeTmp == . voiceRecording ? content.fileDuration : 0 + duration: typeTmp == . voiceRecording ? content.fileDuration : 0, + size: content.fileSize ) attachmentNameList += ", \(content.name!)" attachmentList.append(attachment) @@ -787,7 +795,8 @@ class ConversationViewModel: ObservableObject { name: content.name!, thumbnail: pathThumbnail!, full: path!, - type: .video + type: .video, + size: content.fileSize ) attachmentNameList += ", \(content.name!)" attachmentList.append(attachment) @@ -1027,7 +1036,8 @@ class ConversationViewModel: ObservableObject { id: UUID().uuidString, name: content.name!, url: path!, - type: .fileTransfer + type: .fileTransfer, + size: content.fileSize ) attachmentNameList += ", \(content.name!)" attachmentList.append(attachment) @@ -1057,7 +1067,8 @@ class ConversationViewModel: ObservableObject { name: content.name!, url: path!, type: typeTmp, - duration: typeTmp == . voiceRecording ? content.fileDuration : 0 + duration: typeTmp == . voiceRecording ? content.fileDuration : 0, + size: content.fileSize ) attachmentNameList += ", \(content.name!)" attachmentList.append(attachment) @@ -1073,7 +1084,8 @@ class ConversationViewModel: ObservableObject { name: content.name!, thumbnail: pathThumbnail!, full: path!, - type: .video + type: .video, + size: content.fileSize ) attachmentNameList += ", \(content.name!)" attachmentList.append(attachment) diff --git a/Linphone/Utils/Extensions/IntExtension.swift b/Linphone/Utils/Extensions/IntExtension.swift index b4dce52f2..4067dcac5 100644 --- a/Linphone/Utils/Extensions/IntExtension.swift +++ b/Linphone/Utils/Extensions/IntExtension.swift @@ -34,6 +34,14 @@ extension Int { } 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):" From 233ff399ff2341cdf1493c434be1504890f5cab8 Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Tue, 24 Sep 2024 18:20:56 +0200 Subject: [PATCH 424/486] Add event message --- .../clock-countdown.svg | 2 +- .../door.imageset/Contents.json | 21 + .../Assets.xcassets/door.imageset/door.svg | 1 + .../pencil-simple.imageset/pencil-simple.svg | 2 +- .../user-circle-gear.svg | 2 +- .../user-circle.imageset/user-circle.svg | 2 +- .../warning-circle.svg | 2 +- Linphone/Localizable.xcstrings | 204 ++++++++ .../Fragments/ChatBubbleView.swift | 452 ++++++++++-------- .../Fragments/ConversationFragment.swift | 10 + .../Main/Conversations/Fragments/UIList.swift | 2 +- .../Main/Conversations/Model/EventModel.swift | 122 +++++ .../ConversationForwardMessageViewModel.swift | 2 +- .../ViewModel/ConversationViewModel.swift | 64 +-- 14 files changed, 641 insertions(+), 247 deletions(-) create mode 100644 Linphone/Assets.xcassets/door.imageset/Contents.json create mode 100644 Linphone/Assets.xcassets/door.imageset/door.svg create mode 100644 Linphone/UI/Main/Conversations/Model/EventModel.swift diff --git a/Linphone/Assets.xcassets/clock-countdown.imageset/clock-countdown.svg b/Linphone/Assets.xcassets/clock-countdown.imageset/clock-countdown.svg index 548aeabcd..c59988986 100644 --- a/Linphone/Assets.xcassets/clock-countdown.imageset/clock-countdown.svg +++ b/Linphone/Assets.xcassets/clock-countdown.imageset/clock-countdown.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file 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/pencil-simple.imageset/pencil-simple.svg b/Linphone/Assets.xcassets/pencil-simple.imageset/pencil-simple.svg index 35cfc71c7..ceb292bbf 100644 --- a/Linphone/Assets.xcassets/pencil-simple.imageset/pencil-simple.svg +++ b/Linphone/Assets.xcassets/pencil-simple.imageset/pencil-simple.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file 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 index c406aac63..5b383bc57 100644 --- a/Linphone/Assets.xcassets/user-circle-gear.imageset/user-circle-gear.svg +++ b/Linphone/Assets.xcassets/user-circle-gear.imageset/user-circle-gear.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/Linphone/Assets.xcassets/user-circle.imageset/user-circle.svg b/Linphone/Assets.xcassets/user-circle.imageset/user-circle.svg index 761ce7d97..797854dd3 100644 --- a/Linphone/Assets.xcassets/user-circle.imageset/user-circle.svg +++ b/Linphone/Assets.xcassets/user-circle.imageset/user-circle.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/Linphone/Assets.xcassets/warning-circle.imageset/warning-circle.svg b/Linphone/Assets.xcassets/warning-circle.imageset/warning-circle.svg index a04e6ff79..1b69e522a 100644 --- a/Linphone/Assets.xcassets/warning-circle.imageset/warning-circle.svg +++ b/Linphone/Assets.xcassets/warning-circle.imageset/warning-circle.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/Linphone/Localizable.xcstrings b/Linphone/Localizable.xcstrings index 6fdd2ee8b..797be6b75 100644 --- a/Linphone/Localizable.xcstrings +++ b/Linphone/Localizable.xcstrings @@ -947,6 +947,210 @@ } } }, + "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" : { diff --git a/Linphone/UI/Main/Conversations/Fragments/ChatBubbleView.swift b/Linphone/UI/Main/Conversations/Fragments/ChatBubbleView.swift index fd37145de..4ad63f305 100644 --- a/Linphone/UI/Main/Conversations/Fragments/ChatBubbleView.swift +++ b/Linphone/UI/Main/Conversations/Fragments/ChatBubbleView.swift @@ -36,244 +36,272 @@ struct ChatBubbleView: View { var body: some View { HStack { - VStack { - if !eventLogMessage.message.text.isEmpty || !eventLogMessage.message.attachments.isEmpty { - 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) + if eventLogMessage.eventModel.eventLogType == .ConferenceChatMessage { + VStack { + if !eventLogMessage.message.text.isEmpty || !eventLogMessage.message.attachments.isEmpty { + HStack(alignment: .top, content: { + if eventLogMessage.message.isOutgoing { + Spacer() } - } 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 + 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, 10) - .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() - } + VStack { + Avatar( + contactAvatarModel: conversationViewModel.participantConversationModel.first(where: {$0.address == eventLogMessage.message.address}) ?? + ContactAvatarModel(friend: nil, name: "??", address: "", withPresence: false), + avatarSize: 35 + ) + .padding(.top, 30) } - .frame(maxWidth: .infinity) + } else if conversationViewModel.displayedConversation != nil + && conversationViewModel.displayedConversation!.isGroup && !eventLogMessage.message.isOutgoing { + VStack { + } + .padding(.leading, 43) } - 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) - } + 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, 10) .padding(.bottom, 2) + } + + if eventLogMessage.message.isForward { + HStack { + if eventLogMessage.message.isOutgoing { + Spacer() + } - 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: 16) - .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: 16) - .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 { - messageAttachments() - } - - if !eventLogMessage.message.text.isEmpty { - Text(eventLogMessage.message.text) - .foregroundStyle(Color.grayMain2c700) - .default_text_style(styleSize: 16) - } - - HStack(alignment: .center) { - Text(conversationViewModel.getMessageTime(startDate: eventLogMessage.message.dateReceived)) - .foregroundStyle(Color.grayMain2c500) - .default_text_style_300(styleSize: 14) - .padding(.top, 1) + VStack(alignment: eventLogMessage.message.isOutgoing ? .trailing : .leading, spacing: 0) { + HStack { + Image("forward") + .resizable() + .frame(width: 15, height: 15, alignment: .leading) - 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) - } + 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: 16) + .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: 16) + .lineLimit(/*@START_MENU_TOKEN@*/2/*@END_MENU_TOKEN@*/) } } - .onTapGesture { - conversationViewModel.selectedMessageToDisplayDetails = eventLogMessage - conversationViewModel.prepareBottomSheetForDeliveryStatus() - } - .disabled(conversationViewModel.selectedMessage != nil) - .padding(.top, -4) + .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) } - .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... + */ + +import SwiftUI +import linphonesw + +class EventModel: ObservableObject { + @Published var text: String + @Published var icon: Image? + + var eventLog: EventLog + var eventLogType: EventLog.Kind + + init(eventLog: EventLog) { + self.eventLog = eventLog + self.eventLogType = eventLog.type + self.text = "" + self.icon = nil + setupEventData() + } + + private func setupEventData() { + 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 self.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: ""), self.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(self.eventLog.ephemeralMessageLifetime)).lowercased()) + default: + textValue = String(self.eventLog.type.rawValue) + } + + // Icon assignment + switch self.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/ViewModel/ConversationForwardMessageViewModel.swift b/Linphone/UI/Main/Conversations/ViewModel/ConversationForwardMessageViewModel.swift index 6217bc9c1..eaabb4f48 100644 --- a/Linphone/UI/Main/Conversations/ViewModel/ConversationForwardMessageViewModel.swift +++ b/Linphone/UI/Main/Conversations/ViewModel/ConversationForwardMessageViewModel.swift @@ -280,7 +280,7 @@ class ConversationForwardMessageViewModel: ObservableObject { func forwardMessage() { CoreContext.shared.doOnCoreQueue { _ in if self.displayedConversation != nil && self.selectedMessage != nil { - if let messageToForward = self.selectedMessage!.eventLog.chatMessage { + if let messageToForward = self.selectedMessage!.eventModel.eventLog.chatMessage { DispatchQueue.main.asyncAfter(deadline: .now() + 1) { do { let forwardedMessage = try self.displayedConversation!.chatRoom.createForwardMessage(message: messageToForward) diff --git a/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift b/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift index f4ea9e59a..c06f29420 100644 --- a/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift +++ b/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift @@ -125,7 +125,7 @@ class ConversationViewModel: ObservableObject { } if !self.conversationMessagesSection.isEmpty && !self.conversationMessagesSection[0].rows.isEmpty { - if let indexMessage = self.conversationMessagesSection[0].rows.firstIndex(where: {$0.eventLog.chatMessage?.messageId == message.messageId}) { + if let indexMessage = self.conversationMessagesSection[0].rows.firstIndex(where: {$0.eventModel.eventLog.chatMessage?.messageId == message.messageId}) { if indexMessage < self.conversationMessagesSection[0].rows.count && self.conversationMessagesSection[0].rows[indexMessage].message.status != statusTmp { DispatchQueue.main.async { self.objectWillChange.send() @@ -153,7 +153,7 @@ class ConversationViewModel: ObservableObject { statusTmp = .sending } - let indexMessage = self.conversationMessagesSection[0].rows.firstIndex(where: {$0.eventLog.chatMessage?.messageId == message.messageId}) + let indexMessage = self.conversationMessagesSection[0].rows.firstIndex(where: {$0.eventModel.eventLog.chatMessage?.messageId == message.messageId}) DispatchQueue.main.async { if indexMessage != nil { @@ -162,7 +162,7 @@ class ConversationViewModel: ObservableObject { } } }, onNewMessageReaction: { (message: ChatMessage, _: ChatMessageReaction) in - let indexMessage = self.conversationMessagesSection[0].rows.firstIndex(where: {$0.eventLog.chatMessage?.messageId == message.messageId}) + let indexMessage = self.conversationMessagesSection[0].rows.firstIndex(where: {$0.eventModel.eventLog.chatMessage?.messageId == message.messageId}) var reactionsTmp: [String] = [] message.reactions.forEach({ chatMessageReaction in reactionsTmp.append(chatMessageReaction.body) @@ -175,7 +175,7 @@ class ConversationViewModel: ObservableObject { } } }, onReactionRemoved: { (message: ChatMessage, _: Address) in - let indexMessage = self.conversationMessagesSection[0].rows.firstIndex(where: {$0.eventLog.chatMessage?.messageId == message.messageId}) + let indexMessage = self.conversationMessagesSection[0].rows.firstIndex(where: {$0.eventModel.eventLog.chatMessage?.messageId == message.messageId}) var reactionsTmp: [String] = [] message.reactions.forEach({ chatMessageReaction in reactionsTmp.append(chatMessageReaction.body) @@ -453,7 +453,7 @@ class ConversationViewModel: ObservableObject { if eventLog.chatMessage != nil { conversationMessage.append( EventLogMessage( - eventLog: eventLog, + eventModel: EventModel(eventLog: eventLog), message: Message( id: !eventLog.chatMessage!.messageId.isEmpty ? eventLog.chatMessage!.messageId : UUID().uuidString, status: statusTmp, @@ -474,9 +474,9 @@ class ConversationViewModel: ObservableObject { self.addChatMessageDelegate(message: eventLog.chatMessage!) } else { - conversationMessage.insert( + conversationMessage.append( EventLogMessage( - eventLog: eventLog, + eventModel: EventModel(eventLog: eventLog), message: Message( id: UUID().uuidString, status: nil, @@ -489,7 +489,7 @@ class ConversationViewModel: ObservableObject { ownReaction: "", reactions: [] ) - ), at: 0 + ) ) } } @@ -672,7 +672,7 @@ class ConversationViewModel: ObservableObject { if eventLog.chatMessage != nil { conversationMessagesTmp.insert( EventLogMessage( - eventLog: eventLog, + eventModel: EventModel(eventLog: eventLog), message: Message( id: !eventLog.chatMessage!.messageId.isEmpty ? eventLog.chatMessage!.messageId : UUID().uuidString, status: statusTmp, @@ -695,7 +695,7 @@ class ConversationViewModel: ObservableObject { } else { conversationMessagesTmp.insert( EventLogMessage( - eventLog: eventLog, + eventModel: EventModel(eventLog: eventLog), message: Message( id: UUID().uuidString, status: nil, @@ -902,7 +902,7 @@ class ConversationViewModel: ObservableObject { if eventLog.chatMessage != nil { let message = EventLogMessage( - eventLog: eventLog, + eventModel: EventModel(eventLog: eventLog), message: Message( id: !eventLog.chatMessage!.messageId.isEmpty ? eventLog.chatMessage!.messageId : UUID().uuidString, appData: eventLog.chatMessage!.appdata ?? "", @@ -943,7 +943,7 @@ class ConversationViewModel: ObservableObject { } } else { let message = EventLogMessage( - eventLog: eventLog, + eventModel: EventModel(eventLog: eventLog), message: Message( id: UUID().uuidString, status: nil, @@ -987,7 +987,7 @@ class ConversationViewModel: ObservableObject { func scrollToMessage(message: Message) { coreContext.doOnCoreQueue { _ in if message.replyMessage != nil { - if let indexMessage = self.conversationMessagesSection[0].rows.firstIndex(where: {$0.eventLog.chatMessage?.messageId == message.replyMessage!.id}) { + if let indexMessage = self.conversationMessagesSection[0].rows.firstIndex(where: {$0.eventModel.eventLog.chatMessage?.messageId == 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 { @@ -1177,7 +1177,7 @@ class ConversationViewModel: ObservableObject { if eventLog.chatMessage != nil { conversationMessagesTmp.insert( EventLogMessage( - eventLog: eventLog, + eventModel: EventModel(eventLog: eventLog), message: Message( id: !eventLog.chatMessage!.messageId.isEmpty ? eventLog.chatMessage!.messageId : UUID().uuidString, status: statusTmp, @@ -1200,7 +1200,7 @@ class ConversationViewModel: ObservableObject { } else { conversationMessagesTmp.insert( EventLogMessage( - eventLog: eventLog, + eventModel: EventModel(eventLog: eventLog), message: Message( id: UUID().uuidString, status: nil, @@ -1243,7 +1243,7 @@ class ConversationViewModel: ObservableObject { do { var message: ChatMessage? if self.messageToReply != nil { - let chatMessageToReply = self.messageToReply!.eventLog.chatMessage + let chatMessageToReply = self.messageToReply!.eventModel.eventLog.chatMessage if chatMessageToReply != nil { message = try self.displayedConversation!.chatRoom.createReplyMessage(message: chatMessageToReply!) } @@ -1482,7 +1482,7 @@ class ConversationViewModel: ObservableObject { coreContext.doOnCoreQueue { _ in if self.selectedMessageToDisplayDetails != nil { Log.info("[ConversationViewModel] Remove reaction to message with ID \(self.selectedMessageToDisplayDetails!.message.id)") - let messageToSendReaction = self.selectedMessageToDisplayDetails!.eventLog.chatMessage + let messageToSendReaction = self.selectedMessageToDisplayDetails!.eventModel.eventLog.chatMessage if messageToSendReaction != nil { do { let reaction = try messageToSendReaction!.createReaction(utf8Reaction: "") @@ -1509,7 +1509,7 @@ class ConversationViewModel: ObservableObject { coreContext.doOnCoreQueue { _ in if self.selectedMessage != nil { Log.info("[ConversationViewModel] Sending reaction \(emoji) to message with ID \(self.selectedMessage!.message.id)") - let messageToSendReaction = self.selectedMessage!.eventLog.chatMessage + let messageToSendReaction = self.selectedMessage!.eventModel.eventLog.chatMessage if messageToSendReaction != nil { do { let reaction = try messageToSendReaction!.createReaction(utf8Reaction: messageToSendReaction?.ownReaction?.body == emoji ? "" : emoji) @@ -1533,9 +1533,9 @@ class ConversationViewModel: ObservableObject { func resend() { coreContext.doOnCoreQueue { _ in - if self.selectedMessage != nil && self.selectedMessage!.eventLog.chatMessage != nil { - Log.info("[ConversationViewModel] Re-sending message with ID \(self.selectedMessage!.eventLog.chatMessage!)") - self.selectedMessage!.eventLog.chatMessage!.send() + if self.selectedMessage != nil && self.selectedMessage!.eventModel.eventLog.chatMessage != nil { + Log.info("[ConversationViewModel] Re-sending message with ID \(self.selectedMessage!.eventModel.eventLog.chatMessage!)") + self.selectedMessage!.eventModel.eventLog.chatMessage!.send() } } } @@ -1543,9 +1543,9 @@ class ConversationViewModel: ObservableObject { func prepareBottomSheetForDeliveryStatus() { self.sheetCategories.removeAll() coreContext.doOnCoreQueue { _ in - if self.selectedMessageToDisplayDetails != nil && self.selectedMessageToDisplayDetails!.eventLog.chatMessage != nil { + if self.selectedMessageToDisplayDetails != nil && self.selectedMessageToDisplayDetails!.eventModel.eventLog.chatMessage != nil { - let participantsImdnDisplayed = self.selectedMessageToDisplayDetails!.eventLog.chatMessage!.getParticipantsByImdnState(state: .Displayed) + let participantsImdnDisplayed = self.selectedMessageToDisplayDetails!.eventModel.eventLog.chatMessage!.getParticipantsByImdnState(state: .Displayed) var participantListDisplayed: [InnerSheetCategory] = [] participantsImdnDisplayed.forEach({ participantImdn in if participantImdn.participant != nil && participantImdn.participant!.address != nil { @@ -1556,7 +1556,7 @@ class ConversationViewModel: ObservableObject { } }) - let participantsImdnDeliveredToUser = self.selectedMessageToDisplayDetails!.eventLog.chatMessage!.getParticipantsByImdnState(state: .DeliveredToUser) + let participantsImdnDeliveredToUser = self.selectedMessageToDisplayDetails!.eventModel.eventLog.chatMessage!.getParticipantsByImdnState(state: .DeliveredToUser) var participantListDeliveredToUser: [InnerSheetCategory] = [] participantsImdnDeliveredToUser.forEach({ participantImdn in if participantImdn.participant != nil && participantImdn.participant!.address != nil { @@ -1567,7 +1567,7 @@ class ConversationViewModel: ObservableObject { } }) - let participantsImdnDelivered = self.selectedMessageToDisplayDetails!.eventLog.chatMessage!.getParticipantsByImdnState(state: .Delivered) + let participantsImdnDelivered = self.selectedMessageToDisplayDetails!.eventModel.eventLog.chatMessage!.getParticipantsByImdnState(state: .Delivered) var participantListDelivered: [InnerSheetCategory] = [] participantsImdnDelivered.forEach({ participantImdn in if participantImdn.participant != nil && participantImdn.participant!.address != nil { @@ -1578,7 +1578,7 @@ class ConversationViewModel: ObservableObject { } }) - let participantsImdnNotDelivered = self.selectedMessageToDisplayDetails!.eventLog.chatMessage!.getParticipantsByImdnState(state: .NotDelivered) + let participantsImdnNotDelivered = self.selectedMessageToDisplayDetails!.eventModel.eventLog.chatMessage!.getParticipantsByImdnState(state: .NotDelivered) var participantListNotDelivered: [InnerSheetCategory] = [] participantsImdnNotDelivered.forEach({ participantImdn in if participantImdn.participant != nil && participantImdn.participant!.address != nil { @@ -1604,7 +1604,7 @@ class ConversationViewModel: ObservableObject { func prepareBottomSheetForReactions() { self.sheetCategories.removeAll() coreContext.doOnCoreQueue { core in - if self.selectedMessageToDisplayDetails != nil && self.selectedMessageToDisplayDetails!.eventLog.chatMessage != nil { + if self.selectedMessageToDisplayDetails != nil && self.selectedMessageToDisplayDetails!.eventModel.eventLog.chatMessage != nil { let dispatchGroup = DispatchGroup() var sheetCategoriesTmp: [SheetCategory] = [] @@ -1612,7 +1612,7 @@ class ConversationViewModel: ObservableObject { var participantList: [[InnerSheetCategory]] = [[]] var reactionList: [String] = [] - self.selectedMessageToDisplayDetails!.eventLog.chatMessage!.reactions.forEach { chatMessageReaction in + self.selectedMessageToDisplayDetails!.eventModel.eventLog.chatMessage!.reactions.forEach { chatMessageReaction in if chatMessageReaction.fromAddress != nil { dispatchGroup.enter() ContactAvatarModel.getAvatarModelFromAddress(address: chatMessageReaction.fromAddress!) { avatarResult in @@ -1701,6 +1701,14 @@ class ConversationViewModel: ObservableObject { } } } + + func compose() { + coreContext.doOnCoreQueue { _ in + if self.displayedConversation != nil { + self.displayedConversation!.chatRoom.compose() + } + } + } } // swiftlint:enable line_length // swiftlint:enable type_body_length From aa5b0abd6775d2ebbcf75e447e14adedf0a6a275 Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Wed, 25 Sep 2024 14:30:42 +0200 Subject: [PATCH 425/486] Add banner when users are writing (composing) --- Linphone.xcodeproj/project.pbxproj | 4 ++ Linphone/Localizable.xcstrings | 67 +++++++++++++++++- .../Fragments/ConversationFragment.swift | 14 ++++ .../ViewModel/ConversationViewModel.swift | 68 +++++++++++++++++++ 4 files changed, 150 insertions(+), 3 deletions(-) diff --git a/Linphone.xcodeproj/project.pbxproj b/Linphone.xcodeproj/project.pbxproj index 284e9cf84..c365f1847 100644 --- a/Linphone.xcodeproj/project.pbxproj +++ b/Linphone.xcodeproj/project.pbxproj @@ -140,6 +140,7 @@ 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 */; }; @@ -327,6 +328,7 @@ 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 = ""; }; @@ -482,6 +484,7 @@ children = ( D70959F02B8DF3EC0014AC0B /* ConversationModel.swift */, D7E6ADF22B9875C20009A2BC /* Message.swift */, + D7C2DA1C2CA44DE400A2441B /* EventModel.swift */, D7E6ADF42B9876ED0009A2BC /* Attachment.swift */, ); path = Model; @@ -1098,6 +1101,7 @@ 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 */, diff --git a/Linphone/Localizable.xcstrings b/Linphone/Localizable.xcstrings index 797be6b75..8f316799a 100644 --- a/Linphone/Localizable.xcstrings +++ b/Linphone/Localizable.xcstrings @@ -180,9 +180,15 @@ }, "9" : { + }, + "A subject and at least one participant is required to create a meeting" : { + }, "Accept all" : { + }, + "Account successfully logged out" : { + }, "Active" : { @@ -913,6 +919,40 @@ } } }, + "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" : { @@ -946,6 +986,24 @@ } } } + }, + "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", @@ -1281,6 +1339,12 @@ }, "Copy SIP address" : { + }, + "Could not reach network" : { + + }, + "Could not send ICS invitations to meeting to any participant" : { + }, "D'accord" : { @@ -1677,9 +1741,6 @@ } } } - }, - "Incoming call" : { - }, "Information" : { diff --git a/Linphone/UI/Main/Conversations/Fragments/ConversationFragment.swift b/Linphone/UI/Main/Conversations/Fragments/ConversationFragment.swift index d1376d0de..e029d1ef1 100644 --- a/Linphone/UI/Main/Conversations/Fragments/ConversationFragment.swift +++ b/Linphone/UI/Main/Conversations/Fragments/ConversationFragment.swift @@ -379,6 +379,20 @@ struct ConversationFragment: View { } } + 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 { diff --git a/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift b/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift index c06f29420..28379afba 100644 --- a/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift +++ b/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift @@ -35,6 +35,7 @@ class ConversationViewModel: ObservableObject { @Published var displayedConversationUnreadMessagesCount: Int = 0 @Published var messageText: String = "" + @Published var composingLabel: String = "" private var chatRoomDelegate: ChatRoomDelegate? @@ -99,6 +100,8 @@ class ConversationViewModel: ObservableObject { self.getNewMessages(eventLogs: eventLogs) }, onChatMessageSending: { (_: ChatRoom, eventLog: EventLog) in self.getNewMessages(eventLogs: [eventLog]) + }, onIsComposingReceived: { (_: ChatRoom, _: Address, _: Bool) in + self.computeComposingLabel() }) self.displayedConversation?.chatRoom.addDelegate(delegate: self.chatRoomDelegate!) } @@ -282,6 +285,7 @@ class ConversationViewModel: ObservableObject { self.getHistorySize() self.getUnreadMessagesCount() self.getParticipantConversationModel() + self.computeComposingLabel() self.mediasToSend.removeAll() self.messageToReply = nil @@ -1375,10 +1379,26 @@ class ConversationViewModel: ObservableObject { conversationsList.forEach { conversation in if conversation.id == self.displayedConversation!.id { self.displayedConversation = conversation +<<<<<<< HEAD self.chatRoomDelegate = ChatRoomDelegateStub(onChatMessagesReceived: { (_: ChatRoom, eventLogs: [EventLog]) in self.getNewMessages(eventLogs: eventLogs) }, onChatMessageSending: { (_: ChatRoom, eventLog: EventLog) in self.getNewMessages(eventLogs: [eventLog]) +======= + + let messageID = self.displayedConversation!.chatRoom.getHistoryRangeEvents(begin: 0, end: 1).first?.chatMessage?.messageId + if self.conversationMessagesSection[0].rows.first?.message.id != messageID { + self.resetMessage() + self.getMessages() + } + + self.chatRoomSuscriptions.insert(conversation.chatRoom.publisher?.onChatMessageSending?.postOnCoreQueue { (cbValue: (chatRoom: ChatRoom, eventLog: EventLog)) in + self.getNewMessages(eventLogs: [cbValue.eventLog]) + }) + + self.chatRoomSuscriptions.insert(conversation.chatRoom.publisher?.onChatMessagesReceived?.postOnCoreQueue { (cbValue: (chatRoom: ChatRoom, eventLogs: [EventLog])) in + self.getNewMessages(eventLogs: cbValue.eventLogs) +>>>>>>> aa3a58b (Add banner when users are writing (composing)) }) self.displayedConversation?.chatRoom.addDelegate(delegate: self.chatRoomDelegate!) } @@ -1709,6 +1729,54 @@ class ConversationViewModel: ObservableObject { } } } + + 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 = "" + } + } + } + } } // swiftlint:enable line_length // swiftlint:enable type_body_length From 5ea8c2917f23bcf5f450c791a48a6254636567c0 Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Thu, 26 Sep 2024 17:28:23 +0200 Subject: [PATCH 426/486] Add new messages received when app moves to the foreground --- .../ViewModel/ConversationViewModel.swift | 51 +++++++++++-------- 1 file changed, 31 insertions(+), 20 deletions(-) diff --git a/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift b/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift index 28379afba..2bc5a1a30 100644 --- a/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift +++ b/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift @@ -500,6 +500,7 @@ class ConversationViewModel: ObservableObject { 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())) } } @@ -719,6 +720,7 @@ class ConversationViewModel: ObservableObject { 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 } @@ -928,7 +930,8 @@ class ConversationViewModel: ObservableObject { self.addChatMessageDelegate(message: eventLog.chatMessage!) DispatchQueue.main.async { - if !self.conversationMessagesSection.isEmpty + 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) { @@ -963,6 +966,7 @@ class ConversationViewModel: ObservableObject { ) 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 { @@ -1379,28 +1383,35 @@ class ConversationViewModel: ObservableObject { conversationsList.forEach { conversation in if conversation.id == self.displayedConversation!.id { self.displayedConversation = conversation -<<<<<<< HEAD - self.chatRoomDelegate = ChatRoomDelegateStub(onChatMessagesReceived: { (_: ChatRoom, eventLogs: [EventLog]) in - self.getNewMessages(eventLogs: eventLogs) - }, onChatMessageSending: { (_: ChatRoom, eventLog: EventLog) in - self.getNewMessages(eventLogs: [eventLog]) -======= + self.computeComposingLabel() - let messageID = self.displayedConversation!.chatRoom.getHistoryRangeEvents(begin: 0, end: 1).first?.chatMessage?.messageId - if self.conversationMessagesSection[0].rows.first?.message.id != messageID { - self.resetMessage() - self.getMessages() + if self.displayedConversation != nil { + + let eventLogFirst = self.displayedConversation!.chatRoom.findEventLog(messageId: self.conversationMessagesSection[0].rows.first!.eventModel.eventLog.chatMessage!.messageId) + + let eventLogLast = self.displayedConversation!.chatRoom.getHistoryRangeEvents(begin: 0, end: 1).first + + var eventLogList = self.displayedConversation!.chatRoom.getHistoryRangeBetween( + firstEvent: eventLogFirst, + lastEvent: eventLogLast, + filters: UInt(ChatRoom.HistoryFilter([.ChatMessage, .InfoNoDevice]).rawValue) + ) + + if eventLogLast != nil { + eventLogList.append(eventLogLast!) + if !eventLogList.isEmpty && self.conversationMessagesSection[0].rows.first?.message.id != eventLogLast!.chatMessage?.messageId { + self.getNewMessages(eventLogs: eventLogList) + } + } + + self.chatRoomDelegate = ChatRoomDelegateStub(onChatMessagesReceived: { (_: ChatRoom, eventLogs: [EventLog]) in + self.getNewMessages(eventLogs: eventLogs) + }, onChatMessageSending: { (_: ChatRoom, eventLog: EventLog) in + self.getNewMessages(eventLogs: [eventLog]) + }) + self.displayedConversation?.chatRoom.addDelegate(delegate: self.chatRoomDelegate!) } - self.chatRoomSuscriptions.insert(conversation.chatRoom.publisher?.onChatMessageSending?.postOnCoreQueue { (cbValue: (chatRoom: ChatRoom, eventLog: EventLog)) in - self.getNewMessages(eventLogs: [cbValue.eventLog]) - }) - - self.chatRoomSuscriptions.insert(conversation.chatRoom.publisher?.onChatMessagesReceived?.postOnCoreQueue { (cbValue: (chatRoom: ChatRoom, eventLogs: [EventLog])) in - self.getNewMessages(eventLogs: cbValue.eventLogs) ->>>>>>> aa3a58b (Add banner when users are writing (composing)) - }) - self.displayedConversation?.chatRoom.addDelegate(delegate: self.chatRoomDelegate!) } } } From 062aea1df316b30a2b021f5e84802e613fbec378 Mon Sep 17 00:00:00 2001 From: QuentinArguillere Date: Tue, 1 Oct 2024 10:56:11 +0200 Subject: [PATCH 427/486] Fix build --- .../Conversations/ViewModel/ConversationViewModel.swift | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift b/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift index 2bc5a1a30..4abf50311 100644 --- a/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift +++ b/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift @@ -96,12 +96,12 @@ class ConversationViewModel: ObservableObject { func addConversationDelegate() { coreContext.doOnCoreQueue { _ in if self.displayedConversation != nil { - self.chatRoomDelegate = ChatRoomDelegateStub(onChatMessagesReceived: { (_: ChatRoom, eventLogs: [EventLog]) in + self.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]) - }, onIsComposingReceived: { (_: ChatRoom, _: Address, _: Bool) in - self.computeComposingLabel() }) self.displayedConversation?.chatRoom.addDelegate(delegate: self.chatRoomDelegate!) } From dc4c3833f7bd297e2b9e4fb2a14b94a9d4f842be Mon Sep 17 00:00:00 2001 From: QuentinArguillere Date: Tue, 1 Oct 2024 10:56:20 +0200 Subject: [PATCH 428/486] Remove delegate being added twice --- .../UI/Main/Conversations/ViewModel/ConversationViewModel.swift | 1 - 1 file changed, 1 deletion(-) diff --git a/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift b/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift index 4abf50311..8a096855b 100644 --- a/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift +++ b/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift @@ -191,7 +191,6 @@ class ConversationViewModel: ObservableObject { } } }) - message.addDelegate(delegate: chatMessageDelegate) self.chatMessageDelegateHolders.append(ChatMessageDelegateHolder(message: message, delegate: chatMessageDelegate)) } } From 86cd7f452e6697d7aa5080282fea35a324275d96 Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Wed, 2 Oct 2024 14:15:09 +0200 Subject: [PATCH 429/486] Add click notification listener to open app in the chat room --- Linphone.xcodeproj/project.pbxproj | 9 +- Linphone/LinphoneApp.swift | 59 ++++++++++-- Linphone/Localizable.xcstrings | 12 --- Linphone/UI/Call/CallView.swift | 6 +- Linphone/UI/Main/ContentView.swift | 31 +++++-- .../ConversationForwardMessageFragment.swift | 2 +- .../Fragments/ConversationFragment.swift | 2 +- .../Fragments/ConversationsListFragment.swift | 29 +++++- .../Fragments/StartConversationFragment.swift | 2 +- .../Model/ConversationModel.swift | 2 +- .../ViewModel/ConversationViewModel.swift | 92 ++++++++++++------- .../ConversationsListViewModel.swift | 1 + 12 files changed, 175 insertions(+), 72 deletions(-) diff --git a/Linphone.xcodeproj/project.pbxproj b/Linphone.xcodeproj/project.pbxproj index c365f1847..47d57c7b6 100644 --- a/Linphone.xcodeproj/project.pbxproj +++ b/Linphone.xcodeproj/project.pbxproj @@ -7,7 +7,7 @@ objects = { /* Begin PBXBuildFile section */ - 4ED1F0A881A9ACB5977A8987 /* BuildFile in Frameworks */ = {isa = PBXBuildFile; }; + 4ED1F0A881A9ACB5977A8987 /* (null) 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 */; }; @@ -17,7 +17,6 @@ 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 */; }; - 663E07E42CAAFD3B0010029D /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = D70C93DD2AC2D0F60063CA3B /* Localizable.xcstrings */; }; 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 */; }; @@ -373,7 +372,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - 4ED1F0A881A9ACB5977A8987 /* BuildFile in Frameworks */, + 4ED1F0A881A9ACB5977A8987 /* (null) in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -1445,7 +1444,7 @@ ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = NO; CODE_SIGN_ENTITLEMENTS = Linphone/Linphone.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 45; + CURRENT_PROJECT_VERSION = 48; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_ASSET_PATHS = "\"Linphone/Preview Content\""; @@ -1502,7 +1501,7 @@ ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = NO; CODE_SIGN_ENTITLEMENTS = Linphone/Linphone.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 45; + CURRENT_PROJECT_VERSION = 48; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_ASSET_PATHS = "\"Linphone/Preview Content\""; DEVELOPMENT_TEAM = Z2V957B3D6; diff --git a/Linphone/LinphoneApp.swift b/Linphone/LinphoneApp.swift index 636ce5427..9ecfffa09 100644 --- a/Linphone/LinphoneApp.swift +++ b/Linphone/LinphoneApp.swift @@ -19,10 +19,17 @@ import SwiftUI import linphonesw +import UserNotifications let accountTokenNotification = Notification.Name("AccountCreationTokenReceived") -class AppDelegate: NSObject, UIApplicationDelegate { +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() @@ -44,8 +51,35 @@ class AppDelegate: NSObject, UIApplicationDelegate { 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() + } func applicationWillTerminate(_ application: UIApplication) { Log.info("IOS applicationWillTerminate") @@ -59,7 +93,6 @@ class AppDelegate: NSObject, UIApplicationDelegate { } } } - } @main @@ -67,6 +100,8 @@ 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 @@ -129,7 +164,19 @@ struct LinphoneApp: App { meetingsListViewModel: meetingsListViewModel!, meetingViewModel: meetingViewModel!, conversationForwardMessageViewModel: conversationForwardMessageViewModel! - ).onOpenURL { url in + ) + .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 { @@ -161,12 +208,6 @@ struct LinphoneApp: App { if newPhase == .active { Log.info("Entering foreground") coreContext.onEnterForeground() - - DispatchQueue.main.asyncAfter(deadline: .now() + 1) { - if conversationViewModel != nil && conversationViewModel!.displayedConversation != nil && conversationsListViewModel != nil { - conversationViewModel!.resetDisplayedChatRoom(conversationsList: conversationsListViewModel!.conversationsList) - } - } } else if newPhase == .inactive { } else if newPhase == .background { Log.info("Entering background") diff --git a/Linphone/Localizable.xcstrings b/Linphone/Localizable.xcstrings index 8f316799a..205ff1ae7 100644 --- a/Linphone/Localizable.xcstrings +++ b/Linphone/Localizable.xcstrings @@ -222,9 +222,6 @@ }, "All contacts" : { - }, - "All day" : { - }, "All modifications will be canceled." : { @@ -899,9 +896,6 @@ } } } - }, - "Content" : { - }, "Continue" : { "localizations" : { @@ -1339,9 +1333,6 @@ }, "Copy SIP address" : { - }, - "Could not reach network" : { - }, "Could not send ICS invitations to meeting to any participant" : { @@ -2361,9 +2352,6 @@ }, "Time Zone: %@" : { - }, - "Title" : { - }, "TLS" : { diff --git a/Linphone/UI/Call/CallView.swift b/Linphone/UI/Call/CallView.swift index 7d12df34a..395919d31 100644 --- a/Linphone/UI/Call/CallView.swift +++ b/Linphone/UI/Call/CallView.swift @@ -205,7 +205,7 @@ struct CallView: View { .zIndex(4) .transition(.move(edge: .bottom)) .onDisappear { - conversationViewModel.displayedConversation = nil + conversationViewModel.removeConversationDelegate() isShowConversationFragment = false } } @@ -2199,7 +2199,7 @@ struct CallView: View { .onDisappear { if callViewModel.isOneOneCall && callViewModel.displayedConversation != nil { if conversationViewModel.displayedConversation != nil { - conversationViewModel.displayedConversation = nil + conversationViewModel.removeConversationDelegate() conversationViewModel.resetMessage() conversationViewModel.changeDisplayedChatRoom(conversationModel: callViewModel.displayedConversation!) @@ -2582,7 +2582,7 @@ struct CallView: View { .onDisappear { if callViewModel.isOneOneCall && callViewModel.displayedConversation != nil { if conversationViewModel.displayedConversation != nil { - conversationViewModel.displayedConversation = nil + conversationViewModel.removeConversationDelegate() conversationViewModel.resetMessage() conversationViewModel.changeDisplayedChatRoom(conversationModel: callViewModel.displayedConversation!) diff --git a/Linphone/UI/Main/ContentView.swift b/Linphone/UI/Main/ContentView.swift index 72f6cd6e9..81347cdea 100644 --- a/Linphone/UI/Main/ContentView.swift +++ b/Linphone/UI/Main/ContentView.swift @@ -27,6 +27,8 @@ 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 @@ -127,7 +129,7 @@ struct ContentView: View { Button(action: { self.index = 0 historyViewModel.displayedCall = nil - conversationViewModel.displayedConversation = nil + conversationViewModel.removeConversationDelegate() meetingViewModel.displayedMeeting = nil }, label: { VStack { @@ -172,7 +174,7 @@ struct ContentView: View { Button(action: { self.index = 1 contactViewModel.indexDisplayedFriend = nil - conversationViewModel.displayedConversation = nil + conversationViewModel.removeConversationDelegate() meetingViewModel.displayedMeeting = nil if historyListViewModel.missedCallsCount > 0 { historyListViewModel.resetMissedCallsCount() @@ -248,7 +250,7 @@ struct ContentView: View { self.index = 3 contactViewModel.indexDisplayedFriend = nil historyViewModel.displayedCall = nil - conversationViewModel.displayedConversation = nil + conversationViewModel.removeConversationDelegate() }, label: { VStack { Image("video-conference") @@ -656,7 +658,7 @@ struct ContentView: View { Button(action: { self.index = 0 historyViewModel.displayedCall = nil - conversationViewModel.displayedConversation = nil + conversationViewModel.removeConversationDelegate() meetingViewModel.displayedMeeting = nil }, label: { VStack { @@ -703,7 +705,7 @@ struct ContentView: View { Button(action: { self.index = 1 contactViewModel.indexDisplayedFriend = nil - conversationViewModel.displayedConversation = nil + conversationViewModel.removeConversationDelegate() meetingViewModel.displayedMeeting = nil if historyListViewModel.missedCallsCount > 0 { historyListViewModel.resetMissedCallsCount() @@ -782,7 +784,7 @@ struct ContentView: View { self.index = 3 contactViewModel.indexDisplayedFriend = nil historyViewModel.displayedCall = nil - conversationViewModel.displayedConversation = nil + conversationViewModel.removeConversationDelegate() }, label: { VStack { Image("video-conference") @@ -1199,6 +1201,11 @@ struct ContentView: View { .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() @@ -1231,6 +1238,18 @@ struct ContentView: View { } } +class NavigationManager: ObservableObject { + @Published var selectedCallId: String? = nil + @Published var peerAddr: String? = nil + @Published var localAddr: String? = nil + + func openChatRoom(callId: String, peerAddr: String, localAddr: String) { + self.selectedCallId = callId + self.peerAddr = peerAddr + self.localAddr = localAddr + } +} + #Preview { ContentView( contactViewModel: ContactViewModel(), diff --git a/Linphone/UI/Main/Conversations/Fragments/ConversationForwardMessageFragment.swift b/Linphone/UI/Main/Conversations/Fragments/ConversationForwardMessageFragment.swift index 88ecd8cf2..d1830ea5f 100644 --- a/Linphone/UI/Main/Conversations/Fragments/ConversationForwardMessageFragment.swift +++ b/Linphone/UI/Main/Conversations/Fragments/ConversationForwardMessageFragment.swift @@ -211,7 +211,7 @@ struct ConversationForwardMessageFragment: View { if conversationForwardMessageViewModel.displayedConversation != nil { if conversationViewModel.displayedConversation != nil { - conversationViewModel.displayedConversation = nil + conversationViewModel.removeConversationDelegate() DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { conversationViewModel.selectedMessage = nil conversationViewModel.resetMessage() diff --git a/Linphone/UI/Main/Conversations/Fragments/ConversationFragment.swift b/Linphone/UI/Main/Conversations/Fragments/ConversationFragment.swift index e029d1ef1..45fc441e4 100644 --- a/Linphone/UI/Main/Conversations/Fragments/ConversationFragment.swift +++ b/Linphone/UI/Main/Conversations/Fragments/ConversationFragment.swift @@ -178,7 +178,7 @@ struct ConversationFragment: View { if isShowConversationFragment { isShowConversationFragment = false } - conversationViewModel.displayedConversation = nil + conversationViewModel.removeConversationDelegate() } } } diff --git a/Linphone/UI/Main/Conversations/Fragments/ConversationsListFragment.swift b/Linphone/UI/Main/Conversations/Fragments/ConversationsListFragment.swift index bbc9dc750..af2ab8f36 100644 --- a/Linphone/UI/Main/Conversations/Fragments/ConversationsListFragment.swift +++ b/Linphone/UI/Main/Conversations/Fragments/ConversationsListFragment.swift @@ -22,6 +22,10 @@ import linphonesw struct ConversationsListFragment: View { + @EnvironmentObject var navigationManager: NavigationManager + @Environment(\.scenePhase) var scenePhase + @State private var enteredForeground: Bool = false + @ObservedObject var conversationViewModel: ConversationViewModel @ObservedObject var conversationsListViewModel: ConversationsListViewModel @@ -29,6 +33,9 @@ struct ConversationsListFragment: View { @Binding var text: String var body: some View { + let pub = NotificationCenter.default + .publisher(for: NSNotification.Name("ChatRoomsComputed")) + VStack { List { ForEach(0.. Date: Thu, 3 Oct 2024 10:34:03 +0200 Subject: [PATCH 430/486] Include Localizable in msgNotificationService target (conflict error removed it earlier) --- Linphone.xcodeproj/project.pbxproj | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Linphone.xcodeproj/project.pbxproj b/Linphone.xcodeproj/project.pbxproj index 47d57c7b6..e6c5d806f 100644 --- a/Linphone.xcodeproj/project.pbxproj +++ b/Linphone.xcodeproj/project.pbxproj @@ -21,6 +21,7 @@ 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 */; }; @@ -969,6 +970,7 @@ files = ( 667E5D7F2B8E430C00EBCFC4 /* linphonerc-factory in Resources */, 667E5D812B8E444E00EBCFC4 /* GoogleService-Info.plist in Resources */, + 66A3E5B72CAE8E5C00FCB7FA /* Localizable.xcstrings in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; From bbce741911c002e406275fa2071c1ef03e9c63bd Mon Sep 17 00:00:00 2001 From: QuentinArguillere Date: Thu, 3 Oct 2024 10:40:16 +0200 Subject: [PATCH 431/486] Update version to (49) --- Linphone.xcodeproj/project.pbxproj | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Linphone.xcodeproj/project.pbxproj b/Linphone.xcodeproj/project.pbxproj index e6c5d806f..4d2cd948b 100644 --- a/Linphone.xcodeproj/project.pbxproj +++ b/Linphone.xcodeproj/project.pbxproj @@ -1246,7 +1246,7 @@ CODE_SIGN_ENTITLEMENTS = msgNotificationService/msgNotificationService.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 45; + CURRENT_PROJECT_VERSION = 49; DEVELOPMENT_TEAM = Z2V957B3D6; ENABLE_USER_SCRIPT_SANDBOXING = NO; GCC_C_LANGUAGE_STANDARD = gnu17; @@ -1289,7 +1289,7 @@ CODE_SIGN_ENTITLEMENTS = msgNotificationService/msgNotificationService.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 45; + CURRENT_PROJECT_VERSION = 49; DEVELOPMENT_TEAM = Z2V957B3D6; ENABLE_USER_SCRIPT_SANDBOXING = NO; GCC_C_LANGUAGE_STANDARD = gnu17; @@ -1446,7 +1446,7 @@ ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = NO; CODE_SIGN_ENTITLEMENTS = Linphone/Linphone.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 48; + CURRENT_PROJECT_VERSION = 49; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_ASSET_PATHS = "\"Linphone/Preview Content\""; @@ -1503,7 +1503,7 @@ ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = NO; CODE_SIGN_ENTITLEMENTS = Linphone/Linphone.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 48; + CURRENT_PROJECT_VERSION = 49; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_ASSET_PATHS = "\"Linphone/Preview Content\""; DEVELOPMENT_TEAM = Z2V957B3D6; From be09800b5a2dc63872b6f248d66da7a928634cf1 Mon Sep 17 00:00:00 2001 From: QuentinArguillere Date: Thu, 3 Oct 2024 12:19:16 +0200 Subject: [PATCH 432/486] Move (almost) all chatroom delegate management into changeDisplayedChatRoom --- Linphone/UI/Call/CallView.swift | 34 +------- Linphone/UI/Main/ContentView.swift | 12 +-- .../ConversationForwardMessageFragment.swift | 13 +--- .../Fragments/ConversationFragment.swift | 8 +- .../Fragments/ConversationsListFragment.swift | 20 +---- .../Fragments/StartConversationFragment.swift | 12 +-- .../ViewModel/ConversationViewModel.swift | 77 ++++++++++--------- 7 files changed, 58 insertions(+), 118 deletions(-) diff --git a/Linphone/UI/Call/CallView.swift b/Linphone/UI/Call/CallView.swift index 395919d31..d0e46d4b6 100644 --- a/Linphone/UI/Call/CallView.swift +++ b/Linphone/UI/Call/CallView.swift @@ -205,7 +205,7 @@ struct CallView: View { .zIndex(4) .transition(.move(edge: .bottom)) .onDisappear { - conversationViewModel.removeConversationDelegate() + conversationViewModel.displayedConversation = nil isShowConversationFragment = false } } @@ -2198,21 +2198,7 @@ struct CallView: View { .frame(width: 32, height: 32, alignment: .center) .onDisappear { if callViewModel.isOneOneCall && callViewModel.displayedConversation != nil { - if conversationViewModel.displayedConversation != nil { - conversationViewModel.removeConversationDelegate() - conversationViewModel.resetMessage() - conversationViewModel.changeDisplayedChatRoom(conversationModel: callViewModel.displayedConversation!) - - conversationViewModel.getMessages() - withAnimation { - isShowConversationFragment = true - } - } else { - conversationViewModel.changeDisplayedChatRoom(conversationModel: callViewModel.displayedConversation!) - withAnimation { - isShowConversationFragment = true - } - } + conversationViewModel.changeDisplayedChatRoom(conversationModel: callViewModel.displayedConversation!) } } } @@ -2581,21 +2567,7 @@ struct CallView: View { .frame(width: 32, height: 32, alignment: .center) .onDisappear { if callViewModel.isOneOneCall && callViewModel.displayedConversation != nil { - if conversationViewModel.displayedConversation != nil { - conversationViewModel.removeConversationDelegate() - conversationViewModel.resetMessage() - conversationViewModel.changeDisplayedChatRoom(conversationModel: callViewModel.displayedConversation!) - - conversationViewModel.getMessages() - withAnimation { - isShowConversationFragment = true - } - } else { - conversationViewModel.changeDisplayedChatRoom(conversationModel: callViewModel.displayedConversation!) - withAnimation { - isShowConversationFragment = true - } - } + conversationViewModel.changeDisplayedChatRoom(conversationModel: callViewModel.displayedConversation!) } } } diff --git a/Linphone/UI/Main/ContentView.swift b/Linphone/UI/Main/ContentView.swift index 81347cdea..4e147f5fc 100644 --- a/Linphone/UI/Main/ContentView.swift +++ b/Linphone/UI/Main/ContentView.swift @@ -129,7 +129,7 @@ struct ContentView: View { Button(action: { self.index = 0 historyViewModel.displayedCall = nil - conversationViewModel.removeConversationDelegate() + conversationViewModel.displayedConversation = nil meetingViewModel.displayedMeeting = nil }, label: { VStack { @@ -174,7 +174,7 @@ struct ContentView: View { Button(action: { self.index = 1 contactViewModel.indexDisplayedFriend = nil - conversationViewModel.removeConversationDelegate() + conversationViewModel.displayedConversation = nil meetingViewModel.displayedMeeting = nil if historyListViewModel.missedCallsCount > 0 { historyListViewModel.resetMissedCallsCount() @@ -250,7 +250,7 @@ struct ContentView: View { self.index = 3 contactViewModel.indexDisplayedFriend = nil historyViewModel.displayedCall = nil - conversationViewModel.removeConversationDelegate() + conversationViewModel.displayedConversation = nil }, label: { VStack { Image("video-conference") @@ -658,7 +658,7 @@ struct ContentView: View { Button(action: { self.index = 0 historyViewModel.displayedCall = nil - conversationViewModel.removeConversationDelegate() + conversationViewModel.displayedConversation = nil meetingViewModel.displayedMeeting = nil }, label: { VStack { @@ -705,7 +705,7 @@ struct ContentView: View { Button(action: { self.index = 1 contactViewModel.indexDisplayedFriend = nil - conversationViewModel.removeConversationDelegate() + conversationViewModel.displayedConversation = nil meetingViewModel.displayedMeeting = nil if historyListViewModel.missedCallsCount > 0 { historyListViewModel.resetMissedCallsCount() @@ -784,7 +784,7 @@ struct ContentView: View { self.index = 3 contactViewModel.indexDisplayedFriend = nil historyViewModel.displayedCall = nil - conversationViewModel.removeConversationDelegate() + conversationViewModel.displayedConversation = nil }, label: { VStack { Image("video-conference") diff --git a/Linphone/UI/Main/Conversations/Fragments/ConversationForwardMessageFragment.swift b/Linphone/UI/Main/Conversations/Fragments/ConversationForwardMessageFragment.swift index d1830ea5f..5f3028247 100644 --- a/Linphone/UI/Main/Conversations/Fragments/ConversationForwardMessageFragment.swift +++ b/Linphone/UI/Main/Conversations/Fragments/ConversationForwardMessageFragment.swift @@ -211,20 +211,11 @@ struct ConversationForwardMessageFragment: View { if conversationForwardMessageViewModel.displayedConversation != nil { if conversationViewModel.displayedConversation != nil { - conversationViewModel.removeConversationDelegate() DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { - conversationViewModel.selectedMessage = nil - conversationViewModel.resetMessage() - withAnimation { - self.conversationViewModel.changeDisplayedChatRoom(conversationModel: conversationForwardMessageViewModel.displayedConversation!) - } - conversationViewModel.getMessages() - } - } else { - conversationViewModel.selectedMessage = nil - withAnimation { self.conversationViewModel.changeDisplayedChatRoom(conversationModel: conversationForwardMessageViewModel.displayedConversation!) } + } else { + self.conversationViewModel.changeDisplayedChatRoom(conversationModel: conversationForwardMessageViewModel.displayedConversation!) } } } diff --git a/Linphone/UI/Main/Conversations/Fragments/ConversationFragment.swift b/Linphone/UI/Main/Conversations/Fragments/ConversationFragment.swift index 45fc441e4..6e3c299a1 100644 --- a/Linphone/UI/Main/Conversations/Fragments/ConversationFragment.swift +++ b/Linphone/UI/Main/Conversations/Fragments/ConversationFragment.swift @@ -68,9 +68,6 @@ struct ConversationFragment: View { .onRotate { newOrientation in orientation = newOrientation } - .onAppear { - conversationViewModel.addConversationDelegate() - } .onDisappear { conversationViewModel.removeConversationDelegate() } @@ -111,9 +108,6 @@ struct ConversationFragment: View { .onRotate { newOrientation in orientation = newOrientation } - .onAppear { - conversationViewModel.addConversationDelegate() - } .onDisappear { conversationViewModel.removeConversationDelegate() } @@ -178,7 +172,7 @@ struct ConversationFragment: View { if isShowConversationFragment { isShowConversationFragment = false } - conversationViewModel.removeConversationDelegate() + conversationViewModel.displayedConversation = nil } } } diff --git a/Linphone/UI/Main/Conversations/Fragments/ConversationsListFragment.swift b/Linphone/UI/Main/Conversations/Fragments/ConversationsListFragment.swift index af2ab8f36..f8c48da1d 100644 --- a/Linphone/UI/Main/Conversations/Fragments/ConversationsListFragment.swift +++ b/Linphone/UI/Main/Conversations/Fragments/ConversationsListFragment.swift @@ -152,29 +152,15 @@ struct ConversationsListFragment: View { enteredForeground = false - if navigationManager.peerAddr != nil && conversationsListViewModel.conversationsList[index].remoteSipUri.contains(navigationManager.peerAddr!) { + if navigationManager.peerAddr != nil + && conversationsListViewModel.conversationsList[index].remoteSipUri.contains(navigationManager.peerAddr!) { conversationViewModel.getChatRoomWithStringAddress(conversationsList: conversationsListViewModel.conversationsList, stringAddr: navigationManager.peerAddr!) navigationManager.peerAddr = nil } } .onTapGesture { if index < conversationsListViewModel.conversationsList.count { - if conversationViewModel.displayedConversation != nil { - conversationViewModel.removeConversationDelegate() - DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { - conversationViewModel.selectedMessage = nil - conversationViewModel.resetMessage() - withAnimation { - conversationViewModel.changeDisplayedChatRoom(conversationModel: conversationsListViewModel.conversationsList[index]) - } - conversationViewModel.getMessages() - } - } else { - conversationViewModel.selectedMessage = nil - withAnimation { - conversationViewModel.changeDisplayedChatRoom(conversationModel: conversationsListViewModel.conversationsList[index]) - } - } + conversationViewModel.changeDisplayedChatRoom(conversationModel: conversationsListViewModel.conversationsList[index]) } } .onLongPressGesture(minimumDuration: 0.2) { diff --git a/Linphone/UI/Main/Conversations/Fragments/StartConversationFragment.swift b/Linphone/UI/Main/Conversations/Fragments/StartConversationFragment.swift index b39a496ac..c7d37a931 100644 --- a/Linphone/UI/Main/Conversations/Fragments/StartConversationFragment.swift +++ b/Linphone/UI/Main/Conversations/Fragments/StartConversationFragment.swift @@ -238,17 +238,7 @@ struct StartConversationFragment: View { isShowStartConversationFragment = false if startConversationViewModel.displayedConversation != nil { - if self.conversationViewModel.displayedConversation != nil { - self.conversationViewModel.removeConversationDelegate() - self.conversationViewModel.resetMessage() - self.conversationViewModel.changeDisplayedChatRoom(conversationModel: startConversationViewModel.displayedConversation!) - - self.conversationViewModel.getMessages() - } else { - withAnimation { - self.conversationViewModel.changeDisplayedChatRoom(conversationModel: startConversationViewModel.displayedConversation!) - } - } + self.conversationViewModel.changeDisplayedChatRoom(conversationModel: startConversationViewModel.displayedConversation!) startConversationViewModel.displayedConversation = nil } diff --git a/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift b/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift index a37a3d0ca..d02859592 100644 --- a/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift +++ b/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift @@ -37,22 +37,34 @@ class ConversationViewModel: ObservableObject { @Published var messageText: String = "" @Published var composingLabel: String = "" - private var chatRoomDelegate: ChatRoomDelegate? + // 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) - chatMessage = message - chatMessageDelegate = delegate + self.chatMessage = message + self.chatMessageDelegate = delegate } - deinit { - chatMessage.removeDelegate(delegate: chatMessageDelegate) + self.chatMessage.removeDelegate(delegate: chatMessageDelegate) } } @@ -95,15 +107,15 @@ class ConversationViewModel: ObservableObject { func addConversationDelegate() { coreContext.doOnCoreQueue { _ in - if self.displayedConversation != nil { - self.chatRoomDelegate = ChatRoomDelegateStub( onIsComposingReceived: { (_: ChatRoom, _: Address, _: Bool) 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]) }) - self.displayedConversation?.chatRoom.addDelegate(delegate: self.chatRoomDelegate!) + self.chatRoomDelegateHolder = ChatRoomDelegateHolder(chatroom: chatroom, delegate: chatRoomDelegate) } } } @@ -198,14 +210,10 @@ class ConversationViewModel: ObservableObject { } func removeConversationDelegate() { - if let crDelegate = self.chatRoomDelegate { - if self.displayedConversation != nil { - self.displayedConversation!.chatRoom.removeDelegate(delegate: crDelegate) - } + coreContext.doOnCoreQueue { _ in + self.chatRoomDelegateHolder = nil + self.chatMessageDelegateHolders.removeAll() } - self.chatRoomDelegate = nil - self.chatMessageDelegateHolders.removeAll() - self.displayedConversation = nil } func getHistorySize() { @@ -283,7 +291,7 @@ class ConversationViewModel: ObservableObject { } } - func getMessages() { + func getMessages() { self.getHistorySize() self.getUnreadMessagesCount() self.getParticipantConversationModel() @@ -1376,7 +1384,14 @@ class ConversationViewModel: ObservableObject { } func changeDisplayedChatRoom(conversationModel: ConversationModel) { - self.displayedConversation = conversationModel + self.selectedMessage = nil + self.resetMessage() + self.removeConversationDelegate() + withAnimation { + self.displayedConversation = conversationModel + } + self.addConversationDelegate() + self.getMessages() } func resetDisplayedChatRoom(conversationsList: [ConversationModel]) { @@ -1792,28 +1807,20 @@ class ConversationViewModel: ObservableObject { 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.async { - self.removeConversationDelegate() - DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { - self.selectedMessage = nil - self.resetMessage() - self.changeDisplayedChatRoom(conversationModel: dispChatRoom) - self.getMessages() - } - } - } - } else { - DispatchQueue.main.async { - self.selectedMessage = nil + 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 { - } } } From b79541295fd845471b0296741e26daff5aba25ac Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Thu, 3 Oct 2024 14:30:15 +0200 Subject: [PATCH 433/486] Change of message id used for condition in resetDisplayedChatRoom --- .../UI/Main/Conversations/ViewModel/ConversationViewModel.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift b/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift index d02859592..40ca23567 100644 --- a/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift +++ b/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift @@ -1416,7 +1416,7 @@ class ConversationViewModel: ObservableObject { if eventLogLast != nil { eventLogList.append(eventLogLast!) - if !eventLogList.isEmpty && self.conversationMessagesSection[0].rows.first?.message.id != eventLogLast!.chatMessage?.messageId { + if !eventLogList.isEmpty && (self.conversationMessagesSection[0].rows.first?.eventModel.eventLog.chatMessage?.messageId != eventLogLast!.chatMessage?.messageId) { self.getNewMessages(eventLogs: eventLogList) } } From d1148cca1c0ea8a25e1c358e1be6abc7ae75f49f Mon Sep 17 00:00:00 2001 From: QuentinArguillere Date: Thu, 3 Oct 2024 14:43:41 +0200 Subject: [PATCH 434/486] Update build version to (50) --- Linphone.xcodeproj/project.pbxproj | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Linphone.xcodeproj/project.pbxproj b/Linphone.xcodeproj/project.pbxproj index 4d2cd948b..86e6b5348 100644 --- a/Linphone.xcodeproj/project.pbxproj +++ b/Linphone.xcodeproj/project.pbxproj @@ -1246,7 +1246,7 @@ CODE_SIGN_ENTITLEMENTS = msgNotificationService/msgNotificationService.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 49; + CURRENT_PROJECT_VERSION = 50; DEVELOPMENT_TEAM = Z2V957B3D6; ENABLE_USER_SCRIPT_SANDBOXING = NO; GCC_C_LANGUAGE_STANDARD = gnu17; @@ -1289,7 +1289,7 @@ CODE_SIGN_ENTITLEMENTS = msgNotificationService/msgNotificationService.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 49; + CURRENT_PROJECT_VERSION = 50; DEVELOPMENT_TEAM = Z2V957B3D6; ENABLE_USER_SCRIPT_SANDBOXING = NO; GCC_C_LANGUAGE_STANDARD = gnu17; @@ -1446,7 +1446,7 @@ ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = NO; CODE_SIGN_ENTITLEMENTS = Linphone/Linphone.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 49; + CURRENT_PROJECT_VERSION = 50; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_ASSET_PATHS = "\"Linphone/Preview Content\""; @@ -1503,7 +1503,7 @@ ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = NO; CODE_SIGN_ENTITLEMENTS = Linphone/Linphone.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 49; + CURRENT_PROJECT_VERSION = 50; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_ASSET_PATHS = "\"Linphone/Preview Content\""; DEVELOPMENT_TEAM = Z2V957B3D6; From 423fb56401db62e3c516b5dbfbe704bd1c16cc9c Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Thu, 3 Oct 2024 17:45:19 +0200 Subject: [PATCH 435/486] Check whether the new message received is different from the first message in the list --- Linphone/Localizable.xcstrings | 18 +++++++++ .../ViewModel/ConversationViewModel.swift | 38 ++++++++++--------- 2 files changed, 38 insertions(+), 18 deletions(-) diff --git a/Linphone/Localizable.xcstrings b/Linphone/Localizable.xcstrings index 205ff1ae7..f225dd1d9 100644 --- a/Linphone/Localizable.xcstrings +++ b/Linphone/Localizable.xcstrings @@ -6,9 +6,15 @@ }, " et " : { + }, + " has reacted by " : { + }, " or " : { + }, + " to: " : { + }, "." : { @@ -141,6 +147,15 @@ }, "👍" : { + }, + "📅 Meeting has been cancelled" : { + + }, + "📅 Meeting has been modified" : { + + }, + "📅 You are invited to a meeting" : { + }, "😂" : { @@ -1912,6 +1927,9 @@ }, "Message copied into clipboard" : { + }, + "Message received" : { + }, "message_delivery_info_error_title" : { "extractionState" : "manual", diff --git a/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift b/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift index 40ca23567..da8a6fde1 100644 --- a/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift +++ b/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift @@ -937,25 +937,27 @@ class ConversationViewModel: ObservableObject { ) ) - 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[0].rows.first?.eventModel.eventLog.chatMessage?.messageId != eventLog.chatMessage?.messageId { + self.addChatMessageDelegate(message: eventLog.chatMessage!) - 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 + 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 { From c58610d6f8ae7196dfbc9fc550b1db6a8a380ad4 Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Fri, 4 Oct 2024 11:26:22 +0200 Subject: [PATCH 436/486] Prevents asStringUriOnly on a nullable address --- .../ViewModel/ConversationViewModel.swift | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift b/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift index da8a6fde1..5421596a0 100644 --- a/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift +++ b/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift @@ -837,19 +837,19 @@ class ConversationViewModel: ObservableObject { } let isFirstMessageIncomingTmp = index > 0 - ? addressPrecCleaned?.asStringUriOnly() != addressCleaned?.asStringUriOnly() + ? addressPrecCleaned != nil && addressCleaned != nil && addressPrecCleaned!.asStringUriOnly() != addressCleaned!.asStringUriOnly() : ( self.conversationMessagesSection.isEmpty || self.conversationMessagesSection[0].rows.isEmpty ? true - : self.conversationMessagesSection[0].rows[0].message.address != addressCleaned?.asStringUriOnly() + : addressCleaned != nil && self.conversationMessagesSection[0].rows[0].message.address != addressCleaned!.asStringUriOnly() ) let isFirstMessageOutgoingTmp = index <= eventLogs.count - 2 - ? addressNextCleaned?.asStringUriOnly() == addressCleaned?.asStringUriOnly() + ? addressNextCleaned != nil && addressCleaned != nil && addressNextCleaned!.asStringUriOnly() == addressCleaned!.asStringUriOnly() : ( self.conversationMessagesSection.isEmpty || self.conversationMessagesSection[0].rows.isEmpty ? true - : !self.conversationMessagesSection[0].rows[0].message.isOutgoing || self.conversationMessagesSection[0].rows[0].message.address == addressCleaned?.asStringUriOnly() + : !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 @@ -906,7 +906,7 @@ class ConversationViewModel: ObservableObject { replyMessageTmp = ReplyMessage( id: eventLog.chatMessage?.replyMessage!.messageId ?? UUID().uuidString, - address: addressReplyCleaned?.asStringUriOnly() ?? "", + address: addressReplyCleaned != nil ? addressReplyCleaned!.asStringUriOnly() : "", isFirstMessage: false, text: contentReplyText, isOutgoing: false, @@ -925,7 +925,7 @@ class ConversationViewModel: ObservableObject { status: statusTmp, isOutgoing: eventLog.chatMessage?.isOutgoing ?? false, dateReceived: eventLog.chatMessage?.time ?? 0, - address: addressCleaned?.asStringUriOnly() ?? "", + address: addressCleaned != nil ? addressCleaned!.asStringUriOnly() : "", isFirstMessage: isFirstMessageTmp, text: contentText, attachmentsNames: attachmentNameList, From 9bbd554547cd0da6db5a465aef00e2d9b2c5cf81 Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Fri, 4 Oct 2024 11:28:02 +0200 Subject: [PATCH 437/486] Check if chatRoom.peerAddress is not nil in didReceive NotificationService --- msgNotificationService/NotificationService.swift | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/msgNotificationService/NotificationService.swift b/msgNotificationService/NotificationService.swift index 98b2b210e..d8e77cbc8 100644 --- a/msgNotificationService/NotificationService.swift +++ b/msgNotificationService/NotificationService.swift @@ -125,10 +125,16 @@ class NotificationService: UNNotificationServiceExtension { Log.info("chat room invite received") bestAttemptContent.title = NSLocalizedString("GC_MSG", comment: "") if chatRoom.hasCapability(mask: ChatRoom.Capabilities.OneToOne.rawValue) { - if chatRoom.peerAddress?.displayName?.isEmpty != true { - bestAttemptContent.body = chatRoom.peerAddress!.displayName! + 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 = chatRoom.peerAddress!.username! + bestAttemptContent.body = "Peer Address Error" } } else { bestAttemptContent.body = chatRoom.subject! From 5d330ce7dc0541f170e638992e7b584d7807b8e9 Mon Sep 17 00:00:00 2001 From: QuentinArguillere Date: Tue, 8 Oct 2024 13:57:08 +0200 Subject: [PATCH 438/486] Set message to error state if statusTmp is nil --- .../Main/Conversations/ViewModel/ConversationViewModel.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift b/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift index 5421596a0..4434a4d7f 100644 --- a/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift +++ b/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift @@ -144,7 +144,7 @@ class ConversationViewModel: ObservableObject { if indexMessage < self.conversationMessagesSection[0].rows.count && self.conversationMessagesSection[0].rows[indexMessage].message.status != statusTmp { DispatchQueue.main.async { self.objectWillChange.send() - self.conversationMessagesSection[0].rows[indexMessage].message.status = statusTmp + self.conversationMessagesSection[0].rows[indexMessage].message.status = statusTmp ?? .error } } } @@ -173,7 +173,7 @@ class ConversationViewModel: ObservableObject { DispatchQueue.main.async { if indexMessage != nil { self.objectWillChange.send() - self.conversationMessagesSection[0].rows[indexMessage!].message.status = statusTmp + self.conversationMessagesSection[0].rows[indexMessage!].message.status = statusTmp ?? .error } } }, onNewMessageReaction: { (message: ChatMessage, _: ChatMessageReaction) in From d2afeb483a83d9b61804be7753e3f75f88f3f4bc Mon Sep 17 00:00:00 2001 From: QuentinArguillere Date: Tue, 8 Oct 2024 17:29:25 +0200 Subject: [PATCH 439/486] Update build version to (51) --- Linphone.xcodeproj/project.pbxproj | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Linphone.xcodeproj/project.pbxproj b/Linphone.xcodeproj/project.pbxproj index 86e6b5348..79975d7de 100644 --- a/Linphone.xcodeproj/project.pbxproj +++ b/Linphone.xcodeproj/project.pbxproj @@ -1246,7 +1246,7 @@ CODE_SIGN_ENTITLEMENTS = msgNotificationService/msgNotificationService.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 50; + CURRENT_PROJECT_VERSION = 51; DEVELOPMENT_TEAM = Z2V957B3D6; ENABLE_USER_SCRIPT_SANDBOXING = NO; GCC_C_LANGUAGE_STANDARD = gnu17; @@ -1289,7 +1289,7 @@ CODE_SIGN_ENTITLEMENTS = msgNotificationService/msgNotificationService.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 50; + CURRENT_PROJECT_VERSION = 51; DEVELOPMENT_TEAM = Z2V957B3D6; ENABLE_USER_SCRIPT_SANDBOXING = NO; GCC_C_LANGUAGE_STANDARD = gnu17; @@ -1446,7 +1446,7 @@ ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = NO; CODE_SIGN_ENTITLEMENTS = Linphone/Linphone.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 50; + CURRENT_PROJECT_VERSION = 51; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_ASSET_PATHS = "\"Linphone/Preview Content\""; @@ -1503,7 +1503,7 @@ ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = NO; CODE_SIGN_ENTITLEMENTS = Linphone/Linphone.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 50; + CURRENT_PROJECT_VERSION = 51; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_ASSET_PATHS = "\"Linphone/Preview Content\""; DEVELOPMENT_TEAM = Z2V957B3D6; From 33c67e78b9fc4cc2bb2fde455b90a14ac90339e8 Mon Sep 17 00:00:00 2001 From: QuentinArguillere Date: Tue, 8 Oct 2024 17:30:10 +0200 Subject: [PATCH 440/486] Add debugtraces to investigate crash in resetDisplayedChatroom --- .../Conversations/ViewModel/ConversationViewModel.swift | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift b/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift index 4434a4d7f..0f8e57032 100644 --- a/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift +++ b/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift @@ -1406,10 +1406,13 @@ class ConversationViewModel: ObservableObject { if self.displayedConversation != nil { CoreContext.shared.doOnCoreQueue { _ in + Log.info("debugtrace -- resetDisplayedChatRoom -- eventLogFirst") let eventLogFirst = self.displayedConversation!.chatRoom.findEventLog(messageId: self.conversationMessagesSection[0].rows.first!.eventModel.eventLog.chatMessage!.messageId) + Log.info("debugtrace -- resetDisplayedChatRoom -- eventLogLast") let eventLogLast = self.displayedConversation!.chatRoom.getHistoryRangeEvents(begin: 0, end: 1).first + Log.info("debugtrace -- resetDisplayedChatRoom -- eventLogList") var eventLogList = self.displayedConversation!.chatRoom.getHistoryRangeBetween( firstEvent: eventLogFirst, lastEvent: eventLogLast, @@ -1417,12 +1420,16 @@ class ConversationViewModel: ObservableObject { ) if eventLogLast != nil { + Log.info("debugtrace -- resetDisplayedChatRoom -- eventLogList.append(eventLogLast!)") eventLogList.append(eventLogLast!) if !eventLogList.isEmpty && (self.conversationMessagesSection[0].rows.first?.eventModel.eventLog.chatMessage?.messageId != eventLogLast!.chatMessage?.messageId) { + + Log.info("debugtrace -- resetDisplayedChatRoom -- getNewMessage") self.getNewMessages(eventLogs: eventLogList) } } + Log.info("debugtrace -- addConversationDelegate -- eventLogList.append(eventLogLast!)") self.addConversationDelegate() } } From f3271778cc0006a267221c89843d00c8472deb0a Mon Sep 17 00:00:00 2001 From: QuentinArguillere Date: Tue, 8 Oct 2024 17:33:38 +0200 Subject: [PATCH 441/486] Comment self.objectWillChange.send() in ConversationViewModel --- .../Conversations/ViewModel/ConversationViewModel.swift | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift b/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift index 0f8e57032..99d01de51 100644 --- a/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift +++ b/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift @@ -143,7 +143,7 @@ class ConversationViewModel: ObservableObject { if let indexMessage = self.conversationMessagesSection[0].rows.firstIndex(where: {$0.eventModel.eventLog.chatMessage?.messageId == message.messageId}) { if indexMessage < self.conversationMessagesSection[0].rows.count && self.conversationMessagesSection[0].rows[indexMessage].message.status != statusTmp { DispatchQueue.main.async { - self.objectWillChange.send() + //self.objectWillChange.send() self.conversationMessagesSection[0].rows[indexMessage].message.status = statusTmp ?? .error } } @@ -171,8 +171,7 @@ class ConversationViewModel: ObservableObject { let indexMessage = self.conversationMessagesSection[0].rows.firstIndex(where: {$0.eventModel.eventLog.chatMessage?.messageId == message.messageId}) DispatchQueue.main.async { - if indexMessage != nil { - self.objectWillChange.send() + if indexMessage != nil { // self.objectWillChange.send() self.conversationMessagesSection[0].rows[indexMessage!].message.status = statusTmp ?? .error } } @@ -185,7 +184,7 @@ class ConversationViewModel: ObservableObject { DispatchQueue.main.async { if indexMessage != nil { - self.objectWillChange.send() + //self.objectWillChange.send() self.conversationMessagesSection[0].rows[indexMessage!].message.reactions = reactionsTmp } } @@ -198,7 +197,7 @@ class ConversationViewModel: ObservableObject { DispatchQueue.main.async { if indexMessage != nil { - self.objectWillChange.send() + // self.objectWillChange.send() self.conversationMessagesSection[0].rows[indexMessage!].message.reactions = reactionsTmp } } From f41a236d1b4a5304b81cec1dd419ea60c6b1306b Mon Sep 17 00:00:00 2001 From: QuentinArguillere Date: Thu, 10 Oct 2024 11:51:32 +0200 Subject: [PATCH 442/486] Remove debug traces, fixe indent --- .../ViewModel/ConversationViewModel.swift | 21 +++++++------------ 1 file changed, 7 insertions(+), 14 deletions(-) diff --git a/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift b/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift index 99d01de51..940b0b95e 100644 --- a/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift +++ b/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift @@ -151,8 +151,8 @@ class ConversationViewModel: ObservableObject { } self.coreContext.doOnCoreQueue { _ in - let chatMessageDelegate = ChatMessageDelegateStub(onMsgStateChanged: { (message: ChatMessage, _: ChatMessage.State) in - var statusTmp: Message.Status? = .sending + let chatMessageDelegate = ChatMessageDelegateStub(onMsgStateChanged: { (message: ChatMessage, msgState: ChatMessage.State) in + var statusTmp: Message.Status? switch message.state { case .InProgress: statusTmp = .sending @@ -290,7 +290,7 @@ class ConversationViewModel: ObservableObject { } } - func getMessages() { + func getMessages() { self.getHistorySize() self.getUnreadMessagesCount() self.getParticipantConversationModel() @@ -1405,13 +1405,10 @@ class ConversationViewModel: ObservableObject { if self.displayedConversation != nil { CoreContext.shared.doOnCoreQueue { _ in - Log.info("debugtrace -- resetDisplayedChatRoom -- eventLogFirst") let eventLogFirst = self.displayedConversation!.chatRoom.findEventLog(messageId: self.conversationMessagesSection[0].rows.first!.eventModel.eventLog.chatMessage!.messageId) - Log.info("debugtrace -- resetDisplayedChatRoom -- eventLogLast") let eventLogLast = self.displayedConversation!.chatRoom.getHistoryRangeEvents(begin: 0, end: 1).first - Log.info("debugtrace -- resetDisplayedChatRoom -- eventLogList") var eventLogList = self.displayedConversation!.chatRoom.getHistoryRangeBetween( firstEvent: eventLogFirst, lastEvent: eventLogLast, @@ -1419,16 +1416,12 @@ class ConversationViewModel: ObservableObject { ) if eventLogLast != nil { - Log.info("debugtrace -- resetDisplayedChatRoom -- eventLogList.append(eventLogLast!)") eventLogList.append(eventLogLast!) if !eventLogList.isEmpty && (self.conversationMessagesSection[0].rows.first?.eventModel.eventLog.chatMessage?.messageId != eventLogLast!.chatMessage?.messageId) { - - Log.info("debugtrace -- resetDisplayedChatRoom -- getNewMessage") self.getNewMessages(eventLogs: eventLogList) } } - Log.info("debugtrace -- addConversationDelegate -- eventLogList.append(eventLogLast!)") self.addConversationDelegate() } } @@ -1476,7 +1469,7 @@ class ConversationViewModel: ObservableObject { } let urlName = pathThumbnail == nil - ? URL(string: "file://" + ? URL(string: "file://" + FileUtil.sharedContainerUrl().appendingPathComponent("Library/Images").absoluteString + "preview_" + (name.addingPercentEncoding(withAllowedCharacters: .urlHostAllowed) ?? "") @@ -2040,7 +2033,7 @@ class AudioRecorder: NSObject, ObservableObject { } } } - + func stopVoiceRecorder() { if linphoneAudioRecorder.state == .Running { Log.info("[ConversationViewModel] [AudioRecorder] Closing voice recorder") @@ -2113,10 +2106,10 @@ class AudioRecorder: NSObject, ObservableObject { } } } - + 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 } } From 4666678f37f32ec5e24fd667001fc2d8ea496ebc Mon Sep 17 00:00:00 2001 From: QuentinArguillere Date: Thu, 10 Oct 2024 12:03:09 +0200 Subject: [PATCH 443/486] Update build to (52) --- Linphone.xcodeproj/project.pbxproj | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Linphone.xcodeproj/project.pbxproj b/Linphone.xcodeproj/project.pbxproj index 79975d7de..eaf0ae353 100644 --- a/Linphone.xcodeproj/project.pbxproj +++ b/Linphone.xcodeproj/project.pbxproj @@ -1246,7 +1246,7 @@ CODE_SIGN_ENTITLEMENTS = msgNotificationService/msgNotificationService.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 51; + CURRENT_PROJECT_VERSION = 52; DEVELOPMENT_TEAM = Z2V957B3D6; ENABLE_USER_SCRIPT_SANDBOXING = NO; GCC_C_LANGUAGE_STANDARD = gnu17; @@ -1289,7 +1289,7 @@ CODE_SIGN_ENTITLEMENTS = msgNotificationService/msgNotificationService.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 51; + CURRENT_PROJECT_VERSION = 52; DEVELOPMENT_TEAM = Z2V957B3D6; ENABLE_USER_SCRIPT_SANDBOXING = NO; GCC_C_LANGUAGE_STANDARD = gnu17; @@ -1446,7 +1446,7 @@ ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = NO; CODE_SIGN_ENTITLEMENTS = Linphone/Linphone.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 51; + CURRENT_PROJECT_VERSION = 52; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_ASSET_PATHS = "\"Linphone/Preview Content\""; @@ -1503,7 +1503,7 @@ ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = NO; CODE_SIGN_ENTITLEMENTS = Linphone/Linphone.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 51; + CURRENT_PROJECT_VERSION = 52; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_ASSET_PATHS = "\"Linphone/Preview Content\""; DEVELOPMENT_TEAM = Z2V957B3D6; From 0b28aa5179bac51473279402043ed0118b4179ee Mon Sep 17 00:00:00 2001 From: QuentinArguillere Date: Thu, 10 Oct 2024 15:50:43 +0200 Subject: [PATCH 444/486] Fix condition typo that prevented meeting creation --- Linphone/UI/Main/Meetings/ViewModel/MeetingViewModel.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Linphone/UI/Main/Meetings/ViewModel/MeetingViewModel.swift b/Linphone/UI/Main/Meetings/ViewModel/MeetingViewModel.swift index 0cf9422e4..84ac81e15 100644 --- a/Linphone/UI/Main/Meetings/ViewModel/MeetingViewModel.swift +++ b/Linphone/UI/Main/Meetings/ViewModel/MeetingViewModel.swift @@ -238,7 +238,7 @@ class MeetingViewModel: ObservableObject { } func schedule() { - guard !subject.isEmpty && participants.isEmpty else { + 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" From 00187e97a25d6e11a156666ceda3b9b0c45581e3 Mon Sep 17 00:00:00 2001 From: QuentinArguillere Date: Fri, 11 Oct 2024 15:01:01 +0200 Subject: [PATCH 445/486] Fix "No meeting today" line not appearing if all meetings in the list are in the past --- Linphone/UI/Main/Meetings/Fragments/MeetingsFragment.swift | 2 +- .../UI/Main/Meetings/ViewModel/MeetingsListViewModel.swift | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/Linphone/UI/Main/Meetings/Fragments/MeetingsFragment.swift b/Linphone/UI/Main/Meetings/Fragments/MeetingsFragment.swift index 28e883319..4c8f55955 100644 --- a/Linphone/UI/Main/Meetings/Fragments/MeetingsFragment.swift +++ b/Linphone/UI/Main/Meetings/Fragments/MeetingsFragment.swift @@ -137,7 +137,7 @@ struct MeetingsFragment: View { Text("No meeting today") .fontWeight(.bold) .padding(.leading, 20) - .padding(.top, 10) + .padding(.top, 15) .default_text_style_500(styleSize: 15) } else { createMeetingLine(model: itemModel) diff --git a/Linphone/UI/Main/Meetings/ViewModel/MeetingsListViewModel.swift b/Linphone/UI/Main/Meetings/ViewModel/MeetingsListViewModel.swift index 64312cebf..a3b8c1403 100644 --- a/Linphone/UI/Main/Meetings/ViewModel/MeetingsListViewModel.swift +++ b/Linphone/UI/Main/Meetings/ViewModel/MeetingsListViewModel.swift @@ -108,6 +108,12 @@ class MeetingsListViewModel: ObservableObject { } } + 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 From 137abcfe743528f2dcc20d89633954ad080d7ba3 Mon Sep 17 00:00:00 2001 From: QuentinArguillere Date: Fri, 11 Oct 2024 15:12:08 +0200 Subject: [PATCH 446/486] When deleting all meetings from the list, also remove the "No meeting today" line --- .../UI/Main/Meetings/ViewModel/MeetingsListViewModel.swift | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Linphone/UI/Main/Meetings/ViewModel/MeetingsListViewModel.swift b/Linphone/UI/Main/Meetings/ViewModel/MeetingsListViewModel.swift index a3b8c1403..1a89b6bfa 100644 --- a/Linphone/UI/Main/Meetings/ViewModel/MeetingsListViewModel.swift +++ b/Linphone/UI/Main/Meetings/ViewModel/MeetingsListViewModel.swift @@ -137,6 +137,10 @@ class MeetingsListViewModel: ObservableObject { 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 } From 2eee40a7ae10643a5f078bc3b11bfdfb55fe4a03 Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Mon, 7 Oct 2024 10:09:17 +0200 Subject: [PATCH 447/486] Check index validity before accessing conversation list --- .../Conversations/Fragments/ConversationsListFragment.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Linphone/UI/Main/Conversations/Fragments/ConversationsListFragment.swift b/Linphone/UI/Main/Conversations/Fragments/ConversationsListFragment.swift index f8c48da1d..73132c185 100644 --- a/Linphone/UI/Main/Conversations/Fragments/ConversationsListFragment.swift +++ b/Linphone/UI/Main/Conversations/Fragments/ConversationsListFragment.swift @@ -152,7 +152,8 @@ struct ConversationsListFragment: View { enteredForeground = false - if navigationManager.peerAddr != nil + if navigationManager.peerAddr != nil + && index < conversationsListViewModel.conversationsList.count && conversationsListViewModel.conversationsList[index].remoteSipUri.contains(navigationManager.peerAddr!) { conversationViewModel.getChatRoomWithStringAddress(conversationsList: conversationsListViewModel.conversationsList, stringAddr: navigationManager.peerAddr!) navigationManager.peerAddr = nil From 2b80c5b78bf4138788e73a1029afa2c9d64be0d6 Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Tue, 8 Oct 2024 09:12:16 +0200 Subject: [PATCH 448/486] Ephemeral message --- .../Fragments/ChatBubbleView.swift | 72 +++++++++++++++++ .../Main/Conversations/Fragments/UIList.swift | 2 +- .../UI/Main/Conversations/Model/Message.swift | 14 +++- .../ViewModel/ConversationViewModel.swift | 79 +++++++++++++++++-- 4 files changed, 156 insertions(+), 11 deletions(-) diff --git a/Linphone/UI/Main/Conversations/Fragments/ChatBubbleView.swift b/Linphone/UI/Main/Conversations/Fragments/ChatBubbleView.swift index 4ad63f305..24e1cacc7 100644 --- a/Linphone/UI/Main/Conversations/Fragments/ChatBubbleView.swift +++ b/Linphone/UI/Main/Conversations/Fragments/ChatBubbleView.swift @@ -34,6 +34,9 @@ struct ChatBubbleView: View { @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 { @@ -165,6 +168,28 @@ struct ChatBubbleView: View { } HStack(alignment: .center) { + if eventLogMessage.message.isEphemeral && eventLogMessage.message.isOutgoing { + Text(ephemeralLifetime) + .foregroundStyle(Color.grayMain2c500) + .default_text_style_300(styleSize: 14) + .padding(.top, 1) + .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: 14) @@ -187,6 +212,29 @@ struct ChatBubbleView: View { .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: 14) + .padding(.top, 1) + .onAppear { + updateEphemeralTimer() + } + .onChange(of: eventLogMessage.message.ephemeralExpireTime) { ephemeralExpireTimeTmp in + if ephemeralExpireTimeTmp > 0 { + updateEphemeralTimer() + } + } + } } .onTapGesture { conversationViewModel.selectedMessageToDisplayDetails = eventLogMessage @@ -551,6 +599,30 @@ struct ChatBubbleView: View { 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() + } + } + } + } + } + } } enum URLType { diff --git a/Linphone/UI/Main/Conversations/Fragments/UIList.swift b/Linphone/UI/Main/Conversations/Fragments/UIList.swift index 08d37e850..a98fea944 100644 --- a/Linphone/UI/Main/Conversations/Fragments/UIList.swift +++ b/Linphone/UI/Main/Conversations/Fragments/UIList.swift @@ -233,7 +233,7 @@ struct UIList: UIViewRepresentable { tableView.insertSections([section], with: .top) case .delete(let section, let row): - tableView.deleteRows(at: [IndexPath(row: row, section: section)], with: .top) + 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): diff --git a/Linphone/UI/Main/Conversations/Model/Message.swift b/Linphone/UI/Main/Conversations/Model/Message.swift index 94644eaae..271b23298 100644 --- a/Linphone/UI/Main/Conversations/Model/Message.swift +++ b/Linphone/UI/Main/Conversations/Model/Message.swift @@ -80,6 +80,10 @@ public struct Message: Identifiable, Hashable { 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 init( id: String, @@ -97,7 +101,10 @@ public struct Message: Identifiable, Hashable { replyMessage: ReplyMessage? = nil, isForward: Bool = false, ownReaction: String = "", - reactions: [String] = [] + reactions: [String] = [], + isEphemeral: Bool = false, + ephemeralExpireTime: Int = 0, + ephemeralLifetime: Int = 0 ) { self.id = id self.appData = appData @@ -115,6 +122,9 @@ public struct Message: Identifiable, Hashable { self.isForward = isForward self.ownReaction = ownReaction self.reactions = reactions + self.isEphemeral = isEphemeral + self.ephemeralExpireTime = ephemeralExpireTime + self.ephemeralLifetime = ephemeralLifetime } public static func makeMessage( @@ -167,7 +177,7 @@ extension Message { 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.id == rhs.id && lhs.status == rhs.status && lhs.isFirstMessage == rhs.isFirstMessage && lhs.ownReaction == rhs.ownReaction && lhs.reactions == rhs.reactions && lhs.ephemeralExpireTime == rhs.ephemeralExpireTime } } diff --git a/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift b/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift index 940b0b95e..45e344877 100644 --- a/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift +++ b/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift @@ -114,6 +114,8 @@ class ConversationViewModel: ObservableObject { self.getNewMessages(eventLogs: eventLogs) }, onChatMessageSending: { (_: ChatRoom, eventLog: EventLog) in self.getNewMessages(eventLogs: [eventLog]) + }, onEphemeralMessageDeleted: {(_: ChatRoom, eventLog: EventLog) in + self.removeMessage(eventLog) }) self.chatRoomDelegateHolder = ChatRoomDelegateHolder(chatroom: chatroom, delegate: chatRoomDelegate) } @@ -139,12 +141,22 @@ class ConversationViewModel: ObservableObject { statusTmp = .sending } + let ephemeralExpireTimeTmp = message.ephemeralExpireTime + if !self.conversationMessagesSection.isEmpty && !self.conversationMessagesSection[0].rows.isEmpty { if let indexMessage = self.conversationMessagesSection[0].rows.firstIndex(where: {$0.eventModel.eventLog.chatMessage?.messageId == message.messageId}) { - if indexMessage < self.conversationMessagesSection[0].rows.count && self.conversationMessagesSection[0].rows[indexMessage].message.status != statusTmp { - DispatchQueue.main.async { - //self.objectWillChange.send() - self.conversationMessagesSection[0].rows[indexMessage].message.status = statusTmp ?? .error + if indexMessage < self.conversationMessagesSection[0].rows.count { + if self.conversationMessagesSection[0].rows[indexMessage].message.status != statusTmp { + DispatchQueue.main.async { + //self.objectWillChange.send() + self.conversationMessagesSection[0].rows[indexMessage].message.status = statusTmp ?? .error + self.conversationMessagesSection[0].rows[indexMessage].message.ephemeralExpireTime = ephemeralExpireTimeTmp + } + } else { + DispatchQueue.main.async { + //self.objectWillChange.send() + self.conversationMessagesSection[0].rows[indexMessage].message.ephemeralExpireTime = ephemeralExpireTimeTmp + } } } } @@ -201,7 +213,18 @@ class ConversationViewModel: ObservableObject { self.conversationMessagesSection[0].rows[indexMessage!].message.reactions = reactionsTmp } } + }, onEphemeralMessageTimerStarted: { (message: ChatMessage) in + let indexMessage = self.conversationMessagesSection[0].rows.firstIndex(where: {$0.eventModel.eventLog.chatMessage?.messageId == message.messageId}) + let ephemeralExpireTimeTmp = message.ephemeralExpireTime + + DispatchQueue.main.async { + if indexMessage != nil { + self.objectWillChange.send() + self.conversationMessagesSection[0].rows[indexMessage!].message.ephemeralExpireTime = ephemeralExpireTimeTmp + } + } }) + self.chatMessageDelegateHolders.append(ChatMessageDelegateHolder(message: message, delegate: chatMessageDelegate)) } } @@ -480,7 +503,10 @@ class ConversationViewModel: ObservableObject { replyMessage: replyMessageTmp, isForward: eventLog.chatMessage?.isForward ?? false, ownReaction: eventLog.chatMessage?.ownReaction?.body ?? "", - reactions: reactionsTmp + reactions: reactionsTmp, + isEphemeral: eventLog.chatMessage?.isEphemeral ?? false, + ephemeralExpireTime: eventLog.chatMessage?.ephemeralExpireTime ?? 0, + ephemeralLifetime: eventLog.chatMessage?.ephemeralLifetime ?? 0 ) ) ) @@ -700,7 +726,10 @@ class ConversationViewModel: ObservableObject { replyMessage: replyMessageTmp, isForward: eventLog.chatMessage?.isForward ?? false, ownReaction: eventLog.chatMessage?.ownReaction?.body ?? "", - reactions: reactionsTmp + reactions: reactionsTmp, + isEphemeral: eventLog.chatMessage?.isEphemeral ?? false, + ephemeralExpireTime: eventLog.chatMessage?.ephemeralExpireTime ?? 0, + ephemeralLifetime: eventLog.chatMessage?.ephemeralLifetime ?? 0 ) ), at: 0 ) @@ -932,7 +961,10 @@ class ConversationViewModel: ObservableObject { replyMessage: replyMessageTmp, isForward: eventLog.chatMessage?.isForward ?? false, ownReaction: eventLog.chatMessage?.ownReaction?.body ?? "", - reactions: reactionsTmp + reactions: reactionsTmp, + isEphemeral: eventLog.chatMessage?.isEphemeral ?? false, + ephemeralExpireTime: eventLog.chatMessage?.ephemeralExpireTime ?? 0, + ephemeralLifetime: eventLog.chatMessage?.ephemeralLifetime ?? 0 ) ) @@ -1210,7 +1242,10 @@ class ConversationViewModel: ObservableObject { replyMessage: replyMessageTmp, isForward: eventLog.chatMessage?.isForward ?? false, ownReaction: eventLog.chatMessage?.ownReaction?.body ?? "", - reactions: reactionsTmp + reactions: reactionsTmp, + isEphemeral: eventLog.chatMessage?.isEphemeral ?? false, + ephemeralExpireTime: eventLog.chatMessage?.ephemeralExpireTime ?? 0, + ephemeralLifetime: eventLog.chatMessage?.ephemeralLifetime ?? 0 ) ), at: 0 ) @@ -1257,6 +1292,34 @@ class ConversationViewModel: ObservableObject { } } + 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) { coreContext.doOnCoreQueue { _ in do { From 33fae2447bebc3b792d3f96ad7ab585b549aeeb9 Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Wed, 9 Oct 2024 11:10:36 +0200 Subject: [PATCH 449/486] Minor redesign of messages --- .../Fragments/ChatBubbleView.swift | 26 ++++++++++--------- .../Fragments/ConversationFragment.swift | 4 +++ 2 files changed, 18 insertions(+), 12 deletions(-) diff --git a/Linphone/UI/Main/Conversations/Fragments/ChatBubbleView.swift b/Linphone/UI/Main/Conversations/Fragments/ChatBubbleView.swift index 24e1cacc7..99e159d18 100644 --- a/Linphone/UI/Main/Conversations/Fragments/ChatBubbleView.swift +++ b/Linphone/UI/Main/Conversations/Fragments/ChatBubbleView.swift @@ -68,7 +68,7 @@ struct ChatBubbleView: View { && !eventLogMessage.message.isOutgoing && eventLogMessage.message.isFirstMessage { Text(conversationViewModel.participantConversationModel.first(where: {$0.address == eventLogMessage.message.address})?.name ?? "") .default_text_style(styleSize: 12) - .padding(.top, 10) + .padding(.top, 5) .padding(.bottom, 2) } @@ -119,12 +119,12 @@ struct ChatBubbleView: View { if !eventLogMessage.message.replyMessage!.text.isEmpty { Text(eventLogMessage.message.replyMessage!.text) .foregroundStyle(Color.grayMain2c700) - .default_text_style(styleSize: 16) + .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: 16) + .default_text_style(styleSize: 14) .lineLimit(/*@START_MENU_TOKEN@*/2/*@END_MENU_TOKEN@*/) } } @@ -164,15 +164,16 @@ struct ChatBubbleView: View { if !eventLogMessage.message.text.isEmpty { Text(eventLogMessage.message.text) .foregroundStyle(Color.grayMain2c700) - .default_text_style(styleSize: 16) + .default_text_style(styleSize: 14) } HStack(alignment: .center) { if eventLogMessage.message.isEphemeral && eventLogMessage.message.isOutgoing { Text(ephemeralLifetime) .foregroundStyle(Color.grayMain2c500) - .default_text_style_300(styleSize: 14) + .default_text_style_300(styleSize: 12) .padding(.top, 1) + .padding(.trailing, -4) .onAppear { updateEphemeralTimer() } @@ -192,8 +193,9 @@ struct ChatBubbleView: View { Text(conversationViewModel.getMessageTime(startDate: eventLogMessage.message.dateReceived)) .foregroundStyle(Color.grayMain2c500) - .default_text_style_300(styleSize: 14) + .default_text_style_300(styleSize: 12) .padding(.top, 1) + .padding(.trailing, -4) if (conversationViewModel.displayedConversation != nil && conversationViewModel.displayedConversation!.isGroup) || eventLogMessage.message.isOutgoing { @@ -224,7 +226,7 @@ struct ChatBubbleView: View { Text(ephemeralLifetime) .foregroundStyle(Color.grayMain2c500) - .default_text_style_300(styleSize: 14) + .default_text_style_300(styleSize: 12) .padding(.top, 1) .onAppear { updateEphemeralTimer() @@ -256,14 +258,14 @@ struct ChatBubbleView: View { ForEach(0.. 50 ? 50 : iconSize, height: iconSize > 50 ? 50 : iconSize, alignment: .leading) } .padding(.trailing, 5) + */ } .padding(.vertical, 5) .padding(.horizontal, 10) @@ -854,6 +857,7 @@ struct ConversationFragment: View { } .frame(maxWidth: .infinity) .padding(.horizontal, 10) + .padding(.bottom, 20) .padding(.leading, conversationViewModel.displayedConversation!.isGroup ? 43 : 0) .shadow(color: .black.opacity(0.1), radius: 10) } From a13f44e1898f4080f26ff198e087bc365ec24877 Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Wed, 9 Oct 2024 14:29:25 +0200 Subject: [PATCH 450/486] Fix ImdnOrReactionsSheet for iOS 15 --- .../Fragments/ConversationFragment.swift | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/Linphone/UI/Main/Conversations/Fragments/ConversationFragment.swift b/Linphone/UI/Main/Conversations/Fragments/ConversationFragment.swift index 28908c951..0f970c8f5 100644 --- a/Linphone/UI/Main/Conversations/Fragments/ConversationFragment.swift +++ b/Linphone/UI/Main/Conversations/Fragments/ConversationFragment.swift @@ -74,7 +74,7 @@ struct ConversationFragment: View { .sheet(isPresented: $conversationViewModel.isShowSelectedMessageToDisplayDetails, onDismiss: { conversationViewModel.isShowSelectedMessageToDisplayDetails = false }, content: { - imdnOrReactionsSheet() + ImdnOrReactionsSheet(conversationViewModel: conversationViewModel, selectedCategoryIndex: $selectedCategoryIndex) .presentationDetents([.medium]) .presentationDragIndicator(.visible) }) @@ -112,7 +112,7 @@ struct ConversationFragment: View { conversationViewModel.removeConversationDelegate() } .halfSheet(showSheet: $conversationViewModel.isShowSelectedMessageToDisplayDetails) { - imdnOrReactionsSheet() + ImdnOrReactionsSheet(conversationViewModel: conversationViewModel, selectedCategoryIndex: $selectedCategoryIndex) } onDismiss: { conversationViewModel.isShowSelectedMessageToDisplayDetails = false } @@ -893,9 +893,14 @@ struct ConversationFragment: View { } // swiftlint:enable cyclomatic_complexity // swiftlint:enable function_body_length +} + +struct ImdnOrReactionsSheet: View { + @ObservedObject var conversationViewModel: ConversationViewModel - @ViewBuilder - func imdnOrReactionsSheet() -> some View { + @Binding var selectedCategoryIndex: Int + + var body: some View { VStack { Picker("Categories", selection: $selectedCategoryIndex) { ForEach(0.. Date: Thu, 10 Oct 2024 09:48:02 +0200 Subject: [PATCH 451/486] Add conference message bubble --- Linphone.xcodeproj/project.pbxproj | 8 +- Linphone/Localizable.xcstrings | 3 + .../Fragments/ChatBubbleView.swift | 4 + .../UI/Main/Conversations/Model/Message.swift | 6 +- .../Model/MessageConferenceInfo.swift | 45 +++++++++++ .../ViewModel/ConversationViewModel.swift | 81 ++++++++++++++++++- 6 files changed, 140 insertions(+), 7 deletions(-) create mode 100644 Linphone/UI/Main/Conversations/Model/MessageConferenceInfo.swift diff --git a/Linphone.xcodeproj/project.pbxproj b/Linphone.xcodeproj/project.pbxproj index eaf0ae353..603952be3 100644 --- a/Linphone.xcodeproj/project.pbxproj +++ b/Linphone.xcodeproj/project.pbxproj @@ -7,7 +7,7 @@ objects = { /* Begin PBXBuildFile section */ - 4ED1F0A881A9ACB5977A8987 /* (null) in Frameworks */ = {isa = PBXBuildFile; }; + 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 */; }; @@ -118,6 +118,7 @@ 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 */; }; @@ -305,6 +306,7 @@ 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 = ""; }; @@ -373,7 +375,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - 4ED1F0A881A9ACB5977A8987 /* (null) in Frameworks */, + 4ED1F0A881A9ACB5977A8987 /* BuildFile in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -482,6 +484,7 @@ D70959EF2B8DF33B0014AC0B /* Model */ = { isa = PBXGroup; children = ( + D77A080D2CB6BCA10095D589 /* MessageConferenceInfo.swift */, D70959F02B8DF3EC0014AC0B /* ConversationModel.swift */, D7E6ADF22B9875C20009A2BC /* Message.swift */, D7C2DA1C2CA44DE400A2441B /* EventModel.swift */, @@ -1215,6 +1218,7 @@ 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 */, diff --git a/Linphone/Localizable.xcstrings b/Linphone/Localizable.xcstrings index f225dd1d9..622a43465 100644 --- a/Linphone/Localizable.xcstrings +++ b/Linphone/Localizable.xcstrings @@ -1817,6 +1817,9 @@ }, "Meeting added to iPhone calendar" : { + }, + "Meeting invite !!" : { + }, "Meetings" : { diff --git a/Linphone/UI/Main/Conversations/Fragments/ChatBubbleView.swift b/Linphone/UI/Main/Conversations/Fragments/ChatBubbleView.swift index 99e159d18..b69e85d1e 100644 --- a/Linphone/UI/Main/Conversations/Fragments/ChatBubbleView.swift +++ b/Linphone/UI/Main/Conversations/Fragments/ChatBubbleView.swift @@ -302,6 +302,10 @@ struct ChatBubbleView: View { }) .padding(.leading, eventLogMessage.message.isOutgoing ? 40 : 0) .padding(.trailing, !eventLogMessage.message.isOutgoing ? 40 : 0) + } else if eventLogMessage.message.isIcalendar { + Text("Meeting invite !!") + .foregroundStyle(Color.grayMain2c500) + .default_text_style(styleSize: 12) } } .onTapGesture {} diff --git a/Linphone/UI/Main/Conversations/Model/Message.swift b/Linphone/UI/Main/Conversations/Model/Message.swift index 271b23298..282be1e5a 100644 --- a/Linphone/UI/Main/Conversations/Model/Message.swift +++ b/Linphone/UI/Main/Conversations/Model/Message.swift @@ -84,6 +84,8 @@ public struct Message: Identifiable, Hashable { public var isEphemeral: Bool public var ephemeralExpireTime: Int public var ephemeralLifetime: Int + + public var isIcalendar: Bool public init( id: String, @@ -104,7 +106,8 @@ public struct Message: Identifiable, Hashable { reactions: [String] = [], isEphemeral: Bool = false, ephemeralExpireTime: Int = 0, - ephemeralLifetime: Int = 0 + ephemeralLifetime: Int = 0, + isIcalendar: Bool = false ) { self.id = id self.appData = appData @@ -125,6 +128,7 @@ public struct Message: Identifiable, Hashable { self.isEphemeral = isEphemeral self.ephemeralExpireTime = ephemeralExpireTime self.ephemeralLifetime = ephemeralLifetime + self.isIcalendar = isIcalendar } public static func makeMessage( diff --git a/Linphone/UI/Main/Conversations/Model/MessageConferenceInfo.swift b/Linphone/UI/Main/Conversations/Model/MessageConferenceInfo.swift new file mode 100644 index 000000000..7ec3238e0 --- /dev/null +++ b/Linphone/UI/Main/Conversations/Model/MessageConferenceInfo.swift @@ -0,0 +1,45 @@ +/* + * 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 { + case updated + case cancelled +} + +public struct MessageConferenceInfo { + public let id: String + var uri: URL + var subject: String + var description: String + var state: MessageConferenceState + var dateTime: String + //var duration: time_t + //var participantInfos: [ParticipantInfo] + + public init(id: String, uri: URL, subject: String, description: String, state: MessageConferenceState, dateTime: String) { + self.id = id + self.uri = uri + self.subject = subject + self.description = description + self.state = state + self.dateTime = dateTime + } +} diff --git a/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift b/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift index 45e344877..75c90451b 100644 --- a/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift +++ b/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift @@ -506,7 +506,8 @@ class ConversationViewModel: ObservableObject { reactions: reactionsTmp, isEphemeral: eventLog.chatMessage?.isEphemeral ?? false, ephemeralExpireTime: eventLog.chatMessage?.ephemeralExpireTime ?? 0, - ephemeralLifetime: eventLog.chatMessage?.ephemeralLifetime ?? 0 + ephemeralLifetime: eventLog.chatMessage?.ephemeralLifetime ?? 0, + isIcalendar: eventLog.chatMessage?.contents.first?.isIcalendar ?? false ) ) ) @@ -729,7 +730,8 @@ class ConversationViewModel: ObservableObject { reactions: reactionsTmp, isEphemeral: eventLog.chatMessage?.isEphemeral ?? false, ephemeralExpireTime: eventLog.chatMessage?.ephemeralExpireTime ?? 0, - ephemeralLifetime: eventLog.chatMessage?.ephemeralLifetime ?? 0 + ephemeralLifetime: eventLog.chatMessage?.ephemeralLifetime ?? 0, + isIcalendar: eventLog.chatMessage?.contents.first?.isIcalendar ?? false ) ), at: 0 ) @@ -964,7 +966,8 @@ class ConversationViewModel: ObservableObject { reactions: reactionsTmp, isEphemeral: eventLog.chatMessage?.isEphemeral ?? false, ephemeralExpireTime: eventLog.chatMessage?.ephemeralExpireTime ?? 0, - ephemeralLifetime: eventLog.chatMessage?.ephemeralLifetime ?? 0 + ephemeralLifetime: eventLog.chatMessage?.ephemeralLifetime ?? 0, + isIcalendar: eventLog.chatMessage?.contents.first?.isIcalendar ?? false ) ) @@ -1245,7 +1248,8 @@ class ConversationViewModel: ObservableObject { reactions: reactionsTmp, isEphemeral: eventLog.chatMessage?.isEphemeral ?? false, ephemeralExpireTime: eventLog.chatMessage?.ephemeralExpireTime ?? 0, - ephemeralLifetime: eventLog.chatMessage?.ephemeralLifetime ?? 0 + ephemeralLifetime: eventLog.chatMessage?.ephemeralLifetime ?? 0, + isIcalendar: eventLog.chatMessage?.contents.first?.isIcalendar ?? false ) ), at: 0 ) @@ -1888,6 +1892,75 @@ class ConversationViewModel: ObservableObject { } } } + + func parseConferenceInvite(content: Content) -> MessageConferenceInfo? { + + var meetingConferenceUri: URL? + var meetingSubject: String = "" + var meetingDescription: String = "" + var meetingUpdated: Bool = false + var meetingCancelled: Bool = false + var meetingDate: String = "" + var meetingTime: String = "" + var meetingDay: String = "" + var meetingDayNumber: String = "" + var meetingParticipants: String = "" + var meetingFound: Bool = false + + 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)]") + meetingConferenceUri = URL(string: conferenceAddress.asStringUriOnly()) + meetingSubject = conferenceInfo.subject ?? "" + meetingDescription = conferenceInfo.description ?? "" + + meetingUpdated = (conferenceInfo.state == .Updated) + meetingCancelled = (conferenceInfo.state == .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 + + let date = dateFormatter.string(from: dateTmp) + + 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) + + let startTime = TimestampUtils.timeToString(timestamp) + let end = timestamp + (duration * 60) + let endTime = TimestampUtils.timeToString(end) + + meetingDate = date + meetingTime = "\(startTime) - \(endTime)" + meetingDay = TimestampUtils.dayOfWeek(timestamp) + meetingDayNumber = TimestampUtils.dayOfMonth(timestamp) + + let count = conferenceInfo.participantInfos.count + meetingParticipants = AppUtils.getStringWithPlural(R.plurals.conference_participants_list_title, count: count, countString: "\(count)") + + meetingFound = true + */ + if meetingConferenceUri != nil { + return MessageConferenceInfo(id: UUID().uuidString, uri: meetingConferenceUri!, subject: meetingSubject, description: meetingDescription, state: .updated, dateTime: timeTmp) + } + } + } + + return nil + } } // swiftlint:enable line_length // swiftlint:enable type_body_length From 764b8f860c5639ee75a152cdeec9f7338e1ac314 Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Thu, 10 Oct 2024 16:16:19 +0200 Subject: [PATCH 452/486] Conference message bubble --- .../Fragments/ChatBubbleView.swift | 20 ++++- .../UI/Main/Conversations/Model/Message.swift | 7 +- .../Model/MessageConferenceInfo.swift | 39 +++++---- .../ViewModel/ConversationViewModel.swift | 83 ++++++++++--------- 4 files changed, 89 insertions(+), 60 deletions(-) diff --git a/Linphone/UI/Main/Conversations/Fragments/ChatBubbleView.swift b/Linphone/UI/Main/Conversations/Fragments/ChatBubbleView.swift index b69e85d1e..e4a4c0c4d 100644 --- a/Linphone/UI/Main/Conversations/Fragments/ChatBubbleView.swift +++ b/Linphone/UI/Main/Conversations/Fragments/ChatBubbleView.swift @@ -167,6 +167,22 @@ struct ChatBubbleView: View { .default_text_style(styleSize: 14) } + if eventLogMessage.message.isIcalendar { + VStack{ + VStack { + + } + + VStack { + + } + } + + Text("Meeting invite !!") + .foregroundStyle(Color.grayMain2c500) + .default_text_style(styleSize: 12) + } + HStack(alignment: .center) { if eventLogMessage.message.isEphemeral && eventLogMessage.message.isOutgoing { Text(ephemeralLifetime) @@ -302,10 +318,6 @@ struct ChatBubbleView: View { }) .padding(.leading, eventLogMessage.message.isOutgoing ? 40 : 0) .padding(.trailing, !eventLogMessage.message.isOutgoing ? 40 : 0) - } else if eventLogMessage.message.isIcalendar { - Text("Meeting invite !!") - .foregroundStyle(Color.grayMain2c500) - .default_text_style(styleSize: 12) } } .onTapGesture {} diff --git a/Linphone/UI/Main/Conversations/Model/Message.swift b/Linphone/UI/Main/Conversations/Model/Message.swift index 282be1e5a..ec035c713 100644 --- a/Linphone/UI/Main/Conversations/Model/Message.swift +++ b/Linphone/UI/Main/Conversations/Model/Message.swift @@ -86,7 +86,8 @@ public struct Message: Identifiable, Hashable { public var ephemeralLifetime: Int public var isIcalendar: Bool - + public var messageConferenceInfo: MessageConferenceInfo? + public init( id: String, appData: String = "", @@ -107,7 +108,8 @@ public struct Message: Identifiable, Hashable { isEphemeral: Bool = false, ephemeralExpireTime: Int = 0, ephemeralLifetime: Int = 0, - isIcalendar: Bool = false + isIcalendar: Bool = false, + messageConferenceInfo: MessageConferenceInfo? = nil ) { self.id = id self.appData = appData @@ -129,6 +131,7 @@ public struct Message: Identifiable, Hashable { self.ephemeralExpireTime = ephemeralExpireTime self.ephemeralLifetime = ephemeralLifetime self.isIcalendar = isIcalendar + self.messageConferenceInfo = messageConferenceInfo } public static func makeMessage( diff --git a/Linphone/UI/Main/Conversations/Model/MessageConferenceInfo.swift b/Linphone/UI/Main/Conversations/Model/MessageConferenceInfo.swift index 7ec3238e0..6bd5ec3ec 100644 --- a/Linphone/UI/Main/Conversations/Model/MessageConferenceInfo.swift +++ b/Linphone/UI/Main/Conversations/Model/MessageConferenceInfo.swift @@ -19,27 +19,34 @@ import Foundation -public enum MessageConferenceState { +public enum MessageConferenceState: Codable { + case new case updated case cancelled } -public struct MessageConferenceInfo { - public let id: String - var uri: URL - var subject: String - var description: String - var state: MessageConferenceState - var dateTime: String - //var duration: time_t - //var participantInfos: [ParticipantInfo] +public struct MessageConferenceInfo: Codable, Identifiable, Hashable { + public let id: UUID + public let meetingConferenceUri: URL + 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: String, uri: URL, subject: String, description: String, state: MessageConferenceState, dateTime: String) { + public init(id: UUID, meetingConferenceUri: URL, meetingSubject: String, meetingDescription: String, meetingState: MessageConferenceState, meetingDate: String, meetingTime: String, meetingDay: String, meetingDayNumber: String, meetingParticipants: String) { self.id = id - self.uri = uri - self.subject = subject - self.description = description - self.state = state - self.dateTime = dateTime + 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/ConversationViewModel.swift b/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift index 75c90451b..fa53553a3 100644 --- a/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift +++ b/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift @@ -507,7 +507,8 @@ class ConversationViewModel: ObservableObject { isEphemeral: eventLog.chatMessage?.isEphemeral ?? false, ephemeralExpireTime: eventLog.chatMessage?.ephemeralExpireTime ?? 0, ephemeralLifetime: eventLog.chatMessage?.ephemeralLifetime ?? 0, - isIcalendar: eventLog.chatMessage?.contents.first?.isIcalendar ?? false + 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 ) ) ) @@ -731,7 +732,8 @@ class ConversationViewModel: ObservableObject { isEphemeral: eventLog.chatMessage?.isEphemeral ?? false, ephemeralExpireTime: eventLog.chatMessage?.ephemeralExpireTime ?? 0, ephemeralLifetime: eventLog.chatMessage?.ephemeralLifetime ?? 0, - isIcalendar: eventLog.chatMessage?.contents.first?.isIcalendar ?? false + 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 ) @@ -967,7 +969,8 @@ class ConversationViewModel: ObservableObject { isEphemeral: eventLog.chatMessage?.isEphemeral ?? false, ephemeralExpireTime: eventLog.chatMessage?.ephemeralExpireTime ?? 0, ephemeralLifetime: eventLog.chatMessage?.ephemeralLifetime ?? 0, - isIcalendar: eventLog.chatMessage?.contents.first?.isIcalendar ?? false + 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 ) ) @@ -1249,7 +1252,8 @@ class ConversationViewModel: ObservableObject { isEphemeral: eventLog.chatMessage?.isEphemeral ?? false, ephemeralExpireTime: eventLog.chatMessage?.ephemeralExpireTime ?? 0, ephemeralLifetime: eventLog.chatMessage?.ephemeralLifetime ?? 0, - isIcalendar: eventLog.chatMessage?.contents.first?.isIcalendar ?? false + 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 ) @@ -1894,30 +1898,30 @@ class ConversationViewModel: ObservableObject { } func parseConferenceInvite(content: Content) -> MessageConferenceInfo? { - - var meetingConferenceUri: URL? - var meetingSubject: String = "" - var meetingDescription: String = "" - var meetingUpdated: Bool = false - var meetingCancelled: Bool = false - var meetingDate: String = "" - var meetingTime: String = "" - var meetingDay: String = "" - var meetingDayNumber: String = "" - var meetingParticipants: String = "" - var meetingFound: Bool = false + var meetingConferenceUriTmp: URL? + 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)]") - meetingConferenceUri = URL(string: conferenceAddress.asStringUriOnly()) - meetingSubject = conferenceInfo.subject ?? "" - meetingDescription = conferenceInfo.description ?? "" + Log.info("Found conference info with URI [\(conferenceUri)] and subject [\(conferenceInfo.subject ?? "")]") + meetingConferenceUriTmp = URL(string: conferenceAddress.asStringUriOnly()) + meetingSubjectTmp = conferenceInfo.subject ?? "" + meetingDescriptionTmp = conferenceInfo.description ?? "" - meetingUpdated = (conferenceInfo.state == .Updated) - meetingCancelled = (conferenceInfo.state == .Cancelled) + if conferenceInfo.state == .Updated { + meetingStateTmp = .updated + } else if conferenceInfo.state == .Cancelled { + meetingStateTmp = .cancelled + } let timestamp = conferenceInfo.dateTime let duration = conferenceInfo.duration @@ -1928,33 +1932,36 @@ class ConversationViewModel: ObservableObject { dateFormatter.dateStyle = .full dateFormatter.timeStyle = .none - let date = dateFormatter.string(from: dateTmp) + meetingDateTmp = dateFormatter.string(from: dateTmp) 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) - let startTime = TimestampUtils.timeToString(timestamp) - let end = timestamp + (duration * 60) - let endTime = TimestampUtils.timeToString(end) + meetingTimeTmp = "\(timeTmp) - \(endTime)" - meetingDate = date - meetingTime = "\(startTime) - \(endTime)" - meetingDay = TimestampUtils.dayOfWeek(timestamp) - meetingDayNumber = TimestampUtils.dayOfMonth(timestamp) + meetingDayTmp = dateTmp.formatted(Date.FormatStyle().weekday(.abbreviated)).capitalized + meetingDayNumberTmp = dateTmp.formatted(Date.FormatStyle().day(.twoDigits)) - let count = conferenceInfo.participantInfos.count - meetingParticipants = AppUtils.getStringWithPlural(R.plurals.conference_participants_list_title, count: count, countString: "\(count)") - - meetingFound = true - */ - if meetingConferenceUri != nil { - return MessageConferenceInfo(id: UUID().uuidString, uri: meetingConferenceUri!, subject: meetingSubject, description: meetingDescription, state: .updated, dateTime: timeTmp) + meetingParticipantsTmp = String(conferenceInfo.participantInfos.count) + + if meetingConferenceUriTmp != nil { + return MessageConferenceInfo( + id: UUID(), + meetingConferenceUri: meetingConferenceUriTmp!, + meetingSubject: meetingSubjectTmp, + meetingDescription: meetingDescriptionTmp, + meetingState: meetingStateTmp, + meetingDate: meetingDateTmp, + meetingTime: meetingTimeTmp, + meetingDay: meetingDayTmp, + meetingDayNumber: meetingDayNumberTmp, + meetingParticipants: meetingParticipantsTmp + ) } } } From 1957fa7b15a28c5e6ac4fe70229538cfdc0f3542 Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Fri, 11 Oct 2024 11:34:17 +0200 Subject: [PATCH 453/486] Made "enteredForeground" variable accessible to all conversation views and added a check for its value before performing chatroom.compose --- Linphone/UI/Call/CallView.swift | 8 ++++++-- Linphone/UI/Main/ContentView.swift | 14 +++++++++++--- .../UI/Main/Conversations/ConversationsView.swift | 7 +++++-- .../Fragments/ConversationFragment.swift | 6 ++++-- .../Fragments/ConversationsFragment.swift | 8 +++++--- .../Fragments/ConversationsListFragment.swift | 10 ++++------ 6 files changed, 35 insertions(+), 18 deletions(-) diff --git a/Linphone/UI/Call/CallView.swift b/Linphone/UI/Call/CallView.swift index d0e46d4b6..494c46680 100644 --- a/Linphone/UI/Call/CallView.swift +++ b/Linphone/UI/Call/CallView.swift @@ -68,6 +68,8 @@ struct CallView: View { @State var buttonSize = 60.0 + @Binding var enteredForeground: Bool + var body: some View { GeometryReader { geo in ZStack { @@ -197,7 +199,8 @@ struct CallView: View { conversationViewModel: conversationViewModel, conversationsListViewModel: conversationsListViewModel, conversationForwardMessageViewModel: conversationForwardMessageViewModel, - isShowConversationFragment: $isShowConversationFragment + isShowConversationFragment: $isShowConversationFragment, + enteredForeground: $enteredForeground ) .frame(maxWidth: .infinity) .background(Color.gray100) @@ -2785,7 +2788,8 @@ struct PressedButtonStyle: ButtonStyle { conversationForwardMessageViewModel: ConversationForwardMessageViewModel(), fullscreenVideo: .constant(false), isShowStartCallFragment: .constant(false), - isShowConversationFragment: .constant(false) + isShowConversationFragment: .constant(false), + enteredForeground: .constant(false) ) } // swiftlint:enable type_body_length diff --git a/Linphone/UI/Main/ContentView.swift b/Linphone/UI/Main/ContentView.swift index 4e147f5fc..497b8ea6f 100644 --- a/Linphone/UI/Main/ContentView.swift +++ b/Linphone/UI/Main/ContentView.swift @@ -75,6 +75,8 @@ struct ContentView: View { @State var isShowScheduleMeetingFragment = false @State private var isShowLoginFragment: Bool = false + @State private var enteredForeground: Bool = false + var body: some View { let pub = NotificationCenter.default .publisher(for: NSNotification.Name("ContactLoaded")) @@ -597,7 +599,8 @@ struct ContentView: View { conversationViewModel: conversationViewModel, conversationsListViewModel: conversationsListViewModel, text: $text, - isShowStartConversationFragment: $isShowStartConversationFragment + isShowStartConversationFragment: $isShowStartConversationFragment, + enteredForeground: $enteredForeground ) .roundedCorner(25, corners: [.topRight, .topLeft]) .shadow( @@ -861,7 +864,8 @@ struct ContentView: View { conversationViewModel: conversationViewModel, conversationsListViewModel: conversationsListViewModel, conversationForwardMessageViewModel: conversationForwardMessageViewModel, - isShowConversationFragment: $isShowConversationFragment + isShowConversationFragment: $isShowConversationFragment, + enteredForeground: $enteredForeground ) .frame(maxWidth: .infinity) .background(Color.gray100) @@ -1176,7 +1180,8 @@ struct ContentView: View { conversationForwardMessageViewModel: conversationForwardMessageViewModel, fullscreenVideo: $fullscreenVideo, isShowStartCallFragment: $isShowStartCallFragment, - isShowConversationFragment: $isShowConversationFragment + isShowConversationFragment: $isShowConversationFragment, + enteredForeground: $enteredForeground ) .zIndex(5) .transition(.scale.combined(with: .move(edge: .top))) @@ -1229,6 +1234,9 @@ struct ContentView: View { } orientation = newOrientation } + .onChange(of: scenePhase) { newPhase in + enteredForeground = newPhase == .active + } } func openMenu() { diff --git a/Linphone/UI/Main/Conversations/ConversationsView.swift b/Linphone/UI/Main/Conversations/ConversationsView.swift index 3918ed6c8..06a120dd5 100644 --- a/Linphone/UI/Main/Conversations/ConversationsView.swift +++ b/Linphone/UI/Main/Conversations/ConversationsView.swift @@ -27,10 +27,12 @@ struct ConversationsView: View { @Binding var isShowStartConversationFragment: Bool + @Binding var enteredForeground: Bool + var body: some View { NavigationView { ZStack(alignment: .bottomTrailing) { - ConversationsFragment(conversationViewModel: conversationViewModel, conversationsListViewModel: conversationsListViewModel, text: $text) + ConversationsFragment(conversationViewModel: conversationViewModel, conversationsListViewModel: conversationsListViewModel, text: $text, enteredForeground: $enteredForeground) Button { withAnimation { @@ -62,6 +64,7 @@ struct ConversationsView: View { conversationViewModel: ConversationViewModel(), conversationsListViewModel: ConversationsListViewModel(), showingSheet: .constant(false), - text: .constant("") + text: .constant(""), + enteredForeground: .constant(false) ) } diff --git a/Linphone/UI/Main/Conversations/Fragments/ConversationFragment.swift b/Linphone/UI/Main/Conversations/Fragments/ConversationFragment.swift index 0f970c8f5..c0b1707b5 100644 --- a/Linphone/UI/Main/Conversations/Fragments/ConversationFragment.swift +++ b/Linphone/UI/Main/Conversations/Fragments/ConversationFragment.swift @@ -58,6 +58,8 @@ struct ConversationFragment: View { @State private var selectedCategoryIndex = 0 + @Binding var enteredForeground: Bool + var body: some View { NavigationView { GeometryReader { geometry in @@ -573,7 +575,7 @@ struct ConversationFragment: View { .focused($isMessageTextFocused) .padding(.vertical, 5) .onChange(of: conversationViewModel.messageText) { text in - if !text.isEmpty { + if !text.isEmpty && !enteredForeground { conversationViewModel.compose() } } @@ -586,7 +588,7 @@ struct ConversationFragment: View { .default_text_style(styleSize: 15) .focused($isMessageTextFocused) .onChange(of: conversationViewModel.messageText) { text in - if !text.isEmpty { + if !text.isEmpty && !enteredForeground { conversationViewModel.compose() } } diff --git a/Linphone/UI/Main/Conversations/Fragments/ConversationsFragment.swift b/Linphone/UI/Main/Conversations/Fragments/ConversationsFragment.swift index b91bf86e0..22e62e055 100644 --- a/Linphone/UI/Main/Conversations/Fragments/ConversationsFragment.swift +++ b/Linphone/UI/Main/Conversations/Fragments/ConversationsFragment.swift @@ -29,11 +29,13 @@ struct ConversationsFragment: View { @State var showingSheet: Bool = false @Binding var text: String + @Binding var enteredForeground: Bool + var body: some View { ZStack { if #available(iOS 16.0, *), idiom != .pad { ConversationsListFragment(conversationViewModel: conversationViewModel, - conversationsListViewModel: conversationsListViewModel, showingSheet: $showingSheet, text: $text) + conversationsListViewModel: conversationsListViewModel, showingSheet: $showingSheet, text: $text, enteredForeground: $enteredForeground) .sheet(isPresented: $showingSheet) { ConversationsListBottomSheet( conversationsListViewModel: conversationsListViewModel, @@ -43,7 +45,7 @@ struct ConversationsFragment: View { } } else { ConversationsListFragment(conversationViewModel: conversationViewModel, - conversationsListViewModel: conversationsListViewModel, showingSheet: $showingSheet, text: $text) + conversationsListViewModel: conversationsListViewModel, showingSheet: $showingSheet, text: $text, enteredForeground: $enteredForeground) .halfSheet(showSheet: $showingSheet) { ConversationsListBottomSheet( conversationsListViewModel: conversationsListViewModel, @@ -56,5 +58,5 @@ struct ConversationsFragment: View { } #Preview { - ConversationsFragment(conversationViewModel: ConversationViewModel(), conversationsListViewModel: ConversationsListViewModel(), text: .constant("")) + ConversationsFragment(conversationViewModel: ConversationViewModel(), conversationsListViewModel: ConversationsListViewModel(), text: .constant(""), enteredForeground: .constant(false)) } diff --git a/Linphone/UI/Main/Conversations/Fragments/ConversationsListFragment.swift b/Linphone/UI/Main/Conversations/Fragments/ConversationsListFragment.swift index 73132c185..2e4e8fc75 100644 --- a/Linphone/UI/Main/Conversations/Fragments/ConversationsListFragment.swift +++ b/Linphone/UI/Main/Conversations/Fragments/ConversationsListFragment.swift @@ -23,8 +23,6 @@ import linphonesw struct ConversationsListFragment: View { @EnvironmentObject var navigationManager: NavigationManager - @Environment(\.scenePhase) var scenePhase - @State private var enteredForeground: Bool = false @ObservedObject var conversationViewModel: ConversationViewModel @ObservedObject var conversationsListViewModel: ConversationsListViewModel @@ -32,6 +30,8 @@ struct ConversationsListFragment: View { @Binding var showingSheet: Bool @Binding var text: String + @Binding var enteredForeground: Bool + var body: some View { let pub = NotificationCenter.default .publisher(for: NSNotification.Name("ChatRoomsComputed")) @@ -198,9 +198,6 @@ struct ConversationsListFragment: View { } .navigationTitle("") .navigationBarHidden(true) - .onChange(of: scenePhase) { newPhase in - enteredForeground = newPhase == .active - } } } @@ -209,6 +206,7 @@ struct ConversationsListFragment: View { conversationViewModel: ConversationViewModel(), conversationsListViewModel: ConversationsListViewModel(), showingSheet: .constant(false), - text: .constant("") + text: .constant(""), + enteredForeground: .constant(false) ) } From 51ca670369fcb7ed8f781d3498378566662ca061 Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Fri, 11 Oct 2024 15:55:26 +0200 Subject: [PATCH 454/486] Fixed resetDisplayedChatRoom, added event callbacks and increased minimum bubble size --- .../Main/Conversations/Fragments/UIList.swift | 2 +- .../ViewModel/ConversationViewModel.swift | 28 ++++++++++--------- 2 files changed, 16 insertions(+), 14 deletions(-) diff --git a/Linphone/UI/Main/Conversations/Fragments/UIList.swift b/Linphone/UI/Main/Conversations/Fragments/UIList.swift index a98fea944..f2ac9d30c 100644 --- a/Linphone/UI/Main/Conversations/Fragments/UIList.swift +++ b/Linphone/UI/Main/Conversations/Fragments/UIList.swift @@ -439,7 +439,7 @@ struct UIList: UIViewRepresentable { .padding(.horizontal, 10) .onTapGesture { } } - .minSize(width: 0, height: 0) + .minSize(width: 0, height: 50) .margins(.all, 0) } diff --git a/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift b/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift index fa53553a3..d1845b0a3 100644 --- a/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift +++ b/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift @@ -114,6 +114,14 @@ class ConversationViewModel: ObservableObject { 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) }) @@ -1024,6 +1032,8 @@ class ConversationViewModel: ObservableObject { } } } + + getHistorySize() } func resetMessage() { @@ -1476,19 +1486,11 @@ class ConversationViewModel: ObservableObject { if self.displayedConversation != nil { CoreContext.shared.doOnCoreQueue { _ in - let eventLogFirst = self.displayedConversation!.chatRoom.findEventLog(messageId: self.conversationMessagesSection[0].rows.first!.eventModel.eventLog.chatMessage!.messageId) - - let eventLogLast = self.displayedConversation!.chatRoom.getHistoryRangeEvents(begin: 0, end: 1).first - - var eventLogList = self.displayedConversation!.chatRoom.getHistoryRangeBetween( - firstEvent: eventLogFirst, - lastEvent: eventLogLast, - filters: UInt(ChatRoom.HistoryFilter([.ChatMessage, .InfoNoDevice]).rawValue) - ) - - if eventLogLast != nil { - eventLogList.append(eventLogLast!) - if !eventLogList.isEmpty && (self.conversationMessagesSection[0].rows.first?.eventModel.eventLog.chatMessage?.messageId != eventLogLast!.chatMessage?.messageId) { + 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) } } From eaefc50626aca3b4025421d522d25caac7bf5f73 Mon Sep 17 00:00:00 2001 From: QuentinArguillere Date: Fri, 11 Oct 2024 18:19:08 +0200 Subject: [PATCH 455/486] Update build version to (53) --- Linphone.xcodeproj/project.pbxproj | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Linphone.xcodeproj/project.pbxproj b/Linphone.xcodeproj/project.pbxproj index 603952be3..2d7269dd7 100644 --- a/Linphone.xcodeproj/project.pbxproj +++ b/Linphone.xcodeproj/project.pbxproj @@ -1250,7 +1250,7 @@ CODE_SIGN_ENTITLEMENTS = msgNotificationService/msgNotificationService.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 52; + CURRENT_PROJECT_VERSION = 53; DEVELOPMENT_TEAM = Z2V957B3D6; ENABLE_USER_SCRIPT_SANDBOXING = NO; GCC_C_LANGUAGE_STANDARD = gnu17; @@ -1293,7 +1293,7 @@ CODE_SIGN_ENTITLEMENTS = msgNotificationService/msgNotificationService.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 52; + CURRENT_PROJECT_VERSION = 53; DEVELOPMENT_TEAM = Z2V957B3D6; ENABLE_USER_SCRIPT_SANDBOXING = NO; GCC_C_LANGUAGE_STANDARD = gnu17; @@ -1450,7 +1450,7 @@ ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = NO; CODE_SIGN_ENTITLEMENTS = Linphone/Linphone.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 52; + CURRENT_PROJECT_VERSION = 53; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_ASSET_PATHS = "\"Linphone/Preview Content\""; @@ -1507,7 +1507,7 @@ ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = NO; CODE_SIGN_ENTITLEMENTS = Linphone/Linphone.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 52; + CURRENT_PROJECT_VERSION = 53; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_ASSET_PATHS = "\"Linphone/Preview Content\""; DEVELOPMENT_TEAM = Z2V957B3D6; From 54f1c2a27d819da522f2219c938efe9f0d0671cf Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Mon, 14 Oct 2024 12:33:14 +0200 Subject: [PATCH 456/486] Move enteredForeground variable to CoreContext and add a check before using prepareBottomSheetForDeliveryStatus --- Linphone/Core/CoreContext.swift | 1 + Linphone/UI/Call/CallView.swift | 8 ++------ Linphone/UI/Main/ContentView.swift | 13 ++++--------- .../UI/Main/Conversations/ConversationsView.swift | 7 ++----- .../Conversations/Fragments/ChatBubbleView.swift | 8 ++++++-- .../Fragments/ConversationFragment.swift | 7 +++---- .../Fragments/ConversationsFragment.swift | 8 +++----- .../Fragments/ConversationsListFragment.swift | 9 +++------ 8 files changed, 24 insertions(+), 37 deletions(-) diff --git a/Linphone/Core/CoreContext.swift b/Linphone/Core/CoreContext.swift index b2a95fdd0..5f168ff1f 100644 --- a/Linphone/Core/CoreContext.swift +++ b/Linphone/Core/CoreContext.swift @@ -41,6 +41,7 @@ final class CoreContext: ObservableObject { @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? diff --git a/Linphone/UI/Call/CallView.swift b/Linphone/UI/Call/CallView.swift index 494c46680..d0e46d4b6 100644 --- a/Linphone/UI/Call/CallView.swift +++ b/Linphone/UI/Call/CallView.swift @@ -68,8 +68,6 @@ struct CallView: View { @State var buttonSize = 60.0 - @Binding var enteredForeground: Bool - var body: some View { GeometryReader { geo in ZStack { @@ -199,8 +197,7 @@ struct CallView: View { conversationViewModel: conversationViewModel, conversationsListViewModel: conversationsListViewModel, conversationForwardMessageViewModel: conversationForwardMessageViewModel, - isShowConversationFragment: $isShowConversationFragment, - enteredForeground: $enteredForeground + isShowConversationFragment: $isShowConversationFragment ) .frame(maxWidth: .infinity) .background(Color.gray100) @@ -2788,8 +2785,7 @@ struct PressedButtonStyle: ButtonStyle { conversationForwardMessageViewModel: ConversationForwardMessageViewModel(), fullscreenVideo: .constant(false), isShowStartCallFragment: .constant(false), - isShowConversationFragment: .constant(false), - enteredForeground: .constant(false) + isShowConversationFragment: .constant(false) ) } // swiftlint:enable type_body_length diff --git a/Linphone/UI/Main/ContentView.swift b/Linphone/UI/Main/ContentView.swift index 497b8ea6f..776f6f0bd 100644 --- a/Linphone/UI/Main/ContentView.swift +++ b/Linphone/UI/Main/ContentView.swift @@ -75,8 +75,6 @@ struct ContentView: View { @State var isShowScheduleMeetingFragment = false @State private var isShowLoginFragment: Bool = false - @State private var enteredForeground: Bool = false - var body: some View { let pub = NotificationCenter.default .publisher(for: NSNotification.Name("ContactLoaded")) @@ -599,8 +597,7 @@ struct ContentView: View { conversationViewModel: conversationViewModel, conversationsListViewModel: conversationsListViewModel, text: $text, - isShowStartConversationFragment: $isShowStartConversationFragment, - enteredForeground: $enteredForeground + isShowStartConversationFragment: $isShowStartConversationFragment ) .roundedCorner(25, corners: [.topRight, .topLeft]) .shadow( @@ -864,8 +861,7 @@ struct ContentView: View { conversationViewModel: conversationViewModel, conversationsListViewModel: conversationsListViewModel, conversationForwardMessageViewModel: conversationForwardMessageViewModel, - isShowConversationFragment: $isShowConversationFragment, - enteredForeground: $enteredForeground + isShowConversationFragment: $isShowConversationFragment ) .frame(maxWidth: .infinity) .background(Color.gray100) @@ -1180,8 +1176,7 @@ struct ContentView: View { conversationForwardMessageViewModel: conversationForwardMessageViewModel, fullscreenVideo: $fullscreenVideo, isShowStartCallFragment: $isShowStartCallFragment, - isShowConversationFragment: $isShowConversationFragment, - enteredForeground: $enteredForeground + isShowConversationFragment: $isShowConversationFragment ) .zIndex(5) .transition(.scale.combined(with: .move(edge: .top))) @@ -1235,7 +1230,7 @@ struct ContentView: View { orientation = newOrientation } .onChange(of: scenePhase) { newPhase in - enteredForeground = newPhase == .active + CoreContext.shared.enteredForeground = newPhase == .active } } diff --git a/Linphone/UI/Main/Conversations/ConversationsView.swift b/Linphone/UI/Main/Conversations/ConversationsView.swift index 06a120dd5..3918ed6c8 100644 --- a/Linphone/UI/Main/Conversations/ConversationsView.swift +++ b/Linphone/UI/Main/Conversations/ConversationsView.swift @@ -27,12 +27,10 @@ struct ConversationsView: View { @Binding var isShowStartConversationFragment: Bool - @Binding var enteredForeground: Bool - var body: some View { NavigationView { ZStack(alignment: .bottomTrailing) { - ConversationsFragment(conversationViewModel: conversationViewModel, conversationsListViewModel: conversationsListViewModel, text: $text, enteredForeground: $enteredForeground) + ConversationsFragment(conversationViewModel: conversationViewModel, conversationsListViewModel: conversationsListViewModel, text: $text) Button { withAnimation { @@ -64,7 +62,6 @@ struct ConversationsView: View { conversationViewModel: ConversationViewModel(), conversationsListViewModel: ConversationsListViewModel(), showingSheet: .constant(false), - text: .constant(""), - enteredForeground: .constant(false) + text: .constant("") ) } diff --git a/Linphone/UI/Main/Conversations/Fragments/ChatBubbleView.swift b/Linphone/UI/Main/Conversations/Fragments/ChatBubbleView.swift index e4a4c0c4d..fab330f10 100644 --- a/Linphone/UI/Main/Conversations/Fragments/ChatBubbleView.swift +++ b/Linphone/UI/Main/Conversations/Fragments/ChatBubbleView.swift @@ -24,6 +24,8 @@ import WebKit // swiftlint:disable cyclomatic_complexity struct ChatBubbleView: View { + @ObservedObject private var sharedMainViewModel = SharedMainViewModel.shared + @ObservedObject var conversationViewModel: ConversationViewModel let eventLogMessage: EventLogMessage @@ -255,8 +257,10 @@ struct ChatBubbleView: View { } } .onTapGesture { - conversationViewModel.selectedMessageToDisplayDetails = eventLogMessage - conversationViewModel.prepareBottomSheetForDeliveryStatus() + if !CoreContext.shared.enteredForeground { + conversationViewModel.selectedMessageToDisplayDetails = eventLogMessage + conversationViewModel.prepareBottomSheetForDeliveryStatus() + } } .disabled(conversationViewModel.selectedMessage != nil) .padding(.top, -4) diff --git a/Linphone/UI/Main/Conversations/Fragments/ConversationFragment.swift b/Linphone/UI/Main/Conversations/Fragments/ConversationFragment.swift index c0b1707b5..4a60f942e 100644 --- a/Linphone/UI/Main/Conversations/Fragments/ConversationFragment.swift +++ b/Linphone/UI/Main/Conversations/Fragments/ConversationFragment.swift @@ -28,6 +28,7 @@ struct ConversationFragment: View { 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 @@ -58,8 +59,6 @@ struct ConversationFragment: View { @State private var selectedCategoryIndex = 0 - @Binding var enteredForeground: Bool - var body: some View { NavigationView { GeometryReader { geometry in @@ -575,7 +574,7 @@ struct ConversationFragment: View { .focused($isMessageTextFocused) .padding(.vertical, 5) .onChange(of: conversationViewModel.messageText) { text in - if !text.isEmpty && !enteredForeground { + if !text.isEmpty && !CoreContext.shared.enteredForeground { conversationViewModel.compose() } } @@ -588,7 +587,7 @@ struct ConversationFragment: View { .default_text_style(styleSize: 15) .focused($isMessageTextFocused) .onChange(of: conversationViewModel.messageText) { text in - if !text.isEmpty && !enteredForeground { + if !text.isEmpty && !CoreContext.shared.enteredForeground { conversationViewModel.compose() } } diff --git a/Linphone/UI/Main/Conversations/Fragments/ConversationsFragment.swift b/Linphone/UI/Main/Conversations/Fragments/ConversationsFragment.swift index 22e62e055..b91bf86e0 100644 --- a/Linphone/UI/Main/Conversations/Fragments/ConversationsFragment.swift +++ b/Linphone/UI/Main/Conversations/Fragments/ConversationsFragment.swift @@ -29,13 +29,11 @@ struct ConversationsFragment: View { @State var showingSheet: Bool = false @Binding var text: String - @Binding var enteredForeground: Bool - var body: some View { ZStack { if #available(iOS 16.0, *), idiom != .pad { ConversationsListFragment(conversationViewModel: conversationViewModel, - conversationsListViewModel: conversationsListViewModel, showingSheet: $showingSheet, text: $text, enteredForeground: $enteredForeground) + conversationsListViewModel: conversationsListViewModel, showingSheet: $showingSheet, text: $text) .sheet(isPresented: $showingSheet) { ConversationsListBottomSheet( conversationsListViewModel: conversationsListViewModel, @@ -45,7 +43,7 @@ struct ConversationsFragment: View { } } else { ConversationsListFragment(conversationViewModel: conversationViewModel, - conversationsListViewModel: conversationsListViewModel, showingSheet: $showingSheet, text: $text, enteredForeground: $enteredForeground) + conversationsListViewModel: conversationsListViewModel, showingSheet: $showingSheet, text: $text) .halfSheet(showSheet: $showingSheet) { ConversationsListBottomSheet( conversationsListViewModel: conversationsListViewModel, @@ -58,5 +56,5 @@ struct ConversationsFragment: View { } #Preview { - ConversationsFragment(conversationViewModel: ConversationViewModel(), conversationsListViewModel: ConversationsListViewModel(), text: .constant(""), enteredForeground: .constant(false)) + ConversationsFragment(conversationViewModel: ConversationViewModel(), conversationsListViewModel: ConversationsListViewModel(), text: .constant("")) } diff --git a/Linphone/UI/Main/Conversations/Fragments/ConversationsListFragment.swift b/Linphone/UI/Main/Conversations/Fragments/ConversationsListFragment.swift index 2e4e8fc75..e4cdd1fcd 100644 --- a/Linphone/UI/Main/Conversations/Fragments/ConversationsListFragment.swift +++ b/Linphone/UI/Main/Conversations/Fragments/ConversationsListFragment.swift @@ -30,8 +30,6 @@ struct ConversationsListFragment: View { @Binding var showingSheet: Bool @Binding var text: String - @Binding var enteredForeground: Bool - var body: some View { let pub = NotificationCenter.default .publisher(for: NSNotification.Name("ChatRoomsComputed")) @@ -143,14 +141,14 @@ struct ConversationsListFragment: View { .listRowSeparator(.hidden) .background(.white) .onReceive(pub) { _ in - if enteredForeground && conversationViewModel.displayedConversation != nil + if CoreContext.shared.enteredForeground && conversationViewModel.displayedConversation != nil && (navigationManager.peerAddr == nil || navigationManager.peerAddr == conversationViewModel.displayedConversation!.remoteSipUri) { if conversationViewModel.displayedConversation != nil { conversationViewModel.resetDisplayedChatRoom(conversationsList: conversationsListViewModel.conversationsList) } } - enteredForeground = false + CoreContext.shared.enteredForeground = false if navigationManager.peerAddr != nil && index < conversationsListViewModel.conversationsList.count @@ -206,7 +204,6 @@ struct ConversationsListFragment: View { conversationViewModel: ConversationViewModel(), conversationsListViewModel: ConversationsListViewModel(), showingSheet: .constant(false), - text: .constant(""), - enteredForeground: .constant(false) + text: .constant("") ) } From d3ca95b46faa8b969fba81be065fbaf925e93692 Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Tue, 15 Oct 2024 16:25:20 +0200 Subject: [PATCH 457/486] Use eventLogId instead of eventLog to prevent crashes on background/foreground transitions --- .../Main/Conversations/Model/EventModel.swift | 18 +- .../ConversationForwardMessageViewModel.swift | 3 +- .../ViewModel/ConversationViewModel.swift | 307 +++++++++--------- 3 files changed, 173 insertions(+), 155 deletions(-) diff --git a/Linphone/UI/Main/Conversations/Model/EventModel.swift b/Linphone/UI/Main/Conversations/Model/EventModel.swift index 02c0522df..18abeedfb 100644 --- a/Linphone/UI/Main/Conversations/Model/EventModel.swift +++ b/Linphone/UI/Main/Conversations/Model/EventModel.swift @@ -25,17 +25,19 @@ class EventModel: ObservableObject { @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() + setupEventData(eventLog: eventLog) } - - private func setupEventData() { + + private func setupEventData(eventLog: EventLog) { let address = eventLog.participantAddress ?? eventLog.peerAddress if address != nil { ContactsManager.shared.getFriendWithAddressInCoreQueue(address: address) { friendResult in @@ -49,7 +51,7 @@ class EventModel: ObservableObject { let textValue: String let iconValue: Image? - switch self.eventLog.type { + switch eventLog.type { case .ConferenceCreated: textValue = NSLocalizedString("conversation_event_conference_created", comment: "") case .ConferenceTerminated: @@ -59,7 +61,7 @@ class EventModel: ObservableObject { 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: ""), self.eventLog.subject ?? "") + 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: @@ -74,13 +76,13 @@ class EventModel: ObservableObject { 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(self.eventLog.ephemeralMessageLifetime)).lowercased()) + self.formatEphemeralExpiration(duration: Int64(eventLog.ephemeralMessageLifetime)).lowercased()) default: - textValue = String(self.eventLog.type.rawValue) + textValue = String(eventLog.type.rawValue) } // Icon assignment - switch self.eventLog.type { + switch eventLog.type { case .ConferenceEphemeralMessageEnabled, .ConferenceEphemeralMessageDisabled, .ConferenceEphemeralMessageLifetimeChanged: iconValue = Image("clock-countdown") case .ConferenceTerminated: diff --git a/Linphone/UI/Main/Conversations/ViewModel/ConversationForwardMessageViewModel.swift b/Linphone/UI/Main/Conversations/ViewModel/ConversationForwardMessageViewModel.swift index eaabb4f48..3f1a936ee 100644 --- a/Linphone/UI/Main/Conversations/ViewModel/ConversationForwardMessageViewModel.swift +++ b/Linphone/UI/Main/Conversations/ViewModel/ConversationForwardMessageViewModel.swift @@ -280,7 +280,8 @@ class ConversationForwardMessageViewModel: ObservableObject { func forwardMessage() { CoreContext.shared.doOnCoreQueue { _ in if self.displayedConversation != nil && self.selectedMessage != nil { - if let messageToForward = self.selectedMessage!.eventModel.eventLog.chatMessage { + 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) diff --git a/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift b/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift index d1845b0a3..c1fceee5b 100644 --- a/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift +++ b/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift @@ -152,7 +152,11 @@ class ConversationViewModel: ObservableObject { let ephemeralExpireTimeTmp = message.ephemeralExpireTime if !self.conversationMessagesSection.isEmpty && !self.conversationMessagesSection[0].rows.isEmpty { - if let indexMessage = self.conversationMessagesSection[0].rows.firstIndex(where: {$0.eventModel.eventLog.chatMessage?.messageId == message.messageId}) { + 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 { @@ -188,7 +192,11 @@ class ConversationViewModel: ObservableObject { statusTmp = .sending } - let indexMessage = self.conversationMessagesSection[0].rows.firstIndex(where: {$0.eventModel.eventLog.chatMessage?.messageId == message.messageId}) + 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.objectWillChange.send() @@ -196,7 +204,7 @@ class ConversationViewModel: ObservableObject { } } }, onNewMessageReaction: { (message: ChatMessage, _: ChatMessageReaction) in - let indexMessage = self.conversationMessagesSection[0].rows.firstIndex(where: {$0.eventModel.eventLog.chatMessage?.messageId == message.messageId}) + 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) @@ -209,7 +217,7 @@ class ConversationViewModel: ObservableObject { } } }, onReactionRemoved: { (message: ChatMessage, _: Address) in - let indexMessage = self.conversationMessagesSection[0].rows.firstIndex(where: {$0.eventModel.eventLog.chatMessage?.messageId == message.messageId}) + 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) @@ -222,7 +230,7 @@ class ConversationViewModel: ObservableObject { } } }, onEphemeralMessageTimerStarted: { (message: ChatMessage) in - let indexMessage = self.conversationMessagesSection[0].rows.firstIndex(where: {$0.eventModel.eventLog.chatMessage?.messageId == message.messageId}) + let indexMessage = self.conversationMessagesSection[0].rows.firstIndex(where: {$0.eventModel.eventLogId == message.messageId}) let ephemeralExpireTimeTmp = message.ephemeralExpireTime DispatchQueue.main.async { @@ -982,7 +990,7 @@ class ConversationViewModel: ObservableObject { ) ) - if self.conversationMessagesSection[0].rows.first?.eventModel.eventLog.chatMessage?.messageId != eventLog.chatMessage?.messageId { + if self.conversationMessagesSection[0].rows.first?.eventModel.eventLogId != eventLog.chatMessage?.messageId { self.addChatMessageDelegate(message: eventLog.chatMessage!) DispatchQueue.main.async { @@ -1054,7 +1062,7 @@ class ConversationViewModel: ObservableObject { func scrollToMessage(message: Message) { coreContext.doOnCoreQueue { _ in if message.replyMessage != nil { - if let indexMessage = self.conversationMessagesSection[0].rows.firstIndex(where: {$0.eventModel.eventLog.chatMessage?.messageId == message.replyMessage!.id}) { + 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 { @@ -1339,128 +1347,130 @@ class ConversationViewModel: ObservableObject { } func sendMessage(audioRecorder: AudioRecorder? = nil) { - coreContext.doOnCoreQueue { _ in - do { - var message: ChatMessage? - if self.messageToReply != nil { - let chatMessageToReply = self.messageToReply!.eventModel.eventLog.chatMessage - if chatMessageToReply != nil { - message = try self.displayedConversation!.chatRoom.createReplyMessage(message: chatMessageToReply!) + 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() } - self.messageToReply = nil - } 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)" - ) - + + let toSend = self.messageText.trimmingCharacters(in: .whitespacesAndNewlines) + if !toSend.isEmpty { if message != nil { - message!.addContent(content: content) + message!.addUtf8TextContent(text: toSend) } } - } else { - self.mediasToSend.forEach { attachment in + + if audioRecorder != nil { 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 + 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 { - - 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) - } + 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 { } - } 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 { - withAnimation { - self.mediasToSend.removeAll() + + if message != nil && !message!.contents.isEmpty { + Log.info("[ConversationViewModel] Sending message") + message!.send() } - self.messageText = "" + + 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 { + } - - /* - 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 { - } } } @@ -1597,26 +1607,28 @@ class ConversationViewModel: ObservableObject { } func removeReaction() { - coreContext.doOnCoreQueue { _ in - if self.selectedMessageToDisplayDetails != nil { - Log.info("[ConversationViewModel] Remove reaction to message with ID \(self.selectedMessageToDisplayDetails!.message.id)") - let messageToSendReaction = self.selectedMessageToDisplayDetails!.eventModel.eventLog.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 = "" + 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 } - self.selectedMessageToDisplayDetails = nil - self.isShowSelectedMessageToDisplayDetails = false + } catch { + Log.info("[ConversationViewModel] Error: Can't remove reaction to message with ID \(self.selectedMessageToDisplayDetails!.message.id)") } - } catch { - Log.info("[ConversationViewModel] Error: Can't remove reaction to message with ID \(self.selectedMessageToDisplayDetails!.message.id)") } } } @@ -1627,7 +1639,7 @@ class ConversationViewModel: ObservableObject { coreContext.doOnCoreQueue { _ in if self.selectedMessage != nil { Log.info("[ConversationViewModel] Sending reaction \(emoji) to message with ID \(self.selectedMessage!.message.id)") - let messageToSendReaction = self.selectedMessage!.eventModel.eventLog.chatMessage + 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) @@ -1651,9 +1663,10 @@ class ConversationViewModel: ObservableObject { func resend() { coreContext.doOnCoreQueue { _ in - if self.selectedMessage != nil && self.selectedMessage!.eventModel.eventLog.chatMessage != nil { - Log.info("[ConversationViewModel] Re-sending message with ID \(self.selectedMessage!.eventModel.eventLog.chatMessage!)") - self.selectedMessage!.eventModel.eventLog.chatMessage!.send() + 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() } } } @@ -1661,9 +1674,10 @@ class ConversationViewModel: ObservableObject { func prepareBottomSheetForDeliveryStatus() { self.sheetCategories.removeAll() coreContext.doOnCoreQueue { _ in - if self.selectedMessageToDisplayDetails != nil && self.selectedMessageToDisplayDetails!.eventModel.eventLog.chatMessage != nil { + let chatMessageToDisplay = self.displayedConversation!.chatRoom.findEventLog(messageId: self.selectedMessageToDisplayDetails!.eventModel.eventLogId)?.chatMessage + if self.selectedMessageToDisplayDetails != nil && chatMessageToDisplay != nil { - let participantsImdnDisplayed = self.selectedMessageToDisplayDetails!.eventModel.eventLog.chatMessage!.getParticipantsByImdnState(state: .Displayed) + let participantsImdnDisplayed = chatMessageToDisplay!.getParticipantsByImdnState(state: .Displayed) var participantListDisplayed: [InnerSheetCategory] = [] participantsImdnDisplayed.forEach({ participantImdn in if participantImdn.participant != nil && participantImdn.participant!.address != nil { @@ -1674,7 +1688,7 @@ class ConversationViewModel: ObservableObject { } }) - let participantsImdnDeliveredToUser = self.selectedMessageToDisplayDetails!.eventModel.eventLog.chatMessage!.getParticipantsByImdnState(state: .DeliveredToUser) + let participantsImdnDeliveredToUser = chatMessageToDisplay!.getParticipantsByImdnState(state: .DeliveredToUser) var participantListDeliveredToUser: [InnerSheetCategory] = [] participantsImdnDeliveredToUser.forEach({ participantImdn in if participantImdn.participant != nil && participantImdn.participant!.address != nil { @@ -1685,7 +1699,7 @@ class ConversationViewModel: ObservableObject { } }) - let participantsImdnDelivered = self.selectedMessageToDisplayDetails!.eventModel.eventLog.chatMessage!.getParticipantsByImdnState(state: .Delivered) + let participantsImdnDelivered = chatMessageToDisplay!.getParticipantsByImdnState(state: .Delivered) var participantListDelivered: [InnerSheetCategory] = [] participantsImdnDelivered.forEach({ participantImdn in if participantImdn.participant != nil && participantImdn.participant!.address != nil { @@ -1696,7 +1710,7 @@ class ConversationViewModel: ObservableObject { } }) - let participantsImdnNotDelivered = self.selectedMessageToDisplayDetails!.eventModel.eventLog.chatMessage!.getParticipantsByImdnState(state: .NotDelivered) + let participantsImdnNotDelivered = chatMessageToDisplay!.getParticipantsByImdnState(state: .NotDelivered) var participantListNotDelivered: [InnerSheetCategory] = [] participantsImdnNotDelivered.forEach({ participantImdn in if participantImdn.participant != nil && participantImdn.participant!.address != nil { @@ -1722,7 +1736,8 @@ class ConversationViewModel: ObservableObject { func prepareBottomSheetForReactions() { self.sheetCategories.removeAll() coreContext.doOnCoreQueue { core in - if self.selectedMessageToDisplayDetails != nil && self.selectedMessageToDisplayDetails!.eventModel.eventLog.chatMessage != nil { + let chatMessageToDisplay = self.displayedConversation!.chatRoom.findEventLog(messageId: self.selectedMessageToDisplayDetails!.eventModel.eventLogId)?.chatMessage + if self.selectedMessageToDisplayDetails != nil && chatMessageToDisplay != nil { let dispatchGroup = DispatchGroup() var sheetCategoriesTmp: [SheetCategory] = [] @@ -1730,7 +1745,7 @@ class ConversationViewModel: ObservableObject { var participantList: [[InnerSheetCategory]] = [[]] var reactionList: [String] = [] - self.selectedMessageToDisplayDetails!.eventModel.eventLog.chatMessage!.reactions.forEach { chatMessageReaction in + chatMessageToDisplay!.reactions.forEach { chatMessageReaction in if chatMessageReaction.fromAddress != nil { dispatchGroup.enter() ContactAvatarModel.getAvatarModelFromAddress(address: chatMessageReaction.fromAddress!) { avatarResult in From 27e0757c5fb413b3952ef4c22829d5123f10eb50 Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Wed, 16 Oct 2024 10:39:22 +0200 Subject: [PATCH 458/486] Change params.videoDirection to SendRecv when video is started --- Linphone/UI/Call/ViewModel/CallViewModel.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Linphone/UI/Call/ViewModel/CallViewModel.swift b/Linphone/UI/Call/ViewModel/CallViewModel.swift index aa38da837..6ce03614d 100644 --- a/Linphone/UI/Call/ViewModel/CallViewModel.swift +++ b/Linphone/UI/Call/ViewModel/CallViewModel.swift @@ -696,7 +696,7 @@ class CallViewModel: ObservableObject { } else if params.videoDirection == .SendOnly { params.videoDirection = .Inactive } else if params.videoDirection == .Inactive { - params.videoDirection = .SendOnly + params.videoDirection = .SendRecv } } From 3203cb3cccf0a5a641883e0abd8e4af2ab693d88 Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Wed, 16 Oct 2024 17:10:54 +0200 Subject: [PATCH 459/486] Add Meeting invite --- Linphone/Localizable.xcstrings | 58 +++++++- Linphone/TelecomManager/TelecomManager.swift | 8 +- .../UI/Call/MeetingWaitingRoomFragment.swift | 4 +- .../Fragments/ChatBubbleView.swift | 127 ++++++++++++++++-- .../Model/MessageConferenceInfo.swift | 4 +- .../ViewModel/ConversationViewModel.swift | 22 ++- .../Fragments/HistoryContactFragment.swift | 2 +- .../Meetings/Fragments/MeetingFragment.swift | 3 +- .../Meetings/ViewModel/MeetingViewModel.swift | 8 ++ 9 files changed, 206 insertions(+), 30 deletions(-) diff --git a/Linphone/Localizable.xcstrings b/Linphone/Localizable.xcstrings index 622a43465..d1c538639 100644 --- a/Linphone/Localizable.xcstrings +++ b/Linphone/Localizable.xcstrings @@ -1303,6 +1303,40 @@ } } }, + "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" : { @@ -1384,6 +1418,9 @@ }, "Deny all" : { + }, + "Description" : { + }, "Dialer" : { @@ -1818,8 +1855,22 @@ "Meeting added to iPhone calendar" : { }, - "Meeting invite !!" : { - + "meeting_waiting_room_join" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Join" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Rejoindre" + } + } + } }, "Meetings" : { @@ -2238,9 +2289,6 @@ } } } - }, - "Rejoindre" : { - }, "Remove from favourites" : { diff --git a/Linphone/TelecomManager/TelecomManager.swift b/Linphone/TelecomManager/TelecomManager.swift index 16ce445f9..0736f6fc5 100644 --- a/Linphone/TelecomManager/TelecomManager.swift +++ b/Linphone/TelecomManager/TelecomManager.swift @@ -171,8 +171,12 @@ class TelecomManager: ObservableObject { do { let meetingAddress = try Factory.Instance.createAddress(addr: address.asStringUriOnly()) - meetingWaitingRoomDisplayed = true - meetingWaitingRoomSelected = meetingAddress + DispatchQueue.main.async { + withAnimation { + self.meetingWaitingRoomDisplayed = true + self.meetingWaitingRoomSelected = meetingAddress + } + } } catch {} } else { doCallWithCore( diff --git a/Linphone/UI/Call/MeetingWaitingRoomFragment.swift b/Linphone/UI/Call/MeetingWaitingRoomFragment.swift index 1c1be011e..8f8920261 100644 --- a/Linphone/UI/Call/MeetingWaitingRoomFragment.swift +++ b/Linphone/UI/Call/MeetingWaitingRoomFragment.swift @@ -335,7 +335,7 @@ struct MeetingWaitingRoomFragment: View { Button(action: { meetingWaitingRoomViewModel.joinMeeting() }, label: { - Text("Rejoindre") + Text("meeting_waiting_room_join") .default_text_style_white_600(styleSize: 20) .frame(height: 35) .frame(maxWidth: .infinity) @@ -356,7 +356,7 @@ struct MeetingWaitingRoomFragment: View { Button(action: { meetingWaitingRoomViewModel.joinMeeting() }, label: { - Text("Rejoindre") + Text("meeting_waiting_room_join") .default_text_style_white_600(styleSize: 20) .frame(height: 35) .frame(maxWidth: .infinity) diff --git a/Linphone/UI/Main/Conversations/Fragments/ChatBubbleView.swift b/Linphone/UI/Main/Conversations/Fragments/ChatBubbleView.swift index fab330f10..a673a81de 100644 --- a/Linphone/UI/Main/Conversations/Fragments/ChatBubbleView.swift +++ b/Linphone/UI/Main/Conversations/Fragments/ChatBubbleView.swift @@ -43,7 +43,7 @@ struct ChatBubbleView: View { HStack { if eventLogMessage.eventModel.eventLogType == .ConferenceChatMessage { VStack { - if !eventLogMessage.message.text.isEmpty || !eventLogMessage.message.attachments.isEmpty { + if !eventLogMessage.message.text.isEmpty || !eventLogMessage.message.attachments.isEmpty || eventLogMessage.message.isIcalendar { HStack(alignment: .top, content: { if eventLogMessage.message.isOutgoing { Spacer() @@ -159,7 +159,7 @@ struct ChatBubbleView: View { VStack(alignment: eventLogMessage.message.isOutgoing ? .trailing : .leading) { VStack(alignment: eventLogMessage.message.isOutgoing ? .trailing : .leading) { - if !eventLogMessage.message.attachments.isEmpty { + if !eventLogMessage.message.attachments.isEmpty && !eventLogMessage.message.isIcalendar { messageAttachments() } @@ -169,20 +169,129 @@ struct ChatBubbleView: View { .default_text_style(styleSize: 14) } - if eventLogMessage.message.isIcalendar { - VStack{ + 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 { + 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) } - - Text("Meeting invite !!") - .foregroundStyle(Color.grayMain2c500) - .default_text_style(styleSize: 12) + .frame(width: geometryProxy.size.width - 110) + .background(.white) + .cornerRadius(10) } HStack(alignment: .center) { diff --git a/Linphone/UI/Main/Conversations/Model/MessageConferenceInfo.swift b/Linphone/UI/Main/Conversations/Model/MessageConferenceInfo.swift index 6bd5ec3ec..489fcf210 100644 --- a/Linphone/UI/Main/Conversations/Model/MessageConferenceInfo.swift +++ b/Linphone/UI/Main/Conversations/Model/MessageConferenceInfo.swift @@ -27,7 +27,7 @@ public enum MessageConferenceState: Codable { public struct MessageConferenceInfo: Codable, Identifiable, Hashable { public let id: UUID - public let meetingConferenceUri: URL + public let meetingConferenceUri: String public let meetingSubject: String public let meetingDescription: String public let meetingState: MessageConferenceState @@ -37,7 +37,7 @@ public struct MessageConferenceInfo: Codable, Identifiable, Hashable { public let meetingDayNumber: String public let meetingParticipants: String - public init(id: UUID, meetingConferenceUri: URL, meetingSubject: String, meetingDescription: String, meetingState: MessageConferenceState, meetingDate: String, meetingTime: String, meetingDay: String, meetingDayNumber: String, 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 diff --git a/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift b/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift index c1fceee5b..f54dd238d 100644 --- a/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift +++ b/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift @@ -1915,7 +1915,7 @@ class ConversationViewModel: ObservableObject { } func parseConferenceInvite(content: Content) -> MessageConferenceInfo? { - var meetingConferenceUriTmp: URL? + var meetingConferenceUriTmp: String = "" var meetingSubjectTmp: String = "" var meetingDescriptionTmp: String = "" var meetingStateTmp: MessageConferenceState = .new @@ -1930,7 +1930,7 @@ class ConversationViewModel: ObservableObject { if let conferenceAddress = conferenceInfo.uri { let conferenceUri = conferenceAddress.asStringUriOnly() Log.info("Found conference info with URI [\(conferenceUri)] and subject [\(conferenceInfo.subject ?? "")]") - meetingConferenceUriTmp = URL(string: conferenceAddress.asStringUriOnly()) + meetingConferenceUriTmp = conferenceAddress.asStringUriOnly() meetingSubjectTmp = conferenceInfo.subject ?? "" meetingDescriptionTmp = conferenceInfo.description ?? "" @@ -1949,7 +1949,7 @@ class ConversationViewModel: ObservableObject { dateFormatter.dateStyle = .full dateFormatter.timeStyle = .none - meetingDateTmp = dateFormatter.string(from: dateTmp) + meetingDateTmp = dateFormatter.string(from: dateTmp).capitalized let timeFormatter = DateFormatter() timeFormatter.dateFormat = Locale.current.identifier == "fr_FR" ? "HH:mm" : "h:mm a" @@ -1962,14 +1962,14 @@ class ConversationViewModel: ObservableObject { meetingTimeTmp = "\(timeTmp) - \(endTime)" meetingDayTmp = dateTmp.formatted(Date.FormatStyle().weekday(.abbreviated)).capitalized - meetingDayNumberTmp = dateTmp.formatted(Date.FormatStyle().day(.twoDigits)) + meetingDayNumberTmp = dateTmp.formatted(Date.FormatStyle().day(.defaultDigits)) - meetingParticipantsTmp = String(conferenceInfo.participantInfos.count) + meetingParticipantsTmp = String(conferenceInfo.participantInfos.count) + " participant" + (conferenceInfo.participantInfos.count > 1 ? "s" : "") - if meetingConferenceUriTmp != nil { + if !meetingConferenceUriTmp.isEmpty { return MessageConferenceInfo( id: UUID(), - meetingConferenceUri: meetingConferenceUriTmp!, + meetingConferenceUri: meetingConferenceUriTmp, meetingSubject: meetingSubjectTmp, meetingDescription: meetingDescriptionTmp, meetingState: meetingStateTmp, @@ -1985,6 +1985,14 @@ class ConversationViewModel: ObservableObject { 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 diff --git a/Linphone/UI/Main/History/Fragments/HistoryContactFragment.swift b/Linphone/UI/Main/History/Fragments/HistoryContactFragment.swift index 505477d01..ab49963ef 100644 --- a/Linphone/UI/Main/History/Fragments/HistoryContactFragment.swift +++ b/Linphone/UI/Main/History/Fragments/HistoryContactFragment.swift @@ -357,7 +357,7 @@ struct HistoryContactFragment: View { .background(Color.grayMain2c200) .cornerRadius(40) - Text("Rejoindre") + Text("meeting_waiting_room_join") .default_text_style(styleSize: 14) .frame(minWidth: 80) } diff --git a/Linphone/UI/Main/Meetings/Fragments/MeetingFragment.swift b/Linphone/UI/Main/Meetings/Fragments/MeetingFragment.swift index 05cfc0b30..1a5ce37d8 100644 --- a/Linphone/UI/Main/Meetings/Fragments/MeetingFragment.swift +++ b/Linphone/UI/Main/Meetings/Fragments/MeetingFragment.swift @@ -299,8 +299,7 @@ struct MeetingFragment: View { Spacer() Button(action: { - TelecomManager.shared.meetingWaitingRoomSelected = try? Factory.Instance.createAddress(addr: meetingViewModel.displayedMeeting?.address ?? "") - TelecomManager.shared.meetingWaitingRoomDisplayed = true + meetingViewModel.joinMeeting(addressUri: meetingViewModel.displayedMeeting?.address ?? "") }, label: { Text("Join the meeting now") .bold() diff --git a/Linphone/UI/Main/Meetings/ViewModel/MeetingViewModel.swift b/Linphone/UI/Main/Meetings/ViewModel/MeetingViewModel.swift index 84ac81e15..2bfbc9007 100644 --- a/Linphone/UI/Main/Meetings/ViewModel/MeetingViewModel.swift +++ b/Linphone/UI/Main/Meetings/ViewModel/MeetingViewModel.swift @@ -379,6 +379,14 @@ class MeetingViewModel: ObservableObject { } }) } + + 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 From fa6034a426f762391d832476c5d2bb61b6beb722 Mon Sep 17 00:00:00 2001 From: QuentinArguillere Date: Wed, 16 Oct 2024 18:34:33 +0200 Subject: [PATCH 460/486] Add LossRate and JitterBufferSize to call stats --- .../CallStatisticsSheetBottomSheet.swift | 15 +++++++++++++++ Linphone/UI/Call/Model/CallStatsModel.swift | 16 ++++++++++++++++ 2 files changed, 31 insertions(+) diff --git a/Linphone/UI/Call/Fragments/CallStatisticsSheetBottomSheet.swift b/Linphone/UI/Call/Fragments/CallStatisticsSheetBottomSheet.swift index 3b94f53b5..0ec6bd92e 100644 --- a/Linphone/UI/Call/Fragments/CallStatisticsSheetBottomSheet.swift +++ b/Linphone/UI/Call/Fragments/CallStatisticsSheetBottomSheet.swift @@ -66,6 +66,16 @@ struct CallStatisticsSheetBottomSheet: View { 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) @@ -83,6 +93,11 @@ struct CallStatisticsSheetBottomSheet: View { Spacer() + Text(callViewModel.callStatsModel.videoLossRate) + .default_text_style_white(styleSize: 15) + + Spacer() + Text(callViewModel.callStatsModel.videoResolution) .default_text_style_white(styleSize: 15) diff --git a/Linphone/UI/Call/Model/CallStatsModel.swift b/Linphone/UI/Call/Model/CallStatsModel.swift index 605cb95cb..7e19cfe62 100644 --- a/Linphone/UI/Call/Model/CallStatsModel.swift +++ b/Linphone/UI/Call/Model/CallStatsModel.swift @@ -25,10 +25,13 @@ class CallStatsModel: ObservableObject { @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 = "" @@ -52,9 +55,17 @@ class CallStatsModel: ObservableObject { 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: @@ -72,6 +83,10 @@ class CallStatsModel: ObservableObject { 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!)" @@ -83,6 +98,7 @@ class CallStatsModel: ObservableObject { DispatchQueue.main.async { self.videoCodec = codecLabel self.videoBandwidth = bandwidthLabel + self.videoLossRate = lossRateLabel self.videoResolution = resolutionLabel self.videoFps = fpsLabel } From 42821c983ac216a5f306cd479aec516894aaf76a Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Thu, 17 Oct 2024 16:29:00 +0200 Subject: [PATCH 461/486] Fix insertion of multiple messages --- .../ViewModel/ConversationViewModel.swift | 434 +++++++++--------- 1 file changed, 218 insertions(+), 216 deletions(-) diff --git a/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift b/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift index f54dd238d..ba7879790 100644 --- a/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift +++ b/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift @@ -791,257 +791,259 @@ class ConversationViewModel: ObservableObject { } func getNewMessages(eventLogs: [EventLog]) { - eventLogs.enumerated().forEach { index, eventLog in - var attachmentNameList: String = "" - var attachmentList: [Attachment] = [] - var contentText = "" - - if eventLog.chatMessage != nil && !eventLog.chatMessage!.contents.isEmpty { - eventLog.chatMessage!.contents.forEach { content in - if content.isText { - contentText = content.utf8Text ?? "" - } else { - if content.filePath == nil || content.filePath!.isEmpty { - // self.downloadContent(chatMessage: eventLog.chatMessage!, content: content) - let path = URL(string: self.getNewFilePath(name: content.name ?? "")) - - if path != nil { - let attachment = - Attachment( - id: UUID().uuidString, - name: content.name ?? "???", - url: path!, - type: .fileTransfer, - size: content.fileSize - ) - attachmentNameList += ", \(content.name ?? "???")" - attachmentList.append(attachment) - } - } else if content.name != nil && !content.name!.isEmpty { - if content.type != "video" { + if self.conversationMessagesSection[0].rows.first?.eventModel.eventLogId != eventLogs.last?.chatMessage?.messageId { + eventLogs.enumerated().forEach { index, eventLog in + var attachmentNameList: String = "" + var attachmentList: [Attachment] = [] + var contentText = "" + + if eventLog.chatMessage != nil && !eventLog.chatMessage!.contents.isEmpty { + eventLog.chatMessage!.contents.forEach { content in + if content.isText { + contentText = content.utf8Text ?? "" + } else { + if content.filePath == nil || content.filePath!.isEmpty { + // self.downloadContent(chatMessage: eventLog.chatMessage!, content: content) let path = URL(string: self.getNewFilePath(name: content.name ?? "")) - var typeTmp: AttachmentType = .other - - switch content.type { - case "image": - typeTmp = (content.name?.lowercased().hasSuffix("gif"))! ? .gif : .image - case "audio": - typeTmp = content.isVoiceRecording ? .voiceRecording : .audio - case "application": - typeTmp = content.subtype.lowercased() == "pdf" ? .pdf : .other - case "text": - typeTmp = .text - default: - typeTmp = .other - } if path != nil { let attachment = Attachment( id: UUID().uuidString, - name: content.name!, + name: content.name ?? "???", url: path!, - type: typeTmp, - duration: typeTmp == . voiceRecording ? content.fileDuration : 0, + type: .fileTransfer, size: content.fileSize ) - attachmentNameList += ", \(content.name!)" + attachmentNameList += ", \(content.name ?? "???")" attachmentList.append(attachment) } - } else if content.type == "video" { - let path = URL(string: self.getNewFilePath(name: content.name ?? "")) - - let pathThumbnail = URL(string: self.generateThumbnail(name: content.name ?? "")) - if path != nil && pathThumbnail != nil { - let attachment = - Attachment( - id: UUID().uuidString, - name: content.name!, - thumbnail: pathThumbnail!, - full: path!, - type: .video, - size: content.fileSize - ) - attachmentNameList += ", \(content.name!)" - attachmentList.append(attachment) + } else if content.name != nil && !content.name!.isEmpty { + if content.type != "video" { + let path = URL(string: self.getNewFilePath(name: content.name ?? "")) + var typeTmp: AttachmentType = .other + + switch content.type { + case "image": + typeTmp = (content.name?.lowercased().hasSuffix("gif"))! ? .gif : .image + case "audio": + typeTmp = content.isVoiceRecording ? .voiceRecording : .audio + case "application": + typeTmp = content.subtype.lowercased() == "pdf" ? .pdf : .other + case "text": + typeTmp = .text + default: + typeTmp = .other + } + + if path != nil { + let attachment = + Attachment( + id: UUID().uuidString, + name: content.name!, + url: path!, + type: typeTmp, + duration: typeTmp == . voiceRecording ? content.fileDuration : 0, + size: content.fileSize + ) + attachmentNameList += ", \(content.name!)" + attachmentList.append(attachment) + } + } else if content.type == "video" { + let path = URL(string: self.getNewFilePath(name: content.name ?? "")) + + let pathThumbnail = URL(string: self.generateThumbnail(name: content.name ?? "")) + if path != nil && pathThumbnail != nil { + let attachment = + Attachment( + id: UUID().uuidString, + name: content.name!, + thumbnail: pathThumbnail!, + full: path!, + type: .video, + size: content.fileSize + ) + attachmentNameList += ", \(content.name!)" + attachmentList.append(attachment) + } } } } } } - } - - let addressPrecCleaned = index > 0 ? eventLogs[index - 1].chatMessage?.fromAddress?.clone() : eventLog.chatMessage?.fromAddress?.clone() - addressPrecCleaned?.clean() - - let addressNextCleaned = index <= eventLogs.count - 2 ? eventLogs[index + 1].chatMessage?.fromAddress?.clone() : eventLog.chatMessage?.fromAddress?.clone() - addressNextCleaned?.clean() - - let addressCleaned = eventLog.chatMessage?.fromAddress?.clone() - addressCleaned?.clean() - - if addressCleaned != nil && self.participantConversationModel.first(where: {$0.address == addressCleaned!.asStringUriOnly()}) == nil { - self.addParticipantConversationModel(address: addressCleaned!) - } - - let isFirstMessageIncomingTmp = index > 0 - ? addressPrecCleaned != nil && addressCleaned != nil && addressPrecCleaned!.asStringUriOnly() != addressCleaned!.asStringUriOnly() - : ( - self.conversationMessagesSection.isEmpty || self.conversationMessagesSection[0].rows.isEmpty - ? true - : addressCleaned != nil && self.conversationMessagesSection[0].rows[0].message.address != addressCleaned!.asStringUriOnly() - ) - - let isFirstMessageOutgoingTmp = index <= eventLogs.count - 2 - ? addressNextCleaned != nil && addressCleaned != nil && addressNextCleaned!.asStringUriOnly() == addressCleaned!.asStringUriOnly() - : ( - self.conversationMessagesSection.isEmpty || self.conversationMessagesSection[0].rows.isEmpty - ? true - : !self.conversationMessagesSection[0].rows[0].message.isOutgoing || (addressCleaned != nil && self.conversationMessagesSection[0].rows[0].message.address == addressCleaned!.asStringUriOnly()) - ) - - let isFirstMessageTmp = (eventLog.chatMessage?.isOutgoing ?? false) ? isFirstMessageOutgoingTmp : isFirstMessageIncomingTmp - - let unreadMessagesCount = self.displayedConversation != nil ? self.displayedConversation!.chatRoom.unreadMessagesCount : 0 - - var statusTmp: Message.Status? = .sending - switch eventLog.chatMessage?.state { - case .InProgress: - statusTmp = .sending - case .Delivered: - statusTmp = .sent - case .DeliveredToUser: - statusTmp = .received - case .Displayed: - statusTmp = .read - case .NotDelivered: - statusTmp = .error - default: - statusTmp = .sending - } - - var reactionsTmp: [String] = [] - eventLog.chatMessage?.reactions.forEach({ chatMessageReaction in - reactionsTmp.append(chatMessageReaction.body) - }) - - if !attachmentNameList.isEmpty { - attachmentNameList = String(attachmentNameList.dropFirst(2)) - } - - var replyMessageTmp: ReplyMessage? - if eventLog.chatMessage?.replyMessage != nil { - let addressReplyCleaned = eventLog.chatMessage?.replyMessage?.fromAddress?.clone() - addressReplyCleaned?.clean() - if addressReplyCleaned != nil && self.participantConversationModel.first(where: {$0.address == addressReplyCleaned!.asStringUriOnly()}) == nil { - self.addParticipantConversationModel(address: addressReplyCleaned!) + let addressPrecCleaned = index > 0 ? eventLogs[index - 1].chatMessage?.fromAddress?.clone() : eventLog.chatMessage?.fromAddress?.clone() + addressPrecCleaned?.clean() + + let addressNextCleaned = index <= eventLogs.count - 2 ? eventLogs[index + 1].chatMessage?.fromAddress?.clone() : eventLog.chatMessage?.fromAddress?.clone() + addressNextCleaned?.clean() + + let addressCleaned = eventLog.chatMessage?.fromAddress?.clone() + addressCleaned?.clean() + + if addressCleaned != nil && self.participantConversationModel.first(where: {$0.address == addressCleaned!.asStringUriOnly()}) == nil { + self.addParticipantConversationModel(address: addressCleaned!) } - let contentReplyText = eventLog.chatMessage?.replyMessage?.utf8Text ?? "" + let isFirstMessageIncomingTmp = index > 0 + ? addressPrecCleaned != nil && addressCleaned != nil && addressPrecCleaned!.asStringUriOnly() != addressCleaned!.asStringUriOnly() + : ( + self.conversationMessagesSection.isEmpty || self.conversationMessagesSection[0].rows.isEmpty + ? true + : addressCleaned != nil && self.conversationMessagesSection[0].rows[0].message.address != addressCleaned!.asStringUriOnly() + ) - var attachmentNameReplyList: String = "" + let isFirstMessageOutgoingTmp = index <= eventLogs.count - 2 + ? addressNextCleaned != nil && addressCleaned != nil && addressNextCleaned!.asStringUriOnly() == addressCleaned!.asStringUriOnly() + : ( + self.conversationMessagesSection.isEmpty || self.conversationMessagesSection[0].rows.isEmpty + ? true + : !self.conversationMessagesSection[0].rows[0].message.isOutgoing || (addressCleaned != nil && self.conversationMessagesSection[0].rows[0].message.address == addressCleaned!.asStringUriOnly()) + ) - eventLog.chatMessage?.replyMessage?.contents.forEach { content in - if !content.isText { - attachmentNameReplyList += ", \(content.name!)" + let isFirstMessageTmp = (eventLog.chatMessage?.isOutgoing ?? false) ? isFirstMessageOutgoingTmp : isFirstMessageIncomingTmp + + let unreadMessagesCount = self.displayedConversation != nil ? self.displayedConversation!.chatRoom.unreadMessagesCount : 0 + + var statusTmp: Message.Status? = .sending + switch eventLog.chatMessage?.state { + case .InProgress: + statusTmp = .sending + case .Delivered: + statusTmp = .sent + case .DeliveredToUser: + statusTmp = .received + case .Displayed: + statusTmp = .read + case .NotDelivered: + statusTmp = .error + default: + statusTmp = .sending + } + + var reactionsTmp: [String] = [] + eventLog.chatMessage?.reactions.forEach({ chatMessageReaction in + reactionsTmp.append(chatMessageReaction.body) + }) + + if !attachmentNameList.isEmpty { + attachmentNameList = String(attachmentNameList.dropFirst(2)) + } + + var replyMessageTmp: ReplyMessage? + if eventLog.chatMessage?.replyMessage != nil { + let addressReplyCleaned = eventLog.chatMessage?.replyMessage?.fromAddress?.clone() + addressReplyCleaned?.clean() + + if addressReplyCleaned != nil && self.participantConversationModel.first(where: {$0.address == addressReplyCleaned!.asStringUriOnly()}) == nil { + self.addParticipantConversationModel(address: addressReplyCleaned!) } - } - - if !attachmentNameReplyList.isEmpty { - attachmentNameReplyList = String(attachmentNameReplyList.dropFirst(2)) - } - - replyMessageTmp = ReplyMessage( - id: eventLog.chatMessage?.replyMessage!.messageId ?? UUID().uuidString, - address: addressReplyCleaned != nil ? addressReplyCleaned!.asStringUriOnly() : "", - isFirstMessage: false, - text: contentReplyText, - isOutgoing: false, - dateReceived: 0, - attachmentsNames: attachmentNameReplyList, - attachments: [] - ) - } - - if eventLog.chatMessage != nil { - let message = EventLogMessage( - eventModel: EventModel(eventLog: eventLog), - message: Message( - id: !eventLog.chatMessage!.messageId.isEmpty ? eventLog.chatMessage!.messageId : UUID().uuidString, - appData: eventLog.chatMessage!.appdata ?? "", - status: statusTmp, - isOutgoing: eventLog.chatMessage?.isOutgoing ?? false, - dateReceived: eventLog.chatMessage?.time ?? 0, - address: addressCleaned != nil ? addressCleaned!.asStringUriOnly() : "", - isFirstMessage: isFirstMessageTmp, - text: contentText, - attachmentsNames: attachmentNameList, - attachments: attachmentList, - replyMessage: replyMessageTmp, - isForward: eventLog.chatMessage?.isForward ?? false, - ownReaction: eventLog.chatMessage?.ownReaction?.body ?? "", - reactions: reactionsTmp, - isEphemeral: eventLog.chatMessage?.isEphemeral ?? false, - ephemeralExpireTime: eventLog.chatMessage?.ephemeralExpireTime ?? 0, - ephemeralLifetime: eventLog.chatMessage?.ephemeralLifetime ?? 0, - isIcalendar: eventLog.chatMessage?.contents.first?.isIcalendar ?? false, - messageConferenceInfo: eventLog.chatMessage != nil && eventLog.chatMessage!.contents.first != nil && eventLog.chatMessage!.contents.first!.isIcalendar == true ? self.parseConferenceInvite(content: eventLog.chatMessage!.contents.first!) : nil + + let contentReplyText = eventLog.chatMessage?.replyMessage?.utf8Text ?? "" + + var attachmentNameReplyList: String = "" + + eventLog.chatMessage?.replyMessage?.contents.forEach { content in + if !content.isText { + attachmentNameReplyList += ", \(content.name!)" + } + } + + if !attachmentNameReplyList.isEmpty { + attachmentNameReplyList = String(attachmentNameReplyList.dropFirst(2)) + } + + replyMessageTmp = ReplyMessage( + id: eventLog.chatMessage?.replyMessage!.messageId ?? UUID().uuidString, + address: addressReplyCleaned != nil ? addressReplyCleaned!.asStringUriOnly() : "", + isFirstMessage: false, + text: contentReplyText, + isOutgoing: false, + dateReceived: 0, + attachmentsNames: attachmentNameReplyList, + attachments: [] ) - ) + } - if self.conversationMessagesSection[0].rows.first?.eventModel.eventLogId != eventLog.chatMessage?.messageId { - self.addChatMessageDelegate(message: eventLog.chatMessage!) + if eventLog.chatMessage != nil { + let message = EventLogMessage( + eventModel: EventModel(eventLog: eventLog), + message: Message( + id: !eventLog.chatMessage!.messageId.isEmpty ? eventLog.chatMessage!.messageId : UUID().uuidString, + appData: eventLog.chatMessage!.appdata ?? "", + status: statusTmp, + isOutgoing: eventLog.chatMessage?.isOutgoing ?? false, + dateReceived: eventLog.chatMessage?.time ?? 0, + address: addressCleaned != nil ? addressCleaned!.asStringUriOnly() : "", + isFirstMessage: isFirstMessageTmp, + text: contentText, + attachmentsNames: attachmentNameList, + attachments: attachmentList, + replyMessage: replyMessageTmp, + isForward: eventLog.chatMessage?.isForward ?? false, + ownReaction: eventLog.chatMessage?.ownReaction?.body ?? "", + reactions: reactionsTmp, + isEphemeral: eventLog.chatMessage?.isEphemeral ?? false, + ephemeralExpireTime: eventLog.chatMessage?.ephemeralExpireTime ?? 0, + ephemeralLifetime: eventLog.chatMessage?.ephemeralLifetime ?? 0, + isIcalendar: eventLog.chatMessage?.contents.first?.isIcalendar ?? false, + messageConferenceInfo: eventLog.chatMessage != nil && eventLog.chatMessage!.contents.first != nil && eventLog.chatMessage!.contents.first!.isIcalendar == true ? self.parseConferenceInvite(content: eventLog.chatMessage!.contents.first!) : nil + ) + ) + + if self.conversationMessagesSection[0].rows.first?.eventModel.eventLogId != eventLog.chatMessage?.messageId { + self.addChatMessageDelegate(message: eventLog.chatMessage!) + + DispatchQueue.main.async { + Log.info("[ConversationViewModel] Get new Messages \(self.conversationMessagesSection.count)") + if !self.conversationMessagesSection.isEmpty + && !self.conversationMessagesSection[0].rows.isEmpty + && self.conversationMessagesSection[0].rows[0].message.isOutgoing + && (self.conversationMessagesSection[0].rows[0].message.address == message.message.address) { + self.conversationMessagesSection[0].rows[0].message.isFirstMessage = false + } + + if self.conversationMessagesSection.isEmpty && self.displayedConversation != nil { + self.conversationMessagesSection.append(MessagesSection(date: Date(), chatRoomID: self.displayedConversation!.id, rows: [message])) + } else { + self.conversationMessagesSection[0].rows.insert(message, at: 0) + } + + if !message.message.isOutgoing { + self.displayedConversationUnreadMessagesCount = unreadMessagesCount + } + } + } + } else { + let message = EventLogMessage( + eventModel: EventModel(eventLog: eventLog), + message: Message( + id: UUID().uuidString, + status: nil, + isOutgoing: false, + dateReceived: 0, + address: "", + isFirstMessage: false, + text: "", + attachments: [], + ownReaction: "", + reactions: [] + ) + ) DispatchQueue.main.async { - Log.info("[ConversationViewModel] Get new Messages \(self.conversationMessagesSection.count)") - if !self.conversationMessagesSection.isEmpty - && !self.conversationMessagesSection[0].rows.isEmpty - && self.conversationMessagesSection[0].rows[0].message.isOutgoing - && (self.conversationMessagesSection[0].rows[0].message.address == message.message.address) { - self.conversationMessagesSection[0].rows[0].message.isFirstMessage = false - } - + Log.info("[ConversationViewModel] Get new Messages (message nil) \(self.conversationMessagesSection.count)") if self.conversationMessagesSection.isEmpty && self.displayedConversation != nil { self.conversationMessagesSection.append(MessagesSection(date: Date(), chatRoomID: self.displayedConversation!.id, rows: [message])) } else { self.conversationMessagesSection[0].rows.insert(message, at: 0) } - - if !message.message.isOutgoing { - self.displayedConversationUnreadMessagesCount = unreadMessagesCount - } - } - } - } else { - let message = EventLogMessage( - eventModel: EventModel(eventLog: eventLog), - message: Message( - id: UUID().uuidString, - status: nil, - isOutgoing: false, - dateReceived: 0, - address: "", - isFirstMessage: false, - text: "", - attachments: [], - ownReaction: "", - reactions: [] - ) - ) - - DispatchQueue.main.async { - Log.info("[ConversationViewModel] Get new Messages (message nil) \(self.conversationMessagesSection.count)") - if self.conversationMessagesSection.isEmpty && self.displayedConversation != nil { - self.conversationMessagesSection.append(MessagesSection(date: Date(), chatRoomID: self.displayedConversation!.id, rows: [message])) - } else { - self.conversationMessagesSection[0].rows.insert(message, at: 0) } } } + + getHistorySize() } - - getHistorySize() } func resetMessage() { From 8f66998a03e95568558e8f440863d5f48542a136 Mon Sep 17 00:00:00 2001 From: QuentinArguillere Date: Thu, 17 Oct 2024 16:44:08 +0200 Subject: [PATCH 462/486] Update build to (54) --- Linphone.xcodeproj/project.pbxproj | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Linphone.xcodeproj/project.pbxproj b/Linphone.xcodeproj/project.pbxproj index 2d7269dd7..e5e52d3c2 100644 --- a/Linphone.xcodeproj/project.pbxproj +++ b/Linphone.xcodeproj/project.pbxproj @@ -1250,7 +1250,7 @@ CODE_SIGN_ENTITLEMENTS = msgNotificationService/msgNotificationService.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 53; + CURRENT_PROJECT_VERSION = 54; DEVELOPMENT_TEAM = Z2V957B3D6; ENABLE_USER_SCRIPT_SANDBOXING = NO; GCC_C_LANGUAGE_STANDARD = gnu17; @@ -1293,7 +1293,7 @@ CODE_SIGN_ENTITLEMENTS = msgNotificationService/msgNotificationService.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 53; + CURRENT_PROJECT_VERSION = 54; DEVELOPMENT_TEAM = Z2V957B3D6; ENABLE_USER_SCRIPT_SANDBOXING = NO; GCC_C_LANGUAGE_STANDARD = gnu17; @@ -1450,7 +1450,7 @@ ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = NO; CODE_SIGN_ENTITLEMENTS = Linphone/Linphone.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 53; + CURRENT_PROJECT_VERSION = 54; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_ASSET_PATHS = "\"Linphone/Preview Content\""; @@ -1507,7 +1507,7 @@ ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = NO; CODE_SIGN_ENTITLEMENTS = Linphone/Linphone.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 53; + CURRENT_PROJECT_VERSION = 54; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_ASSET_PATHS = "\"Linphone/Preview Content\""; DEVELOPMENT_TEAM = Z2V957B3D6; From 67b5f7f5637f9025a1fccada5f7f207bd197763a Mon Sep 17 00:00:00 2001 From: QuentinArguillere Date: Thu, 17 Oct 2024 16:44:17 +0200 Subject: [PATCH 463/486] Remove publisher from corecontext --- Linphone/Core/CoreContext.swift | 4 ---- 1 file changed, 4 deletions(-) diff --git a/Linphone/Core/CoreContext.swift b/Linphone/Core/CoreContext.swift index 5f168ff1f..44ddbae7e 100644 --- a/Linphone/Core/CoreContext.swift +++ b/Linphone/Core/CoreContext.swift @@ -363,10 +363,6 @@ final class CoreContext: ObservableObject { mCore.removeDelegate(delegate: delegate) } - func getCorePublisher() -> CoreDelegatePublisher? { - return mCore.publisher - } - } // swiftlint:enable line_length From 0933b71618cc5bfa40245154138db0c86955f169 Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Fri, 18 Oct 2024 17:32:36 +0200 Subject: [PATCH 464/486] Add call button to the chatroom view --- .../Fragments/ConversationFragment.swift | 1 + .../Model/ConversationModel.swift | 90 ++++++++++++++++++- .../ViewModel/StartCallViewModel.swift | 6 +- 3 files changed, 93 insertions(+), 4 deletions(-) diff --git a/Linphone/UI/Main/Conversations/Fragments/ConversationFragment.swift b/Linphone/UI/Main/Conversations/Fragments/ConversationFragment.swift index 4a60f942e..1bd1637d7 100644 --- a/Linphone/UI/Main/Conversations/Fragments/ConversationFragment.swift +++ b/Linphone/UI/Main/Conversations/Fragments/ConversationFragment.swift @@ -190,6 +190,7 @@ struct ConversationFragment: View { Spacer() Button { + conversationViewModel.displayedConversation!.call() } label: { Image("phone") .renderingMode(.template) diff --git a/Linphone/UI/Main/Conversations/Model/ConversationModel.swift b/Linphone/UI/Main/Conversations/Model/ConversationModel.swift index 2992f66d6..0e84f8a20 100644 --- a/Linphone/UI/Main/Conversations/Model/ConversationModel.swift +++ b/Linphone/UI/Main/Conversations/Model/ConversationModel.swift @@ -50,6 +50,8 @@ class ConversationModel: ObservableObject { @Published var unreadMessagesCount: Int @Published var avatarModel: ContactAvatarModel + private var conferenceSchedulerDelegate: ConferenceSchedulerDelegate? + init(chatRoom: ChatRoom) { self.chatRoom = chatRoom @@ -104,13 +106,97 @@ class ConversationModel: ObservableObject { } func call() { - coreContext.doOnCoreQueue { _ in - if self.chatRoom.peerAddress != nil { + coreContext.doOnCoreQueue { core in + if self.chatRoom.hasCapability(mask: ChatRoom.Capabilities.OneToOne.rawValue) && !self.chatRoom.hasCapability(mask: ChatRoom.Capabilities.Conference.rawValue) { TelecomManager.shared.doCallOrJoinConf(address: self.chatRoom.peerAddress!) + } else if self.chatRoom.hasCapability(mask: ChatRoom.Capabilities.OneToOne.rawValue) && self.chatRoom.hasCapability(mask: ChatRoom.Capabilities.Conference.rawValue) { + if self.chatRoom.participants.first != nil && self.chatRoom.participants.first!.address != nil { + TelecomManager.shared.doCallOrJoinConf(address: self.chatRoom.participants.first!.address!) + } + } else { + //self.createGroupCall(core: core) } } } + func createGroupCall(core: Core) { + let account = core.defaultAccount + if account == nil { + Log.error( + "\(ConversationModel.TAG) No default account found, can't create group call!" + ) + return + } + + do { + let conferenceInfo = try Factory.Instance.createConferenceInfo() + conferenceInfo.organizer = account!.params?.identityAddress + conferenceInfo.subject = self.chatRoom.subject ?? "Conference" + + var participantsList: [ParticipantInfo] = [] + self.chatRoom.participants.forEach { participant in + do { + let info = try Factory.Instance.createParticipantInfo(address: participant.address!) + // For meetings, all participants must have Speaker role + info.role = Participant.Role.Speaker + participantsList.append(info) + } catch let error { + Log.error( + "\(ConversationModel.TAG) Can't create ParticipantInfo: \(error)" + ) + } + } + + conferenceInfo.addParticipantInfos(participantInfos: participantsList) + + Log.info( + "\(ConversationModel.TAG) Creating group call with subject \(self.chatRoom.subject ?? "Conference") and \(participantsList.count) participant(s)" + ) + + let conferenceScheduler = try core.createConferenceScheduler() + self.conferenceAddDelegate(core: core, conferenceScheduler: conferenceScheduler) + conferenceScheduler.account = account + // Will trigger the conference creation/update automatically + conferenceScheduler.info = conferenceInfo + } catch let error { + Log.error( + "\(ConversationModel.TAG) createGroupCall: \(error)" + ) + } + } + + func conferenceAddDelegate(core: Core, conferenceScheduler: ConferenceScheduler) { + self.conferenceSchedulerDelegate = ConferenceSchedulerDelegateStub(onStateChanged: { (conferenceScheduler: ConferenceScheduler, state: ConferenceScheduler.State) in + Log.info("\(ConversationModel.TAG) Conference scheduler state is \(state)") + if state == ConferenceScheduler.State.Ready { + conferenceScheduler.removeDelegate(delegate: self.conferenceSchedulerDelegate!) + self.conferenceSchedulerDelegate = nil + + let conferenceAddress = conferenceScheduler.info?.uri + if conferenceAddress != nil { + Log.info( + "\(ConversationModel.TAG) Conference info created, address is \(conferenceAddress?.asStringUriOnly() ?? "Error conference address")" + ) + + TelecomManager.shared.doCallOrJoinConf(address: conferenceAddress!) + } else { + Log.error("\(ConversationModel.TAG) Conference info URI is null!") + + ToastViewModel.shared.toastMessage = "Failed_to_create_group_call_error" + ToastViewModel.shared.displayToast = true + } + } else if state == ConferenceScheduler.State.Error { + conferenceScheduler.removeDelegate(delegate: self.conferenceSchedulerDelegate!) + self.conferenceSchedulerDelegate = nil + Log.error("\(ConversationModel.TAG) Failed to create group call!") + + ToastViewModel.shared.toastMessage = "Failed_to_create_group_call_error" + ToastViewModel.shared.displayToast = true + } + }) + conferenceScheduler.addDelegate(delegate: self.conferenceSchedulerDelegate!) + } + func getContentTextMessage() { coreContext.doOnCoreQueue { _ in let lastMessage = self.chatRoom.lastMessageInHistory diff --git a/Linphone/UI/Main/History/ViewModel/StartCallViewModel.swift b/Linphone/UI/Main/History/ViewModel/StartCallViewModel.swift index 03fcb657e..4270d7fc5 100644 --- a/Linphone/UI/Main/History/ViewModel/StartCallViewModel.swift +++ b/Linphone/UI/Main/History/ViewModel/StartCallViewModel.swift @@ -94,7 +94,9 @@ class StartCallViewModel: ObservableObject { } } - self.participants.removeAll() + DispatchQueue.main.async { + self.participants.removeAll() + } conferenceInfo.addParticipantInfos(participantInfos: participantsList) @@ -102,7 +104,7 @@ class StartCallViewModel: ObservableObject { "\(StartCallViewModel.TAG) Creating group call with subject \(self.messageText) and \(participantsList.count) participant(s)" ) - let conferenceScheduler = try core.createConferenceScheduler() + let conferenceScheduler = try core.createConferenceScheduler(account: account) self.conferenceAddDelegate(core: core, conferenceScheduler: conferenceScheduler) conferenceScheduler.account = account // Will trigger the conference creation/update automatically From cc1bcd1666b8cfb5fe0020b13e3aebbdda3e71a2 Mon Sep 17 00:00:00 2001 From: QuentinArguillere Date: Mon, 21 Oct 2024 10:08:12 +0200 Subject: [PATCH 465/486] Update build version to (55) --- Linphone.xcodeproj/project.pbxproj | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Linphone.xcodeproj/project.pbxproj b/Linphone.xcodeproj/project.pbxproj index e5e52d3c2..e120285cc 100644 --- a/Linphone.xcodeproj/project.pbxproj +++ b/Linphone.xcodeproj/project.pbxproj @@ -1250,7 +1250,7 @@ CODE_SIGN_ENTITLEMENTS = msgNotificationService/msgNotificationService.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 54; + CURRENT_PROJECT_VERSION = 55; DEVELOPMENT_TEAM = Z2V957B3D6; ENABLE_USER_SCRIPT_SANDBOXING = NO; GCC_C_LANGUAGE_STANDARD = gnu17; @@ -1293,7 +1293,7 @@ CODE_SIGN_ENTITLEMENTS = msgNotificationService/msgNotificationService.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 54; + CURRENT_PROJECT_VERSION = 55; DEVELOPMENT_TEAM = Z2V957B3D6; ENABLE_USER_SCRIPT_SANDBOXING = NO; GCC_C_LANGUAGE_STANDARD = gnu17; @@ -1450,7 +1450,7 @@ ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = NO; CODE_SIGN_ENTITLEMENTS = Linphone/Linphone.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 54; + CURRENT_PROJECT_VERSION = 55; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_ASSET_PATHS = "\"Linphone/Preview Content\""; @@ -1507,7 +1507,7 @@ ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = NO; CODE_SIGN_ENTITLEMENTS = Linphone/Linphone.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 54; + CURRENT_PROJECT_VERSION = 55; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_ASSET_PATHS = "\"Linphone/Preview Content\""; DEVELOPMENT_TEAM = Z2V957B3D6; From 4f3699e72baef0e5cc91984d7e2b43b390b8c1fc Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Mon, 21 Oct 2024 10:59:02 +0200 Subject: [PATCH 466/486] Add local network authorization --- Linphone/Info.plist | 2 + .../Fragments/PermissionsFragment.swift | 2 +- Linphone/Utils/PermissionManager.swift | 53 ++++++++++++++++--- 3 files changed, 49 insertions(+), 8 deletions(-) diff --git a/Linphone/Info.plist b/Linphone/Info.plist index fdbc8ca20..89d3fd147 100644 --- a/Linphone/Info.plist +++ b/Linphone/Info.plist @@ -2,6 +2,8 @@ + NSLocalNetworkUsageDescription + App requires access to the local network to establish VoIP connections CFBundleURLTypes diff --git a/Linphone/UI/Assistant/Fragments/PermissionsFragment.swift b/Linphone/UI/Assistant/Fragments/PermissionsFragment.swift index 40e2fdc8c..370ae5a38 100644 --- a/Linphone/UI/Assistant/Fragments/PermissionsFragment.swift +++ b/Linphone/UI/Assistant/Fragments/PermissionsFragment.swift @@ -194,7 +194,7 @@ struct PermissionsFragment: View { } .navigationViewStyle(StackNavigationViewStyle()) .navigationBarHidden(true) - .onReceive(permissionManager.$contactsPermissionGranted, perform: { (granted) in + .onReceive(permissionManager.$allPermissionsHaveBeenDisplayed, perform: { (granted) in if granted { withAnimation { sharedMainViewModel.changeWelcomeView() diff --git a/Linphone/Utils/PermissionManager.swift b/Linphone/Utils/PermissionManager.swift index e20bc2d51..dab08971b 100644 --- a/Linphone/Utils/PermissionManager.swift +++ b/Linphone/Utils/PermissionManager.swift @@ -22,6 +22,7 @@ import Photos import Contacts import UserNotifications import SwiftUI +import Network class PermissionManager: ObservableObject { @@ -32,18 +33,28 @@ class PermissionManager: ObservableObject { @Published var cameraPermissionGranted = false @Published var contactsPermissionGranted = false @Published var microphonePermissionGranted = false + @Published var allPermissionsHaveBeenDisplayed = false private init() {} func getPermissions() { - pushNotificationRequestPermission() - microphoneRequestPermission() - photoLibraryRequestPermission() - cameraRequestPermission() - contactsRequestPermission() + pushNotificationRequestPermission { + let dispatchGroup = DispatchGroup() + + dispatchGroup.enter() + self.microphoneRequestPermission() + self.photoLibraryRequestPermission() + self.cameraRequestPermission() + self.contactsRequestPermission(group: dispatchGroup) + + dispatchGroup.notify(queue: .main) { + // Now request local network authorization last + self.requestLocalNetworkAuthorization() + } + } } - func pushNotificationRequestPermission() { + func pushNotificationRequestPermission(completion: @escaping () -> Void) { let options: UNAuthorizationOptions = [.alert, .sound, .badge] UNUserNotificationCenter.current().requestAuthorization(options: options) { (granted, error) in if let error = error { @@ -52,6 +63,7 @@ class PermissionManager: ObservableObject { DispatchQueue.main.async { self.pushPermissionGranted = granted } + completion() } } @@ -79,12 +91,39 @@ class PermissionManager: ObservableObject { }) } - func contactsRequestPermission() { + func contactsRequestPermission(group: DispatchGroup) { let store = CNContactStore() store.requestAccess(for: .contacts) { success, _ in DispatchQueue.main.async { self.contactsPermissionGranted = success } + group.leave() + } + } + + func requestLocalNetworkAuthorization() { + // Use a general UDP broadcast endpoint to attempt triggering the authorization request + let host = NWEndpoint.Host("255.255.255.255") // Broadcast on the local network + let port = NWEndpoint.Port(12345) // Choose an arbitrary port + + let params = NWParameters.udp + let connection = NWConnection(host: host, port: port, using: params) + + connection.stateUpdateHandler = { newState in + switch newState { + case .ready: + print("Connection ready") + connection.cancel() // Close the connection after establishing it + case .failed(let error): + print("Connection failed: \(error)") + connection.cancel() + default: + break + } + } + connection.start(queue: .main) + DispatchQueue.main.async { + self.allPermissionsHaveBeenDisplayed = true } } } From 79dc832684d4c9fd4b885a533f37fc77e573aa41 Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Mon, 21 Oct 2024 11:09:34 +0200 Subject: [PATCH 467/486] Hide keyboard when displaying calls --- Linphone/UI/Call/CallView.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Linphone/UI/Call/CallView.swift b/Linphone/UI/Call/CallView.swift index d0e46d4b6..4d42ff3b0 100644 --- a/Linphone/UI/Call/CallView.swift +++ b/Linphone/UI/Call/CallView.swift @@ -235,6 +235,7 @@ struct CallView: View { } } .onAppear { + UIApplication.shared.endEditing() fullscreenVideo = false if geo.size.width < 350 || geo.size.height < 350 { buttonSize = 45.0 From b523315e825bc7639e9956612a8862bcdcbca89c Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Mon, 21 Oct 2024 11:23:31 +0200 Subject: [PATCH 468/486] Fix view layout when app returns to foreground --- Linphone/UI/Main/ContentView.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Linphone/UI/Main/ContentView.swift b/Linphone/UI/Main/ContentView.swift index 776f6f0bd..0dd98f609 100644 --- a/Linphone/UI/Main/ContentView.swift +++ b/Linphone/UI/Main/ContentView.swift @@ -1231,6 +1231,7 @@ struct ContentView: View { } .onChange(of: scenePhase) { newPhase in CoreContext.shared.enteredForeground = newPhase == .active + orientation = UIDevice.current.orientation } } From efa34110c2b7f2daebb83086b61417334296ba4d Mon Sep 17 00:00:00 2001 From: QuentinArguillere Date: Mon, 21 Oct 2024 14:08:05 +0200 Subject: [PATCH 469/486] Display chat notification when app is on foreground if the message comes from elsewhere that the currently displayed chatroom --- Linphone/LinphoneApp.swift | 14 ++++++++++++++ Linphone/UI/Main/ContentView.swift | 6 +++--- .../Fragments/ConversationFragment.swift | 12 ++++++++++++ 3 files changed, 29 insertions(+), 3 deletions(-) diff --git a/Linphone/LinphoneApp.swift b/Linphone/LinphoneApp.swift index 9ecfffa09..487346789 100644 --- a/Linphone/LinphoneApp.swift +++ b/Linphone/LinphoneApp.swift @@ -22,6 +22,7 @@ import linphonesw import UserNotifications let accountTokenNotification = Notification.Name("AccountCreationTokenReceived") +var displayedChatroomPeerAddr: String? class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCenterDelegate { @@ -81,6 +82,19 @@ class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCenterDele completionHandler() } + // Display notifications on foreground + func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification, withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) { + let userInfo = notification.request.content.userInfo + Log.info("Received push notification in foreground, payload= \(userInfo)") + + if let callId = userInfo["CallId"] as? String, let peerAddr = userInfo["peer_addr"] as? String, let localAddr = userInfo["local_addr"] as? String { + // Only display notification if we're not in the chatroom they come from + if displayedChatroomPeerAddr != peerAddr { + completionHandler([.banner, .sound]) + } + } + } + func applicationWillTerminate(_ application: UIApplication) { Log.info("IOS applicationWillTerminate") CoreContext.shared.doOnCoreQueue(synchronous: true) { core in diff --git a/Linphone/UI/Main/ContentView.swift b/Linphone/UI/Main/ContentView.swift index 776f6f0bd..6a11ce794 100644 --- a/Linphone/UI/Main/ContentView.swift +++ b/Linphone/UI/Main/ContentView.swift @@ -1242,9 +1242,9 @@ struct ContentView: View { } class NavigationManager: ObservableObject { - @Published var selectedCallId: String? = nil - @Published var peerAddr: String? = nil - @Published var localAddr: String? = nil + @Published var selectedCallId: String? + @Published var peerAddr: String? + @Published var localAddr: String? func openChatRoom(callId: String, peerAddr: String, localAddr: String) { self.selectedCallId = callId diff --git a/Linphone/UI/Main/Conversations/Fragments/ConversationFragment.swift b/Linphone/UI/Main/Conversations/Fragments/ConversationFragment.swift index 4a60f942e..a61fd9186 100644 --- a/Linphone/UI/Main/Conversations/Fragments/ConversationFragment.swift +++ b/Linphone/UI/Main/Conversations/Fragments/ConversationFragment.swift @@ -69,7 +69,13 @@ struct ConversationFragment: View { .onRotate { newOrientation in orientation = newOrientation } + .onAppear() { + displayedChatroomPeerAddr = conversationViewModel.displayedConversation?.remoteSipUri + Log.info("debugtrace = onAppear: displayedChatroomPeerAddr = \(displayedChatroomPeerAddr)") + } .onDisappear { + displayedChatroomPeerAddr = nil + Log.info("debugtrace = onDisappear: displayedChatroomPeerAddr = nil") conversationViewModel.removeConversationDelegate() } .sheet(isPresented: $conversationViewModel.isShowSelectedMessageToDisplayDetails, onDismiss: { @@ -109,7 +115,13 @@ struct ConversationFragment: View { .onRotate { newOrientation in orientation = newOrientation } + .onAppear() { + displayedChatroomPeerAddr = conversationViewModel.displayedConversation?.remoteSipUri + Log.info("debugtrace = onAppear: displayedChatroomPeerAddr = \(displayedChatroomPeerAddr)") + } .onDisappear { + displayedChatroomPeerAddr = nil + Log.info("debugtrace = onDisappear: displayedChatroomPeerAddr = nil") conversationViewModel.removeConversationDelegate() } .halfSheet(showSheet: $conversationViewModel.isShowSelectedMessageToDisplayDetails) { From e4c64cc4af66a4a82babe2badae51cf09b421aeb Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Mon, 21 Oct 2024 14:43:34 +0200 Subject: [PATCH 470/486] Fix meeting waiting room when headphone is connected --- Linphone/UI/Call/MeetingWaitingRoomFragment.swift | 9 ++++++--- .../ViewModel/MeetingWaitingRoomViewModel.swift | 15 +++++++++++---- 2 files changed, 17 insertions(+), 7 deletions(-) diff --git a/Linphone/UI/Call/MeetingWaitingRoomFragment.swift b/Linphone/UI/Call/MeetingWaitingRoomFragment.swift index 8f8920261..e1fae5807 100644 --- a/Linphone/UI/Call/MeetingWaitingRoomFragment.swift +++ b/Linphone/UI/Call/MeetingWaitingRoomFragment.swift @@ -52,7 +52,9 @@ struct MeetingWaitingRoomFragment: View { }) .onAppear { meetingWaitingRoomViewModel.enableAVAudioSession() - if AVAudioSession.sharedInstance().currentRoute.outputs.filter({ $0.portType.rawValue.contains("Bluetooth") }).isEmpty { + if AVAudioSession.sharedInstance().currentRoute.outputs.filter({ + $0.portType.rawValue.contains("Bluetooth") || $0.portType.rawValue.contains("Headphones") + }).isEmpty { do { try AVAudioSession.sharedInstance().overrideOutputAudioPort(.speaker) } catch _ { @@ -72,8 +74,9 @@ struct MeetingWaitingRoomFragment: View { } .onAppear { meetingWaitingRoomViewModel.enableAVAudioSession() - - if AVAudioSession.sharedInstance().currentRoute.outputs.filter({ $0.portType.rawValue.contains("Bluetooth") }).isEmpty { + if AVAudioSession.sharedInstance().currentRoute.outputs.filter({ + $0.portType.rawValue.contains("Bluetooth") || $0.portType.rawValue.contains("Headphones") + }).isEmpty { do { try AVAudioSession.sharedInstance().overrideOutputAudioPort(.speaker) } catch _ { diff --git a/Linphone/UI/Call/ViewModel/MeetingWaitingRoomViewModel.swift b/Linphone/UI/Call/ViewModel/MeetingWaitingRoomViewModel.swift index 24d4b1480..365f78d2d 100644 --- a/Linphone/UI/Call/ViewModel/MeetingWaitingRoomViewModel.swift +++ b/Linphone/UI/Call/ViewModel/MeetingWaitingRoomViewModel.swift @@ -50,6 +50,11 @@ class MeetingWaitingRoomViewModel: ObservableObject { func resetMeetingRoomView() { if self.telecomManager.meetingWaitingRoomSelected != nil { + do { + try AVAudioSession.sharedInstance().setCategory(.playAndRecord, mode: .voiceChat, options: .allowBluetooth) + } catch _ { + + } coreContext.doOnCoreQueue { core in let conf = core.findConferenceInformationFromUri(uri: self.telecomManager.meetingWaitingRoomSelected!) @@ -73,10 +78,12 @@ class MeetingWaitingRoomViewModel: ObservableObject { if friend != nil && friend!.address != nil && friend!.address!.displayName != nil { userNameTmp = friend!.address!.displayName! } else { - if core.defaultAccount!.contactAddress!.displayName != nil { - userNameTmp = core.defaultAccount!.contactAddress!.displayName! - } else if core.defaultAccount!.contactAddress!.username != nil { - userNameTmp = core.defaultAccount!.contactAddress!.username! + if core.defaultAccount != nil && core.defaultAccount!.contactAddress != nil { + if core.defaultAccount!.contactAddress!.displayName != nil { + userNameTmp = core.defaultAccount!.contactAddress!.displayName! + } else if core.defaultAccount!.contactAddress!.username != nil { + userNameTmp = core.defaultAccount!.contactAddress!.username! + } } } From c41d38679f7cac40b206165754eac9ae77692865 Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Mon, 21 Oct 2024 15:43:48 +0200 Subject: [PATCH 471/486] Fix fullscreen video mode in oneone call --- Linphone/UI/Call/CallView.swift | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/Linphone/UI/Call/CallView.swift b/Linphone/UI/Call/CallView.swift index 4d42ff3b0..7ef1d8a93 100644 --- a/Linphone/UI/Call/CallView.swift +++ b/Linphone/UI/Call/CallView.swift @@ -517,14 +517,8 @@ struct CallView: View { } } .frame( - width: - angleDegree == 0 - ? (fullscreenVideo && !telecomManager.isPausedByRemote ? geometry.size.width : geometry.size.width - 8) - : (fullscreenVideo && !telecomManager.isPausedByRemote ? geometry.size.height + geometry.safeAreaInsets.top + geometry.safeAreaInsets.bottom + geometry.safeAreaInsets.bottom : geometry.size.height - (minBottomSheetHeight * geometry.size.height > 80 ? minBottomSheetHeight * geometry.size.height : 78) - 40 - 20 + geometry.safeAreaInsets.bottom), - height: - angleDegree == 0 - ? (fullscreenVideo && !telecomManager.isPausedByRemote ? geometry.size.height + geometry.safeAreaInsets.top + geometry.safeAreaInsets.bottom + geometry.safeAreaInsets.bottom : geometry.size.height - (minBottomSheetHeight * geometry.size.height > 80 ? minBottomSheetHeight * geometry.size.height : 78) - 40 - 20 + geometry.safeAreaInsets.bottom) - : (fullscreenVideo && !telecomManager.isPausedByRemote ? geometry.size.width : geometry.size.width - 8) + width: fullscreenVideo && !telecomManager.isPausedByRemote ? geometry.size.width : geometry.size.width - 8, + height: fullscreenVideo && !telecomManager.isPausedByRemote ? geometry.size.height + geometry.safeAreaInsets.bottom : geometry.size.height - (minBottomSheetHeight * geometry.size.height > 80 ? minBottomSheetHeight * geometry.size.height : 78) - 40 - 20 + geometry.safeAreaInsets.bottom ) .scaledToFill() .clipped() @@ -711,6 +705,7 @@ struct CallView: View { ) .background(Color.gray900) .cornerRadius(20) + .padding(.top, callViewModel.isOneOneCall && fullscreenVideo && !telecomManager.isPausedByRemote ? geometry.safeAreaInsets.bottom + 10 : 0) .padding(.horizontal, fullscreenVideo && !telecomManager.isPausedByRemote ? 0 : 4) .onRotate { newOrientation in let oldOrientation = orientation From 532332ad948d97b60708ab05369578bb32dc53b8 Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Thu, 17 Oct 2024 16:29:00 +0200 Subject: [PATCH 472/486] Fix insertion of multiple messages --- .../ViewModel/ConversationViewModel.swift | 434 +++++++++--------- 1 file changed, 218 insertions(+), 216 deletions(-) diff --git a/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift b/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift index f54dd238d..ba7879790 100644 --- a/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift +++ b/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift @@ -791,257 +791,259 @@ class ConversationViewModel: ObservableObject { } func getNewMessages(eventLogs: [EventLog]) { - eventLogs.enumerated().forEach { index, eventLog in - var attachmentNameList: String = "" - var attachmentList: [Attachment] = [] - var contentText = "" - - if eventLog.chatMessage != nil && !eventLog.chatMessage!.contents.isEmpty { - eventLog.chatMessage!.contents.forEach { content in - if content.isText { - contentText = content.utf8Text ?? "" - } else { - if content.filePath == nil || content.filePath!.isEmpty { - // self.downloadContent(chatMessage: eventLog.chatMessage!, content: content) - let path = URL(string: self.getNewFilePath(name: content.name ?? "")) - - if path != nil { - let attachment = - Attachment( - id: UUID().uuidString, - name: content.name ?? "???", - url: path!, - type: .fileTransfer, - size: content.fileSize - ) - attachmentNameList += ", \(content.name ?? "???")" - attachmentList.append(attachment) - } - } else if content.name != nil && !content.name!.isEmpty { - if content.type != "video" { + if self.conversationMessagesSection[0].rows.first?.eventModel.eventLogId != eventLogs.last?.chatMessage?.messageId { + eventLogs.enumerated().forEach { index, eventLog in + var attachmentNameList: String = "" + var attachmentList: [Attachment] = [] + var contentText = "" + + if eventLog.chatMessage != nil && !eventLog.chatMessage!.contents.isEmpty { + eventLog.chatMessage!.contents.forEach { content in + if content.isText { + contentText = content.utf8Text ?? "" + } else { + if content.filePath == nil || content.filePath!.isEmpty { + // self.downloadContent(chatMessage: eventLog.chatMessage!, content: content) let path = URL(string: self.getNewFilePath(name: content.name ?? "")) - var typeTmp: AttachmentType = .other - - switch content.type { - case "image": - typeTmp = (content.name?.lowercased().hasSuffix("gif"))! ? .gif : .image - case "audio": - typeTmp = content.isVoiceRecording ? .voiceRecording : .audio - case "application": - typeTmp = content.subtype.lowercased() == "pdf" ? .pdf : .other - case "text": - typeTmp = .text - default: - typeTmp = .other - } if path != nil { let attachment = Attachment( id: UUID().uuidString, - name: content.name!, + name: content.name ?? "???", url: path!, - type: typeTmp, - duration: typeTmp == . voiceRecording ? content.fileDuration : 0, + type: .fileTransfer, size: content.fileSize ) - attachmentNameList += ", \(content.name!)" + attachmentNameList += ", \(content.name ?? "???")" attachmentList.append(attachment) } - } else if content.type == "video" { - let path = URL(string: self.getNewFilePath(name: content.name ?? "")) - - let pathThumbnail = URL(string: self.generateThumbnail(name: content.name ?? "")) - if path != nil && pathThumbnail != nil { - let attachment = - Attachment( - id: UUID().uuidString, - name: content.name!, - thumbnail: pathThumbnail!, - full: path!, - type: .video, - size: content.fileSize - ) - attachmentNameList += ", \(content.name!)" - attachmentList.append(attachment) + } else if content.name != nil && !content.name!.isEmpty { + if content.type != "video" { + let path = URL(string: self.getNewFilePath(name: content.name ?? "")) + var typeTmp: AttachmentType = .other + + switch content.type { + case "image": + typeTmp = (content.name?.lowercased().hasSuffix("gif"))! ? .gif : .image + case "audio": + typeTmp = content.isVoiceRecording ? .voiceRecording : .audio + case "application": + typeTmp = content.subtype.lowercased() == "pdf" ? .pdf : .other + case "text": + typeTmp = .text + default: + typeTmp = .other + } + + if path != nil { + let attachment = + Attachment( + id: UUID().uuidString, + name: content.name!, + url: path!, + type: typeTmp, + duration: typeTmp == . voiceRecording ? content.fileDuration : 0, + size: content.fileSize + ) + attachmentNameList += ", \(content.name!)" + attachmentList.append(attachment) + } + } else if content.type == "video" { + let path = URL(string: self.getNewFilePath(name: content.name ?? "")) + + let pathThumbnail = URL(string: self.generateThumbnail(name: content.name ?? "")) + if path != nil && pathThumbnail != nil { + let attachment = + Attachment( + id: UUID().uuidString, + name: content.name!, + thumbnail: pathThumbnail!, + full: path!, + type: .video, + size: content.fileSize + ) + attachmentNameList += ", \(content.name!)" + attachmentList.append(attachment) + } } } } } } - } - - let addressPrecCleaned = index > 0 ? eventLogs[index - 1].chatMessage?.fromAddress?.clone() : eventLog.chatMessage?.fromAddress?.clone() - addressPrecCleaned?.clean() - - let addressNextCleaned = index <= eventLogs.count - 2 ? eventLogs[index + 1].chatMessage?.fromAddress?.clone() : eventLog.chatMessage?.fromAddress?.clone() - addressNextCleaned?.clean() - - let addressCleaned = eventLog.chatMessage?.fromAddress?.clone() - addressCleaned?.clean() - - if addressCleaned != nil && self.participantConversationModel.first(where: {$0.address == addressCleaned!.asStringUriOnly()}) == nil { - self.addParticipantConversationModel(address: addressCleaned!) - } - - let isFirstMessageIncomingTmp = index > 0 - ? addressPrecCleaned != nil && addressCleaned != nil && addressPrecCleaned!.asStringUriOnly() != addressCleaned!.asStringUriOnly() - : ( - self.conversationMessagesSection.isEmpty || self.conversationMessagesSection[0].rows.isEmpty - ? true - : addressCleaned != nil && self.conversationMessagesSection[0].rows[0].message.address != addressCleaned!.asStringUriOnly() - ) - - let isFirstMessageOutgoingTmp = index <= eventLogs.count - 2 - ? addressNextCleaned != nil && addressCleaned != nil && addressNextCleaned!.asStringUriOnly() == addressCleaned!.asStringUriOnly() - : ( - self.conversationMessagesSection.isEmpty || self.conversationMessagesSection[0].rows.isEmpty - ? true - : !self.conversationMessagesSection[0].rows[0].message.isOutgoing || (addressCleaned != nil && self.conversationMessagesSection[0].rows[0].message.address == addressCleaned!.asStringUriOnly()) - ) - - let isFirstMessageTmp = (eventLog.chatMessage?.isOutgoing ?? false) ? isFirstMessageOutgoingTmp : isFirstMessageIncomingTmp - - let unreadMessagesCount = self.displayedConversation != nil ? self.displayedConversation!.chatRoom.unreadMessagesCount : 0 - - var statusTmp: Message.Status? = .sending - switch eventLog.chatMessage?.state { - case .InProgress: - statusTmp = .sending - case .Delivered: - statusTmp = .sent - case .DeliveredToUser: - statusTmp = .received - case .Displayed: - statusTmp = .read - case .NotDelivered: - statusTmp = .error - default: - statusTmp = .sending - } - - var reactionsTmp: [String] = [] - eventLog.chatMessage?.reactions.forEach({ chatMessageReaction in - reactionsTmp.append(chatMessageReaction.body) - }) - - if !attachmentNameList.isEmpty { - attachmentNameList = String(attachmentNameList.dropFirst(2)) - } - - var replyMessageTmp: ReplyMessage? - if eventLog.chatMessage?.replyMessage != nil { - let addressReplyCleaned = eventLog.chatMessage?.replyMessage?.fromAddress?.clone() - addressReplyCleaned?.clean() - if addressReplyCleaned != nil && self.participantConversationModel.first(where: {$0.address == addressReplyCleaned!.asStringUriOnly()}) == nil { - self.addParticipantConversationModel(address: addressReplyCleaned!) + let addressPrecCleaned = index > 0 ? eventLogs[index - 1].chatMessage?.fromAddress?.clone() : eventLog.chatMessage?.fromAddress?.clone() + addressPrecCleaned?.clean() + + let addressNextCleaned = index <= eventLogs.count - 2 ? eventLogs[index + 1].chatMessage?.fromAddress?.clone() : eventLog.chatMessage?.fromAddress?.clone() + addressNextCleaned?.clean() + + let addressCleaned = eventLog.chatMessage?.fromAddress?.clone() + addressCleaned?.clean() + + if addressCleaned != nil && self.participantConversationModel.first(where: {$0.address == addressCleaned!.asStringUriOnly()}) == nil { + self.addParticipantConversationModel(address: addressCleaned!) } - let contentReplyText = eventLog.chatMessage?.replyMessage?.utf8Text ?? "" + let isFirstMessageIncomingTmp = index > 0 + ? addressPrecCleaned != nil && addressCleaned != nil && addressPrecCleaned!.asStringUriOnly() != addressCleaned!.asStringUriOnly() + : ( + self.conversationMessagesSection.isEmpty || self.conversationMessagesSection[0].rows.isEmpty + ? true + : addressCleaned != nil && self.conversationMessagesSection[0].rows[0].message.address != addressCleaned!.asStringUriOnly() + ) - var attachmentNameReplyList: String = "" + let isFirstMessageOutgoingTmp = index <= eventLogs.count - 2 + ? addressNextCleaned != nil && addressCleaned != nil && addressNextCleaned!.asStringUriOnly() == addressCleaned!.asStringUriOnly() + : ( + self.conversationMessagesSection.isEmpty || self.conversationMessagesSection[0].rows.isEmpty + ? true + : !self.conversationMessagesSection[0].rows[0].message.isOutgoing || (addressCleaned != nil && self.conversationMessagesSection[0].rows[0].message.address == addressCleaned!.asStringUriOnly()) + ) - eventLog.chatMessage?.replyMessage?.contents.forEach { content in - if !content.isText { - attachmentNameReplyList += ", \(content.name!)" + let isFirstMessageTmp = (eventLog.chatMessage?.isOutgoing ?? false) ? isFirstMessageOutgoingTmp : isFirstMessageIncomingTmp + + let unreadMessagesCount = self.displayedConversation != nil ? self.displayedConversation!.chatRoom.unreadMessagesCount : 0 + + var statusTmp: Message.Status? = .sending + switch eventLog.chatMessage?.state { + case .InProgress: + statusTmp = .sending + case .Delivered: + statusTmp = .sent + case .DeliveredToUser: + statusTmp = .received + case .Displayed: + statusTmp = .read + case .NotDelivered: + statusTmp = .error + default: + statusTmp = .sending + } + + var reactionsTmp: [String] = [] + eventLog.chatMessage?.reactions.forEach({ chatMessageReaction in + reactionsTmp.append(chatMessageReaction.body) + }) + + if !attachmentNameList.isEmpty { + attachmentNameList = String(attachmentNameList.dropFirst(2)) + } + + var replyMessageTmp: ReplyMessage? + if eventLog.chatMessage?.replyMessage != nil { + let addressReplyCleaned = eventLog.chatMessage?.replyMessage?.fromAddress?.clone() + addressReplyCleaned?.clean() + + if addressReplyCleaned != nil && self.participantConversationModel.first(where: {$0.address == addressReplyCleaned!.asStringUriOnly()}) == nil { + self.addParticipantConversationModel(address: addressReplyCleaned!) } - } - - if !attachmentNameReplyList.isEmpty { - attachmentNameReplyList = String(attachmentNameReplyList.dropFirst(2)) - } - - replyMessageTmp = ReplyMessage( - id: eventLog.chatMessage?.replyMessage!.messageId ?? UUID().uuidString, - address: addressReplyCleaned != nil ? addressReplyCleaned!.asStringUriOnly() : "", - isFirstMessage: false, - text: contentReplyText, - isOutgoing: false, - dateReceived: 0, - attachmentsNames: attachmentNameReplyList, - attachments: [] - ) - } - - if eventLog.chatMessage != nil { - let message = EventLogMessage( - eventModel: EventModel(eventLog: eventLog), - message: Message( - id: !eventLog.chatMessage!.messageId.isEmpty ? eventLog.chatMessage!.messageId : UUID().uuidString, - appData: eventLog.chatMessage!.appdata ?? "", - status: statusTmp, - isOutgoing: eventLog.chatMessage?.isOutgoing ?? false, - dateReceived: eventLog.chatMessage?.time ?? 0, - address: addressCleaned != nil ? addressCleaned!.asStringUriOnly() : "", - isFirstMessage: isFirstMessageTmp, - text: contentText, - attachmentsNames: attachmentNameList, - attachments: attachmentList, - replyMessage: replyMessageTmp, - isForward: eventLog.chatMessage?.isForward ?? false, - ownReaction: eventLog.chatMessage?.ownReaction?.body ?? "", - reactions: reactionsTmp, - isEphemeral: eventLog.chatMessage?.isEphemeral ?? false, - ephemeralExpireTime: eventLog.chatMessage?.ephemeralExpireTime ?? 0, - ephemeralLifetime: eventLog.chatMessage?.ephemeralLifetime ?? 0, - isIcalendar: eventLog.chatMessage?.contents.first?.isIcalendar ?? false, - messageConferenceInfo: eventLog.chatMessage != nil && eventLog.chatMessage!.contents.first != nil && eventLog.chatMessage!.contents.first!.isIcalendar == true ? self.parseConferenceInvite(content: eventLog.chatMessage!.contents.first!) : nil + + let contentReplyText = eventLog.chatMessage?.replyMessage?.utf8Text ?? "" + + var attachmentNameReplyList: String = "" + + eventLog.chatMessage?.replyMessage?.contents.forEach { content in + if !content.isText { + attachmentNameReplyList += ", \(content.name!)" + } + } + + if !attachmentNameReplyList.isEmpty { + attachmentNameReplyList = String(attachmentNameReplyList.dropFirst(2)) + } + + replyMessageTmp = ReplyMessage( + id: eventLog.chatMessage?.replyMessage!.messageId ?? UUID().uuidString, + address: addressReplyCleaned != nil ? addressReplyCleaned!.asStringUriOnly() : "", + isFirstMessage: false, + text: contentReplyText, + isOutgoing: false, + dateReceived: 0, + attachmentsNames: attachmentNameReplyList, + attachments: [] ) - ) + } - if self.conversationMessagesSection[0].rows.first?.eventModel.eventLogId != eventLog.chatMessage?.messageId { - self.addChatMessageDelegate(message: eventLog.chatMessage!) + if eventLog.chatMessage != nil { + let message = EventLogMessage( + eventModel: EventModel(eventLog: eventLog), + message: Message( + id: !eventLog.chatMessage!.messageId.isEmpty ? eventLog.chatMessage!.messageId : UUID().uuidString, + appData: eventLog.chatMessage!.appdata ?? "", + status: statusTmp, + isOutgoing: eventLog.chatMessage?.isOutgoing ?? false, + dateReceived: eventLog.chatMessage?.time ?? 0, + address: addressCleaned != nil ? addressCleaned!.asStringUriOnly() : "", + isFirstMessage: isFirstMessageTmp, + text: contentText, + attachmentsNames: attachmentNameList, + attachments: attachmentList, + replyMessage: replyMessageTmp, + isForward: eventLog.chatMessage?.isForward ?? false, + ownReaction: eventLog.chatMessage?.ownReaction?.body ?? "", + reactions: reactionsTmp, + isEphemeral: eventLog.chatMessage?.isEphemeral ?? false, + ephemeralExpireTime: eventLog.chatMessage?.ephemeralExpireTime ?? 0, + ephemeralLifetime: eventLog.chatMessage?.ephemeralLifetime ?? 0, + isIcalendar: eventLog.chatMessage?.contents.first?.isIcalendar ?? false, + messageConferenceInfo: eventLog.chatMessage != nil && eventLog.chatMessage!.contents.first != nil && eventLog.chatMessage!.contents.first!.isIcalendar == true ? self.parseConferenceInvite(content: eventLog.chatMessage!.contents.first!) : nil + ) + ) + + if self.conversationMessagesSection[0].rows.first?.eventModel.eventLogId != eventLog.chatMessage?.messageId { + self.addChatMessageDelegate(message: eventLog.chatMessage!) + + DispatchQueue.main.async { + Log.info("[ConversationViewModel] Get new Messages \(self.conversationMessagesSection.count)") + if !self.conversationMessagesSection.isEmpty + && !self.conversationMessagesSection[0].rows.isEmpty + && self.conversationMessagesSection[0].rows[0].message.isOutgoing + && (self.conversationMessagesSection[0].rows[0].message.address == message.message.address) { + self.conversationMessagesSection[0].rows[0].message.isFirstMessage = false + } + + if self.conversationMessagesSection.isEmpty && self.displayedConversation != nil { + self.conversationMessagesSection.append(MessagesSection(date: Date(), chatRoomID: self.displayedConversation!.id, rows: [message])) + } else { + self.conversationMessagesSection[0].rows.insert(message, at: 0) + } + + if !message.message.isOutgoing { + self.displayedConversationUnreadMessagesCount = unreadMessagesCount + } + } + } + } else { + let message = EventLogMessage( + eventModel: EventModel(eventLog: eventLog), + message: Message( + id: UUID().uuidString, + status: nil, + isOutgoing: false, + dateReceived: 0, + address: "", + isFirstMessage: false, + text: "", + attachments: [], + ownReaction: "", + reactions: [] + ) + ) DispatchQueue.main.async { - Log.info("[ConversationViewModel] Get new Messages \(self.conversationMessagesSection.count)") - if !self.conversationMessagesSection.isEmpty - && !self.conversationMessagesSection[0].rows.isEmpty - && self.conversationMessagesSection[0].rows[0].message.isOutgoing - && (self.conversationMessagesSection[0].rows[0].message.address == message.message.address) { - self.conversationMessagesSection[0].rows[0].message.isFirstMessage = false - } - + Log.info("[ConversationViewModel] Get new Messages (message nil) \(self.conversationMessagesSection.count)") if self.conversationMessagesSection.isEmpty && self.displayedConversation != nil { self.conversationMessagesSection.append(MessagesSection(date: Date(), chatRoomID: self.displayedConversation!.id, rows: [message])) } else { self.conversationMessagesSection[0].rows.insert(message, at: 0) } - - if !message.message.isOutgoing { - self.displayedConversationUnreadMessagesCount = unreadMessagesCount - } - } - } - } else { - let message = EventLogMessage( - eventModel: EventModel(eventLog: eventLog), - message: Message( - id: UUID().uuidString, - status: nil, - isOutgoing: false, - dateReceived: 0, - address: "", - isFirstMessage: false, - text: "", - attachments: [], - ownReaction: "", - reactions: [] - ) - ) - - DispatchQueue.main.async { - Log.info("[ConversationViewModel] Get new Messages (message nil) \(self.conversationMessagesSection.count)") - if self.conversationMessagesSection.isEmpty && self.displayedConversation != nil { - self.conversationMessagesSection.append(MessagesSection(date: Date(), chatRoomID: self.displayedConversation!.id, rows: [message])) - } else { - self.conversationMessagesSection[0].rows.insert(message, at: 0) } } } + + getHistorySize() } - - getHistorySize() } func resetMessage() { From 26e2defbe302beb7225f60a50af790e6b6f5ab6f Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Fri, 18 Oct 2024 17:32:36 +0200 Subject: [PATCH 473/486] Add call button to the chatroom view --- .../Fragments/ConversationFragment.swift | 1 + .../Model/ConversationModel.swift | 90 ++++++++++++++++++- .../ViewModel/StartCallViewModel.swift | 6 +- 3 files changed, 93 insertions(+), 4 deletions(-) diff --git a/Linphone/UI/Main/Conversations/Fragments/ConversationFragment.swift b/Linphone/UI/Main/Conversations/Fragments/ConversationFragment.swift index a61fd9186..65381a267 100644 --- a/Linphone/UI/Main/Conversations/Fragments/ConversationFragment.swift +++ b/Linphone/UI/Main/Conversations/Fragments/ConversationFragment.swift @@ -202,6 +202,7 @@ struct ConversationFragment: View { Spacer() Button { + conversationViewModel.displayedConversation!.call() } label: { Image("phone") .renderingMode(.template) diff --git a/Linphone/UI/Main/Conversations/Model/ConversationModel.swift b/Linphone/UI/Main/Conversations/Model/ConversationModel.swift index 2992f66d6..0e84f8a20 100644 --- a/Linphone/UI/Main/Conversations/Model/ConversationModel.swift +++ b/Linphone/UI/Main/Conversations/Model/ConversationModel.swift @@ -50,6 +50,8 @@ class ConversationModel: ObservableObject { @Published var unreadMessagesCount: Int @Published var avatarModel: ContactAvatarModel + private var conferenceSchedulerDelegate: ConferenceSchedulerDelegate? + init(chatRoom: ChatRoom) { self.chatRoom = chatRoom @@ -104,13 +106,97 @@ class ConversationModel: ObservableObject { } func call() { - coreContext.doOnCoreQueue { _ in - if self.chatRoom.peerAddress != nil { + coreContext.doOnCoreQueue { core in + if self.chatRoom.hasCapability(mask: ChatRoom.Capabilities.OneToOne.rawValue) && !self.chatRoom.hasCapability(mask: ChatRoom.Capabilities.Conference.rawValue) { TelecomManager.shared.doCallOrJoinConf(address: self.chatRoom.peerAddress!) + } else if self.chatRoom.hasCapability(mask: ChatRoom.Capabilities.OneToOne.rawValue) && self.chatRoom.hasCapability(mask: ChatRoom.Capabilities.Conference.rawValue) { + if self.chatRoom.participants.first != nil && self.chatRoom.participants.first!.address != nil { + TelecomManager.shared.doCallOrJoinConf(address: self.chatRoom.participants.first!.address!) + } + } else { + //self.createGroupCall(core: core) } } } + func createGroupCall(core: Core) { + let account = core.defaultAccount + if account == nil { + Log.error( + "\(ConversationModel.TAG) No default account found, can't create group call!" + ) + return + } + + do { + let conferenceInfo = try Factory.Instance.createConferenceInfo() + conferenceInfo.organizer = account!.params?.identityAddress + conferenceInfo.subject = self.chatRoom.subject ?? "Conference" + + var participantsList: [ParticipantInfo] = [] + self.chatRoom.participants.forEach { participant in + do { + let info = try Factory.Instance.createParticipantInfo(address: participant.address!) + // For meetings, all participants must have Speaker role + info.role = Participant.Role.Speaker + participantsList.append(info) + } catch let error { + Log.error( + "\(ConversationModel.TAG) Can't create ParticipantInfo: \(error)" + ) + } + } + + conferenceInfo.addParticipantInfos(participantInfos: participantsList) + + Log.info( + "\(ConversationModel.TAG) Creating group call with subject \(self.chatRoom.subject ?? "Conference") and \(participantsList.count) participant(s)" + ) + + let conferenceScheduler = try core.createConferenceScheduler() + self.conferenceAddDelegate(core: core, conferenceScheduler: conferenceScheduler) + conferenceScheduler.account = account + // Will trigger the conference creation/update automatically + conferenceScheduler.info = conferenceInfo + } catch let error { + Log.error( + "\(ConversationModel.TAG) createGroupCall: \(error)" + ) + } + } + + func conferenceAddDelegate(core: Core, conferenceScheduler: ConferenceScheduler) { + self.conferenceSchedulerDelegate = ConferenceSchedulerDelegateStub(onStateChanged: { (conferenceScheduler: ConferenceScheduler, state: ConferenceScheduler.State) in + Log.info("\(ConversationModel.TAG) Conference scheduler state is \(state)") + if state == ConferenceScheduler.State.Ready { + conferenceScheduler.removeDelegate(delegate: self.conferenceSchedulerDelegate!) + self.conferenceSchedulerDelegate = nil + + let conferenceAddress = conferenceScheduler.info?.uri + if conferenceAddress != nil { + Log.info( + "\(ConversationModel.TAG) Conference info created, address is \(conferenceAddress?.asStringUriOnly() ?? "Error conference address")" + ) + + TelecomManager.shared.doCallOrJoinConf(address: conferenceAddress!) + } else { + Log.error("\(ConversationModel.TAG) Conference info URI is null!") + + ToastViewModel.shared.toastMessage = "Failed_to_create_group_call_error" + ToastViewModel.shared.displayToast = true + } + } else if state == ConferenceScheduler.State.Error { + conferenceScheduler.removeDelegate(delegate: self.conferenceSchedulerDelegate!) + self.conferenceSchedulerDelegate = nil + Log.error("\(ConversationModel.TAG) Failed to create group call!") + + ToastViewModel.shared.toastMessage = "Failed_to_create_group_call_error" + ToastViewModel.shared.displayToast = true + } + }) + conferenceScheduler.addDelegate(delegate: self.conferenceSchedulerDelegate!) + } + func getContentTextMessage() { coreContext.doOnCoreQueue { _ in let lastMessage = self.chatRoom.lastMessageInHistory diff --git a/Linphone/UI/Main/History/ViewModel/StartCallViewModel.swift b/Linphone/UI/Main/History/ViewModel/StartCallViewModel.swift index 03fcb657e..4270d7fc5 100644 --- a/Linphone/UI/Main/History/ViewModel/StartCallViewModel.swift +++ b/Linphone/UI/Main/History/ViewModel/StartCallViewModel.swift @@ -94,7 +94,9 @@ class StartCallViewModel: ObservableObject { } } - self.participants.removeAll() + DispatchQueue.main.async { + self.participants.removeAll() + } conferenceInfo.addParticipantInfos(participantInfos: participantsList) @@ -102,7 +104,7 @@ class StartCallViewModel: ObservableObject { "\(StartCallViewModel.TAG) Creating group call with subject \(self.messageText) and \(participantsList.count) participant(s)" ) - let conferenceScheduler = try core.createConferenceScheduler() + let conferenceScheduler = try core.createConferenceScheduler(account: account) self.conferenceAddDelegate(core: core, conferenceScheduler: conferenceScheduler) conferenceScheduler.account = account // Will trigger the conference creation/update automatically From 3fb50958b350d9c866157e313293f25b92aa10b7 Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Mon, 21 Oct 2024 10:59:02 +0200 Subject: [PATCH 474/486] Add local network authorization --- Linphone/Info.plist | 2 + .../Fragments/PermissionsFragment.swift | 2 +- Linphone/Utils/PermissionManager.swift | 53 ++++++++++++++++--- 3 files changed, 49 insertions(+), 8 deletions(-) diff --git a/Linphone/Info.plist b/Linphone/Info.plist index fdbc8ca20..89d3fd147 100644 --- a/Linphone/Info.plist +++ b/Linphone/Info.plist @@ -2,6 +2,8 @@ + NSLocalNetworkUsageDescription + App requires access to the local network to establish VoIP connections CFBundleURLTypes diff --git a/Linphone/UI/Assistant/Fragments/PermissionsFragment.swift b/Linphone/UI/Assistant/Fragments/PermissionsFragment.swift index 40e2fdc8c..370ae5a38 100644 --- a/Linphone/UI/Assistant/Fragments/PermissionsFragment.swift +++ b/Linphone/UI/Assistant/Fragments/PermissionsFragment.swift @@ -194,7 +194,7 @@ struct PermissionsFragment: View { } .navigationViewStyle(StackNavigationViewStyle()) .navigationBarHidden(true) - .onReceive(permissionManager.$contactsPermissionGranted, perform: { (granted) in + .onReceive(permissionManager.$allPermissionsHaveBeenDisplayed, perform: { (granted) in if granted { withAnimation { sharedMainViewModel.changeWelcomeView() diff --git a/Linphone/Utils/PermissionManager.swift b/Linphone/Utils/PermissionManager.swift index e20bc2d51..dab08971b 100644 --- a/Linphone/Utils/PermissionManager.swift +++ b/Linphone/Utils/PermissionManager.swift @@ -22,6 +22,7 @@ import Photos import Contacts import UserNotifications import SwiftUI +import Network class PermissionManager: ObservableObject { @@ -32,18 +33,28 @@ class PermissionManager: ObservableObject { @Published var cameraPermissionGranted = false @Published var contactsPermissionGranted = false @Published var microphonePermissionGranted = false + @Published var allPermissionsHaveBeenDisplayed = false private init() {} func getPermissions() { - pushNotificationRequestPermission() - microphoneRequestPermission() - photoLibraryRequestPermission() - cameraRequestPermission() - contactsRequestPermission() + pushNotificationRequestPermission { + let dispatchGroup = DispatchGroup() + + dispatchGroup.enter() + self.microphoneRequestPermission() + self.photoLibraryRequestPermission() + self.cameraRequestPermission() + self.contactsRequestPermission(group: dispatchGroup) + + dispatchGroup.notify(queue: .main) { + // Now request local network authorization last + self.requestLocalNetworkAuthorization() + } + } } - func pushNotificationRequestPermission() { + func pushNotificationRequestPermission(completion: @escaping () -> Void) { let options: UNAuthorizationOptions = [.alert, .sound, .badge] UNUserNotificationCenter.current().requestAuthorization(options: options) { (granted, error) in if let error = error { @@ -52,6 +63,7 @@ class PermissionManager: ObservableObject { DispatchQueue.main.async { self.pushPermissionGranted = granted } + completion() } } @@ -79,12 +91,39 @@ class PermissionManager: ObservableObject { }) } - func contactsRequestPermission() { + func contactsRequestPermission(group: DispatchGroup) { let store = CNContactStore() store.requestAccess(for: .contacts) { success, _ in DispatchQueue.main.async { self.contactsPermissionGranted = success } + group.leave() + } + } + + func requestLocalNetworkAuthorization() { + // Use a general UDP broadcast endpoint to attempt triggering the authorization request + let host = NWEndpoint.Host("255.255.255.255") // Broadcast on the local network + let port = NWEndpoint.Port(12345) // Choose an arbitrary port + + let params = NWParameters.udp + let connection = NWConnection(host: host, port: port, using: params) + + connection.stateUpdateHandler = { newState in + switch newState { + case .ready: + print("Connection ready") + connection.cancel() // Close the connection after establishing it + case .failed(let error): + print("Connection failed: \(error)") + connection.cancel() + default: + break + } + } + connection.start(queue: .main) + DispatchQueue.main.async { + self.allPermissionsHaveBeenDisplayed = true } } } From 6b2a6573be5d19c537800fa487ac855973acd506 Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Mon, 21 Oct 2024 11:09:34 +0200 Subject: [PATCH 475/486] Hide keyboard when displaying calls --- Linphone/UI/Call/CallView.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Linphone/UI/Call/CallView.swift b/Linphone/UI/Call/CallView.swift index d0e46d4b6..4d42ff3b0 100644 --- a/Linphone/UI/Call/CallView.swift +++ b/Linphone/UI/Call/CallView.swift @@ -235,6 +235,7 @@ struct CallView: View { } } .onAppear { + UIApplication.shared.endEditing() fullscreenVideo = false if geo.size.width < 350 || geo.size.height < 350 { buttonSize = 45.0 From 1615f5caa90e927df02f35e6ad4b63b5b1ddc6c5 Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Mon, 21 Oct 2024 11:23:31 +0200 Subject: [PATCH 476/486] Fix view layout when app returns to foreground --- Linphone/UI/Main/ContentView.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Linphone/UI/Main/ContentView.swift b/Linphone/UI/Main/ContentView.swift index 6a11ce794..599143a0d 100644 --- a/Linphone/UI/Main/ContentView.swift +++ b/Linphone/UI/Main/ContentView.swift @@ -1231,6 +1231,7 @@ struct ContentView: View { } .onChange(of: scenePhase) { newPhase in CoreContext.shared.enteredForeground = newPhase == .active + orientation = UIDevice.current.orientation } } From 37b70f4f327f6b44dd30336844ccbe9833ffd940 Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Mon, 21 Oct 2024 14:43:34 +0200 Subject: [PATCH 477/486] Fix meeting waiting room when headphone is connected --- Linphone/UI/Call/MeetingWaitingRoomFragment.swift | 9 ++++++--- .../ViewModel/MeetingWaitingRoomViewModel.swift | 15 +++++++++++---- 2 files changed, 17 insertions(+), 7 deletions(-) diff --git a/Linphone/UI/Call/MeetingWaitingRoomFragment.swift b/Linphone/UI/Call/MeetingWaitingRoomFragment.swift index 8f8920261..e1fae5807 100644 --- a/Linphone/UI/Call/MeetingWaitingRoomFragment.swift +++ b/Linphone/UI/Call/MeetingWaitingRoomFragment.swift @@ -52,7 +52,9 @@ struct MeetingWaitingRoomFragment: View { }) .onAppear { meetingWaitingRoomViewModel.enableAVAudioSession() - if AVAudioSession.sharedInstance().currentRoute.outputs.filter({ $0.portType.rawValue.contains("Bluetooth") }).isEmpty { + if AVAudioSession.sharedInstance().currentRoute.outputs.filter({ + $0.portType.rawValue.contains("Bluetooth") || $0.portType.rawValue.contains("Headphones") + }).isEmpty { do { try AVAudioSession.sharedInstance().overrideOutputAudioPort(.speaker) } catch _ { @@ -72,8 +74,9 @@ struct MeetingWaitingRoomFragment: View { } .onAppear { meetingWaitingRoomViewModel.enableAVAudioSession() - - if AVAudioSession.sharedInstance().currentRoute.outputs.filter({ $0.portType.rawValue.contains("Bluetooth") }).isEmpty { + if AVAudioSession.sharedInstance().currentRoute.outputs.filter({ + $0.portType.rawValue.contains("Bluetooth") || $0.portType.rawValue.contains("Headphones") + }).isEmpty { do { try AVAudioSession.sharedInstance().overrideOutputAudioPort(.speaker) } catch _ { diff --git a/Linphone/UI/Call/ViewModel/MeetingWaitingRoomViewModel.swift b/Linphone/UI/Call/ViewModel/MeetingWaitingRoomViewModel.swift index 24d4b1480..365f78d2d 100644 --- a/Linphone/UI/Call/ViewModel/MeetingWaitingRoomViewModel.swift +++ b/Linphone/UI/Call/ViewModel/MeetingWaitingRoomViewModel.swift @@ -50,6 +50,11 @@ class MeetingWaitingRoomViewModel: ObservableObject { func resetMeetingRoomView() { if self.telecomManager.meetingWaitingRoomSelected != nil { + do { + try AVAudioSession.sharedInstance().setCategory(.playAndRecord, mode: .voiceChat, options: .allowBluetooth) + } catch _ { + + } coreContext.doOnCoreQueue { core in let conf = core.findConferenceInformationFromUri(uri: self.telecomManager.meetingWaitingRoomSelected!) @@ -73,10 +78,12 @@ class MeetingWaitingRoomViewModel: ObservableObject { if friend != nil && friend!.address != nil && friend!.address!.displayName != nil { userNameTmp = friend!.address!.displayName! } else { - if core.defaultAccount!.contactAddress!.displayName != nil { - userNameTmp = core.defaultAccount!.contactAddress!.displayName! - } else if core.defaultAccount!.contactAddress!.username != nil { - userNameTmp = core.defaultAccount!.contactAddress!.username! + if core.defaultAccount != nil && core.defaultAccount!.contactAddress != nil { + if core.defaultAccount!.contactAddress!.displayName != nil { + userNameTmp = core.defaultAccount!.contactAddress!.displayName! + } else if core.defaultAccount!.contactAddress!.username != nil { + userNameTmp = core.defaultAccount!.contactAddress!.username! + } } } From d7d1b195c665415fd717f9054318ddcbedddbb0f Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Mon, 21 Oct 2024 15:43:48 +0200 Subject: [PATCH 478/486] Fix fullscreen video mode in oneone call --- Linphone/UI/Call/CallView.swift | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/Linphone/UI/Call/CallView.swift b/Linphone/UI/Call/CallView.swift index 4d42ff3b0..7ef1d8a93 100644 --- a/Linphone/UI/Call/CallView.swift +++ b/Linphone/UI/Call/CallView.swift @@ -517,14 +517,8 @@ struct CallView: View { } } .frame( - width: - angleDegree == 0 - ? (fullscreenVideo && !telecomManager.isPausedByRemote ? geometry.size.width : geometry.size.width - 8) - : (fullscreenVideo && !telecomManager.isPausedByRemote ? geometry.size.height + geometry.safeAreaInsets.top + geometry.safeAreaInsets.bottom + geometry.safeAreaInsets.bottom : geometry.size.height - (minBottomSheetHeight * geometry.size.height > 80 ? minBottomSheetHeight * geometry.size.height : 78) - 40 - 20 + geometry.safeAreaInsets.bottom), - height: - angleDegree == 0 - ? (fullscreenVideo && !telecomManager.isPausedByRemote ? geometry.size.height + geometry.safeAreaInsets.top + geometry.safeAreaInsets.bottom + geometry.safeAreaInsets.bottom : geometry.size.height - (minBottomSheetHeight * geometry.size.height > 80 ? minBottomSheetHeight * geometry.size.height : 78) - 40 - 20 + geometry.safeAreaInsets.bottom) - : (fullscreenVideo && !telecomManager.isPausedByRemote ? geometry.size.width : geometry.size.width - 8) + width: fullscreenVideo && !telecomManager.isPausedByRemote ? geometry.size.width : geometry.size.width - 8, + height: fullscreenVideo && !telecomManager.isPausedByRemote ? geometry.size.height + geometry.safeAreaInsets.bottom : geometry.size.height - (minBottomSheetHeight * geometry.size.height > 80 ? minBottomSheetHeight * geometry.size.height : 78) - 40 - 20 + geometry.safeAreaInsets.bottom ) .scaledToFill() .clipped() @@ -711,6 +705,7 @@ struct CallView: View { ) .background(Color.gray900) .cornerRadius(20) + .padding(.top, callViewModel.isOneOneCall && fullscreenVideo && !telecomManager.isPausedByRemote ? geometry.safeAreaInsets.bottom + 10 : 0) .padding(.horizontal, fullscreenVideo && !telecomManager.isPausedByRemote ? 0 : 4) .onRotate { newOrientation in let oldOrientation = orientation From 1bce467959d704118a2f8f33186b69bc724853af Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Tue, 22 Oct 2024 12:04:59 +0200 Subject: [PATCH 479/486] Add DynamicLinkText component for clickable URLs in chat bubbles --- .../Fragments/ChatBubbleView.swift | 42 +++++++++++++++++-- .../Fragments/ConversationFragment.swift | 2 +- 2 files changed, 40 insertions(+), 4 deletions(-) diff --git a/Linphone/UI/Main/Conversations/Fragments/ChatBubbleView.swift b/Linphone/UI/Main/Conversations/Fragments/ChatBubbleView.swift index a673a81de..ab3ea8e76 100644 --- a/Linphone/UI/Main/Conversations/Fragments/ChatBubbleView.swift +++ b/Linphone/UI/Main/Conversations/Fragments/ChatBubbleView.swift @@ -164,9 +164,7 @@ struct ChatBubbleView: View { } if !eventLogMessage.message.text.isEmpty { - Text(eventLogMessage.message.text) - .foregroundStyle(Color.grayMain2c700) - .default_text_style(styleSize: 14) + DynamicLinkText(text: eventLogMessage.message.text) } if eventLogMessage.message.isIcalendar && eventLogMessage.message.messageConferenceInfo != nil { @@ -756,6 +754,44 @@ struct ChatBubbleView: View { } } +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 diff --git a/Linphone/UI/Main/Conversations/Fragments/ConversationFragment.swift b/Linphone/UI/Main/Conversations/Fragments/ConversationFragment.swift index 1bd1637d7..685eb2260 100644 --- a/Linphone/UI/Main/Conversations/Fragments/ConversationFragment.swift +++ b/Linphone/UI/Main/Conversations/Fragments/ConversationFragment.swift @@ -785,7 +785,7 @@ struct ConversationFragment: View { if !conversationViewModel.selectedMessage!.message.text.isEmpty { Button { UIPasteboard.general.setValue( - conversationViewModel.selectedMessage!.message.text, + conversationViewModel.selectedMessage?.message.text ?? "Error_message_not_available", forPasteboardType: UTType.plainText.identifier ) From 1b02fafc43685cad3f3b9a1dc6ba1043808aa590 Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Tue, 22 Oct 2024 14:37:03 +0200 Subject: [PATCH 480/486] Update build version to (56) --- Linphone.xcodeproj/project.pbxproj | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Linphone.xcodeproj/project.pbxproj b/Linphone.xcodeproj/project.pbxproj index e120285cc..64321689e 100644 --- a/Linphone.xcodeproj/project.pbxproj +++ b/Linphone.xcodeproj/project.pbxproj @@ -1250,7 +1250,7 @@ CODE_SIGN_ENTITLEMENTS = msgNotificationService/msgNotificationService.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 55; + CURRENT_PROJECT_VERSION = 56; DEVELOPMENT_TEAM = Z2V957B3D6; ENABLE_USER_SCRIPT_SANDBOXING = NO; GCC_C_LANGUAGE_STANDARD = gnu17; @@ -1293,7 +1293,7 @@ CODE_SIGN_ENTITLEMENTS = msgNotificationService/msgNotificationService.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 55; + CURRENT_PROJECT_VERSION = 56; DEVELOPMENT_TEAM = Z2V957B3D6; ENABLE_USER_SCRIPT_SANDBOXING = NO; GCC_C_LANGUAGE_STANDARD = gnu17; @@ -1450,7 +1450,7 @@ ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = NO; CODE_SIGN_ENTITLEMENTS = Linphone/Linphone.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 55; + CURRENT_PROJECT_VERSION = 56; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_ASSET_PATHS = "\"Linphone/Preview Content\""; @@ -1507,7 +1507,7 @@ ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = NO; CODE_SIGN_ENTITLEMENTS = Linphone/Linphone.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 55; + CURRENT_PROJECT_VERSION = 56; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_ASSET_PATHS = "\"Linphone/Preview Content\""; DEVELOPMENT_TEAM = Z2V957B3D6; From ed619e58e11c1055e13c4302abe665af74fde575 Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Wed, 23 Oct 2024 16:55:30 +0200 Subject: [PATCH 481/486] Fix group call creation --- Linphone/Localizable.xcstrings | 34 +++++++ Linphone/UI/Call/CallView.swift | 14 ++- .../UI/Call/ViewModel/CallViewModel.swift | 11 ++- Linphone/UI/Main/ContentView.swift | 31 ++++++- .../Fragments/ConversationFragment.swift | 7 +- .../Model/ConversationModel.swift | 92 ++++++++++--------- .../ViewModel/StartCallViewModel.swift | 12 ++- 7 files changed, 145 insertions(+), 56 deletions(-) diff --git a/Linphone/Localizable.xcstrings b/Linphone/Localizable.xcstrings index d1c538639..97bfe9292 100644 --- a/Linphone/Localizable.xcstrings +++ b/Linphone/Localizable.xcstrings @@ -1252,6 +1252,40 @@ } } }, + "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" : { diff --git a/Linphone/UI/Call/CallView.swift b/Linphone/UI/Call/CallView.swift index 7ef1d8a93..786b561d7 100644 --- a/Linphone/UI/Call/CallView.swift +++ b/Linphone/UI/Call/CallView.swift @@ -65,6 +65,7 @@ struct CallView: View { @State var isShowParticipantsListFragment: Bool = false @Binding var isShowStartCallFragment: Bool @Binding var isShowConversationFragment: Bool + @Binding var isShowStartCallGroupPopup: Bool @State var buttonSize = 60.0 @@ -197,7 +198,8 @@ struct CallView: View { conversationViewModel: conversationViewModel, conversationsListViewModel: conversationsListViewModel, conversationForwardMessageViewModel: conversationForwardMessageViewModel, - isShowConversationFragment: $isShowConversationFragment + isShowConversationFragment: $isShowConversationFragment, + isShowStartCallGroupPopup: $isShowStartCallGroupPopup ) .frame(maxWidth: .infinity) .background(Color.gray100) @@ -675,6 +677,13 @@ struct CallView: View { 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 { @@ -2781,7 +2790,8 @@ struct PressedButtonStyle: ButtonStyle { conversationForwardMessageViewModel: ConversationForwardMessageViewModel(), fullscreenVideo: .constant(false), isShowStartCallFragment: .constant(false), - isShowConversationFragment: .constant(false) + isShowConversationFragment: .constant(false), + isShowStartCallGroupPopup: .constant(false) ) } // swiftlint:enable type_body_length diff --git a/Linphone/UI/Call/ViewModel/CallViewModel.swift b/Linphone/UI/Call/ViewModel/CallViewModel.swift index 6ce03614d..a3ce8d591 100644 --- a/Linphone/UI/Call/ViewModel/CallViewModel.swift +++ b/Linphone/UI/Call/ViewModel/CallViewModel.swift @@ -98,6 +98,10 @@ class CallViewModel: ObservableObject { } func resetCallView() { + DispatchQueue.main.async { + self.displayName = "" + } + coreContext.doOnCoreQueue { core in if core.currentCall != nil && core.currentCall!.remoteAddress != nil { if self.callDelegate != nil { @@ -123,12 +127,16 @@ class CallViewModel: ObservableObject { } + 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" } } @@ -151,7 +159,6 @@ class CallViewModel: ObservableObject { let remoteAddressStringTmp = remoteAddressTmp != nil ? String(remoteAddressTmp!.asStringUriOnly().dropFirst(4)) : "" - var displayNameTmp = "" if self.currentCall?.conference != nil { displayNameTmp = self.currentCall?.conference?.subject ?? "" } else if self.currentCall?.remoteAddress != nil { @@ -161,7 +168,7 @@ class CallViewModel: ObservableObject { } else { if self.currentCall!.remoteAddress!.displayName != nil { displayNameTmp = self.currentCall!.remoteAddress!.displayName! - } else if self.currentCall!.remoteAddress!.username != nil { + } else if self.currentCall!.remoteAddress!.username != nil && displayNameTmp.isEmpty { displayNameTmp = self.currentCall!.remoteAddress!.username! } } diff --git a/Linphone/UI/Main/ContentView.swift b/Linphone/UI/Main/ContentView.swift index 599143a0d..64c4dd3b7 100644 --- a/Linphone/UI/Main/ContentView.swift +++ b/Linphone/UI/Main/ContentView.swift @@ -66,6 +66,7 @@ struct ContentView: View { @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 @@ -861,7 +862,8 @@ struct ContentView: View { conversationViewModel: conversationViewModel, conversationsListViewModel: conversationsListViewModel, conversationForwardMessageViewModel: conversationForwardMessageViewModel, - isShowConversationFragment: $isShowConversationFragment + isShowConversationFragment: $isShowConversationFragment, + isShowStartCallGroupPopup: $isShowStartCallGroupPopup ) .frame(maxWidth: .infinity) .background(Color.gray100) @@ -1159,6 +1161,30 @@ struct ContentView: View { } } + 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) @@ -1176,7 +1202,8 @@ struct ContentView: View { conversationForwardMessageViewModel: conversationForwardMessageViewModel, fullscreenVideo: $fullscreenVideo, isShowStartCallFragment: $isShowStartCallFragment, - isShowConversationFragment: $isShowConversationFragment + isShowConversationFragment: $isShowConversationFragment, + isShowStartCallGroupPopup: $isShowStartCallGroupPopup ) .zIndex(5) .transition(.scale.combined(with: .move(edge: .top))) diff --git a/Linphone/UI/Main/Conversations/Fragments/ConversationFragment.swift b/Linphone/UI/Main/Conversations/Fragments/ConversationFragment.swift index 8aa44389e..a29489ba4 100644 --- a/Linphone/UI/Main/Conversations/Fragments/ConversationFragment.swift +++ b/Linphone/UI/Main/Conversations/Fragments/ConversationFragment.swift @@ -56,6 +56,7 @@ struct ConversationFragment: View { @State private var isShowConversationForwardMessageFragment = false @Binding var isShowConversationFragment: Bool + @Binding var isShowStartCallGroupPopup: Bool @State private var selectedCategoryIndex = 0 @@ -202,7 +203,11 @@ struct ConversationFragment: View { Spacer() Button { - conversationViewModel.displayedConversation!.call() + if conversationViewModel.displayedConversation!.isGroup { + isShowStartCallGroupPopup.toggle() + } else { + conversationViewModel.displayedConversation!.call() + } } label: { Image("phone") .renderingMode(.template) diff --git a/Linphone/UI/Main/Conversations/Model/ConversationModel.swift b/Linphone/UI/Main/Conversations/Model/ConversationModel.swift index 0e84f8a20..b5d8dca7e 100644 --- a/Linphone/UI/Main/Conversations/Model/ConversationModel.swift +++ b/Linphone/UI/Main/Conversations/Model/ConversationModel.swift @@ -50,6 +50,7 @@ class ConversationModel: ObservableObject { @Published var unreadMessagesCount: Int @Published var avatarModel: ContactAvatarModel + private var conferenceScheduler: ConferenceScheduler? private var conferenceSchedulerDelegate: ConferenceSchedulerDelegate? init(chatRoom: ChatRoom) { @@ -114,54 +115,57 @@ class ConversationModel: ObservableObject { TelecomManager.shared.doCallOrJoinConf(address: self.chatRoom.participants.first!.address!) } } else { - //self.createGroupCall(core: core) + self.createGroupCall() } } } - func createGroupCall(core: Core) { - let account = core.defaultAccount - if account == nil { - Log.error( - "\(ConversationModel.TAG) No default account found, can't create group call!" - ) - return - } - - do { - let conferenceInfo = try Factory.Instance.createConferenceInfo() - conferenceInfo.organizer = account!.params?.identityAddress - conferenceInfo.subject = self.chatRoom.subject ?? "Conference" - - var participantsList: [ParticipantInfo] = [] - self.chatRoom.participants.forEach { participant in - do { - let info = try Factory.Instance.createParticipantInfo(address: participant.address!) - // For meetings, all participants must have Speaker role - info.role = Participant.Role.Speaker - participantsList.append(info) - } catch let error { - Log.error( - "\(ConversationModel.TAG) Can't create ParticipantInfo: \(error)" - ) - } + 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 } - conferenceInfo.addParticipantInfos(participantInfos: participantsList) - - Log.info( - "\(ConversationModel.TAG) Creating group call with subject \(self.chatRoom.subject ?? "Conference") and \(participantsList.count) participant(s)" - ) - - let conferenceScheduler = try core.createConferenceScheduler() - self.conferenceAddDelegate(core: core, conferenceScheduler: conferenceScheduler) - conferenceScheduler.account = account - // Will trigger the conference creation/update automatically - conferenceScheduler.info = conferenceInfo - } catch let error { - Log.error( - "\(ConversationModel.TAG) createGroupCall: \(error)" - ) + 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)" + ) + } } } @@ -175,10 +179,10 @@ class ConversationModel: ObservableObject { let conferenceAddress = conferenceScheduler.info?.uri if conferenceAddress != nil { Log.info( - "\(ConversationModel.TAG) Conference info created, address is \(conferenceAddress?.asStringUriOnly() ?? "Error conference address")" + "\(ConversationModel.TAG) Conference info created, address is \(conferenceAddress!.asStringUriOnly())" ) - TelecomManager.shared.doCallOrJoinConf(address: conferenceAddress!) + TelecomManager.shared.doCallWithCore(addr: conferenceAddress!, isVideo: true, isConference: true) } else { Log.error("\(ConversationModel.TAG) Conference info URI is null!") diff --git a/Linphone/UI/Main/History/ViewModel/StartCallViewModel.swift b/Linphone/UI/Main/History/ViewModel/StartCallViewModel.swift index 4270d7fc5..20bd0d262 100644 --- a/Linphone/UI/Main/History/ViewModel/StartCallViewModel.swift +++ b/Linphone/UI/Main/History/ViewModel/StartCallViewModel.swift @@ -37,6 +37,7 @@ class StartCallViewModel: ObservableObject { @Published var operationInProgress: Bool = false + private var conferenceScheduler: ConferenceScheduler? private var conferenceSchedulerDelegate: ConferenceSchedulerDelegate? init() { @@ -104,11 +105,12 @@ class StartCallViewModel: ObservableObject { "\(StartCallViewModel.TAG) Creating group call with subject \(self.messageText) and \(participantsList.count) participant(s)" ) - let conferenceScheduler = try core.createConferenceScheduler(account: account) - self.conferenceAddDelegate(core: core, conferenceScheduler: conferenceScheduler) - conferenceScheduler.account = account - // Will trigger the conference creation/update automatically - conferenceScheduler.info = conferenceInfo + 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)" From 73ea3362b0adcaf20a36e2cff9edc5a64644b44c Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Thu, 24 Oct 2024 17:55:11 +0200 Subject: [PATCH 482/486] Add Menu in chatroom --- Linphone/Localizable.xcstrings | 68 +++++++++++++++++++ .../Fragments/ConversationFragment.swift | 20 ++++-- .../Model/ConversationModel.swift | 7 +- 3 files changed, 86 insertions(+), 9 deletions(-) diff --git a/Linphone/Localizable.xcstrings b/Linphone/Localizable.xcstrings index 97bfe9292..112190c0e 100644 --- a/Linphone/Localizable.xcstrings +++ b/Linphone/Localizable.xcstrings @@ -928,6 +928,40 @@ } } }, + "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" : { @@ -1303,6 +1337,40 @@ } } }, + "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" : { diff --git a/Linphone/UI/Main/Conversations/Fragments/ConversationFragment.swift b/Linphone/UI/Main/Conversations/Fragments/ConversationFragment.swift index a29489ba4..9b281b80f 100644 --- a/Linphone/UI/Main/Conversations/Fragments/ConversationFragment.swift +++ b/Linphone/UI/Main/Conversations/Fragments/ConversationFragment.swift @@ -223,10 +223,12 @@ struct ConversationFragment: View { isMenuOpen = false } label: { HStack { - Text("See contact") + Text("conversation_menu_go_to_info") Spacer() - Image("user-circle") + Image("info") + .renderingMode(.template) .resizable() + .foregroundStyle(Color.grayMain2c500) .frame(width: 25, height: 25, alignment: .leading) .padding(.all, 10) } @@ -236,23 +238,27 @@ struct ConversationFragment: View { isMenuOpen = false } label: { HStack { - Text("Copy SIP address") + Text(conversationViewModel.displayedConversation!.isMuted ? "conversation_action_unmute" : "conversation_action_mute") Spacer() - Image("copy") + Image(conversationViewModel.displayedConversation!.isMuted ? "bell-simple" : "bell-simple-slash") + .renderingMode(.template) .resizable() + .foregroundStyle(Color.grayMain2c500) .frame(width: 25, height: 25, alignment: .leading) .padding(.all, 10) } } - Button(role: .destructive) { + Button { isMenuOpen = false } label: { HStack { - Text("Delete history") + Text("conversation_menu_configure_ephemeral_messages") Spacer() - Image("trash-simple-red") + Image("clock-countdown") + .renderingMode(.template) .resizable() + .foregroundStyle(Color.grayMain2c500) .frame(width: 25, height: 25, alignment: .leading) .padding(.all, 10) } diff --git a/Linphone/UI/Main/Conversations/Model/ConversationModel.swift b/Linphone/UI/Main/Conversations/Model/ConversationModel.swift index b5d8dca7e..587c433b8 100644 --- a/Linphone/UI/Main/Conversations/Model/ConversationModel.swift +++ b/Linphone/UI/Main/Conversations/Model/ConversationModel.swift @@ -22,7 +22,7 @@ import linphonesw import Combine // swiftlint:disable line_length -class ConversationModel: ObservableObject { +class ConversationModel: ObservableObject, Identifiable { private var coreContext = CoreContext.shared private var contactsManager = ContactsManager.shared @@ -101,8 +101,11 @@ class ConversationModel: ObservableObject { func toggleMute() { coreContext.doOnCoreQueue { _ in + let chatRoomMuted = self.chatRoom.muted self.chatRoom.muted.toggle() - self.isMuted = self.chatRoom.muted + DispatchQueue.main.async { + self.isMuted = !chatRoomMuted + } } } From ac8253e47e157b62054c000fe3141c05fdc13a74 Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Fri, 25 Oct 2024 14:44:56 +0200 Subject: [PATCH 483/486] Fix refresh conversation item in list --- .../Fragments/ConversationsListFragment.swift | 286 +++++++++--------- 1 file changed, 150 insertions(+), 136 deletions(-) diff --git a/Linphone/UI/Main/Conversations/Fragments/ConversationsListFragment.swift b/Linphone/UI/Main/Conversations/Fragments/ConversationsListFragment.swift index e4cdd1fcd..38212c570 100644 --- a/Linphone/UI/Main/Conversations/Fragments/ConversationsListFragment.swift +++ b/Linphone/UI/Main/Conversations/Fragments/ConversationsListFragment.swift @@ -31,144 +31,17 @@ struct ConversationsListFragment: View { @Binding var text: String var body: some View { - let pub = NotificationCenter.default - .publisher(for: NSNotification.Name("ChatRoomsComputed")) - VStack { List { - ForEach(0.. 0) { view in - view.default_text_style_700(styleSize: 14) - } - .default_text_style(styleSize: 14) - .frame(maxWidth: .infinity, alignment: .leading) - .lineLimit(1) - - Text(conversationsListViewModel.conversationsList[index].lastMessageText) - .foregroundStyle(Color.grayMain2c400) - .if(conversationsListViewModel.conversationsList[index].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 !conversationsListViewModel.conversationsList[index].encryptionEnabled { - Image("warning-circle") - .renderingMode(.template) - .resizable() - .foregroundStyle(Color.redDanger500) - .frame(width: 18, height: 18, alignment: .trailing) - } - - Text(conversationsListViewModel.getCallTime(startDate: conversationsListViewModel.conversationsList[index].lastUpdateTime)) - .foregroundStyle(Color.grayMain2c400) - .default_text_style(styleSize: 14) - .lineLimit(1) - } - - Spacer() - - HStack { - if conversationsListViewModel.conversationsList[index].isMuted == false - && !(!conversationsListViewModel.conversationsList[index].lastMessageText.isEmpty - && conversationsListViewModel.conversationsList[index].lastMessageIsOutgoing == true) - && conversationsListViewModel.conversationsList[index].unreadMessagesCount == 0 { - Text("") - .frame(width: 18, height: 18, alignment: .trailing) - } - - if conversationsListViewModel.conversationsList[index].isMuted { - Image("bell-slash") - .renderingMode(.template) - .resizable() - .foregroundStyle(Color.orangeMain500) - .frame(width: 18, height: 18, alignment: .trailing) - } - - if !conversationsListViewModel.conversationsList[index].lastMessageText.isEmpty - && conversationsListViewModel.conversationsList[index].lastMessageIsOutgoing == true { - let imageName = LinphoneUtils.getChatIconState(chatState: conversationsListViewModel.conversationsList[index].lastMessageState) - Image(imageName) - .renderingMode(.template) - .resizable() - .foregroundStyle(Color.orangeMain500) - .frame(width: 18, height: 18, alignment: .trailing) - } - - if conversationsListViewModel.conversationsList[index].unreadMessagesCount > 0 { - HStack { - Text( - conversationsListViewModel.conversationsList[index].unreadMessagesCount < 99 - ? String(conversationsListViewModel.conversationsList[index].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 - && index < conversationsListViewModel.conversationsList.count - && conversationsListViewModel.conversationsList[index].remoteSipUri.contains(navigationManager.peerAddr!) { - conversationViewModel.getChatRoomWithStringAddress(conversationsList: conversationsListViewModel.conversationsList, stringAddr: navigationManager.peerAddr!) - navigationManager.peerAddr = nil - } - } - .onTapGesture { - if index < conversationsListViewModel.conversationsList.count { - conversationViewModel.changeDisplayedChatRoom(conversationModel: conversationsListViewModel.conversationsList[index]) - } - } - .onLongPressGesture(minimumDuration: 0.2) { - if index < conversationsListViewModel.conversationsList.count { - conversationsListViewModel.selectedConversation = conversationsListViewModel.conversationsList[index] - showingSheet.toggle() - } - } - } + ForEach(conversationsListViewModel.conversationsList) { conversation in + ConversationRow( + navigationManager: _navigationManager, + conversation: conversation, + conversationViewModel: conversationViewModel, + conversationsListViewModel: conversationsListViewModel, + showingSheet: $showingSheet, + text: $text + ) } } .safeAreaInset(edge: .top, content: { @@ -199,6 +72,147 @@ struct ConversationsListFragment: View { } } +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(), From 6336d4fae9206ea4e24af16ed6c112a74ce8e892 Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Fri, 25 Oct 2024 14:45:21 +0200 Subject: [PATCH 484/486] Remove all objectWillChange --- .../Contacts/Fragments/ContactInnerActionsFragment.swift | 1 - .../Fragments/ConversationsListBottomSheet.swift | 2 -- .../Conversations/ViewModel/ConversationViewModel.swift | 7 +------ 3 files changed, 1 insertion(+), 9 deletions(-) diff --git a/Linphone/UI/Main/Contacts/Fragments/ContactInnerActionsFragment.swift b/Linphone/UI/Main/Contacts/Fragments/ContactInnerActionsFragment.swift index 708d5eec9..04fe4b782 100644 --- a/Linphone/UI/Main/Contacts/Fragments/ContactInnerActionsFragment.swift +++ b/Linphone/UI/Main/Contacts/Fragments/ContactInnerActionsFragment.swift @@ -275,7 +275,6 @@ struct ContactInnerActionsFragment: View { Button { if contactAvatarModel.friend != nil { - contactViewModel.objectWillChange.send() contactAvatarModel.friend!.edit() contactAvatarModel.friend!.starred.toggle() contactAvatarModel.friend!.done() diff --git a/Linphone/UI/Main/Conversations/Fragments/ConversationsListBottomSheet.swift b/Linphone/UI/Main/Conversations/Fragments/ConversationsListBottomSheet.swift index 384293762..a7a773704 100644 --- a/Linphone/UI/Main/Conversations/Fragments/ConversationsListBottomSheet.swift +++ b/Linphone/UI/Main/Conversations/Fragments/ConversationsListBottomSheet.swift @@ -56,7 +56,6 @@ struct ConversationsListBottomSheet: View { Button { if conversationsListViewModel.selectedConversation != nil { - conversationsListViewModel.objectWillChange.send() conversationsListViewModel.markAsReadSelectedConversation() conversationsListViewModel.updateUnreadMessagesCount() } @@ -96,7 +95,6 @@ struct ConversationsListBottomSheet: View { Button { if conversationsListViewModel.selectedConversation != nil { - conversationsListViewModel.objectWillChange.send() conversationsListViewModel.selectedConversation!.toggleMute() } diff --git a/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift b/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift index ba7879790..213f415f9 100644 --- a/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift +++ b/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift @@ -160,13 +160,11 @@ class ConversationViewModel: ObservableObject { if indexMessage < self.conversationMessagesSection[0].rows.count { if self.conversationMessagesSection[0].rows[indexMessage].message.status != statusTmp { DispatchQueue.main.async { - //self.objectWillChange.send() self.conversationMessagesSection[0].rows[indexMessage].message.status = statusTmp ?? .error self.conversationMessagesSection[0].rows[indexMessage].message.ephemeralExpireTime = ephemeralExpireTimeTmp } } else { DispatchQueue.main.async { - //self.objectWillChange.send() self.conversationMessagesSection[0].rows[indexMessage].message.ephemeralExpireTime = ephemeralExpireTimeTmp } } @@ -199,7 +197,7 @@ class ConversationViewModel: ObservableObject { let indexMessage = self.conversationMessagesSection[0].rows.firstIndex(where: {$0.eventModel.eventLogId == message.messageId}) DispatchQueue.main.async { - if indexMessage != nil { // self.objectWillChange.send() + if indexMessage != nil { self.conversationMessagesSection[0].rows[indexMessage!].message.status = statusTmp ?? .error } } @@ -212,7 +210,6 @@ class ConversationViewModel: ObservableObject { DispatchQueue.main.async { if indexMessage != nil { - //self.objectWillChange.send() self.conversationMessagesSection[0].rows[indexMessage!].message.reactions = reactionsTmp } } @@ -225,7 +222,6 @@ class ConversationViewModel: ObservableObject { DispatchQueue.main.async { if indexMessage != nil { - // self.objectWillChange.send() self.conversationMessagesSection[0].rows[indexMessage!].message.reactions = reactionsTmp } } @@ -235,7 +231,6 @@ class ConversationViewModel: ObservableObject { DispatchQueue.main.async { if indexMessage != nil { - self.objectWillChange.send() self.conversationMessagesSection[0].rows[indexMessage!].message.ephemeralExpireTime = ephemeralExpireTimeTmp } } From d27ff560e91707a6ad7e32cfa9bda506bda702ff Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Fri, 25 Oct 2024 16:25:11 +0200 Subject: [PATCH 485/486] Fix displayed chat room mute menu --- .../Fragments/ConversationFragment.swift | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/Linphone/UI/Main/Conversations/Fragments/ConversationFragment.swift b/Linphone/UI/Main/Conversations/Fragments/ConversationFragment.swift index 9b281b80f..9d1e4f19a 100644 --- a/Linphone/UI/Main/Conversations/Fragments/ConversationFragment.swift +++ b/Linphone/UI/Main/Conversations/Fragments/ConversationFragment.swift @@ -35,6 +35,7 @@ struct ConversationFragment: View { @ObservedObject var conversationForwardMessageViewModel: ConversationForwardMessageViewModel @State var isMenuOpen = false + @State private var isMuted: Bool = false @FocusState var isMessageTextFocused: Bool @@ -236,11 +237,13 @@ struct ConversationFragment: View { Button { isMenuOpen = false + conversationViewModel.displayedConversation!.toggleMute() + isMuted = !isMuted } label: { HStack { - Text(conversationViewModel.displayedConversation!.isMuted ? "conversation_action_unmute" : "conversation_action_mute") + Text(isMuted ? "conversation_action_unmute" : "conversation_action_mute") Spacer() - Image(conversationViewModel.displayedConversation!.isMuted ? "bell-simple" : "bell-simple-slash") + Image(isMuted ? "bell-simple" : "bell-simple-slash") .renderingMode(.template) .resizable() .foregroundStyle(Color.grayMain2c500) @@ -271,6 +274,10 @@ struct ConversationFragment: View { .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 From 6d3577379f6944b00f9c967c96c809c1197f438d Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Fri, 25 Oct 2024 16:28:34 +0200 Subject: [PATCH 486/486] Simplify optional check for chatRooms --- .../Conversations/ViewModel/ConversationsListViewModel.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Linphone/UI/Main/Conversations/ViewModel/ConversationsListViewModel.swift b/Linphone/UI/Main/Conversations/ViewModel/ConversationsListViewModel.swift index 856d16ed3..bab467a99 100644 --- a/Linphone/UI/Main/Conversations/ViewModel/ConversationsListViewModel.swift +++ b/Linphone/UI/Main/Conversations/ViewModel/ConversationsListViewModel.swift @@ -44,7 +44,7 @@ class ConversationsListViewModel: ObservableObject { func computeChatRoomsList(filter: String) { coreContext.doOnCoreQueue { core in let account = core.defaultAccount - let chatRooms = account?.chatRooms != nil ? account!.chatRooms : core.chatRooms + let chatRooms = account != nil ? account!.chatRooms : core.chatRooms self.conversationsListTmp = []

BRRnJOKu^GH4N5_xR+4G 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 X+n2Hp%1>jr9_Z-s_GHunDxdmav$J5N literal 0 HcmV?d00001 diff --git a/Linphone/Fonts/NotoSans-Medium.ttf b/Linphone/Fonts/NotoSans-Medium.ttf new file mode 100644 index 0000000000000000000000000000000000000000..faf167c9161219e9c9fe8cd7f52cc9e6896b48fe GIT binary patch literal 555264 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