From 8413fde3554eb89ea34cda22e4d689037180c1df Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20Brul=C3=A9?= <raphael.brule@savoirfairelinux.com> Date: Mon, 13 Jul 2020 10:03:52 -0400 Subject: [PATCH] conversations: location sharing Adds location sharing feature to the app. Initial implementation is in an expandable MessageCell. Gitlab: #77 Change-Id: I22d1e856051340f58fb7e77d915ae2c789e4e7a2 --- .gitmodules | 3 + Ring/.swiftlint.yml | 1 + Ring/Ring.xcodeproj/project.pbxproj | 117 ++++- Ring/Ring/AppDelegate.swift | 12 +- Ring/Ring/Bridging/Ring-Bridging-Header.h | 4 +- Ring/Ring/Constants/Generated/Images.swift | 4 +- Ring/Ring/Constants/Generated/Strings.swift | 38 +- Ring/Ring/Coordinators/InjectionBag.swift | 8 +- .../DBHelpers/InteractionDataHelper.swift | 83 ++-- Ring/Ring/Database/DBManager.swift | 69 ++- Ring/Ring/Extensions/UIView+Ring.swift | 11 +- .../Conversation/Cells/MessageCell.swift | 29 +- .../Cells/MessageCellLocationSharing.swift | 398 ++++++++++++++++++ .../MessageCellLocationSharingReceived.swift | 59 +++ .../MessageCellLocationSharingReceived.xib | 95 +++++ .../MessageCellLocationSharingSent.swift | 77 ++++ .../Cells/MessageCellLocationSharingSent.xib | 103 +++++ .../ConversationViewController.swift | 162 ++++++- .../Conversation/ConversationViewModel.swift | 75 +++- .../Conversation/MessageViewModel.swift | 4 +- Ring/Ring/Info.plist | 5 +- Ring/Ring/Models/MessageModel.swift | 1 + .../my_location.imageset/Contents.json | 23 + .../my_location.imageset/my_location.png | Bin 0 -> 496 bytes .../my_location.imageset/my_location_2x.png | Bin 0 -> 1030 bytes .../my_location.imageset/my_location_3x.png | Bin 0 -> 1565 bytes .../Resources/en.lproj/Localizable.strings | 15 + Ring/Ring/Services/AccountsService.swift | 9 +- Ring/Ring/Services/ConversationsManager.swift | 177 ++++++-- Ring/Ring/Services/ConversationsService.swift | 102 ++++- .../Services/LocationSharingService.swift | 356 ++++++++++++++++ Ring/Ring/Services/ServiceEvent.swift | 3 + Ring/WhirlyGlobeMaply | 1 + Ring/fetch-dependencies.sh | 16 + Ring/swiftgen/swiftgen.sh | 6 +- 35 files changed, 1925 insertions(+), 141 deletions(-) create mode 100644 .gitmodules create mode 100644 Ring/Ring/Features/Conversations/Conversation/Cells/MessageCellLocationSharing.swift create mode 100644 Ring/Ring/Features/Conversations/Conversation/Cells/MessageCellLocationSharingReceived.swift create mode 100644 Ring/Ring/Features/Conversations/Conversation/Cells/MessageCellLocationSharingReceived.xib create mode 100644 Ring/Ring/Features/Conversations/Conversation/Cells/MessageCellLocationSharingSent.swift create mode 100644 Ring/Ring/Features/Conversations/Conversation/Cells/MessageCellLocationSharingSent.xib create mode 100644 Ring/Ring/Resources/Images.xcassets/my_location.imageset/Contents.json create mode 100644 Ring/Ring/Resources/Images.xcassets/my_location.imageset/my_location.png create mode 100644 Ring/Ring/Resources/Images.xcassets/my_location.imageset/my_location_2x.png create mode 100644 Ring/Ring/Resources/Images.xcassets/my_location.imageset/my_location_3x.png create mode 100644 Ring/Ring/Services/LocationSharingService.swift create mode 160000 Ring/WhirlyGlobeMaply create mode 100755 Ring/fetch-dependencies.sh diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 000000000..a9f9d6749 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "Ring/WhirlyGlobeMaply"] + path = Ring/WhirlyGlobeMaply + url = https://github.com/mousebird/WhirlyGlobe.git diff --git a/Ring/.swiftlint.yml b/Ring/.swiftlint.yml index d3b6db03e..4d1bf6a29 100644 --- a/Ring/.swiftlint.yml +++ b/Ring/.swiftlint.yml @@ -8,6 +8,7 @@ excluded: # paths to ignore during linting. Takes precedence over `included`. - Carthage - Pods - Ring/Constants + - WhirlyGlobeMaply force_cast: warning # implicitly force_try: diff --git a/Ring/Ring.xcodeproj/project.pbxproj b/Ring/Ring.xcodeproj/project.pbxproj index d2ce32b16..cb1aebf19 100644 --- a/Ring/Ring.xcodeproj/project.pbxproj +++ b/Ring/Ring.xcodeproj/project.pbxproj @@ -314,6 +314,18 @@ 62DFAB2E1F9FF0D0002D6F9C /* NetworkService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62DFAB2D1F9FF0D0002D6F9C /* NetworkService.swift */; }; 62E55B6D1F758E6F00D3FEF4 /* String+Helpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62E55B6C1F758E6F00D3FEF4 /* String+Helpers.swift */; }; 62E55B6F1F793ADE00D3FEF4 /* AvatarsColors.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62E55B6E1F793ADE00D3FEF4 /* AvatarsColors.swift */; }; + 6452144424B4ACA7007203D5 /* CoreLocation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 6452144324B4ACA7007203D5 /* CoreLocation.framework */; }; + 645BDD6224B5FEFE009129B1 /* WhirlyGlobeMaplyComponent.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 6452143D24B4AB44007203D5 /* WhirlyGlobeMaplyComponent.framework */; }; + 645BDD6324B5FEFF009129B1 /* WhirlyGlobeMaplyComponent.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 6452143D24B4AB44007203D5 /* WhirlyGlobeMaplyComponent.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; + 645BDD7724B7415A009129B1 /* MessageCellLocationSharingSent.xib in Resources */ = {isa = PBXBuildFile; fileRef = 645BDD6C24B7415A009129B1 /* MessageCellLocationSharingSent.xib */; }; + 645BDD7B24B7415A009129B1 /* MessageCellLocationSharingSent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 645BDD7024B7415A009129B1 /* MessageCellLocationSharingSent.swift */; }; + 645BDD8124B74BCB009129B1 /* LocationSharingService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 645BDD8024B74BCB009129B1 /* LocationSharingService.swift */; }; + 649AD3C324B4CFC700A0236D /* libsqlite3.tbd in Frameworks */ = {isa = PBXBuildFile; fileRef = 6452144724B4ACDE007203D5 /* libsqlite3.tbd */; }; + 649AD3C624B4CFD500A0236D /* libxml2.tbd in Frameworks */ = {isa = PBXBuildFile; fileRef = 6452144524B4ACC8007203D5 /* libxml2.tbd */; }; + 649AD3C724B4D00100A0236D /* libc++.tbd in Frameworks */ = {isa = PBXBuildFile; fileRef = 6452144124B4AC9F007203D5 /* libc++.tbd */; }; + 64F8127724B8AA5200A7DE6A /* MessageCellLocationSharingReceived.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64F8127324B8AA5200A7DE6A /* MessageCellLocationSharingReceived.swift */; }; + 64F8127824B8AA5200A7DE6A /* MessageCellLocationSharingReceived.xib in Resources */ = {isa = PBXBuildFile; fileRef = 64F8127624B8AA5200A7DE6A /* MessageCellLocationSharingReceived.xib */; }; + 64F8127A24BBC19C00A7DE6A /* MessageCellLocationSharing.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64F8127924BBC19C00A7DE6A /* MessageCellLocationSharing.swift */; }; 6613A612214AFF4700B497D1 /* ScanViewController.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 6613A611214AFF4700B497D1 /* ScanViewController.storyboard */; }; 66266FC021557D2F002757A6 /* ScanViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 66266FBF21557D2F002757A6 /* ScanViewModel.swift */; }; 66266FC4215C18F8002757A6 /* Emoji+Helpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 66266FC3215C18F8002757A6 /* Emoji+Helpers.swift */; }; @@ -338,8 +350,36 @@ remoteGlobalIDString = 043999F21D1C2D9D00E99CD9; remoteInfo = Ring; }; + 6452143C24B4AB44007203D5 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 6452143724B4AB43007203D5 /* WhirlyGlobeMaplyComponent.xcodeproj */; + proxyType = 2; + remoteGlobalIDString = 2BE536FF1D2499E500B60FAD; + remoteInfo = WhirlyGlobeMaplyComponent; + }; + 6452143E24B4AB44007203D5 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 6452143724B4AB43007203D5 /* WhirlyGlobeMaplyComponent.xcodeproj */; + proxyType = 2; + remoteGlobalIDString = 2BE537091D2499E500B60FAD; + remoteInfo = WhirlyGlobeMaplyComponentTests; + }; /* End PBXContainerItemProxy section */ +/* Begin PBXCopyFilesBuildPhase section */ + 645BDD6424B5FEFF009129B1 /* Embed Frameworks */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + 645BDD6324B5FEFF009129B1 /* WhirlyGlobeMaplyComponent.framework in Embed Frameworks */, + ); + name = "Embed Frameworks"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + /* Begin PBXFileReference section */ 024B612B1DF7654F00C4F9DE /* DaemonServiceTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DaemonServiceTests.swift; sourceTree = "<group>"; }; 024B612D1DF7656A00C4F9DE /* FixtureFailInitDRingAdapter.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = FixtureFailInitDRingAdapter.h; path = Fixtures/DRingAdaptor/FixtureFailInitDRingAdapter.h; sourceTree = "<group>"; }; @@ -727,6 +767,17 @@ 62DFAB2D1F9FF0D0002D6F9C /* NetworkService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkService.swift; sourceTree = "<group>"; }; 62E55B6C1F758E6F00D3FEF4 /* String+Helpers.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "String+Helpers.swift"; sourceTree = "<group>"; }; 62E55B6E1F793ADE00D3FEF4 /* AvatarsColors.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AvatarsColors.swift; sourceTree = "<group>"; }; + 6452143724B4AB43007203D5 /* WhirlyGlobeMaplyComponent.xcodeproj */ = {isa = PBXFileReference; lastKnownFileType = "wrapper.pb-project"; name = WhirlyGlobeMaplyComponent.xcodeproj; path = "WhirlyGlobeMaply/ios/library/WhirlyGlobe-MaplyComponent/WhirlyGlobeMaplyComponent.xcodeproj"; sourceTree = "<group>"; }; + 6452144124B4AC9F007203D5 /* libc++.tbd */ = {isa = PBXFileReference; lastKnownFileType = "sourcecode.text-based-dylib-definition"; name = "libc++.tbd"; path = "usr/lib/libc++.tbd"; sourceTree = SDKROOT; }; + 6452144324B4ACA7007203D5 /* CoreLocation.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = CoreLocation.framework; path = System/Library/Frameworks/CoreLocation.framework; sourceTree = SDKROOT; }; + 6452144524B4ACC8007203D5 /* libxml2.tbd */ = {isa = PBXFileReference; lastKnownFileType = "sourcecode.text-based-dylib-definition"; name = libxml2.tbd; path = usr/lib/libxml2.tbd; sourceTree = SDKROOT; }; + 6452144724B4ACDE007203D5 /* libsqlite3.tbd */ = {isa = PBXFileReference; lastKnownFileType = "sourcecode.text-based-dylib-definition"; name = libsqlite3.tbd; path = usr/lib/libsqlite3.tbd; sourceTree = SDKROOT; }; + 645BDD6C24B7415A009129B1 /* MessageCellLocationSharingSent.xib */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.xib; path = MessageCellLocationSharingSent.xib; sourceTree = "<group>"; }; + 645BDD7024B7415A009129B1 /* MessageCellLocationSharingSent.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MessageCellLocationSharingSent.swift; sourceTree = "<group>"; }; + 645BDD8024B74BCB009129B1 /* LocationSharingService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationSharingService.swift; sourceTree = "<group>"; }; + 64F8127324B8AA5200A7DE6A /* MessageCellLocationSharingReceived.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MessageCellLocationSharingReceived.swift; sourceTree = "<group>"; }; + 64F8127624B8AA5200A7DE6A /* MessageCellLocationSharingReceived.xib */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.xib; path = MessageCellLocationSharingReceived.xib; sourceTree = "<group>"; }; + 64F8127924BBC19C00A7DE6A /* MessageCellLocationSharing.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageCellLocationSharing.swift; sourceTree = "<group>"; }; 6613A611214AFF4700B497D1 /* ScanViewController.storyboard */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; path = ScanViewController.storyboard; sourceTree = "<group>"; }; 66266FBF21557D2F002757A6 /* ScanViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScanViewModel.swift; sourceTree = "<group>"; }; 66266FC3215C18F8002757A6 /* Emoji+Helpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Emoji+Helpers.swift"; sourceTree = "<group>"; xcLanguageSpecificationIdentifier = xcode.lang.swift; }; @@ -741,6 +792,11 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 649AD3C324B4CFC700A0236D /* libsqlite3.tbd in Frameworks */, + 649AD3C624B4CFD500A0236D /* libxml2.tbd in Frameworks */, + 04399A981D1C2F6100E99CD9 /* libz.tbd in Frameworks */, + 649AD3C724B4D00100A0236D /* libc++.tbd in Frameworks */, + 6452144424B4ACA7007203D5 /* CoreLocation.framework in Frameworks */, 0E4A51CC23282BCC00357AFC /* libhttp_parser.a in Frameworks */, 0ECB4E2A22B2D4BB0097CD7B /* CallKit.framework in Frameworks */, 0ECEE9A3220D1935000E1CF4 /* VideoToolbox.framework in Frameworks */, @@ -801,7 +857,7 @@ 04399B141D1C341A00E99CD9 /* libx264.a in Frameworks */, 04399B151D1C341A00E99CD9 /* libyaml-cpp.a in Frameworks */, 04399A971D1C2F6100E99CD9 /* libbz2.tbd in Frameworks */, - 04399A981D1C2F6100E99CD9 /* libz.tbd in Frameworks */, + 645BDD6224B5FEFE009129B1 /* WhirlyGlobeMaplyComponent.framework in Frameworks */, 04399A941D1C2F5800E99CD9 /* libiconv.tbd in Frameworks */, 04399A2C1D1C2DE900E99CD9 /* AVFoundation.framework in Frameworks */, 04399A2A1D1C2DE300E99CD9 /* CoreMedia.framework in Frameworks */, @@ -847,6 +903,11 @@ 02AED8171DD4C4B000F740BA /* Frameworks */ = { isa = PBXGroup; children = ( + 6452144724B4ACDE007203D5 /* libsqlite3.tbd */, + 6452144524B4ACC8007203D5 /* libxml2.tbd */, + 6452144324B4ACA7007203D5 /* CoreLocation.framework */, + 6452144124B4AC9F007203D5 /* libc++.tbd */, + 6452143724B4AB43007203D5 /* WhirlyGlobeMaplyComponent.xcodeproj */, 0E4A51CB23282BCC00357AFC /* libhttp_parser.a */, 0ECB4E2922B2D4BB0097CD7B /* CallKit.framework */, 0E639459224AB32200C0890A /* Contacts.framework */, @@ -907,6 +968,7 @@ 62B60AF320489E7C001BEACF /* DataTransferService.swift */, 62B60AFA2048A437001BEACF /* DataTransferAdapterDelegate.swift */, 0ECB4E2722B2D4840097CD7B /* CallsProviderDelegate.swift */, + 645BDD8024B74BCB009129B1 /* LocationSharingService.swift */, ); path = Services; sourceTree = "<group>"; @@ -1524,6 +1586,11 @@ 62AD58472056DA6800AF0701 /* MessageCellDataTransferReceived.xib */, 62AD58492056DADF00AF0701 /* MessageCellDataTransferReceived.swift */, 62AD584B2056DB2700AF0701 /* MessageCellDataTransferSent.swift */, + 64F8127924BBC19C00A7DE6A /* MessageCellLocationSharing.swift */, + 645BDD7024B7415A009129B1 /* MessageCellLocationSharingSent.swift */, + 645BDD6C24B7415A009129B1 /* MessageCellLocationSharingSent.xib */, + 64F8127324B8AA5200A7DE6A /* MessageCellLocationSharingReceived.swift */, + 64F8127624B8AA5200A7DE6A /* MessageCellLocationSharingReceived.xib */, ); path = Cells; sourceTree = "<group>"; @@ -1678,6 +1745,15 @@ name = Cells; sourceTree = "<group>"; }; + 6452143824B4AB43007203D5 /* Products */ = { + isa = PBXGroup; + children = ( + 6452143D24B4AB44007203D5 /* WhirlyGlobeMaplyComponent.framework */, + 6452143F24B4AB44007203D5 /* WhirlyGlobeMaplyComponentTests.xctest */, + ); + name = Products; + sourceTree = "<group>"; + }; 6613A610214AF8B100B497D1 /* QRCode */ = { isa = PBXGroup; children = ( @@ -1701,6 +1777,7 @@ 043999EF1D1C2D9D00E99CD9 /* Sources */, 043999F01D1C2D9D00E99CD9 /* Frameworks */, 043999F11D1C2D9D00E99CD9 /* Resources */, + 645BDD6424B5FEFF009129B1 /* Embed Frameworks */, ); buildRules = ( ); @@ -1852,6 +1929,12 @@ mainGroup = 043999EA1D1C2D9D00E99CD9; productRefGroup = 043999F41D1C2D9D00E99CD9 /* Products */; projectDirPath = ""; + projectReferences = ( + { + ProductGroup = 6452143824B4AB43007203D5 /* Products */; + ProjectRef = 6452143724B4AB43007203D5 /* WhirlyGlobeMaplyComponent.xcodeproj */; + }, + ); projectRoot = ""; targets = ( 043999F21D1C2D9D00E99CD9 /* Ring */, @@ -1862,6 +1945,23 @@ }; /* End PBXProject section */ +/* Begin PBXReferenceProxy section */ + 6452143D24B4AB44007203D5 /* WhirlyGlobeMaplyComponent.framework */ = { + isa = PBXReferenceProxy; + fileType = wrapper.framework; + path = WhirlyGlobeMaplyComponent.framework; + remoteRef = 6452143C24B4AB44007203D5 /* PBXContainerItemProxy */; + sourceTree = BUILT_PRODUCTS_DIR; + }; + 6452143F24B4AB44007203D5 /* WhirlyGlobeMaplyComponentTests.xctest */ = { + isa = PBXReferenceProxy; + fileType = wrapper.cfbundle; + path = WhirlyGlobeMaplyComponentTests.xctest; + remoteRef = 6452143E24B4AB44007203D5 /* PBXContainerItemProxy */; + sourceTree = BUILT_PRODUCTS_DIR; + }; +/* End PBXReferenceProxy section */ + /* Begin PBXResourcesBuildPhase section */ 043999F11D1C2D9D00E99CD9 /* Resources */ = { isa = PBXResourcesBuildPhase; @@ -1902,7 +2002,9 @@ 1A5DC03E1F35678D0075E8EF /* ContactRequestsViewController.storyboard in Resources */, 446FAF192373424700519C4F /* SendFileViewController.storyboard in Resources */, 0EF49AA323828CD00064CD98 /* ConferenceParticipantView.xib in Resources */, + 645BDD7724B7415A009129B1 /* MessageCellLocationSharingSent.xib in Resources */, 0E72374A20460320006B0C7D /* ProfileHeaderView.xib in Resources */, + 64F8127824B8AA5200A7DE6A /* MessageCellLocationSharingReceived.xib in Resources */, 4430A66D236CBC5900747177 /* ContactPickerViewController.storyboard in Resources */, 0E96ED75225D06250016C07D /* GeneralSettingsViewController.storyboard in Resources */, 0EB1A5CF1F8EBE03009923E2 /* DeviceCell.xib in Resources */, @@ -2056,6 +2158,7 @@ 0E438A9A204F47E700402900 /* SettingsTableView.swift in Sources */, 0E49096A1FEAB156005CAA50 /* CallsAdapter.mm in Sources */, 1A2D18A61F27F7A400B2C785 /* UIViewController+Rx.swift in Sources */, + 64F8127724B8AA5200A7DE6A /* MessageCellLocationSharingReceived.swift in Sources */, 66E6381221764C2C005EA2B0 /* GrowingTextView.swift in Sources */, 0E7CF4DB20164B6700CD967D /* ButtonsContainerView.swift in Sources */, 0E49097A1FEAC9E1005CAA50 /* CallViewController.swift in Sources */, @@ -2116,6 +2219,7 @@ 1A20417C1F1E56FF00C08435 /* WelcomeViewModel.swift in Sources */, 1A5DC03D1F35678D0075E8EF /* ContactRequestItem.swift in Sources */, 0E403F811F7D797300C80BC2 /* MessageCellGenerated.swift in Sources */, + 64F8127A24BBC19C00A7DE6A /* MessageCellLocationSharing.swift in Sources */, 0E6D959C2407116E00996A28 /* LinkToAccountManagerViewModel.swift in Sources */, 62AD584C2056DB2700AF0701 /* MessageCellDataTransferSent.swift in Sources */, 0ECA5683243394960055D31E /* MigrateAccountViewController.swift in Sources */, @@ -2155,6 +2259,7 @@ 1A5DC0281F3564AA0075E8EF /* MessageModel.swift in Sources */, 0E3697A1203235EA009A68CA /* BannedContactItem.swift in Sources */, 56BBC9DF1EDDC9D300CDAF8B /* LookupNameResponse.m in Sources */, + 645BDD7B24B7415A009129B1 /* MessageCellLocationSharingSent.swift in Sources */, 66F295DE2166A5930044ED6F /* Devices+Helpers.swift in Sources */, 1A2041911F1FD46300C08435 /* DesignableView.swift in Sources */, 0ED2B6FE1F96A16C001572F0 /* LinkNewDeviceViewModel.swift in Sources */, @@ -2199,6 +2304,7 @@ 02B22DFF1DF755DB000358C9 /* AccountsService.swift in Sources */, 1A5DC0421F3567DF0075E8EF /* ContactRequestsCoordinator.swift in Sources */, 62E55B6F1F793ADE00D3FEF4 /* AvatarsColors.swift in Sources */, + 645BDD8124B74BCB009129B1 /* LocationSharingService.swift in Sources */, 1A5DC0401F35678D0075E8EF /* ContactRequestsViewModel.swift in Sources */, 56BBC9A21ED714DF00CDAF8B /* MessagesAdapterDelegate.swift in Sources */, 1A5DC0301F3565AE0075E8EF /* SmartlistViewController.swift in Sources */, @@ -2437,6 +2543,7 @@ HEADER_SEARCH_PATHS = ( "$(SRCROOT)/../fat/include", "$(SRCROOT)/../../daemon/contrib/native-arm64/ffmpeg", + "$(SRCROOT)/WhirlyGlobeMaply/ios/library/WhirlyGlobe-MaplyComponent/include/**", ); INFOPLIST_FILE = Ring/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 11.0; @@ -2447,7 +2554,7 @@ PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE = ""; PROVISIONING_PROFILE_SPECIFIER = ""; - SWIFT_OBJC_BRIDGING_HEADER = "Ring/Bridging/Ring-Bridging-Header.h"; + SWIFT_OBJC_BRIDGING_HEADER = "$(SRCROOT)/Ring/Bridging/Ring-Bridging-Header.h"; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_SWIFT3_OBJC_INFERENCE = Off; SWIFT_VERSION = 5.0; @@ -2478,6 +2585,7 @@ HEADER_SEARCH_PATHS = ( "$(SRCROOT)/../fat/include", "$(SRCROOT)/../../daemon/contrib/native-arm64/ffmpeg", + "$(SRCROOT)/WhirlyGlobeMaply/ios/library/WhirlyGlobe-MaplyComponent/include/**", ); INFOPLIST_FILE = Ring/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 11.0; @@ -2488,7 +2596,7 @@ PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE = ""; PROVISIONING_PROFILE_SPECIFIER = ""; - SWIFT_OBJC_BRIDGING_HEADER = "Ring/Bridging/Ring-Bridging-Header.h"; + SWIFT_OBJC_BRIDGING_HEADER = "$(SRCROOT)/Ring/Bridging/Ring-Bridging-Header.h"; SWIFT_SWIFT3_OBJC_INFERENCE = Off; SWIFT_VERSION = 5.0; VALID_ARCHS = arm64; @@ -2645,6 +2753,7 @@ HEADER_SEARCH_PATHS = ( "$(SRCROOT)/../fat/include", "$(SRCROOT)/../../daemon/contrib/native-arm64/ffmpeg", + "$(SRCROOT)/WhirlyGlobeMaply/ios/library/WhirlyGlobe-MaplyComponent/include/**", ); INFOPLIST_FILE = Ring/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 11.0; @@ -2655,7 +2764,7 @@ PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE = ""; PROVISIONING_PROFILE_SPECIFIER = ""; - SWIFT_OBJC_BRIDGING_HEADER = "Ring/Bridging/Ring-Bridging-Header.h"; + SWIFT_OBJC_BRIDGING_HEADER = "$(SRCROOT)/Ring/Bridging/Ring-Bridging-Header.h"; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_SWIFT3_OBJC_INFERENCE = Off; SWIFT_VERSION = 5.0; diff --git a/Ring/Ring/AppDelegate.swift b/Ring/Ring/AppDelegate.swift index 9a529c39b..8cbe43887 100644 --- a/Ring/Ring/AppDelegate.swift +++ b/Ring/Ring/AppDelegate.swift @@ -1,10 +1,11 @@ /* - * Copyright (C) 2017-2019 Savoir-faire Linux Inc. + * Copyright (C) 2017-2020 Savoir-faire Linux Inc. * * Author: Edric Ladent-Milaret <edric.ladent-milaret@savoirfairelinux.com> * Author: Romain Bertozzi <romain.bertozzi@savoirfairelinux.com> * Author: Thibault Wittemberg <thibault.wittemberg@savoirfairelinux.com> * Author: Quentin Muret <quentin.muret@savoirfairelinux.com> + * Author: Raphaël Brulé <raphael.brule@savoirfairelinux.com> * * This program is free software; you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -63,6 +64,9 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD private lazy var conversationsService: ConversationsService = { ConversationsService(withMessageAdapter: MessagesAdapter(), dbManager: self.dBManager) }() + private lazy var locationSharingService: LocationSharingService = { + LocationSharingService(dbManager: self.dBManager) + }() private let voipRegistry = PKPushRegistry(queue: DispatchQueue.main) @@ -79,7 +83,8 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD withAudioService: self.audioService, withDataTransferService: self.dataTransferService, withProfileService: self.profileService, - withCallsProvider: self.callsProvider) + withCallsProvider: self.callsProvider, + withLocationSharingService: self.locationSharingService) }() private lazy var appCoordinator: AppCoordinator = { return AppCoordinator(with: self.injectionBag) @@ -142,7 +147,8 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD accountsService: self.accountService, nameService: self.nameService, dataTransferService: self.dataTransferService, - callService: self.callService) + callService: self.callService, + locationSharingService: self.locationSharingService) self.window?.rootViewController = self.appCoordinator.rootViewController self.window?.makeKeyAndVisible() diff --git a/Ring/Ring/Bridging/Ring-Bridging-Header.h b/Ring/Ring/Bridging/Ring-Bridging-Header.h index b72f74b30..482aec9e8 100644 --- a/Ring/Ring/Bridging/Ring-Bridging-Header.h +++ b/Ring/Ring/Bridging/Ring-Bridging-Header.h @@ -1,8 +1,9 @@ /* - * Copyright (C) 2016-2019 Savoir-faire Linux Inc. + * Copyright (C) 2016-2020 Savoir-faire Linux Inc. * * Author: Edric Ladent-Milaret <edric.ladent-milaret@savoirfairelinux.com> * Author: Romain Bertozzi <romain.bertozzi@savoirfairelinux.com> + * Author: Raphaël Brulé <raphael.brule@savoirfairelinux.com> * * This program is free software; you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -43,3 +44,4 @@ #import <UserNotifications/UserNotifications.h> #import <GSKStretchyHeaderView/GSKStretchyHeaderView.h> #import "DataTransferAdapter.h" +#import "../../WhirlyGlobeMaply/ios/library/WhirlyGlobe-MaplyComponent/include/MaplyBridge.h" diff --git a/Ring/Ring/Constants/Generated/Images.swift b/Ring/Ring/Constants/Generated/Images.swift index 164c3d302..f267109e5 100644 --- a/Ring/Ring/Constants/Generated/Images.swift +++ b/Ring/Ring/Constants/Generated/Images.swift @@ -58,6 +58,7 @@ internal enum Asset { internal static let leftArrow = ImageAsset(name: "left_arrow") internal static let messageBackgroundColor = ColorAsset(name: "message_background_color") internal static let moreSettings = ImageAsset(name: "more_settings") + internal static let myLocation = ImageAsset(name: "my_location") internal static let pauseCall = ImageAsset(name: "pause_call") internal static let phoneBook = ImageAsset(name: "phone_book") internal static let qrCode = ImageAsset(name: "qr_code") @@ -134,7 +135,8 @@ internal struct ImageAsset { #if os(iOS) || os(tvOS) let image = Image(named: name, in: bundle, compatibleWith: nil) #elseif os(macOS) - let image = bundle.image(forResource: NSImage.Name(name)) + let name = NSImage.Name(self.name) + let image = (bundle == .main) ? NSImage(named: name) : bundle.image(forResource: name) #elseif os(watchOS) let image = Image(named: name) #endif diff --git a/Ring/Ring/Constants/Generated/Strings.swift b/Ring/Ring/Constants/Generated/Strings.swift index c7c61167d..f4001464d 100644 --- a/Ring/Ring/Constants/Generated/Strings.swift +++ b/Ring/Ring/Constants/Generated/Strings.swift @@ -8,7 +8,7 @@ import Foundation // MARK: - Strings // swiftlint:disable explicit_type_interface function_parameter_count identifier_name line_length -// swiftlint:disable nesting type_body_length type_name +// swiftlint:disable nesting type_body_length type_name vertical_whitespace_opening_braces internal enum L10n { internal enum Account { @@ -178,10 +178,14 @@ internal enum L10n { internal static let deleteAction = L10n.tr("Localizable", "actions.deleteAction") /// Done internal static let doneAction = L10n.tr("Localizable", "actions.doneAction") + /// Go to Settings + internal static let goToSettings = L10n.tr("Localizable", "actions.goToSettings") /// Audio Call internal static let startAudioCall = L10n.tr("Localizable", "actions.startAudioCall") /// Video Call internal static let startVideoCall = L10n.tr("Localizable", "actions.startVideoCall") + /// Stop sharing + internal static let stopLocationSharing = L10n.tr("Localizable", "actions.stopLocationSharing") } internal enum Alerts { @@ -201,6 +205,8 @@ internal enum L10n { internal static let accountNoNetworkMessage = L10n.tr("Localizable", "alerts.accountNoNetworkMessage") /// Can't connect to the network internal static let accountNoNetworkTitle = L10n.tr("Localizable", "alerts.accountNoNetworkTitle") + /// Already sharing location with this user + internal static let alreadylocationSharing = L10n.tr("Localizable", "alerts.alreadylocationSharing") /// Are you sure you want to block this contact? The conversation history with this contact will also be deleted permanently. internal static let confirmBlockContact = L10n.tr("Localizable", "alerts.confirmBlockContact") /// Block Contact @@ -227,10 +233,28 @@ internal enum L10n { internal static let incomingCallButtonAccept = L10n.tr("Localizable", "alerts.incomingCallButtonAccept") /// Ignore internal static let incomingCallButtonIgnore = L10n.tr("Localizable", "alerts.incomingCallButtonIgnore") + /// Turn on "Location Services" to allow "Jami" to determine your location. + internal static let locationServiceIsDisabled = L10n.tr("Localizable", "alerts.locationServiceIsDisabled") + /// Share my location + internal static let locationSharing = L10n.tr("Localizable", "alerts.locationSharing") + /// 10 min + internal static let locationSharingDuration10min = L10n.tr("Localizable", "alerts.locationSharingDuration10min") + /// 1 hour + internal static let locationSharingDuration1hour = L10n.tr("Localizable", "alerts.locationSharingDuration1hour") + /// How long should the location sharing be? + internal static let locationSharingDurationTitle = L10n.tr("Localizable", "alerts.locationSharingDurationTitle") + /// Map information + internal static let mapInformation = L10n.tr("Localizable", "alerts.mapInformation") /// Access to photo library not granted internal static let noLibraryPermissionsTitle = L10n.tr("Localizable", "alerts.noLibraryPermissionsTitle") + /// Access to location not granted + internal static let noLocationPermissionsTitle = L10n.tr("Localizable", "alerts.noLocationPermissionsTitle") /// Media permission not granted internal static let noMediaPermissionsTitle = L10n.tr("Localizable", "alerts.noMediaPermissionsTitle") + /// © OpenStreetMap contributors + internal static let openStreetMapCopyright = L10n.tr("Localizable", "alerts.openStreetMapCopyright") + /// Learn more + internal static let openStreetMapCopyrightMoreInfo = L10n.tr("Localizable", "alerts.openStreetMapCopyrightMoreInfo") /// Cancel internal static let profileCancelPhoto = L10n.tr("Localizable", "alerts.profileCancelPhoto") /// Take photo @@ -398,6 +422,8 @@ internal enum L10n { internal static let invitationAccepted = L10n.tr("Localizable", "generatedMessage.invitationAccepted") /// Invitation received internal static let invitationReceived = L10n.tr("Localizable", "generatedMessage.invitationReceived") + /// Live location sharing + internal static let liveLocationSharing = L10n.tr("Localizable", "generatedMessage.liveLocationSharing") /// Missed incoming call internal static let missedIncomingCall = L10n.tr("Localizable", "generatedMessage.missedIncomingCall") /// Missed outgoing call @@ -501,6 +527,10 @@ internal enum L10n { internal static let acceptCall = L10n.tr("Localizable", "notifications.acceptCall") /// Incoming Call internal static let incomingCall = L10n.tr("Localizable", "notifications.incomingCall") + /// Incoming location sharing started + internal static let locationSharingStarted = L10n.tr("Localizable", "notifications.locationSharingStarted") + /// Incoming location sharing stopped + internal static let locationSharingStopped = L10n.tr("Localizable", "notifications.locationSharingStopped") /// Missed Call internal static let missedCall = L10n.tr("Localizable", "notifications.missedCall") /// New file @@ -559,7 +589,7 @@ internal enum L10n { } } // swiftlint:enable explicit_type_interface function_parameter_count identifier_name line_length -// swiftlint:enable nesting type_body_length type_name +// swiftlint:enable nesting type_body_length type_name vertical_whitespace_opening_braces // MARK: - Implementation Details @@ -572,8 +602,6 @@ extension L10n { // swiftlint:disable convenience_type private final class BundleToken { - static let bundle: Bundle = { - Bundle(for: BundleToken.self) - }() + static let bundle = Bundle(for: BundleToken.self) } // swiftlint:enable convenience_type diff --git a/Ring/Ring/Coordinators/InjectionBag.swift b/Ring/Ring/Coordinators/InjectionBag.swift index b15d50412..e343ec3b8 100644 --- a/Ring/Ring/Coordinators/InjectionBag.swift +++ b/Ring/Ring/Coordinators/InjectionBag.swift @@ -1,7 +1,8 @@ /* - * Copyright (C) 2017-2019 Savoir-faire Linux Inc. + * Copyright (C) 2017-2020 Savoir-faire Linux Inc. * * Author: Thibault Wittemberg <thibault.wittemberg@savoirfairelinux.com> + * Author: Raphaël Brulé <raphael.brule@savoirfairelinux.com> * * This program is free software; you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -36,6 +37,7 @@ class InjectionBag { let dataTransferService: DataTransferService let profileService: ProfilesService let callsProvider: CallsProviderDelegate + let locationSharingService: LocationSharingService init (withDaemonService daemonService: DaemonService, withAccountService accountService: AccountsService, @@ -49,7 +51,8 @@ class InjectionBag { withAudioService audioService: AudioService, withDataTransferService dataTransferService: DataTransferService, withProfileService profileService: ProfilesService, - withCallsProvider callsProvider: CallsProviderDelegate) { + withCallsProvider callsProvider: CallsProviderDelegate, + withLocationSharingService locationSharingService: LocationSharingService) { self.daemonService = daemonService self.accountService = accountService self.nameService = nameService @@ -63,5 +66,6 @@ class InjectionBag { self.dataTransferService = dataTransferService self.profileService = profileService self.callsProvider = callsProvider + self.locationSharingService = locationSharingService } } diff --git a/Ring/Ring/Database/DBHelpers/InteractionDataHelper.swift b/Ring/Ring/Database/DBHelpers/InteractionDataHelper.swift index 1fad9f333..bf0296dce 100644 --- a/Ring/Ring/Database/DBHelpers/InteractionDataHelper.swift +++ b/Ring/Ring/Database/DBHelpers/InteractionDataHelper.swift @@ -152,37 +152,10 @@ final class InteractionDataHelper { } } - func delete (item: Interaction, dataBase: Connection) -> Bool { - let interactionId = item.id - let query = table.filter(id == interactionId) - do { - let deletedRows = try dataBase.run(query.delete()) - guard deletedRows == 1 else { - return false - } - return true - } catch _ { - return false - } - } - - func delete (interactionId: Int64, dataBase: Connection) -> Bool { - let query = table.filter(id == interactionId) - do { - let deletedRows = try dataBase.run(query.delete()) - guard deletedRows == 1 else { - return false - } - return true - } catch _ { - return false - } - } - func selectInteraction (interactionId: Int64, dataBase: Connection) throws -> Interaction? { let query = table.filter(id == interactionId) let items = try dataBase.prepare(query) - for item in items { + for item in items { return Interaction(id: item[id], author: item[author], conversation: item[conversation], @@ -215,11 +188,11 @@ final class InteractionDataHelper { return interactions } - func selectInteractionsForConversation (conv: Int64, dataBase: Connection) throws -> [Interaction]? { - let query = table.filter(conversation == conv) + func selectInteractions(where predicat: Expression<Bool>, dataBase: Connection) throws -> [Interaction] { + let query = table.filter(predicat) var interactions = [Interaction]() let items = try dataBase.prepare(query) - for item in items { + for item in items { interactions.append(Interaction(id: item[id], author: item[author], conversation: item[conversation], @@ -234,27 +207,14 @@ final class InteractionDataHelper { return interactions } + func selectInteractionsForConversation(conv: Int64, dataBase: Connection) throws -> [Interaction]? { + return try self.selectInteractions(where: conversation == conv, dataBase: dataBase) + } + func selectInteractionWithDaemonId(interactionDaemonID: String, dataBase: Connection) throws -> Interaction? { - let query = table.filter(daemonId == interactionDaemonID) - var interactions = [Interaction]() - let items = try dataBase.prepare(query) - for item in items { - interactions.append(Interaction(id: item[id], - author: item[author], - conversation: item[conversation], - timestamp: item[timestamp], - duration: item[duration], - body: item[body], - type: item[type], - status: item[status], - daemonID: item[daemonId], - incoming: item[incoming])) - } - if interactions.isEmpty { - return nil - } + let interactions = try self.selectInteractions(where: daemonId == interactionDaemonID, dataBase: dataBase) - if interactions.count > 1 { + if interactions.isEmpty || interactions.count > 1 { return nil } @@ -300,14 +260,23 @@ final class InteractionDataHelper { } } - func deleteAllIntercations(conv: Int64, dataBase: Connection) -> Bool { - let query = table.filter(conversation == conv) + func deleteInteractions(where predicat: Expression<Bool>, dataBase: Connection) throws -> Bool { + let query = table.filter(predicat) + let deletedRows = try dataBase.run(query.delete()) + return deletedRows > 0 + } + + func deleteAllInteractions(conv: Int64, dataBase: Connection) -> Bool { do { - if try dataBase.run(query.delete()) > 0 { - return true - } else { - return false - } + return try self.deleteInteractions(where: conversation == conv, dataBase: dataBase) + } catch { + return false + } + } + + func delete(interactionId: Int64, dataBase: Connection) -> Bool { + do { + return try self.deleteInteractions(where: id == interactionId, dataBase: dataBase) } catch { return false } diff --git a/Ring/Ring/Database/DBManager.swift b/Ring/Ring/Database/DBManager.swift index 526a3055c..e05f520c9 100644 --- a/Ring/Ring/Database/DBManager.swift +++ b/Ring/Ring/Database/DBManager.swift @@ -1,5 +1,5 @@ /* - * Copyright (C) 2017-2019 Savoir-faire Linux Inc. + * Copyright (C) 2017-2020 Savoir-faire Linux Inc. * * Author: Kateryna Kostiuk <kateryna.kostiuk@savoirfairelinux.com> * Author: Raphaël Brulé <raphael.brule@savoirfairelinux.com> @@ -153,6 +153,7 @@ enum InteractionType: String { case contact = "CONTACT" case iTransfer = "INCOMING_DATA_TRANSFER" case oTransfer = "OUTGOING_DATA_TRANSFER" + case location = "LOCATION" } typealias SavedMessageForConversation = (messageID: Int64, conversationID: Int64) @@ -312,7 +313,7 @@ class DBManager { observable.on(.error(DBBridgingError.saveMessageFailed)) } return Disposables.create { } - } + } } func getConversationsObservable(for accountId: String) -> Observable<[ConversationModel]> { @@ -495,7 +496,7 @@ class DBManager { } if !interactions.isEmpty { if !self.interactionHepler - .deleteAllIntercations(conv: conversationsId, dataBase: dataBase) { + .deleteAllInteractions(conv: conversationsId, dataBase: dataBase) { completable(.error(DBBridgingError.deleteConversationFailed)) } } @@ -660,7 +661,8 @@ class DBManager { interaction.type != InteractionType.contact.rawValue && interaction.type != InteractionType.call.rawValue && interaction.type != InteractionType.iTransfer.rawValue && - interaction.type != InteractionType.oTransfer.rawValue { + interaction.type != InteractionType.oTransfer.rawValue && + interaction.type != InteractionType.location.rawValue { return nil } let content = (interaction.type == InteractionType.call.rawValue @@ -689,6 +691,9 @@ class DBManager { message.status = status.toMessageStatus() } } + if interaction.type == InteractionType.location.rawValue { + message.isLocationSharing = true + } message.messageId = interaction.id return message } @@ -708,7 +713,7 @@ class DBManager { let interaction = Interaction(defaultID, author, conversationID, Int64(timeInterval), Int64(duration), message.content, interactionType.rawValue, - status, message.daemonId, + status, message.daemonId, message.incoming) return self.interactionHepler.insert(item: interaction, dataBase: dataBase) } @@ -781,4 +786,58 @@ class DBManager { return try self.conversationHelper .selectConversationsForProfile(profileUri: contactUri, dataBase: dataBase)?.first?.id } + + // MARK: Location sharing + func isFirstLocationIncomingUpdate(incoming: Bool, peerUri: String, accountId: String) -> Bool? { + do { + guard let dataBase = self.dbConnections.forAccount(account: accountId) else { return nil } + + let conversationId = try self.getConversationsFor(contactUri: peerUri, createIfNotExists: true, dataBase: dataBase, accountId: accountId) + let interactions = try self.interactionHepler.selectInteractionsForConversation(conv: conversationId!, dataBase: dataBase) + + var isFirst = true + for (interaction) in interactions! where interaction.type == InteractionType.location.rawValue && interaction.incoming == incoming { + isFirst = false + break + } + return isFirst + } catch { + return nil + } + } + + func deleteLocationUpdates(incoming: Bool, peerUri: String, accountId: String) -> Completable { + return Completable.create(subscribe: { [weak self] completable in + do { + guard let self = self, let dataBase = self.dbConnections.forAccount(account: accountId) else { throw DataAccessError.datastoreConnectionError } + let conversationId = try self.getConversationsFor(contactUri: peerUri, createIfNotExists: true, dataBase: dataBase, accountId: accountId) + + let predicat: Expression<Bool> = (self.interactionHepler.conversation == conversationId! && + self.interactionHepler.type == InteractionType.location.rawValue && + self.interactionHepler.incoming == incoming) + + _ = try self.interactionHepler.deleteInteractions(where: predicat, dataBase: dataBase) + completable(.completed) + } catch { + completable(.error(DBBridgingError.deleteMessageFailed)) + } + return Disposables.create { } + }) + } + + func deleteAllLocationUpdates(accountIds: [String]) -> Bool { + var didNotFailOnce = true + for accountId in accountIds { + do { + guard let dataBase = self.dbConnections.forAccount(account: accountId) else { throw DataAccessError.datastoreConnectionError } + + let predicat: Expression<Bool> = (self.interactionHepler.type == InteractionType.location.rawValue) + + _ = try self.interactionHepler.deleteInteractions(where: predicat, dataBase: dataBase) + } catch { + didNotFailOnce = false + } + } + return didNotFailOnce + } } diff --git a/Ring/Ring/Extensions/UIView+Ring.swift b/Ring/Ring/Extensions/UIView+Ring.swift index ba7ba6e7b..accbcfe8f 100644 --- a/Ring/Ring/Extensions/UIView+Ring.swift +++ b/Ring/Ring/Extensions/UIView+Ring.swift @@ -145,12 +145,11 @@ extension UIView { return UIColor.clear } - public func convertViewToImage() -> UIImage? { - UIGraphicsBeginImageContext(self.bounds.size) - self.drawHierarchy(in: self.bounds, afterScreenUpdates: false) - let image = UIGraphicsGetImageFromCurrentImageContext() - UIGraphicsEndImageContext() - return image + func convertToImage() -> UIImage { + let renderer = UIGraphicsImageRenderer(bounds: bounds) + return renderer.image { rendererContext in + layer.render(in: rendererContext.cgContext) + } } func applyGradient(with colours: [UIColor], locations: [NSNumber]? = nil) { diff --git a/Ring/Ring/Features/Conversations/Conversation/Cells/MessageCell.swift b/Ring/Ring/Features/Conversations/Conversation/Cells/MessageCell.swift index 860cc8250..3fa51ebbe 100644 --- a/Ring/Ring/Features/Conversations/Conversation/Cells/MessageCell.swift +++ b/Ring/Ring/Features/Conversations/Conversation/Cells/MessageCell.swift @@ -1,5 +1,5 @@ /* - * Copyright (C) 2017-2019 Savoir-faire Linux Inc. + * Copyright (C) 2017-2020 Savoir-faire Linux Inc. * * Author: Silbino Gonçalves Matado <silbino.gmatado@savoirfairelinux.com> * Author: Andreas Traczyk <andreas.traczyk@savoirfairelinux.com> @@ -84,7 +84,7 @@ class MessageCell: UITableViewCell, NibReusable, PlayerDelegate { private var previousBubbleConstraint: CGFloat? private var longGestureRecognizer: UILongPressGestureRecognizer? - private var tapGestureRecognizer: UITapGestureRecognizer? + var tapGestureRecognizer: UITapGestureRecognizer? // MARK: PrepareForReuse @@ -194,7 +194,7 @@ class MessageCell: UITableViewCell, NibReusable, PlayerDelegate { // MARK: Configure - private func configureTapGesture() { + func configureTapGesture() { let shownByDefault = !self.timeLabel.isHidden && !showTimeTap.value if !shownByDefault { self.bubble.isUserInteractionEnabled = true @@ -220,7 +220,7 @@ class MessageCell: UITableViewCell, NibReusable, PlayerDelegate { } } - private func onTapGesture() { + func onTapGesture() { self.prepareForTapGesture() if self.timeLabel.isHidden { @@ -233,9 +233,9 @@ class MessageCell: UITableViewCell, NibReusable, PlayerDelegate { self.showTimeTap.accept(true) } - private func configureLongGesture(_ messageId: Int64, _ bubblePosition: BubblePosition, _ isTransfer: Bool) { + private func configureLongGesture(_ messageId: Int64, _ bubblePosition: BubblePosition, _ isTransfer: Bool, _ isLocationSharingBubble: Bool) { self.messageId = messageId - self.isCopyable = bubblePosition != .generated && !isTransfer + self.isCopyable = bubblePosition != .generated && !isTransfer && !isLocationSharingBubble self.bubble.isUserInteractionEnabled = true longGestureRecognizer = UILongPressGestureRecognizer() @@ -412,7 +412,7 @@ class MessageCell: UITableViewCell, NibReusable, PlayerDelegate { self.configureCellTimeLabel(item) self.prepareForReuseLongGesture() - self.configureLongGesture(item.message.messageId, item.bubblePosition(), item.isTransfer) + self.configureLongGesture(item.message.messageId, item.bubblePosition(), item.isTransfer, item.isLocationSharingBubble) self.prepareForReuseTapGesture() self.configureTapGesture() @@ -432,6 +432,13 @@ class MessageCell: UITableViewCell, NibReusable, PlayerDelegate { return case .sent: + guard !item.isLocationSharingBubble else { + self.setCellTimeLabelVisibility(hide: false) + self.bubbleTopConstraint.constant = 32 + self.bubbleBottomConstraint.constant = 1 + break + } + self.configureTransferCell(item, conversationViewModel) self.applyBubbleStyleToCell(items, cellForRowAt: indexPath) @@ -455,6 +462,14 @@ class MessageCell: UITableViewCell, NibReusable, PlayerDelegate { } case .received: + guard !item.isLocationSharingBubble else { + self.setCellTimeLabelVisibility(hide: false) + self.bubbleTopConstraint.constant = 32 + self.bubbleBottomConstraint.constant = 1 + self.configureReceivedMessageAvatar(item.sequencing, conversationViewModel) + break + } + self.configureTransferCell(item, conversationViewModel) self.applyBubbleStyleToCell(items, cellForRowAt: indexPath) diff --git a/Ring/Ring/Features/Conversations/Conversation/Cells/MessageCellLocationSharing.swift b/Ring/Ring/Features/Conversations/Conversation/Cells/MessageCellLocationSharing.swift new file mode 100644 index 000000000..a361cfa96 --- /dev/null +++ b/Ring/Ring/Features/Conversations/Conversation/Cells/MessageCellLocationSharing.swift @@ -0,0 +1,398 @@ +/* + * Copyright (C) 2020 Savoir-faire Linux Inc. + * + * Author: Raphaël Brulé <raphael.brule@savoirfairelinux.com> + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + */ + +import UIKit +import Reusable +import RxCocoa + +class MessageCellLocationSharing: MessageCell { + + private static let osmCopyrightAndLicenseURL = "https://www.openstreetmap.org/copyright" + private static let remoteTileSourceBaseUrl = MessageCellLocationSharing.getBaseURL() + + @IBOutlet weak var bubbleHeight: NSLayoutConstraint! + + var xButton: UIButton? + var myPositionButton: UIButton? + + let locationTapped = BehaviorRelay<(Bool, Bool)>(value: (false, false)) // (shouldAnimate, expanding) + + var maplyViewController: MaplyBaseViewController? // protected in Swift? + /// The usage of this variable allows for the view to not be refreshed on reuse (e.g. when scrolling) + private var preventUnnecessaryReuseCounter = 0 + + override func willRemoveSubview(_ subview: UIView) { + super.willRemoveSubview(subview) + self.preventUnnecessaryReuseCounter = 0 + } + + override func configureFromItem(_ conversationViewModel: ConversationViewModel, _ items: [MessageViewModel]?, cellForRowAt indexPath: IndexPath) { + super.configureFromItem(conversationViewModel, items, cellForRowAt: indexPath) + + self.shrink() + + if self.maplyViewController as? MaplyViewController == nil || preventUnnecessaryReuseCounter < 2 { + self.setupMaply() + self.displayMapTile() + + self.configureTapGesture() + self.setupOSMCopyrightButton() + preventUnnecessaryReuseCounter += 1 + } + } + + override func configureTapGesture() { + self.bubble.isUserInteractionEnabled = true + self.tapGestureRecognizer = UITapGestureRecognizer() + self.tapGestureRecognizer!.rx.event.bind(onNext: { [weak self] _ in self?.onTapGesture() }).disposed(by: self.disposeBag) + self.bubble.addGestureRecognizer(tapGestureRecognizer!) + } + + override func onTapGesture() { + if !locationTapped.value.1 { + self.expandOrShrink() + } + } + + private func removeTapDefaultGestureFromMaply() { + if let whirlyKitEAGLView = (self.maplyViewController as? MaplyViewController)?.view.subviews[0], + let gesture = whirlyKitEAGLView.gestureRecognizers?.first(where: { (gesture) -> Bool in gesture is UITapGestureRecognizer }) { + whirlyKitEAGLView.removeGestureRecognizer(gesture) + } + } + + private func setupMaply() { + self.maplyViewController?.view.removeFromSuperview() + + self.maplyViewController = MaplyViewController(mapType: .typeFlat) + self.removeTapDefaultGestureFromMaply() + + self.bubble.addSubview(self.maplyViewController!.view) + self.maplyViewController!.view.frame = self.bubble.bounds + } + + private func displayMapTile() { + self.maplyViewController!.clearColor = UIColor.white + + // thirty fps if we can get it + self.maplyViewController!.frameInterval = 2 + + if let layer = MessageCellLocationSharing.getMaplyLayer() { + layer.handleEdges = false + layer.coverPoles = false + layer.requireElev = false + layer.waitLoad = false + layer.drawPriority = 0 + layer.singleLevelLoading = false + self.maplyViewController!.add(layer) + } else { + self.log.error("[MessageCellLocationSharing] Could not get the layer") + } + + if let mapViewC = self.maplyViewController as? MaplyViewController { + self.toggleMaplyGesture(false) + mapViewC.height = 0.0001 + } + } + + private func toggleMaplyGesture(_ value: Bool) { + if let mapViewC = self.maplyViewController as? MaplyViewController { + mapViewC.panGesture = value + mapViewC.pinchGesture = value + mapViewC.rotateGesture = value + mapViewC.twoFingerTapGesture = value + mapViewC.doubleTapDragGesture = value + mapViewC.doubleTapZoomGesture = value + } + } + + private static func getMaplyLayer() -> MaplyQuadImageTilesLayer? { + let layer: MaplyQuadImageTilesLayer + + // Because this is a remote tile set, we'll want a cache directory + let baseCacheDir = NSSearchPathForDirectoriesInDomains(.cachesDirectory, .userDomainMask, true)[0] + let tilesCacheDir = "\(baseCacheDir)/openstreetmap/" + let maxZoom = Int32(19) + + guard let tileSource = + MaplyRemoteTileSource(baseURL: MessageCellLocationSharing.remoteTileSourceBaseUrl, + ext: "png", + minZoom: 0, + maxZoom: maxZoom) else { return nil } + tileSource.cacheDir = tilesCacheDir + layer = MaplyQuadImageTilesLayer(tileSource: tileSource)! + + return layer + } + + private static func getBaseURL() -> String { + // OpenStreetMap Tiles, © OpenStreetMap contributors + let urls = ["https://a.tile.openstreetmap.org/", + "https://b.tile.openstreetmap.org/", + "https://c.tile.openstreetmap.org/"] + let rngIndex = Int.random(in: 0 ..< 3) + + return urls[rngIndex] + } +} + +// For children +extension MessageCellLocationSharing { + func updateLocationAndMarker(location: CLLocationCoordinate2D, + imageData: Data?, + username: String?, + marker: MaplyScreenMarker, + markerDump: MaplyComponentObject?) -> MaplyComponentObject? { + // only the first time + if markerDump != nil { + if let imageData = imageData, let circledImage = UIImage(data: imageData)?.circleMasked { + marker.image = circledImage + } else { + marker.image = AvatarView(profileImageData: nil, username: username ?? "", size: 24).convertToImage() + } + marker.size = CGSize(width: 24, height: 24) + } + + let maplyCoordonate = MaplyCoordinateMakeWithDegrees(Float(location.longitude), Float(location.latitude)) + + marker.loc.x = maplyCoordonate.x + marker.loc.y = maplyCoordonate.y + + var dumpToReturn: MaplyComponentObject? + + if let mapViewC = self.maplyViewController as? MaplyViewController { + if markerDump != nil { + self.maplyViewController!.remove(markerDump!) + } + dumpToReturn = self.maplyViewController!.addScreenMarkers([marker], desc: nil) + + if !locationTapped.value.1 { + mapViewC.animate(toPosition: maplyCoordonate, time: 0.1) + } + } + return dumpToReturn + } +} + +// For OSM Copyrights +extension MessageCellLocationSharing { + private func setupOSMCopyrightButton() { + let infoButton = UIButton(type: .detailDisclosure) + infoButton.backgroundColor = UIColor.init(white: 0.75, alpha: 0.25) + infoButton.cornerRadius = infoButton.frame.height / 2.0 + self.bubble.addSubview(infoButton) + + infoButton.translatesAutoresizingMaskIntoConstraints = false + let constraintX = NSLayoutConstraint(item: infoButton, + attribute: NSLayoutConstraint.Attribute.centerX, + relatedBy: NSLayoutConstraint.Relation.equal, + toItem: self.bubble, + attribute: NSLayoutConstraint.Attribute.right, + multiplier: 1, + constant: -28) + let constraintY = NSLayoutConstraint(item: infoButton, + attribute: NSLayoutConstraint.Attribute.centerY, + relatedBy: NSLayoutConstraint.Relation.equal, + toItem: self.bubble, + attribute: NSLayoutConstraint.Attribute.bottom, + multiplier: 1, + constant: -28) + NSLayoutConstraint.activate([constraintX, constraintY]) + infoButton.addTarget(self, action: #selector(buttonAction), for: .touchUpInside) + } + + @objc func buttonAction(sender: UIButton!) { + let alert = UIAlertController.init(title: L10n.Alerts.mapInformation, + message: L10n.Alerts.openStreetMapCopyright, + preferredStyle: .alert) + alert.addAction(.init(title: L10n.Alerts.openStreetMapCopyrightMoreInfo, style: UIAlertAction.Style.default, handler: { (_) in + if let url = URL(string: MessageCellLocationSharing.osmCopyrightAndLicenseURL), UIApplication.shared.canOpenURL(url) { + UIApplication.shared.open(url, completionHandler: nil) + } + })) + alert.addAction(.init(title: L10n.Global.ok, style: UIAlertAction.Style.cancel)) + + self.window?.rootViewController?.present(alert, animated: true, completion: nil) + } +} + +// For bigger map +extension MessageCellLocationSharing { + private func shrink() { + if self.bubbleHeight.constant > 220 { + self.expandOrShrink() + } + } + + private func expandOrShrink() { + let shouldExpand = !self.locationTapped.value.1 + + self.updateHeight(shouldExpand) + //self.updateWidth(shouldExpand) now in controller, for animation + self.toggleMaplyGesture(shouldExpand) + + if shouldExpand { + self.setupXButton() + self.setupMyPositionButton() + } else { + self.removeXButton() + self.removeMyPositionButton() + } + + self.locationTapped.accept((true, shouldExpand)) + } + + private func updateHeight(_ shouldExpand: Bool, extendedHeight: CGFloat = 350) { + let normalHeight: CGFloat = 220 + if shouldExpand { + self.bubbleHeight.constant = extendedHeight + } else { + self.bubbleHeight.constant = normalHeight + } + } + + @objc func updateWidth(_ shouldExpand: Bool) { + fatalError("Must override this function") + } + + func expandHeight(_ shouldExpand: Bool, _ height: CGFloat) { + if shouldExpand { + let percentage: CGFloat = self.hasTopNotch() ? 0.88 : 0.91 + + self.updateHeight(shouldExpand, + extendedHeight: (height * percentage) - self.bubbleTopConstraint.constant) + } + } + + func hasTopNotch() -> Bool { + if #available(iOS 13.0, *) { + return UIApplication.shared.windows.filter {$0.isKeyWindow}.first?.safeAreaInsets.top ?? 0 > 20 + } else { + return UIApplication.shared.delegate?.window??.safeAreaInsets.top ?? 0 > 20 + } + } +} + +extension MessageCellLocationSharing { + private func setupXButton() { + self.xButton = UIButton() + let xButton = self.xButton! + + xButton.setBackgroundImage(UIImage(asset: Asset.closeIcon)!, for: UIControl.State.normal) + xButton.backgroundColor = UIColor.init(white: 0.25, alpha: 0.50) + xButton.cornerRadius = 5 + self.bubble.addSubview(xButton) + + xButton.translatesAutoresizingMaskIntoConstraints = false + let constraintX = NSLayoutConstraint(item: xButton, + attribute: NSLayoutConstraint.Attribute.centerX, + relatedBy: NSLayoutConstraint.Relation.equal, + toItem: self.bubble, + attribute: NSLayoutConstraint.Attribute.left, + multiplier: 1, + constant: 28) + let constraintY = NSLayoutConstraint(item: xButton, + attribute: NSLayoutConstraint.Attribute.centerY, + relatedBy: NSLayoutConstraint.Relation.equal, + toItem: self.bubble, + attribute: NSLayoutConstraint.Attribute.top, + multiplier: 1, + constant: 28) + let height = NSLayoutConstraint(item: xButton, + attribute: NSLayoutConstraint.Attribute.height, + relatedBy: NSLayoutConstraint.Relation.equal, + toItem: nil, + attribute: NSLayoutConstraint.Attribute.notAnAttribute, + multiplier: 1, + constant: 32) + let width = NSLayoutConstraint(item: xButton, + attribute: NSLayoutConstraint.Attribute.width, + relatedBy: NSLayoutConstraint.Relation.equal, + toItem: nil, + attribute: NSLayoutConstraint.Attribute.notAnAttribute, + multiplier: 1, + constant: 32) + NSLayoutConstraint.activate([constraintX, constraintY, height, width]) + xButton.addTarget(self, action: #selector(XButtonAction), for: .touchUpInside) + } + + private func removeXButton() { + self.xButton?.removeFromSuperview() + self.xButton = nil + } + + @objc func XButtonAction(sender: UIButton!) { + self.expandOrShrink() + } +} + +extension MessageCellLocationSharing { + private func setupMyPositionButton() { + if self as? MessageCellLocationSharingSent != nil { + self.myPositionButton = UIButton() + let myLocation = self.myPositionButton! + myLocation.setImage(UIImage(asset: Asset.myLocation)!, for: .normal) + myLocation.backgroundColor = UIColor.init(white: 0.25, alpha: 0.50) + myLocation.cornerRadius = 5 + self.bubble.addSubview(myLocation) + + myLocation.translatesAutoresizingMaskIntoConstraints = false + let constraintX = NSLayoutConstraint(item: myLocation, + attribute: NSLayoutConstraint.Attribute.centerX, + relatedBy: NSLayoutConstraint.Relation.equal, + toItem: self.bubble, + attribute: NSLayoutConstraint.Attribute.right, + multiplier: 1, + constant: -28) + let constraintY = NSLayoutConstraint(item: myLocation, + attribute: NSLayoutConstraint.Attribute.centerY, + relatedBy: NSLayoutConstraint.Relation.equal, + toItem: self.bubble, + attribute: NSLayoutConstraint.Attribute.bottom, + multiplier: 1, + constant: -70) + let height = NSLayoutConstraint(item: myLocation, + attribute: NSLayoutConstraint.Attribute.height, + relatedBy: NSLayoutConstraint.Relation.equal, + toItem: nil, + attribute: NSLayoutConstraint.Attribute.notAnAttribute, + multiplier: 1, + constant: 32) + let width = NSLayoutConstraint(item: myLocation, + attribute: NSLayoutConstraint.Attribute.width, + relatedBy: NSLayoutConstraint.Relation.equal, + toItem: nil, + attribute: NSLayoutConstraint.Attribute.notAnAttribute, + multiplier: 1, + constant: 32) + NSLayoutConstraint.activate([constraintX, constraintY, height, width]) + myLocation.addTarget(self, action: #selector(myPositionButtonAction), for: .touchUpInside) + } + } + + private func removeMyPositionButton() { + self.myPositionButton?.removeFromSuperview() + self.myPositionButton = nil + } + + @objc func myPositionButtonAction(sender: UIButton!) { + fatalError("Must override this function") + } +} diff --git a/Ring/Ring/Features/Conversations/Conversation/Cells/MessageCellLocationSharingReceived.swift b/Ring/Ring/Features/Conversations/Conversation/Cells/MessageCellLocationSharingReceived.swift new file mode 100644 index 000000000..b6abef26c --- /dev/null +++ b/Ring/Ring/Features/Conversations/Conversation/Cells/MessageCellLocationSharingReceived.swift @@ -0,0 +1,59 @@ +/* + * Copyright (C) 2020 Savoir-faire Linux Inc. + * + * Author: Raphaël Brulé <raphael.brule@savoirfairelinux.com> + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + */ + +import UIKit +import Reusable + +class MessageCellLocationSharingReceived: MessageCellLocationSharing { + + private var myContactsLocationMarker = MaplyScreenMarker() + private var markerComponentObject: MaplyComponentObject? + + @IBOutlet weak var receivedBubbleLeading: NSLayoutConstraint! + @IBOutlet weak var receivedBubbleTrailling: NSLayoutConstraint! + + override func configureFromItem(_ conversationViewModel: ConversationViewModel, _ items: [MessageViewModel]?, cellForRowAt indexPath: IndexPath) { + super.configureFromItem(conversationViewModel, items, cellForRowAt: indexPath) + + conversationViewModel.myContactsLocation + .subscribe(onNext: { [weak self, weak conversationViewModel] location in + guard let self = self, let location = location else { return } + + self.markerComponentObject = self.updateLocationAndMarker(location: location, + imageData: conversationViewModel?.profileImageData.value, + username: conversationViewModel?.userName.value, + marker: self.myContactsLocationMarker, + markerDump: self.markerComponentObject) + }) + .disposed(by: self.disposeBag) + } + + override func updateWidth(_ shouldExpand: Bool) { + let normalValue: CGFloat = 116 + let extendedValue: CGFloat = 16 + if shouldExpand { + self.receivedBubbleTrailling.constant = extendedValue + self.receivedBubbleLeading.constant = extendedValue + } else { + self.receivedBubbleTrailling.constant = normalValue + self.receivedBubbleLeading.constant = 64 + } + } +} diff --git a/Ring/Ring/Features/Conversations/Conversation/Cells/MessageCellLocationSharingReceived.xib b/Ring/Ring/Features/Conversations/Conversation/Cells/MessageCellLocationSharingReceived.xib new file mode 100644 index 000000000..5e8a8acb9 --- /dev/null +++ b/Ring/Ring/Features/Conversations/Conversation/Cells/MessageCellLocationSharingReceived.xib @@ -0,0 +1,95 @@ +<?xml version="1.0" encoding="UTF-8"?> +<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="16097.2" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" colorMatched="YES"> + <device id="retina4_7" orientation="portrait" appearance="light"/> + <dependencies> + <deployment identifier="iOS"/> + <plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="16087"/> + <capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/> + </dependencies> + <objects> + <placeholder placeholderIdentifier="IBFilesOwner" id="-1" userLabel="File's Owner"/> + <placeholder placeholderIdentifier="IBFirstResponder" id="-2" customClass="UIResponder"/> + <tableViewCell contentMode="scaleToFill" selectionStyle="none" indentationWidth="10" rowHeight="253" id="3QB-g7-MaS" userLabel="Message Cell Location Sharing Received" customClass="MessageCellLocationSharingReceived" customModule="Ring" customModuleProvider="target"> + <rect key="frame" x="0.0" y="0.0" width="510" height="240"/> + <autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMaxY="YES"/> + <tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" ambiguous="YES" tableViewCell="3QB-g7-MaS" id="Dkz-SA-3Af"> + <rect key="frame" x="0.0" y="0.0" width="510" height="240"/> + <autoresizingMask key="autoresizingMask"/> + <subviews> + <view clipsSubviews="YES" contentMode="scaleToFill" verticalHuggingPriority="251" translatesAutoresizingMaskIntoConstraints="NO" id="xVQ-Jk-Sxy" customClass="MessageBubble" customModule="Ring" customModuleProvider="target"> + <rect key="frame" x="64" y="8" width="330" height="220"/> + <color key="backgroundColor" systemColor="systemGray4Color" red="0.81960784310000001" green="0.81960784310000001" blue="0.83921568629999999" alpha="1" colorSpace="custom" customColorSpace="sRGB"/> + <constraints> + <constraint firstAttribute="height" constant="220" id="hS0-Te-OEU"/> + </constraints> + <userDefinedRuntimeAttributes> + <userDefinedRuntimeAttribute type="number" keyPath="cornerRadius"> + <integer key="value" value="15"/> + </userDefinedRuntimeAttribute> + </userDefinedRuntimeAttributes> + </view> + <label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="11/14/2016 12:34PM" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="4OM-U1-teG" userLabel="Message Time"> + <rect key="frame" x="178" y="9" width="154" height="20.5"/> + <color key="backgroundColor" white="0.0" alpha="0.0" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/> + <fontDescription key="fontDescription" type="system" pointSize="17"/> + <nil key="textColor"/> + <nil key="highlightedColor"/> + </label> + <view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="Wm5-ce-Sf6" userLabel="Left Divider"> + <rect key="frame" x="31" y="19" width="131" height="1"/> + <color key="backgroundColor" red="0.94117647059999998" green="0.94117647059999998" blue="0.94117647059999998" alpha="1" colorSpace="custom" customColorSpace="sRGB"/> + <constraints> + <constraint firstAttribute="height" constant="1" id="dEi-Ni-etd"/> + </constraints> + </view> + <view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="WgT-u7-Mgl" userLabel="Right Divider"> + <rect key="frame" x="348" y="19" width="131" height="1"/> + <color key="backgroundColor" red="0.94117647059999998" green="0.94117647059999998" blue="0.94117647059999998" alpha="1" colorSpace="custom" customColorSpace="sRGB"/> + <constraints> + <constraint firstAttribute="height" constant="1" id="9kZ-1u-mwB"/> + </constraints> + </view> + <view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="aRL-o9-tZc" userLabel="Avatar View"> + <rect key="frame" x="16" y="194" width="32" height="32"/> + <color key="backgroundColor" white="0.0" alpha="0.0" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/> + <constraints> + <constraint firstAttribute="height" constant="32" id="JKg-Rh-evV"/> + <constraint firstAttribute="width" constant="32" id="NQC-fC-Mw5"/> + </constraints> + </view> + </subviews> + <color key="tintColor" name="controlColor" catalog="System" colorSpace="catalog"/> + <constraints> + <constraint firstAttribute="trailingMargin" secondItem="WgT-u7-Mgl" secondAttribute="trailing" constant="16" id="6Dc-L8-sJC"/> + <constraint firstItem="WgT-u7-Mgl" firstAttribute="centerY" secondItem="4OM-U1-teG" secondAttribute="centerY" id="ALc-pa-7ZW"/> + <constraint firstItem="4OM-U1-teG" firstAttribute="top" secondItem="Dkz-SA-3Af" secondAttribute="topMargin" constant="-2" id="CbK-m1-TUR"/> + <constraint firstItem="aRL-o9-tZc" firstAttribute="trailing" secondItem="xVQ-Jk-Sxy" secondAttribute="leading" constant="-16" id="DIQ-qi-aUb"/> + <constraint firstItem="Wm5-ce-Sf6" firstAttribute="leading" secondItem="Dkz-SA-3Af" secondAttribute="leadingMargin" constant="16" id="Faa-N7-gPP"/> + <constraint firstItem="WgT-u7-Mgl" firstAttribute="leading" secondItem="4OM-U1-teG" secondAttribute="trailing" constant="16" id="Foe-Zm-1oU"/> + <constraint firstItem="Wm5-ce-Sf6" firstAttribute="centerY" secondItem="4OM-U1-teG" secondAttribute="centerY" id="Q4u-AX-3D6"/> + <constraint firstAttribute="bottom" secondItem="xVQ-Jk-Sxy" secondAttribute="bottom" constant="8" id="Qbn-zO-KWj"/> + <constraint firstItem="xVQ-Jk-Sxy" firstAttribute="top" secondItem="Dkz-SA-3Af" secondAttribute="top" constant="8" id="R6Q-PY-A3m"/> + <constraint firstItem="aRL-o9-tZc" firstAttribute="bottom" secondItem="xVQ-Jk-Sxy" secondAttribute="bottom" constant="-2" id="aM1-qu-Iys"/> + <constraint firstItem="Wm5-ce-Sf6" firstAttribute="trailing" secondItem="4OM-U1-teG" secondAttribute="leading" constant="-16" id="dlX-Gh-ImE"/> + <constraint firstItem="xVQ-Jk-Sxy" firstAttribute="leading" secondItem="Dkz-SA-3Af" secondAttribute="leading" constant="64" id="qxE-d6-psE"/> + <constraint firstAttribute="trailing" secondItem="xVQ-Jk-Sxy" secondAttribute="trailing" constant="116" id="uTe-7R-qUz"/> + <constraint firstItem="4OM-U1-teG" firstAttribute="centerX" secondItem="Dkz-SA-3Af" secondAttribute="centerX" id="zxy-XJ-QAl"/> + </constraints> + </tableViewCellContentView> + <connections> + <outlet property="avatarView" destination="aRL-o9-tZc" id="ROC-7p-3of"/> + <outlet property="bubble" destination="xVQ-Jk-Sxy" id="dRd-NH-FPh"/> + <outlet property="bubbleBottomConstraint" destination="Qbn-zO-KWj" id="hKY-TA-wId"/> + <outlet property="bubbleHeight" destination="hS0-Te-OEU" id="G9h-6Z-A7m"/> + <outlet property="bubbleTopConstraint" destination="R6Q-PY-A3m" id="IQA-QC-eV0"/> + <outlet property="leftDivider" destination="Wm5-ce-Sf6" id="EaQ-1G-8Db"/> + <outlet property="messageLabelMarginConstraint" destination="CbK-m1-TUR" id="8qD-tP-8QW"/> + <outlet property="receivedBubbleLeading" destination="qxE-d6-psE" id="aaK-f3-9Nx"/> + <outlet property="receivedBubbleTrailling" destination="uTe-7R-qUz" id="RXi-mN-T16"/> + <outlet property="rightDivider" destination="WgT-u7-Mgl" id="k10-3V-ZLw"/> + <outlet property="timeLabel" destination="4OM-U1-teG" id="ub4-Z8-CsM"/> + </connections> + <point key="canvasLocation" x="-411.19999999999999" y="-45.877061469265371"/> + </tableViewCell> + </objects> +</document> diff --git a/Ring/Ring/Features/Conversations/Conversation/Cells/MessageCellLocationSharingSent.swift b/Ring/Ring/Features/Conversations/Conversation/Cells/MessageCellLocationSharingSent.swift new file mode 100644 index 000000000..78a3777ce --- /dev/null +++ b/Ring/Ring/Features/Conversations/Conversation/Cells/MessageCellLocationSharingSent.swift @@ -0,0 +1,77 @@ +/* + * Copyright (C) 2020 Savoir-faire Linux Inc. + * + * Author: Raphaël Brulé <raphael.brule@savoirfairelinux.com> + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + */ + +import UIKit +import Reusable +import RxCocoa + +class MessageCellLocationSharingSent: MessageCellLocationSharing { + + private var myLocationMarker = MaplyScreenMarker() + private var markerComponentObject: MaplyComponentObject? + + @IBOutlet weak var sentBubbleLeading: NSLayoutConstraint! + + @IBOutlet weak var stopSharingButton: UIButton! + @IBAction func stopSharingButton(_ sender: Any) { + self.delete(sender) + } + + override func configureFromItem(_ conversationViewModel: ConversationViewModel, _ items: [MessageViewModel]?, cellForRowAt indexPath: IndexPath) { + super.configureFromItem(conversationViewModel, items, cellForRowAt: indexPath) + + conversationViewModel.myLocation + .subscribe(onNext: { [weak self, weak conversationViewModel] location in + guard let self = self, let location = location?.coordinate else { return } + + self.markerComponentObject = self.updateLocationAndMarker(location: location, + imageData: conversationViewModel?.myOwnProfileImageData, + username: conversationViewModel?.userName.value, + marker: self.myLocationMarker, + markerDump: self.markerComponentObject) + }) + .disposed(by: self.disposeBag) + + self.setupStopSharingButton() + } + + private func setupStopSharingButton() { + self.stopSharingButton.setTitle(L10n.Actions.stopLocationSharing, for: .normal) + self.stopSharingButton.backgroundColor = UIColor.red + self.stopSharingButton.setTitleColor(UIColor.white, for: .normal) + self.bubble.addSubview(stopSharingButton) + } + + override func myPositionButtonAction(sender: UIButton!) { + if let mapViewC = self.maplyViewController as? MaplyViewController { + mapViewC.animate(toPosition: self.myLocationMarker.loc, time: 0.5) + } + } + + override func updateWidth(_ shouldExpand: Bool) { + let normalValue: CGFloat = 164 + let extendedValue: CGFloat = 16 + if shouldExpand { + self.sentBubbleLeading.constant = extendedValue + } else { + self.sentBubbleLeading.constant = normalValue + } + } +} diff --git a/Ring/Ring/Features/Conversations/Conversation/Cells/MessageCellLocationSharingSent.xib b/Ring/Ring/Features/Conversations/Conversation/Cells/MessageCellLocationSharingSent.xib new file mode 100644 index 000000000..d9d4da3ff --- /dev/null +++ b/Ring/Ring/Features/Conversations/Conversation/Cells/MessageCellLocationSharingSent.xib @@ -0,0 +1,103 @@ +<?xml version="1.0" encoding="UTF-8"?> +<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="16097.2" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" colorMatched="YES"> + <device id="retina4_7" orientation="portrait" appearance="light"/> + <dependencies> + <deployment identifier="iOS"/> + <plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="16087"/> + <capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/> + </dependencies> + <objects> + <placeholder placeholderIdentifier="IBFilesOwner" id="-1" userLabel="File's Owner"/> + <placeholder placeholderIdentifier="IBFirstResponder" id="-2" customClass="UIResponder"/> + <tableViewCell contentMode="scaleToFill" selectionStyle="none" indentationWidth="10" rowHeight="253" id="3QB-g7-MaS" userLabel="Message Cell Location Sharing Sent" customClass="MessageCellLocationSharingSent" customModule="Ring" customModuleProvider="target"> + <rect key="frame" x="0.0" y="0.0" width="510" height="240"/> + <autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMaxY="YES"/> + <tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" ambiguous="YES" tableViewCell="3QB-g7-MaS" id="Dkz-SA-3Af"> + <rect key="frame" x="0.0" y="0.0" width="510" height="240"/> + <autoresizingMask key="autoresizingMask"/> + <subviews> + <view clipsSubviews="YES" contentMode="scaleToFill" verticalHuggingPriority="251" translatesAutoresizingMaskIntoConstraints="NO" id="xVQ-Jk-Sxy" customClass="MessageBubble" customModule="Ring" customModuleProvider="target"> + <rect key="frame" x="164" y="8" width="330" height="220"/> + <subviews> + <button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" buttonType="roundedRect" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="PYf-Lj-ehe"> + <rect key="frame" x="134" y="175" width="62" height="34"/> + <inset key="contentEdgeInsets" minX="8" minY="8" maxX="8" maxY="8"/> + <state key="normal" title="Button"> + <color key="titleColor" systemColor="systemRedColor" red="1" green="0.23137254900000001" blue="0.18823529410000001" alpha="1" colorSpace="custom" customColorSpace="sRGB"/> + </state> + <userDefinedRuntimeAttributes> + <userDefinedRuntimeAttribute type="number" keyPath="cornerRadius"> + <real key="value" value="15"/> + </userDefinedRuntimeAttribute> + </userDefinedRuntimeAttributes> + <connections> + <action selector="stopSharingButton:" destination="3QB-g7-MaS" eventType="touchUpInside" id="9px-PV-vvz"/> + </connections> + </button> + </subviews> + <color key="backgroundColor" systemColor="systemGray4Color" red="0.81960784310000001" green="0.81960784310000001" blue="0.83921568629999999" alpha="1" colorSpace="custom" customColorSpace="sRGB"/> + <constraints> + <constraint firstItem="PYf-Lj-ehe" firstAttribute="centerY" secondItem="xVQ-Jk-Sxy" secondAttribute="bottom" constant="-28" id="SPy-c9-P2P"/> + <constraint firstItem="PYf-Lj-ehe" firstAttribute="centerX" secondItem="xVQ-Jk-Sxy" secondAttribute="centerX" id="hQD-IF-ddy"/> + <constraint firstAttribute="height" constant="220" id="hS0-Te-OEU"/> + </constraints> + <userDefinedRuntimeAttributes> + <userDefinedRuntimeAttribute type="number" keyPath="cornerRadius"> + <integer key="value" value="15"/> + </userDefinedRuntimeAttribute> + </userDefinedRuntimeAttributes> + </view> + <label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="11/14/2016 12:34PM" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="4OM-U1-teG" userLabel="Message Time"> + <rect key="frame" x="178" y="9" width="154" height="21"/> + <color key="backgroundColor" white="0.0" alpha="0.0" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/> + <fontDescription key="fontDescription" type="system" pointSize="17"/> + <nil key="textColor"/> + <nil key="highlightedColor"/> + </label> + <view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="Wm5-ce-Sf6" userLabel="Left Divider"> + <rect key="frame" x="31" y="19" width="131" height="1"/> + <color key="backgroundColor" red="0.94117647059999998" green="0.94117647059999998" blue="0.94117647059999998" alpha="1" colorSpace="custom" customColorSpace="sRGB"/> + <constraints> + <constraint firstAttribute="height" constant="1" id="dEi-Ni-etd"/> + </constraints> + </view> + <view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="WgT-u7-Mgl" userLabel="Right Divider"> + <rect key="frame" x="348" y="19" width="131" height="1"/> + <color key="backgroundColor" red="0.94117647059999998" green="0.94117647059999998" blue="0.94117647059999998" alpha="1" colorSpace="custom" customColorSpace="sRGB"/> + <constraints> + <constraint firstAttribute="height" constant="1" id="9kZ-1u-mwB"/> + </constraints> + </view> + </subviews> + <color key="tintColor" name="controlColor" catalog="System" colorSpace="catalog"/> + <constraints> + <constraint firstAttribute="trailingMargin" secondItem="WgT-u7-Mgl" secondAttribute="trailing" constant="16" id="6Dc-L8-sJC"/> + <constraint firstItem="WgT-u7-Mgl" firstAttribute="centerY" secondItem="4OM-U1-teG" secondAttribute="centerY" id="ALc-pa-7ZW"/> + <constraint firstItem="4OM-U1-teG" firstAttribute="top" secondItem="Dkz-SA-3Af" secondAttribute="topMargin" constant="-2" id="CbK-m1-TUR"/> + <constraint firstItem="Wm5-ce-Sf6" firstAttribute="leading" secondItem="Dkz-SA-3Af" secondAttribute="leadingMargin" constant="16" id="Faa-N7-gPP"/> + <constraint firstItem="WgT-u7-Mgl" firstAttribute="leading" secondItem="4OM-U1-teG" secondAttribute="trailing" constant="16" id="Foe-Zm-1oU"/> + <constraint firstItem="Wm5-ce-Sf6" firstAttribute="centerY" secondItem="4OM-U1-teG" secondAttribute="centerY" id="Q4u-AX-3D6"/> + <constraint firstAttribute="bottom" secondItem="xVQ-Jk-Sxy" secondAttribute="bottom" constant="8" id="Qbn-zO-KWj"/> + <constraint firstItem="xVQ-Jk-Sxy" firstAttribute="top" secondItem="Dkz-SA-3Af" secondAttribute="top" constant="8" id="R6Q-PY-A3m"/> + <constraint firstItem="Wm5-ce-Sf6" firstAttribute="trailing" secondItem="4OM-U1-teG" secondAttribute="leading" constant="-16" id="dlX-Gh-ImE"/> + <constraint firstItem="xVQ-Jk-Sxy" firstAttribute="leading" secondItem="Dkz-SA-3Af" secondAttribute="leading" constant="164" id="qxE-d6-psE"/> + <constraint firstAttribute="trailing" secondItem="xVQ-Jk-Sxy" secondAttribute="trailing" constant="16" id="uTe-7R-qUz"/> + <constraint firstItem="4OM-U1-teG" firstAttribute="centerX" secondItem="Dkz-SA-3Af" secondAttribute="centerX" id="zxy-XJ-QAl"/> + </constraints> + </tableViewCellContentView> + <connections> + <outlet property="bubble" destination="xVQ-Jk-Sxy" id="dRd-NH-FPh"/> + <outlet property="bubbleBottomConstraint" destination="Qbn-zO-KWj" id="hKY-TA-wId"/> + <outlet property="bubbleHeight" destination="hS0-Te-OEU" id="oJg-9j-oa8"/> + <outlet property="bubbleTopConstraint" destination="R6Q-PY-A3m" id="IQA-QC-eV0"/> + <outlet property="leftDivider" destination="Wm5-ce-Sf6" id="EaQ-1G-8Db"/> + <outlet property="messageLabelMarginConstraint" destination="CbK-m1-TUR" id="8qD-tP-8QW"/> + <outlet property="rightDivider" destination="WgT-u7-Mgl" id="k10-3V-ZLw"/> + <outlet property="sentBubbleLeading" destination="qxE-d6-psE" id="wIa-XC-C9H"/> + <outlet property="stopSharingButton" destination="PYf-Lj-ehe" id="qXH-bz-cAH"/> + <outlet property="timeLabel" destination="4OM-U1-teG" id="ub4-Z8-CsM"/> + </connections> + <point key="canvasLocation" x="-411.19999999999999" y="-45.877061469265371"/> + </tableViewCell> + </objects> +</document> diff --git a/Ring/Ring/Features/Conversations/Conversation/ConversationViewController.swift b/Ring/Ring/Features/Conversations/Conversation/ConversationViewController.swift index fbd504194..15fe75bc1 100644 --- a/Ring/Ring/Features/Conversations/Conversation/ConversationViewController.swift +++ b/Ring/Ring/Features/Conversations/Conversation/ConversationViewController.swift @@ -1,5 +1,5 @@ /* - * Copyright (C) 2017-2019 Savoir-faire Linux Inc. + * Copyright (C) 2017-2020 Savoir-faire Linux Inc. * * Author: Silbino Gonçalves Matado <silbino.gmatado@savoirfairelinux.com> * Author: Quentin Muret <quentin.muret@savoirfairelinux.com> @@ -58,6 +58,8 @@ class ConversationViewController: UIViewController, var keyboardDismissTapRecognizer: UITapGestureRecognizer! + private lazy var locationManager: CLLocationManager = { return CLLocationManager() }() + func setIsComposing(isComposing: Bool) { self.viewModel.setIsComposingMsg(isComposing: isComposing) } @@ -166,7 +168,7 @@ class ConversationViewController: UIViewController, @objc func imageTapped() { let alert = UIAlertController.init(title: nil, message: nil, - preferredStyle: .alert) + preferredStyle: .actionSheet) let pictureAction = UIAlertAction(title: L10n.Alerts.uploadPhoto, style: UIAlertAction.Style.default) {[weak self] _ in self?.checkPhotoLibraryPermission() } @@ -224,10 +226,12 @@ class ConversationViewController: UIViewController, } let cancelAction = UIAlertAction(title: L10n.Alerts.profileCancelPhoto, style: UIAlertAction.Style.cancel) + alert.addAction(pictureAction) alert.addAction(recordVideoAction) alert.addAction(recordAudioAction) alert.addAction(documentsAction) + alert.addAction(locationSharingAction()) alert.addAction(cancelAction) alert.popoverPresentationController?.sourceView = self.view alert.popoverPresentationController?.permittedArrowDirections = UIPopoverArrowDirection() @@ -638,6 +642,8 @@ class ConversationViewController: UIViewController, self.tableView.register(cellType: MessageCellDataTransferSent.self) self.tableView.register(cellType: MessageCellDataTransferReceived.self) self.tableView.register(cellType: MessageCellGenerated.self) + self.tableView.register(cellType: MessageCellLocationSharingSent.self) + self.tableView.register(cellType: MessageCellLocationSharingReceived.self) //Bind the TableView to the ViewModel self.viewModel.messages.asObservable() @@ -667,7 +673,7 @@ class ConversationViewController: UIViewController, } fileprivate func scrollToBottomIfNeed() { - if self.isBottomContentOffset && !self.isExecutingDeleteMessage { + if (self.isBottomContentOffset || !self.tableView.isScrollEnabled) && !self.isExecutingDeleteMessage { self.scrollToBottom(animated: false) } if self.isExecutingDeleteMessage { @@ -679,6 +685,7 @@ class ConversationViewController: UIViewController, let numberOfRows = self.tableView.numberOfRows(inSection: 0) if numberOfRows > 0 { let last = IndexPath(row: numberOfRows - 1, section: 0) + self.tableView.isScrollEnabled = true self.tableView.scrollToRow(at: last, at: .bottom, animated: animated) } } @@ -908,6 +915,7 @@ class ConversationViewController: UIViewController, } } +// MARK: TableDataSource extension ConversationViewController: UITableViewDataSource { func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { return self.messageViewModels?.count ?? 0 @@ -923,20 +931,35 @@ extension ConversationViewController: UITableViewDataSource { messageId: item.message.messageId) } - let cellType = { (bubblePosition: BubblePosition, isTransfer: Bool) -> MessageCell.Type in + let cellType = { (bubblePosition: BubblePosition, isTransfer: Bool, isLocationSharing: Bool) -> MessageCell.Type in switch bubblePosition { - case .received: return isTransfer ? MessageCellDataTransferReceived.self : MessageCellReceived.self - case .sent: return isTransfer ? MessageCellDataTransferSent.self : MessageCellSent.self + case .received: + if isLocationSharing { + return MessageCellLocationSharingReceived.self + } else if isTransfer { + return MessageCellDataTransferReceived.self + } else { + return MessageCellReceived.self + } + case .sent: + if isLocationSharing { + return MessageCellLocationSharingSent.self + } else if isTransfer { + return MessageCellDataTransferSent.self + } else { + return MessageCellSent.self + } case .generated: return MessageCellGenerated.self } - }(item.bubblePosition(), item.isTransfer) + }(item.bubblePosition(), item.isTransfer, item.isLocationSharingBubble) let cell = tableView.dequeueReusableCell(for: indexPath, cellType: cellType) cell.configureFromItem(viewModel, self.messageViewModels, cellForRowAt: indexPath) - transferCellSetup(item, cell, tableView, indexPath) - deleteCellSetup(cell) - tapToShowTimeCellSetup(cell) + self.transferCellSetup(item, cell, tableView, indexPath) + self.locationCellSetup(item, cell) + self.deleteCellSetup(cell) + self.tapToShowTimeCellSetup(cell) return cell } @@ -948,6 +971,13 @@ extension ConversationViewController: UITableViewDataSource { .observeOn(MainScheduler.instance) .subscribe(onNext: { [weak self, weak cell] (shouldDelete) in guard shouldDelete, let self = self, let cell = cell, let messageId = cell.messageId else { return } + + if cell as? MessageCellLocationSharing != nil { + self.tableView.isScrollEnabled = true + if cell as? MessageCellLocationSharingSent != nil { + self.viewModel.stopSendingLocation() + } + } self.isExecutingDeleteMessage = true self.viewModel.deleteMessage(messageId: messageId) }) @@ -972,6 +1002,42 @@ extension ConversationViewController: UITableViewDataSource { .disposed(by: cell.disposeBag) } + private func locationCellSetup(_ item: MessageViewModel, _ cell: MessageCell) { + guard item.isLocationSharingBubble, let cell = cell as? MessageCellLocationSharing else { return } + + cell.locationTapped + .observeOn(MainScheduler.instance) + .subscribe(onNext: { [weak self, weak cell] (locationTapped) in + guard locationTapped.0, let self = self, let cell = cell else { return } + + let expanding = locationTapped.1 + + if let index = self.tableView.indexPath(for: cell) { + cell.expandHeight(expanding, + self.tableView.frame.height - self.tableView.contentInset.top - self.tableView.contentInset.bottom) + self.tableView.performBatchUpdates({ + self.tableView.updateConstraintsIfNeeded() + UIView.animate(withDuration: 0.4) { + cell.updateWidth(expanding) + cell.layoutIfNeeded() + } + }, completion: nil) + + if expanding { + self.tableView.scrollToRow(at: index, at: UITableView.ScrollPosition.top, animated: true) + } + self.tableView.isScrollEnabled = !expanding + + cell.locationTapped.accept((false, expanding)) + } else { + self.log.warning("[ConversationViewController] locationCellSetup, something went weird, let's retry") + self.tableView.isScrollEnabled = true + cell.locationTapped.accept((true, expanding)) // retry + } + }) + .disposed(by: cell.disposeBag) + } + // swiftlint:disable cyclomatic_complexity private func transferCellSetup(_ item: MessageViewModel, _ cell: MessageCell, _ tableView: UITableView, _ indexPath: IndexPath) { if item.isTransfer { @@ -1056,5 +1122,81 @@ extension ConversationViewController: UITableViewDataSource { } } } + +// MARK: Location sharing +extension ConversationViewController { + private func locationSharingAction() -> UIAlertAction { + return UIAlertAction(title: L10n.Alerts.locationSharing, style: .default) { [weak self] _ in + guard let self = self else { return } + + if self.canShareLocation() && self.isNotAlreadySharingWithThisContact() { + self.askLocationSharingDuration() + } + } + } + + private func askLocationSharingDuration() { + let alert = UIAlertController.init(title: L10n.Alerts.locationSharingDurationTitle, + message: nil, + preferredStyle: .alert) + + alert.addAction(.init(title: L10n.Alerts.locationSharingDuration10min, style: .default, handler: { [weak self] _ in + self?.viewModel.startSendingLocation(duration: 10 * 60) + })) + alert.addAction(.init(title: L10n.Alerts.locationSharingDuration1hour, style: .default, handler: { [weak self] _ in + self?.viewModel.startSendingLocation(duration: 60 * 60) + })) + alert.addAction(.init(title: L10n.Alerts.profileCancelPhoto, style: UIAlertAction.Style.cancel)) + + self.present(alert, animated: true, completion: nil) + } + + private func isNotAlreadySharingWithThisContact() -> Bool { + if self.viewModel.isAlreadySharingLocation() { + let alert = UIAlertController.init(title: L10n.Alerts.alreadylocationSharing, + message: nil, + preferredStyle: .alert) + alert.addAction(.init(title: L10n.Global.ok, style: UIAlertAction.Style.cancel)) + self.present(alert, animated: true, completion: nil) + + return false + } + return true + } + + private func canShareLocation() -> Bool { + if CLLocationManager.locationServicesEnabled() { + return checkLocationAuthorization() + } else { + self.showGoToSettingsAlert(title: L10n.Alerts.locationServiceIsDisabled) + return false + } + } + + private func showGoToSettingsAlert(title: String) { + let alertController = UIAlertController(title: title, message: nil, preferredStyle: .alert) + + alertController.addAction(UIAlertAction(title: L10n.Actions.goToSettings, style: .default, handler: { (_) in + if let url = URL(string: UIApplication.openSettingsURLString), UIApplication.shared.canOpenURL(url) { + UIApplication.shared.open(url, completionHandler: nil) + } + })) + + alertController.addAction(UIAlertAction(title: L10n.Actions.cancelAction, style: .cancel, handler: nil)) + + self.present(alertController, animated: true, completion: nil) + } + + private func checkLocationAuthorization() -> Bool { + switch CLLocationManager.authorizationStatus() { + case .notDetermined: locationManager.requestWhenInUseAuthorization() + case .restricted, .denied: self.showGoToSettingsAlert(title: L10n.Alerts.noLocationPermissionsTitle) + case .authorizedAlways, .authorizedWhenInUse: return true + @unknown default: break + } + + return false + } +} // swiftlint:enable type_body_length // swiftlint:enable file_length diff --git a/Ring/Ring/Features/Conversations/Conversation/ConversationViewModel.swift b/Ring/Ring/Features/Conversations/Conversation/ConversationViewModel.swift index 0181dbf48..ffd13c85e 100644 --- a/Ring/Ring/Features/Conversations/Conversation/ConversationViewModel.swift +++ b/Ring/Ring/Features/Conversations/Conversation/ConversationViewModel.swift @@ -1,5 +1,5 @@ /* - * Copyright (C) 2017-2019 Savoir-faire Linux Inc. + * Copyright (C) 2017-2020 Savoir-faire Linux Inc. * * Author: Silbino Gonçalves Matado <silbino.gmatado@savoirfairelinux.com> * Author: Kateryna Kostiuk <kateryna.kostiuk@savoirfairelinux.com> @@ -44,6 +44,7 @@ class ConversationViewModel: Stateable, ViewModel { private let profileService: ProfilesService private let dataTransferService: DataTransferService private let callService: CallsService + private let locationSharingService: LocationSharingService private let injectionBag: InjectionBag @@ -97,9 +98,39 @@ class ConversationViewModel: Stateable, ViewModel { self.profileService = injectionBag.profileService self.dataTransferService = injectionBag.dataTransferService self.callService = injectionBag.callService + self.locationSharingService = injectionBag.locationSharingService dateFormatter.dateStyle = .medium hourFormatter.dateFormat = "HH:mm" + + self.subscribeLocationReceivedEvent() + self.subscribeProfileServiceEvent() + } + + private func subscribeLocationReceivedEvent() { + self.locationSharingService + .peerUriAndLocationReceived + .subscribe(onNext: { [weak self] tuple in + guard let self = self, let peerUri = tuple.0, let coordinates = tuple.1, let conversation = self.conversation else { return } + if peerUri == conversation.value.participantUri { + self.myContactsLocation.onNext(coordinates) + } + }) + .disposed(by: self.disposeBag) + } + + private func subscribeProfileServiceEvent() { + guard let account = self.accountService.currentAccount else { return } + self.profileService + .getAccountProfile(accountId: account.id) + .subscribe(onNext: { [weak self] profile in + guard let self = self else { return } + if let photo = profile.photo, + let data = NSData(base64Encoded: photo, options: NSData.Base64DecodingOptions.ignoreUnknownCharacters) as Data? { + self.myOwnProfileImageData = data + } + }) + .disposed(by: self.disposeBag) } var conversation: Variable<ConversationModel>! { @@ -136,12 +167,9 @@ class ConversationViewModel: Stateable, ViewModel { }) .observeOn(MainScheduler.instance) .subscribe(onNext: { [weak self] messageViewModels in - guard let self = self else { - return - } + guard let self = self else { return } var msg = messageViewModels - if self - .peerComposingMessage { + if self.peerComposingMessage { let msgModel = MessageModel(withId: "", receivedDate: Date(), content: " ", @@ -283,8 +311,12 @@ class ConversationViewModel: Stateable, ViewModel { } }() + // My contact's var profileImageData = Variable<Data?>(nil) + // Mine + var myOwnProfileImageData: Data? + var inviteButtonIsAvailable = BehaviorSubject(value: true) var contactPresence = Variable<Bool>(false) @@ -655,4 +687,35 @@ class ConversationViewModel: Stateable, ViewModel { func isLastDisplayed(messageId: Int64) -> Bool { return messageId == self.conversation.value.lastDisplayedMessage.id } + + var myLocation: Observable<CLLocation?> { return self.locationSharingService.currentLocation.asObservable() } + + var myContactsLocation = BehaviorSubject<CLLocationCoordinate2D?>(value: nil) +} + +// MARK: Sharing my location +extension ConversationViewModel { + + func isAlreadySharingLocation() -> Bool { + guard let account = self.accountService.currentAccount else { return true } + return self.locationSharingService.isAlreadySharing(accountId: account.id, + contactUri: self.conversation.value.participantUri) + } + + func startSendingLocation(duration: TimeInterval) { + if self.conversation.value.messages.isEmpty { + self.sendContactRequest() + } + + guard let account = self.accountService.currentAccount else { return } + self.locationSharingService.startSharingLocation(from: account.id, + to: self.conversation.value.participantUri, + duration: duration) + } + + func stopSendingLocation() { + guard let account = self.accountService.currentAccount else { return } + self.locationSharingService.stopSharingLocation(accountId: account.id, + contactUri: self.conversation.value.participantUri) + } } diff --git a/Ring/Ring/Features/Conversations/Conversation/MessageViewModel.swift b/Ring/Ring/Features/Conversations/Conversation/MessageViewModel.swift index b9e2833b4..be60044c6 100644 --- a/Ring/Ring/Features/Conversations/Conversation/MessageViewModel.swift +++ b/Ring/Ring/Features/Conversations/Conversation/MessageViewModel.swift @@ -1,5 +1,5 @@ /* - * Copyright (C) 2017-2019 Savoir-faire Linux Inc. + * Copyright (C) 2017-2020 Savoir-faire Linux Inc. * * Author: Silbino Gonçalves Matado <silbino.gmatado@savoirfairelinux.com> * Author: Kateryna Kostiuk <kateryna.kostiuk@savoirfairelinux.com> @@ -66,6 +66,8 @@ class MessageViewModel { var sequencing: MessageSequencing = .unknown var isComposingIndicator: Bool = false + var isLocationSharingBubble: Bool { return self.message.isLocationSharing } + private let disposeBag = DisposeBag() let injectBug: InjectionBag diff --git a/Ring/Ring/Info.plist b/Ring/Ring/Info.plist index 6c6e947dc..4b8ef7557 100644 --- a/Ring/Ring/Info.plist +++ b/Ring/Ring/Info.plist @@ -33,6 +33,8 @@ </dict> <key>NSCameraUsageDescription</key> <string>Used to create avatar, record video and share photos with contacts</string> + <key>NSLocationWhenInUseUsageDescription</key> + <string>Used to share your location with your contacts</string> <key>NSMicrophoneUsageDescription</key> <string>The microphone will be used for communication during a call.</string> <key>NSPhotoLibraryAddUsageDescription</key> @@ -53,9 +55,10 @@ </dict> <key>UIBackgroundModes</key> <array> - <string>voip</string> <string>fetch</string> + <string>location</string> <string>remote-notification</string> + <string>voip</string> </array> <key>UIFileSharingEnabled</key> <true/> diff --git a/Ring/Ring/Models/MessageModel.swift b/Ring/Ring/Models/MessageModel.swift index b281fe2cc..187daf34b 100644 --- a/Ring/Ring/Models/MessageModel.swift +++ b/Ring/Ring/Models/MessageModel.swift @@ -30,6 +30,7 @@ class MessageModel { var isGenerated: Bool = false var isTransfer: Bool = false var incoming: Bool + var isLocationSharing: Bool = false init(withId id: String, receivedDate: Date, content: String, authorURI: String, incoming: Bool) { self.daemonId = id diff --git a/Ring/Ring/Resources/Images.xcassets/my_location.imageset/Contents.json b/Ring/Ring/Resources/Images.xcassets/my_location.imageset/Contents.json new file mode 100644 index 000000000..bc2011f27 --- /dev/null +++ b/Ring/Ring/Resources/Images.xcassets/my_location.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "my_location.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "my_location_2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "my_location_3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Ring/Ring/Resources/Images.xcassets/my_location.imageset/my_location.png b/Ring/Ring/Resources/Images.xcassets/my_location.imageset/my_location.png new file mode 100644 index 0000000000000000000000000000000000000000..c6904f2bd3f3af553f37970d0f3f78af2c4fc260 GIT binary patch literal 496 zcmV<M0T2F(P)<h;3K|Lk000e1NJLTq000;O000;W1^@s6;CDUv00009a7bBm000id z000id0mpBsWB>pF8FWQhbW?9;ba!ELWdL_~cP?peYja~^aAhuUa%Y?FJQ@H10ewkC zK~zYI&6Q0~0znXkYuM_*1!QFecM5w+M3;IRHzuez!LSuEu3Z@XS?CEEuONmYa)Hmq zR3{Z|dj<>%m2`Thy54&=)m=Rz>tv`cf+?*knL+|U1RMiBATj<#eMdmFlrpdjypV6t zKz&j27BE$UPoM+r0~<OTz=f^>q(Ezt%pG|jhy$@PaBD<rdE^oBYRGfzs~9Kp6vXI; zy0XN^Uj+uhOvk;gJ5UD3ifxAkdK%Q3BcFISHwCID?o$2TkU*kAd&W2LkUQ&}xQ6;h zKH_<^<(UNpzD-;_zbNwmPPW&EcXLZ3B9WSie5ilh#5@KB9?Y}DB!=(T7rg6yVtnT! zGWC#8MC8o8pX&VfXF6ne3;2&|ANbaB18kY~3h<@a!;nBkcDF#*JVhJrPBFt=3{WRK z<4&X;h^-j%6xhj2$N^hMWK7mXO<+?;6Sy*ENr8hx<VK`McDLl&uyE!ckqBr5*TBe- mj(~1(Lzff;*6g3Wa{B?Cv9eCBUW@kt0000<MNUMnLSTaatk2>A literal 0 HcmV?d00001 diff --git a/Ring/Ring/Resources/Images.xcassets/my_location.imageset/my_location_2x.png b/Ring/Ring/Resources/Images.xcassets/my_location.imageset/my_location_2x.png new file mode 100644 index 0000000000000000000000000000000000000000..ac4587fec7bc2475f16739efa6cd95157b753773 GIT binary patch literal 1030 zcmV+h1o``kP)<h;3K|Lk000e1NJLTq001xm001xu1^@s6R|5Hm00009a7bBm0013_ z0013_0gvVJWdHyG8FWQhbW?9;ba!ELWdL_~cP?peYja~^aAhuUa%Y?FJQ@H11CvQa zK~!jg?U_q!6hRb)Pfg6YHHm^o6L%`8AO=lT6cKdmEB*ix#9t#S@ddgUH49M`A4!BP zqGIAA(Ty(<A4>(@nVHW;_vBHj+cP!ONfyq5UQAcrbI$FmzFk!#HL<*Ki$x4DPsq7N z(JCwk=~AMZz;a_nUPM5bq$84crkCG7CTSwQ9y{m0=A~UXfEJ)1cm$NF9n-)=;3Tj- zvjEryyofMuVFK8<l*Ws|HK3e>`3lOwWuP_IPgHFN+9eH2Iv8qJGQaOh8kO`zQe~cZ zNm?uEfTW`eydx=wCG|V!e#ZE!2|zpW&UkvafK3rxn}FNKC{6)wd7Cc+BLT7Zz^(>( zdx4Ju-l2M?Q<l&*L!><i?har$z(0_OcLDnXL!Ijr0$KwjTmt$UB`yH3JmOEl>YTjK z)oM?t<XnscEsc=&dxT}G%z0otaFOI?{F7c&zy)AimDs+(XdiDRfkz(Mt&F}afh%Nx zkxL}?zL>#t$BTJTula7zOc|q8rshL0qJ<$V31AEJM`0RRUrz#OJki$~eXmBCufVJ_ zB+HArIBoUKT1fd{%X@~{c1dS5cr2U)wi<1|R*yvzlfbCa?@SdQ3oU7E2)>q?z|4LC z=I>zx_-@=kTqQ0GR_r(Lcc1UCvd4~ja#jM}5ga}?8u$GKOvEIx7^e!>sMqW(CV_Rv z{Vx$5wZ>Xn3ow&_^C0Pk*Vn4WPiqy6i1oF`0{$drkHCcYd%w}|hNM!q&mc;YZW?U| zyx-#%NlXGqL!F#+Q<AP{@L0IwoO^4u`C4WICV;N=GhRU%*lhGIrptp`@r>-H#9aIZ zI-?Uv$sT%Sw=?<{fdMaRAJf1k%Y4c+=*8Tx%X~_9(jzMay;Wki0_TBA;CFgWruVF+ zLnU@E3zyT!8cCo4yz~e^18eFgwY55c51!U*8a?;|_5_~FF!^pNi@<YFqXg{EOW-mK zF#>etz`Z(P9^hgg-YX)lgsaR)8rXP~%=ym&-lsrr2hSC;Hj=%qk21+NFj!&@t}P_h zCUmeh39PD>EB0uyP10RShm0<7G<&r8U9(4v8zgmV_God35&ulmDd*g;7++CHr_+G( z^y)>4<Rl~CX;USU9$+j7^W(stB{S|NQ2<T=56J!k`<Mpq1IK8v&gZsCAvyXuqGbQt zBgwB$V@eif<C2~@=d2xG6q^4NFE^SAH1WUVAB9lI!@lykp8x;=07*qoM6N<$f~iT( A*8l(j literal 0 HcmV?d00001 diff --git a/Ring/Ring/Resources/Images.xcassets/my_location.imageset/my_location_3x.png b/Ring/Ring/Resources/Images.xcassets/my_location.imageset/my_location_3x.png new file mode 100644 index 0000000000000000000000000000000000000000..51fa8dae767b7d13d2d3877c280cd9dac85ebc83 GIT binary patch literal 1565 zcmV+&2IBdNP)<h;3K|Lk000e1NJLTq002k;002k`1^@s6RqeA!00009a7bBm001mX z001mX0e5<IO#lD@8FWQhbW?9;ba!ELWdL_~cP?peYja~^aAhuUa%Y?FJQ@H11*%Cz zK~#90?VL-9mQ@_apVtvxsO1Gq(@Zfc)R|n?B2k0NER#qSiz4vW#S1|M(WXU7?OKQ> zVbQW(l+ksx0-I3FOE!+821Q;rHKV1L<FCc}X3*g~=l#F;8F(L<&HJ7A|2*gU&i(wq zk!sYaQ6QqK1xOM=v*%G1RjzMX)dB`PYJ*}>8x(`upcvE!#b9{ZgupOKvn9=wG)L0y zfwEsqIx6XBN#96197R#n8E;4fh=6;6ZNOR5<1FwtumGrh36L4UNZ=XZNCs1U=mgdQ zBde}wV&GcLlfbV97&8zjfyWstEee<cd{u%m1Mw|zTh;Xs0$2+CRe?EuI0r1xvrf4* zOI-7QL(+@JS1w7~EorBuLz22AotAXI@%d;;ljLT&Hc1O4-7U#7-q6tU@}QXUfEM6A z<2SqqSOr|4f@dtS8rW;(+77f7QRgJ0BcnO{fctXfeE|5$Xi{~9H}GbRz`ud@MSPRE zmb(%7Cq~2Oyt%Ccm&XX}1m={WqYdZ^=(qwrR<w?RqGsY|3?Fs_<4e>t0q6+m=>cYx zB_;yeW6T*<ikNG4jfyepOUf9Ja4TdVI?HT1O|(rQ+}&0dEnoz2+(Ui=xU*0JHjQ)e z58Njya_@%Qc>1tDMZ9UaS?)93hW32p??~g>^T5q1;%*A)T$?nWl{CV2p=Q`ef~g#s z0=xrU4pgKMy}*aS^aSx+@UN4)C}J$ao#*&qUIgOyYoQs?haO-_0-i;ISo2ev5JR}l zL$)VDjE%S!krutc1|wI5YuU}PHN6wf$%f%S30!52u{5JOSDR#NNx)i9jML=V&2X#L zX1Iu9;|WY9UAOX~2e`?=I}W(yi80$!ck8XjJ)Y;gqbTY&y#K1)5k_7Z(})>F6h(hZ z`rh-guTIy%YLIVFwKMKTV49?-j66y3G%(drZkOkItA)gBknd{dfZ^Sxl8QK&X_54Z zp`1_S9Tt*n4Dxk&xf8u+IA-8jWD$}Q4;soG^*mn~sBb^A-mIQ6l({t{M+h?wWh@%K z$ZF8}_#&E=kt2jDhBD`}5DfLZ`71>eWHrb#b^no(BWvvIvJhl7=te`C6B#+O#=ap7 z!8Ha&9>~AmvD{?t$RaQXpBT#cmMHCk`u4-xg1S7<EeB*DN$NH7B*A4#9~;WGd7dA! zkXQ{m<azE}AN3=OqTeLFZ{$gW?NJo{ZYbx|crZ65K&xj>brBeAcz;S`gP9k)fyoBm z@%T5Zv+{9A{bws@JVdLFF_w_uamT|(&UKy`r^x$M5trH340{vA*pShrUW{koxG(ib zPn<33o$wPb2nfGFL7XL|>u%k^LkV~m2V%`j=R%k;0)Fuj9t6w@p}<7oE#N<cF}=VC z7{;8d;tvP*d*U6Ys0&2=eHvh6ig;6j=YUUfHzwyAf1SV%;8|>EJ_F;v5YV|MX}l!F zaTkYk3nRB{Y{h?Zc!HvnK7l6!-%b}Wp-=%~Z4-fG0evgV5D^hh((Is`<b~0|4*^}D zQ&ut+E|WDh6Lpl?k{T^1WKK75OIdpQL@mQ5C;8At3CRq=T*BSz3h-#rI(*_b2ZVte zEH{y036Ow$IBvGO5ToI>yt$KzjBtsPao7*!a(Lh1TnrpA@V-mc8ThzN3X4hL?#kB! z<5KX9$F0MB+lOZ6WWH|7Gm-ptNiP{={wt|NQoG!-!%?|J^1cK1F_I?B9musxS}5r* zNh2(BByE=T%AiOjzZ$rGR+T?R=mC~h?L!d2bl{5$%-IDLc?Xvta0BT|TuxxoI1a3! z>cKb-a6hP9i~kfL3l0No0L!>ExPfa?^MS3n<fXJY1#AK4QBsR3y;(LYHcKu)r%mo! zW}&BeyWEY+0ZHFU>MVO^8WjB&>i^v_WKkOwgW8}N)CR?%HmF988dc(dOBUTn0B?;P P00000NkvXXu0mjf7dh7? literal 0 HcmV?d00001 diff --git a/Ring/Ring/Resources/en.lproj/Localizable.strings b/Ring/Ring/Resources/en.lproj/Localizable.strings index 331546d49..44c4e21bf 100644 --- a/Ring/Ring/Resources/en.lproj/Localizable.strings +++ b/Ring/Ring/Resources/en.lproj/Localizable.strings @@ -132,11 +132,21 @@ "alerts.confirmClearConversationTitle" = "Clear Conversation"; "alerts.noMediaPermissionsTitle" = "Media permission not granted"; "alerts.noLibraryPermissionsTitle" = "Access to photo library not granted"; +"alerts.noLocationPermissionsTitle" = "Access to location not granted"; "alerts.errorWrongCredentials" = "Cannot connect to provided account manager. Please check your credentials"; "alerts.recordVideoMessage" = "Record a video message"; "alerts.recordAudioMessage" = "Record an audio message"; "alerts.uploadFile" = "Upload file"; "alerts.uploadPhoto" = "Upload photo or movie"; +"alerts.locationServiceIsDisabled" = "Turn on \"Location Services\" to allow \"Jami\" to determine your location."; +"alerts.locationSharing" = "Share my location"; +"alerts.alreadylocationSharing" = "Already sharing location with this user"; +"alerts.locationSharingDurationTitle" = "How long should the location sharing be?"; +"alerts.locationSharingDuration10min" = "10 min"; +"alerts.locationSharingDuration1hour" = "1 hour"; +"alerts.mapInformation" = "Map information"; +"alerts.openStreetMapCopyright" = "© OpenStreetMap contributors"; +"alerts.openStreetMapCopyrightMoreInfo" = "Learn more"; //Actions "actions.blockAction" = "Block"; @@ -150,6 +160,8 @@ "alerts.incomingCallButtonIgnore" = "Ignore"; "actions.startAudioCall" = " Audio Call"; "actions.startVideoCall" = " Video Call"; +"actions.goToSettings" = "Go to Settings"; +"actions.stopLocationSharing" = "Stop sharing"; //Calls "calls.callItemTitle" = "Call"; @@ -277,6 +289,8 @@ "notifications.acceptCall" = "ACCEPT"; "notifications.refuseCall" = "REFUSE"; "notifications.newFile" = "New file"; +"notifications.locationSharingStarted" = "Incoming location sharing started"; +"notifications.locationSharingStopped" = "Incoming location sharing stopped"; "dataTransfer.readableStatusAwaiting" = "Pending…"; "dataTransfer.readableStatusRefuse" = "Refuse"; "dataTransfer.readableStatusOngoing" = "Transferring"; @@ -296,6 +310,7 @@ "generatedMessage.incomingCall" = "Incoming call"; "generatedMessage.missedOutgoingCall" = "Missed outgoing call"; "generatedMessage.missedIncomingCall" = "Missed incoming call"; +"generatedMessage.liveLocationSharing" = "Live location sharing"; //General Settings "generalSettings.title" = "General settings"; diff --git a/Ring/Ring/Services/AccountsService.swift b/Ring/Ring/Services/AccountsService.swift index e2570bd4c..dc3f27de1 100644 --- a/Ring/Ring/Services/AccountsService.swift +++ b/Ring/Ring/Services/AccountsService.swift @@ -221,13 +221,18 @@ class AccountsService: AccountAdapterDelegate { return true } + /// This function clears the temporary database entries + private func sanitizeDatabases() -> Bool { + let accountIds = self.accountList.map({ $0.id }) + return self.dbManager.deleteAllLocationUpdates(accountIds: accountIds) + } + func initialAccountsLoading() -> Completable { return Completable.create { [unowned self] completable in self.loadAccountsFromDaemon() if self.accountList.isEmpty { completable(.completed) - } - if self.loadDatabases() { + } else if self.loadDatabases() && self.sanitizeDatabases() { completable(.completed) } else { completable(.error(DataAccessError.databaseError)) diff --git a/Ring/Ring/Services/ConversationsManager.swift b/Ring/Ring/Services/ConversationsManager.swift index 11041a06e..058a9fa29 100644 --- a/Ring/Ring/Services/ConversationsManager.swift +++ b/Ring/Ring/Services/ConversationsManager.swift @@ -1,7 +1,8 @@ /* - * Copyright (C) 2017-2019 Savoir-faire Linux Inc. + * Copyright (C) 2017-2020 Savoir-faire Linux Inc. * * Author: Kateryna Kostiuk <kateryna.kostiuk@savoirfairelinux.com> + * Author: Raphaël Brulé <raphael.brule@savoirfairelinux.com> * * This program is free software; you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -22,34 +23,111 @@ import Foundation import RxSwift import SwiftyBeaver +// swiftlint:disable type_body_length class ConversationsManager: MessagesAdapterDelegate { let log = SwiftyBeaver.self - let conversationService: ConversationsService - let accountsService: AccountsService - let nameService: NameService - let dataTransferService: DataTransferService - let callService: CallsService + private let conversationService: ConversationsService + private let accountsService: AccountsService + private let nameService: NameService + private let dataTransferService: DataTransferService + private let callService: CallsService + private let locationSharingService: LocationSharingService private let disposeBag = DisposeBag() fileprivate let textPlainMIMEType = "text/plain" + private let geoLocationMIMEType = "application/geo" fileprivate let maxSizeForAutoaccept = 20 * 1024 * 1024 private let notificationHandler = LocalNotificationsHelper() // swiftlint:disable cyclomatic_complexity - init(with conversationService: ConversationsService, accountsService: AccountsService, nameService: NameService, dataTransferService: DataTransferService, callService: CallsService) { + init(with conversationService: ConversationsService, accountsService: AccountsService, nameService: NameService, + dataTransferService: DataTransferService, callService: CallsService, locationSharingService: LocationSharingService) { self.conversationService = conversationService self.accountsService = accountsService self.nameService = nameService self.dataTransferService = dataTransferService self.callService = callService + self.locationSharingService = locationSharingService + MessagesAdapter.delegate = self - subscribeFileTransferEvents() - subscribeCallsEvents() + self.subscribeFileTransferEvents() + self.subscribeCallsEvents() + self.subscribeLocationSharingEvent() + } + + private func subscribeLocationSharingEvent() { + self.locationSharingService + .locationServiceEventShared + .filter({ $0.eventType == ServiceEventType.stopLocationSharing }) + .subscribe(onNext: { [weak self] event in + guard let self = self else { return } + var data = [String: String]() + data[NotificationUserInfoKeys.messageContent.rawValue] = event.getEventInput(ServiceEventInput.content) + data[NotificationUserInfoKeys.participantID.rawValue] = event.getEventInput(ServiceEventInput.peerUri) + data[NotificationUserInfoKeys.accountID.rawValue] = event.getEventInput(ServiceEventInput.accountId) + + guard let contactUri = data[NotificationUserInfoKeys.participantID.rawValue], + let hash = JamiURI(schema: URIType.ring, infoHach: contactUri).hash else { return } + + DispatchQueue.main.async { [unowned self] in + self.searchNameAndPresentNotification(data: data, hash: hash) + } + }) + .disposed(by: self.disposeBag) + + self.locationSharingService + .locationServiceEventShared + .filter({ $0.eventType == ServiceEventType.sendLocation }) + .subscribe(onNext: { [weak self] event in + guard let self = self, + let currentAccount = self.accountsService.currentAccount, + let (content, shouldTryToSave): (String, Bool) = event.getEventInput(ServiceEventInput.content), + let accountId: String = event.getEventInput(ServiceEventInput.accountId), + let account = self.accountsService.getAccount(fromAccountId: accountId), + let peerUri: String = event.getEventInput(ServiceEventInput.peerUri) + else { return } + + let shouldRefresh = currentAccount.id == accountId + + self.conversationService + .sendLocation(withContent: content, + from: account, + recipientUri: peerUri, + shouldRefreshConversations: shouldRefresh, + shouldTryToSave: shouldTryToSave) + .subscribe(onCompleted: { [weak self] in + self?.log.debug("[LocationSharingService] Location sent") + }).disposed(by: self.disposeBag) + }) + .disposed(by: self.disposeBag) + + self.locationSharingService + .locationServiceEventShared + .filter({ $0.eventType == ServiceEventType.deleteLocation }) + .subscribe(onNext: { [weak self] event in + guard let self = self, + let currentAccount = self.accountsService.currentAccount, + let (incoming, shouldRefreshConversations): (Bool, Bool) = event.getEventInput(ServiceEventInput.content), + let accountId: String = event.getEventInput(ServiceEventInput.accountId), + let peerUri: String = event.getEventInput(ServiceEventInput.peerUri) + else { return } + + let shouldRefresh = currentAccount.id == accountId && shouldRefreshConversations + + self.conversationService.deleteLocationUpdate(incoming: incoming, + peerUri: peerUri, + accountId: accountId, + shouldRefreshConversations: shouldRefresh) + .subscribeOn(ConcurrentDispatchQueueScheduler(qos: .background)) + .subscribe() + .disposed(by: self.disposeBag) + }) + .disposed(by: self.disposeBag) } - func subscribeCallsEvents() { + private func subscribeCallsEvents() { self.callService.newMessage.filter({ (event) in return event.eventType == ServiceEventType.newIncomingMessage }) @@ -95,7 +173,7 @@ class ConversationsManager: MessagesAdapterDelegate { }) .disposed(by: disposeBag) } - func subscribeFileTransferEvents() { + private func subscribeFileTransferEvents() { self.dataTransferService .sharedResponseStream .filter({ (event) in @@ -173,25 +251,73 @@ class ConversationsManager: MessagesAdapterDelegate { if self.accountsService.boothMode() { return } - guard let content = message[textPlainMIMEType] else { - return + if let content = message[textPlainMIMEType] { + DispatchQueue.main.async { [unowned self] in + self.handleNewMessage(from: senderAccount, + to: receiverAccountId, + messageId: messageId, + message: content, + peerName: nil) + } + } else if let content = message[geoLocationMIMEType] { + DispatchQueue.main.async { [unowned self] in + self.handleReceivedLocationUpdate(from: senderAccount, + to: receiverAccountId, + messageId: messageId, + locationJSON: content) + } } - DispatchQueue.main.async { [unowned self] in - self.handleNewMessage(from: senderAccount, - to: receiverAccountId, - messageId: messageId, - message: content, - peerName: nil) + } + + private func handleReceivedLocationUpdate(from peerUri: String, to accountId: String, messageId: String, locationJSON content: String) { + guard let currentAccount = self.accountsService.currentAccount, + let accountForMessage = self.accountsService.getAccount(fromAccountId: accountId) else { return } + + let type = AccountModelHelper.init(withAccount: accountForMessage).isAccountSip() ? URIType.sip : URIType.ring + guard let peerUri = JamiURI.init(schema: type, infoHach: peerUri, account: accountForMessage).uriString else {return} + + if self.conversationService.isBeginningOfLocationSharing(incoming: true, contactUri: peerUri, accountId: accountId) { + // Handle notification + if UIApplication.shared.applicationState != .active && AccountModelHelper + .init(withAccount: accountForMessage).isAccountRing() && + accountsService.getCurrentProxyState(accountID: accountId) { + var data = [String: String]() + data [NotificationUserInfoKeys.messageContent.rawValue] = L10n.Notifications.locationSharingStarted + data [NotificationUserInfoKeys.participantID.rawValue] = peerUri + data [NotificationUserInfoKeys.accountID.rawValue] = accountId + + // only for jami accounts + if let hash = JamiURI(schema: URIType.ring, infoHach: peerUri).hash { + self.searchNameAndPresentNotification(data: data, hash: hash) + } + } + + let shouldRefresh = currentAccount.id == accountId + + // Save (if first) + guard let uriString = JamiURI.init(schema: type, + infoHach: peerUri, + account: accountForMessage).uriString else { return } + let message = self.conversationService.createLocation(withId: messageId, + byAuthor: uriString, + incoming: true) + self.conversationService.saveLocation(message: message, + toConversationWith: uriString, + toAccountId: accountId, + shouldRefreshConversations: shouldRefresh, + contactUri: peerUri) + .subscribe() + .disposed(by: self.disposeBag) } + + // Tell the location sharing service + self.locationSharingService.handleReceivedLocationUpdate(from: peerUri, to: accountId, messageId: messageId, locationJSON: content) } func handleNewMessage(from peerUri: String, to accountId: String, messageId: String, message content: String, peerName: String?) { - guard let currentAccount = self.accountsService.currentAccount else { - return - } - guard let accountForMessage = self.accountsService.getAccount(fromAccountId: accountId) else { - return - } + guard let currentAccount = self.accountsService.currentAccount, + let accountForMessage = self.accountsService.getAccount(fromAccountId: accountId) else { return } + if UIApplication.shared.applicationState != .active && AccountModelHelper .init(withAccount: accountForMessage).isAccountRing() && accountsService.getCurrentProxyState(accountID: accountId) { @@ -324,3 +450,4 @@ class ConversationsManager: MessagesAdapterDelegate { conversationService.detectingMessageTyping(from, for: accountId, status: status) } } +// swiftlint:enable type_body_length diff --git a/Ring/Ring/Services/ConversationsService.swift b/Ring/Ring/Services/ConversationsService.swift index 73a2e7c08..6b6af5eb3 100644 --- a/Ring/Ring/Services/ConversationsService.swift +++ b/Ring/Ring/Services/ConversationsService.swift @@ -1,5 +1,5 @@ /* - * Copyright (C) 2017-2019 Savoir-faire Linux Inc. + * Copyright (C) 2017-2020 Savoir-faire Linux Inc. * * Author: Silbino Gonçalves Matado <silbino.gmatado@savoirfairelinux.com> * Author: Quentin Muret <quentin.muret@savoirfairelinux.com> @@ -34,6 +34,7 @@ class ConversationsService { fileprivate let messageAdapter: MessagesAdapter fileprivate let disposeBag = DisposeBag() fileprivate let textPlainMIMEType = "text/plain" + private let geoLocationMIMEType = "application/geo" fileprivate let responseStream = PublishSubject<ServiceEvent>() var sharedResponseStream: Observable<ServiceEvent> @@ -158,6 +159,16 @@ class ConversationsService { toConversationWith recipientRingId: String, toAccountId: String, shouldRefreshConversations: Bool) -> Completable { + return self.saveMessageModel(message: message, toConversationWith: recipientRingId, + toAccountId: toAccountId, shouldRefreshConversations: shouldRefreshConversations, + interactionType: InteractionType.text) + } + + func saveMessageModel(message: MessageModel, + toConversationWith recipientRingId: String, + toAccountId: String, + shouldRefreshConversations: Bool, + interactionType: InteractionType = InteractionType.text) -> Completable { return Completable.create(subscribe: { [unowned self] completable in self.messagesSemaphore.wait() @@ -165,7 +176,7 @@ class ConversationsService { with: recipientRingId, message: message, incoming: message.incoming, - interactionType: InteractionType.text, duration: 0) + interactionType: interactionType, duration: 0) .subscribeOn(ConcurrentDispatchQueueScheduler(qos: .background)) .subscribe(onNext: { [unowned self] _ in // append new message so it can be found if a status update is received before the DB finishes reload @@ -500,3 +511,90 @@ class ConversationsService { self.responseStream.onNext(serviceEvent) } } + +// MARK: Location +extension ConversationsService { + + func createLocation(withId messageId: String, byAuthor author: String, incoming: Bool) -> MessageModel { + return MessageModel(withId: messageId, receivedDate: Date(), content: L10n.GeneratedMessage.liveLocationSharing, authorURI: author, incoming: incoming) + } + + // TODO: Possible extraction with sendMessage + func sendLocation(withContent content: String, from senderAccount: AccountModel, + recipientUri: String, shouldRefreshConversations: Bool, + shouldTryToSave: Bool) -> Completable { + + return Completable.create(subscribe: { [unowned self] completable in + let contentDict = [self.geoLocationMIMEType: content] + let messageId = String(self.messageAdapter.sendMessage(withContent: contentDict, withAccountId: senderAccount.id, to: recipientUri)) + let accountHelper = AccountModelHelper(withAccount: senderAccount) + let type = accountHelper.isAccountSip() ? URIType.sip : URIType.ring + let contactUri = JamiURI.init(schema: type, infoHach: recipientUri, account: senderAccount) + guard let stringUri = contactUri.uriString else { + completable(.completed) + return Disposables.create {} + } + if shouldTryToSave, let uri = accountHelper.uri, uri != recipientUri { + let message = self.createLocation(withId: messageId, + byAuthor: uri, + incoming: false) + self.saveLocation(message: message, + toConversationWith: stringUri, + toAccountId: senderAccount.id, + shouldRefreshConversations: shouldRefreshConversations, + contactUri: recipientUri) + .subscribe(onCompleted: { [unowned self] in + self.log.debug("Location saved") + }) + .disposed(by: self.disposeBag) + } + completable(.completed) + return Disposables.create {} + }) + } + + // Save location only if it's the first one + func isBeginningOfLocationSharing(incoming: Bool, contactUri: String, accountId: String) -> Bool { + let isFirstLocationIncomingUpdate = self.dbManager.isFirstLocationIncomingUpdate(incoming: incoming, peerUri: contactUri, accountId: accountId) + return isFirstLocationIncomingUpdate != nil && isFirstLocationIncomingUpdate! + } + + // Location saved doesn't actually contain the geolocation data + func saveLocation(message: MessageModel, + toConversationWith recipientRingId: String, + toAccountId: String, + shouldRefreshConversations: Bool, + contactUri: String) -> Completable { + if self.isBeginningOfLocationSharing(incoming: message.incoming, contactUri: contactUri, accountId: toAccountId) { + return self.saveMessageModel(message: message, toConversationWith: recipientRingId, + toAccountId: toAccountId, shouldRefreshConversations: shouldRefreshConversations, + interactionType: InteractionType.location) + } + return Completable.create(subscribe: { completable in + completable(.completed) + return Disposables.create { } + }) + } + + func deleteLocationUpdate(incoming: Bool, peerUri: String, accountId: String, shouldRefreshConversations: Bool) -> Completable { + return Completable.create(subscribe: { [unowned self] completable in + self.dbManager.deleteLocationUpdates(incoming: incoming, peerUri: peerUri, accountId: accountId) + .subscribeOn(ConcurrentDispatchQueueScheduler(qos: .background)) + .subscribe(onCompleted: { + if shouldRefreshConversations { + self.dbManager.getConversationsObservable(for: accountId) + .subscribeOn(ConcurrentDispatchQueueScheduler(qos: .background)) + .subscribe(onNext: { [unowned self] conversationsModels in + self.conversations.value = conversationsModels + }) + .disposed(by: (self.disposeBag)) + } + completable(.completed) + }, onError: { (error) in + completable(.error(error)) + }) + .disposed(by: self.disposeBag) + return Disposables.create { } + }) + } +} diff --git a/Ring/Ring/Services/LocationSharingService.swift b/Ring/Ring/Services/LocationSharingService.swift new file mode 100644 index 000000000..363c7edae --- /dev/null +++ b/Ring/Ring/Services/LocationSharingService.swift @@ -0,0 +1,356 @@ +/* +* Copyright (C) 2020 Savoir-faire Linux Inc. +* +* Author: Raphaël Brulé <raphael.brule@savoirfairelinux.com> +* +* This program is free software; you can redistribute it and/or modify +* it under the terms of the GNU General Public License as published by +* the Free Software Foundation; either version 3 of the License, or +* (at your option) any later version. +* +* This program is distributed in the hope that it will be useful, +* but WITHOUT ANY WARRANTY; without even the implied warranty of +* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +* GNU General Public License for more details. +* +* You should have received a copy of the GNU General Public License +* along with this program; if not, write to the Free Software +* Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. +*/ + +import RxSwift +import RxCocoa +import SwiftyBeaver + +// swiftlint:disable redundant_string_enum_value +enum SerializableLocationTypes: String { + case position = "position" + case stop = "stop" +} +// swiftlint:enable redundant_string_enum_value + +struct SerializableLocation: Codable { + var type: String? //position (optional) and stop + var lat: Double? //position + var long: Double? //position + var alt: Double? //position + var time: Int64 //position and stop + var bearing: Float? //position (optional) + var speed: Float? //position (optional) +} + +private class LocationSharingInstanceDictionary<T: LocationSharingInstance> { + private var instances: [String: T] = [:] + + var isEmpty: Bool { return self.instances.isEmpty } + + private func key(_ accountId: String, _ contactUri: String) -> String { + return accountId + contactUri + } + + func get(_ accountId: String, _ contactUri: String) -> T? { + return self.instances[key(accountId, contactUri)] + } + + func insertOrUpdate(_ instance: T) { + self.instances[key(instance.accountId, instance.contactUri)] = instance + } + + func remove(_ accountId: String, _ contactUri: String) -> T? { + return self.instances.removeValue(forKey: key(accountId, contactUri)) + } + + func asArray() -> [T] { + return self.instances.map({ $0.value }) + } +} + +private class LocationSharingInstance { + let accountId: String + let contactUri: String + + init(accountId: String, contactUri: String) { + self.accountId = accountId + self.contactUri = contactUri + } +} + +private class OutgoingLocationSharingInstance: LocationSharingInstance { + + private let locationSharingService: LocationSharingService + let duration: TimeInterval + + private var endSharingTimer: Timer? + + init(locationSharingService: LocationSharingService, accountId: String, contactUri: String, duration: TimeInterval) { + self.locationSharingService = locationSharingService + self.duration = duration + super.init(accountId: accountId, contactUri: contactUri) + + self.endSharingTimer = + Timer.scheduledTimer(timeInterval: self.duration, + target: self, + selector: #selector(self.endSharing), + userInfo: nil, + repeats: false) + } + + @objc private func endSharing(timer: Timer) { + self.locationSharingService.stopSharingLocation(accountId: self.accountId, contactUri: self.contactUri) + } + + func invalidate() { + if let timer = self.endSharingTimer { + timer.invalidate() + self.endSharingTimer = nil + } + } +} + +private class IncomingLocationSharingInstance: LocationSharingInstance { + + var lastReceivedDate: Date + var lastReceivedTimeStamp: Int64 + + init(accountId: String, contactUri: String, lastReceivedDate: Date, lastReceivedTimeStamp: Int64) { + self.lastReceivedDate = lastReceivedDate + self.lastReceivedTimeStamp = lastReceivedTimeStamp + super.init(accountId: accountId, contactUri: contactUri) + } +} + +class LocationSharingService: NSObject { + + private let incomingLocationSharingEndingDelay: TimeInterval = 10 * 60 // 10 mins + + private let log = SwiftyBeaver.self + + private let dbManager: DBManager + + private let disposeBag = DisposeBag() + private let locationManager = CLLocationManager() + + // Sharing my location + let currentLocation = BehaviorRelay<CLLocation?>(value: nil) + private let outgoingInstances = LocationSharingInstanceDictionary<OutgoingLocationSharingInstance>() + + // Receiving my contact's location + let peerUriAndLocationReceived = BehaviorRelay<(String?, CLLocationCoordinate2D?)>(value: (nil, nil)) + private let incomingInstances = LocationSharingInstanceDictionary<IncomingLocationSharingInstance>() + + var receivingService: Disposable? + + // ServiceEvents + private let locationServiceEventStream = PublishSubject<ServiceEvent>() + let locationServiceEventShared: Observable<ServiceEvent> + + init(dbManager: DBManager) { + self.dbManager = dbManager + + self.locationServiceEventStream.disposed(by: self.disposeBag) + self.locationServiceEventShared = self.locationServiceEventStream.share() + super.init() + + self.locationManager.delegate = self + self.locationManager.desiredAccuracy = kCLLocationAccuracyBest + self.locationManager.allowsBackgroundLocationUpdates = true + self.initialize() + } + + private func initialize() { + self.currentLocation + .throttle(10, scheduler: MainScheduler.instance) + .subscribe(onNext: { [weak self] location in + guard let self = self, let location = location else { return } + self.doShareLocationAction(location) + }) + .disposed(by: self.disposeBag) + } + + private static func serializeLocation(location: SerializableLocation) -> String? { + do { + let data = try JSONEncoder().encode(location) + return String(data: data, encoding: .utf8)! + } catch { + return nil + } + } + + private static func deserializeLocation(json: String) -> SerializableLocation? { + do { + return try JSONDecoder().decode(SerializableLocation.self, from: json.data(using: .utf8)!) + } catch { + return nil + } + } + + private func triggerSendLocation(accountId: String, peerUri: String, content: String, shouldTryToSave: Bool) { + var event = ServiceEvent(withEventType: .sendLocation) + event.addEventInput(.accountId, value: accountId) + event.addEventInput(.peerUri, value: peerUri) + event.addEventInput(.content, value: (content, shouldTryToSave)) + self.locationServiceEventStream.onNext(event) + } + + private func triggerDeleteLocation(accountId: String, peerUri: String, incoming: Bool, shouldRefreshConversations: Bool) { + var event = ServiceEvent(withEventType: .deleteLocation) + event.addEventInput(.accountId, value: accountId) + event.addEventInput(.peerUri, value: peerUri) + event.addEventInput(.content, value: (incoming, shouldRefreshConversations)) + self.locationServiceEventStream.onNext(event) + } + + private func triggerStopSharing(accountId: String, peerUri: String, content: String) { + var event = ServiceEvent(withEventType: .stopLocationSharing) + event.addEventInput(.accountId, value: accountId) + event.addEventInput(.peerUri, value: peerUri) + event.addEventInput(.content, value: content) + self.locationServiceEventStream.onNext(event) + } +} + +// MARK: Sharing my location +extension LocationSharingService { + + func isAlreadySharing(accountId: String, contactUri: String) -> Bool { + return self.outgoingInstances.get(accountId, contactUri) != nil + } + + func startSharingLocation(from accountId: String, to recipientUri: String, duration: TimeInterval) { + guard !self.isAlreadySharing(accountId: accountId, contactUri: recipientUri) else { return } + + let instanceToInsert = OutgoingLocationSharingInstance(locationSharingService: self, + accountId: accountId, + contactUri: recipientUri, + duration: duration) + self.outgoingInstances.insertOrUpdate(instanceToInsert) + + self.locationManager.startUpdatingLocation() + } + + private func doShareLocationAction(_ location: CLLocation) { + let serializable = SerializableLocation(type: SerializableLocationTypes.position.rawValue, + lat: location.coordinate.latitude, + long: location.coordinate.longitude, + alt: location.altitude, + time: Int64(Date().timeIntervalSince1970)) + guard let jsonLocation = LocationSharingService.serializeLocation(location: serializable) else { return } + + for instance in outgoingInstances.asArray() { + self.triggerSendLocation(accountId: instance.accountId, + peerUri: instance.contactUri, + content: jsonLocation, + shouldTryToSave: true) + } + } + + func stopSharingLocation(accountId: String, contactUri: String) { + self.outgoingInstances.get(accountId, contactUri)?.invalidate() + _ = self.outgoingInstances.remove(accountId, contactUri) + + if self.outgoingInstances.isEmpty { + self.locationManager.stopUpdatingLocation() + } + + self.triggerDeleteLocation(accountId: accountId, peerUri: contactUri, incoming: false, shouldRefreshConversations: true) + + self.sendStopSharingLocationMessage(from: accountId, to: contactUri) + } + + private func sendStopSharingLocationMessage(from accountId: String, to contactUri: String) { + let serializable = SerializableLocation(type: SerializableLocationTypes.stop.rawValue, + time: Int64(Date().timeIntervalSince1970)) + guard let jsonLocation = LocationSharingService.serializeLocation(location: serializable) else { return } + + self.triggerSendLocation(accountId: accountId, + peerUri: contactUri, + content: jsonLocation, + shouldTryToSave: false) + } +} + +// MARK: Receiving my contact's location +extension LocationSharingService { + + func handleReceivedLocationUpdate(from peerUri: String, to accountId: String, messageId: String, locationJSON content: String) { + guard let incomingData = LocationSharingService.deserializeLocation(json: content) else { return } + + if incomingInstances.isEmpty { + self.startReceivingService() + } + + if let incomingInstance = self.incomingInstances.get(accountId, peerUri) { + if incomingInstance.lastReceivedTimeStamp < incomingData.time { + incomingInstance.lastReceivedDate = Date() + incomingInstance.lastReceivedTimeStamp = incomingData.time + } else { + return // ignore messages older than the newest we have (when receiving not in order) + } + } else { + self.incomingInstances.insertOrUpdate(IncomingLocationSharingInstance(accountId: accountId, + contactUri: peerUri, + lastReceivedDate: Date(), + lastReceivedTimeStamp: incomingData.time)) + } + + if incomingData.type == nil || incomingData.type == SerializableLocationTypes.position.rawValue { + // TODO: altitude? + let peerUriAndData = (peerUri, CLLocationCoordinate2D(latitude: incomingData.lat!, longitude: incomingData.long!)) + self.peerUriAndLocationReceived.accept(peerUriAndData) + + } else if incomingData.type == SerializableLocationTypes.stop.rawValue { + self.stopReceivingLocation(accountId: accountId, contactUri: peerUri) + } + } + + func stopReceivingLocation(accountId: String, contactUri: String) { + self.triggerDeleteLocation(accountId: accountId, peerUri: contactUri, incoming: true, shouldRefreshConversations: true) + + _ = self.incomingInstances.remove(accountId, contactUri) + + if incomingInstances.isEmpty { + self.stopReceivingService() + } + + self.triggerStopSharing(accountId: accountId, peerUri: contactUri, content: L10n.Notifications.locationSharingStopped) + } + + func startReceivingService() { + self.stopReceivingService() + self.receivingService = Observable<Int>.interval(60, scheduler: MainScheduler.instance) + .subscribe({ [weak self] _ in + guard let self = self else { return } + + for (instance) in self.incomingInstances.asArray() { + let positiveTimeElapsed = -instance.lastReceivedDate.timeIntervalSinceNow + if positiveTimeElapsed > self.incomingLocationSharingEndingDelay { + self.stopReceivingLocation(accountId: instance.accountId, contactUri: instance.contactUri) + } + } + }) + } + + func stopReceivingService() { + self.receivingService?.dispose() + self.receivingService = nil + } +} + +// MARK: CLLocationManagerDelegate +extension LocationSharingService: CLLocationManagerDelegate { + func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) { + guard let location = locations.last else { return } + self.currentLocation.accept(location) + } + func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) { + self.log.debug("[LocationSharingService] didFailWithError: \(error)") + } + + func locationManager(_ manager: CLLocationManager, didChangeAuthorization status: CLAuthorizationStatus) { + if status == .notDetermined || status == .denied || status == .restricted { + for instance in outgoingInstances.asArray() { + self.stopSharingLocation(accountId: instance.accountId, contactUri: instance.contactUri) + } + } + } +} diff --git a/Ring/Ring/Services/ServiceEvent.swift b/Ring/Ring/Services/ServiceEvent.swift index eb7132cf1..53d1c96ce 100644 --- a/Ring/Ring/Services/ServiceEvent.swift +++ b/Ring/Ring/Services/ServiceEvent.swift @@ -52,6 +52,9 @@ enum ServiceEventType { case migrationEnded case lastDisplayedMessageUpdated case presenseSubscribed + case sendLocation + case deleteLocation + case stopLocationSharing } /** diff --git a/Ring/WhirlyGlobeMaply b/Ring/WhirlyGlobeMaply new file mode 160000 index 000000000..2c6c03d9b --- /dev/null +++ b/Ring/WhirlyGlobeMaply @@ -0,0 +1 @@ +Subproject commit 2c6c03d9b5c3846cb7c4aa8269b878664e02d4e0 diff --git a/Ring/fetch-dependencies.sh b/Ring/fetch-dependencies.sh new file mode 100755 index 000000000..33c5d2072 --- /dev/null +++ b/Ring/fetch-dependencies.sh @@ -0,0 +1,16 @@ +#!/bin/sh + +#################################### +## DOWNLOAD CARTHAGE DEPENDENCIES ## +#################################### + +# Bootstrap Carthage +carthage bootstrap --platform iOS --no-use-binaries --cache-builds + +############################################ +## DOWNLOAD WHIRLYGLOBEMAPLY DEPENDENCIES ## +############################################ + +cd WhirlyGlobeMaply +git submodule init +git submodule update diff --git a/Ring/swiftgen/swiftgen.sh b/Ring/swiftgen/swiftgen.sh index 93b5fc6ff..36fa0b31b 100755 --- a/Ring/swiftgen/swiftgen.sh +++ b/Ring/swiftgen/swiftgen.sh @@ -8,9 +8,9 @@ run_swiftgen() { TPLDIR=$(dirname $0) echo "SwiftGen: Generating files..." - swiftgen storyboards "$SRCDIR" -t swift5 --output "$OUTDIR/Storyboards.swift" - swiftgen xcassets "$SRCDIR/Resources/Images.xcassets" -t swift5 --output "$OUTDIR/Images.swift" - swiftgen strings -t structured-swift5 "$SRCDIR/Resources/en.lproj/Localizable.strings" --output "$OUTDIR/Strings.swift" + swiftgen run storyboards "$SRCDIR" -t swift5 --output "$OUTDIR/Storyboards.swift" + swiftgen run xcassets "$SRCDIR/Resources/Images.xcassets" -t swift5 --output "$OUTDIR/Images.swift" + swiftgen run strings -t structured-swift5 "$SRCDIR/Resources/en.lproj/Localizable.strings" --output "$OUTDIR/Strings.swift" } # Main script to check if SwiftGen is installed, check the version, and run it only if version matches -- GitLab