diff --git a/.drone.jsonnet b/.drone.jsonnet index d1f21a6d6..29f24c2b5 100644 --- a/.drone.jsonnet +++ b/.drone.jsonnet @@ -71,7 +71,7 @@ local update_cocoapods_cache = { name: 'Run Unit Tests', commands: [ 'mkdir build', - 'NSUnbufferedIO=YES set -o pipefail && xcodebuild test -workspace Session.xcworkspace -scheme Session -destination "platform=iOS Simulator,name=iPhone 14" -destination "platform=iOS Simulator,name=iPhone 14 Pro Max" -parallel-testing-enabled YES -test-timeouts-enabled YES -maximum-test-execution-time-allowance 2 -collect-test-diagnostics never 2>&1 | ./Pods/xcbeautify/xcbeautify --is-ci --report junit --report-path ./build/reports --junit-report-filename junit2.xml' + 'NSUnbufferedIO=YES set -o pipefail && xcodebuild test -workspace Session.xcworkspace -scheme Session -derivedDataPath ./build/derivedData -destination "platform=iOS Simulator,name=iPhone 14" -destination "platform=iOS Simulator,name=iPhone 14 Pro Max" -parallel-testing-enabled YES -test-timeouts-enabled YES -maximum-test-execution-time-allowance 2 -collect-test-diagnostics never 2>&1 | ./Pods/xcbeautify/xcbeautify --is-ci --report junit --report-path ./build/reports --junit-report-filename junit2.xml' ], }, update_cocoapods_cache @@ -91,7 +91,7 @@ local update_cocoapods_cache = { name: 'Build', commands: [ 'mkdir build', - 'xcodebuild archive -workspace Session.xcworkspace -scheme Session -configuration "App Store Release" -sdk iphonesimulator -archivePath ./build/Session_sim.xcarchive -destination "generic/platform=iOS Simulator" | ./Pods/xcbeautify/xcbeautify --is-ci' + 'xcodebuild archive -workspace Session.xcworkspace -scheme Session -derivedDataPath ./build/derivedData -configuration "App Store Release" -sdk iphonesimulator -archivePath ./build/Session_sim.xcarchive -destination "generic/platform=iOS Simulator" | ./Pods/xcbeautify/xcbeautify --is-ci' ], }, update_cocoapods_cache, @@ -118,7 +118,7 @@ local update_cocoapods_cache = { name: 'Build', commands: [ 'mkdir build', - 'xcodebuild archive -workspace Session.xcworkspace -scheme Session -configuration "App Store Release" -sdk iphoneos -archivePath ./build/Session.xcarchive -destination "generic/platform=iOS" -allowProvisioningUpdates' + 'xcodebuild archive -workspace Session.xcworkspace -scheme Session -derivedDataPath ./build/derivedData -configuration "App Store Release" -sdk iphoneos -archivePath ./build/Session.xcarchive -destination "generic/platform=iOS" -allowProvisioningUpdates CODE_SIGNING_ALLOWED=NO' ], }, update_cocoapods_cache, diff --git a/Scripts/drone-static-upload.sh b/Scripts/drone-static-upload.sh index 4fd13faa5..60906b619 100755 --- a/Scripts/drone-static-upload.sh +++ b/Scripts/drone-static-upload.sh @@ -28,13 +28,13 @@ else base="session-ios-$(date --date=@$DRONE_BUILD_CREATED +%Y%m%dT%H%M%SZ)-${DRONE_COMMIT:0:9}" fi -mkdir -v "$base" +mkdir -vp "$base" # Copy over the build products prod_path="build/Session.xcarchive" sim_path="build/Session_sim.xcarchive/Products/Applications/Session.app" -mkdir build +mkdir -p build echo "Test" > "build/test.txt" if [ ! -d $prod_path ]; then diff --git a/Session.xcodeproj/project.pbxproj b/Session.xcodeproj/project.pbxproj index ee51b772a..121b8920a 100644 --- a/Session.xcodeproj/project.pbxproj +++ b/Session.xcodeproj/project.pbxproj @@ -470,11 +470,10 @@ FD078E4827E02561000769AF /* CommonMockedExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD078E4727E02561000769AF /* CommonMockedExtensions.swift */; }; FD078E4927E02576000769AF /* CommonMockedExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD078E4727E02561000769AF /* CommonMockedExtensions.swift */; }; FD078E4D27E17156000769AF /* MockOGMCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD078E4C27E17156000769AF /* MockOGMCache.swift */; }; - FD078E4F27E175F1000769AF /* DependencyExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD078E4E27E175F1000769AF /* DependencyExtensions.swift */; }; - FD078E5227E1760A000769AF /* OGMDependencyExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD078E5127E1760A000769AF /* OGMDependencyExtensions.swift */; }; FD078E5427E197CA000769AF /* OpenGroupManagerSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC2909D27D85751005DAE71 /* OpenGroupManagerSpec.swift */; }; - FD078E5A27E29F09000769AF /* MockNonce16Generator.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD078E5927E29F09000769AF /* MockNonce16Generator.swift */; }; - FD078E5C27E29F78000769AF /* MockNonce24Generator.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD078E5B27E29F78000769AF /* MockNonce24Generator.swift */; }; + FD0969F92A69FFE700C5C365 /* Mocked.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD0969F82A69FFE700C5C365 /* Mocked.swift */; }; + FD0969FA2A6A00B000C5C365 /* Mocked.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD0969F82A69FFE700C5C365 /* Mocked.swift */; }; + FD0969FB2A6A00B100C5C365 /* Mocked.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD0969F82A69FFE700C5C365 /* Mocked.swift */; }; FD09796B27F6C67500936362 /* Failable.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD09796A27F6C67500936362 /* Failable.swift */; }; FD09796E27FA6D0000936362 /* Contact.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD09796D27FA6D0000936362 /* Contact.swift */; }; FD09797027FA6FF300936362 /* Profile.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD09796F27FA6FF300936362 /* Profile.swift */; }; @@ -532,6 +531,21 @@ FD1A94FB2900D1C2000D73D3 /* PersistableRecord+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD1A94FA2900D1C2000D73D3 /* PersistableRecord+Utilities.swift */; }; FD1A94FE2900D2EA000D73D3 /* PersistableRecordUtilitiesSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD1A94FD2900D2EA000D73D3 /* PersistableRecordUtilitiesSpec.swift */; }; FD1C98E4282E3C5B00B76F9E /* UINavigationBar+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD1C98E3282E3C5B00B76F9E /* UINavigationBar+Utilities.swift */; }; + FD23CE1B2A651E6D0000B97C /* NetworkType.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD23CE1A2A651E6D0000B97C /* NetworkType.swift */; }; + FD23CE1F2A65269C0000B97C /* Crypto.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD23CE1E2A65269C0000B97C /* Crypto.swift */; }; + FD23CE222A661D000000B97C /* OpenGroupAPI+Crypto.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD23CE212A661D000000B97C /* OpenGroupAPI+Crypto.swift */; }; + FD23CE242A675C440000B97C /* Crypto+SessionMessagingKit.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD23CE232A675C440000B97C /* Crypto+SessionMessagingKit.swift */; }; + FD23CE262A676B5B0000B97C /* DependenciesSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD23CE252A676B5B0000B97C /* DependenciesSpec.swift */; }; + FD23CE282A67755C0000B97C /* MockCrypto.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD23CE272A67755C0000B97C /* MockCrypto.swift */; }; + FD23CE292A6775650000B97C /* MockCrypto.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD23CE272A67755C0000B97C /* MockCrypto.swift */; }; + FD23CE2A2A6775660000B97C /* MockCrypto.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD23CE272A67755C0000B97C /* MockCrypto.swift */; }; + FD23CE2C2A678DF80000B97C /* MockCaches.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD23CE2B2A678DF80000B97C /* MockCaches.swift */; }; + FD23CE2D2A678E1E0000B97C /* MockCaches.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD23CE2B2A678DF80000B97C /* MockCaches.swift */; }; + FD23CE2E2A678E1E0000B97C /* MockCaches.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD23CE2B2A678DF80000B97C /* MockCaches.swift */; }; + FD23CE302A67B8820000B97C /* Caches.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD23CE2F2A67B8820000B97C /* Caches.swift */; }; + FD23CE332A67C4D90000B97C /* MockNetwork.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD23CE312A67C38D0000B97C /* MockNetwork.swift */; }; + FD23CE342A67C4D90000B97C /* MockNetwork.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD23CE312A67C38D0000B97C /* MockNetwork.swift */; }; + FD23CE352A67C4DA0000B97C /* MockNetwork.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD23CE312A67C38D0000B97C /* MockNetwork.swift */; }; FD23EA5C28ED00F80058676E /* Mock.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC290A527D860CE005DAE71 /* Mock.swift */; }; FD23EA5D28ED00FA0058676E /* TestConstants.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD83B9BD27CF2243005E1583 /* TestConstants.swift */; }; FD23EA5E28ED00FD0058676E /* NimbleExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC290A727D9B46D005DAE71 /* NimbleExtensions.swift */; }; @@ -615,9 +629,8 @@ FD3C905C27E3FBEF00CD579F /* BatchRequestInfoSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD3C905B27E3FBEF00CD579F /* BatchRequestInfoSpec.swift */; }; FD3C906027E410F700CD579F /* FileUploadResponseSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD3C905F27E410F700CD579F /* FileUploadResponseSpec.swift */; }; FD3C906727E416AF00CD579F /* BlindedIdLookupSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD3C906627E416AF00CD579F /* BlindedIdLookupSpec.swift */; }; - FD3C906A27E417CE00CD579F /* SodiumUtilitiesSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD3C906927E417CE00CD579F /* SodiumUtilitiesSpec.swift */; }; + FD3C906A27E417CE00CD579F /* CryptoSMKSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD3C906927E417CE00CD579F /* CryptoSMKSpec.swift */; }; FD3C906D27E43C4B00CD579F /* MessageSenderEncryptionSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD3C906C27E43C4B00CD579F /* MessageSenderEncryptionSpec.swift */; }; - FD3C906F27E43E8700CD579F /* MockBox.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD3C906E27E43E8700CD579F /* MockBox.swift */; }; FD3C907127E445E500CD579F /* MessageReceiverDecryptionSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD3C907027E445E500CD579F /* MessageReceiverDecryptionSpec.swift */; }; FD3E0C84283B5835002A425C /* SessionThreadViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD3E0C83283B5835002A425C /* SessionThreadViewModel.swift */; }; FD42F9A8285064B800A0C77D /* PushNotificationAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = C33FDBDE255A581900E217F9 /* PushNotificationAPI.swift */; }; @@ -634,6 +647,7 @@ FD52090528B4915F006098F6 /* PrivacySettingsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD52090428B4915F006098F6 /* PrivacySettingsViewModel.swift */; }; FD52090928B59411006098F6 /* ScreenLockUI.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD52090828B59411006098F6 /* ScreenLockUI.swift */; }; FD52090B28B59BB4006098F6 /* ScreenLockViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD52090A28B59BB4006098F6 /* ScreenLockViewController.swift */; }; + FD559DF52A7368CB00C7C62A /* DispatchQueue+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD559DF42A7368CB00C7C62A /* DispatchQueue+Utilities.swift */; }; FD5C72F7284F0E560029977D /* MessageReceiver+ReadReceipts.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD5C72F6284F0E560029977D /* MessageReceiver+ReadReceipts.swift */; }; FD5C72F9284F0E880029977D /* MessageReceiver+TypingIndicators.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD5C72F8284F0E880029977D /* MessageReceiver+TypingIndicators.swift */; }; FD5C72FB284F0EA10029977D /* MessageReceiver+DataExtractionNotification.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD5C72FA284F0EA10029977D /* MessageReceiver+DataExtractionNotification.swift */; }; @@ -696,7 +710,6 @@ FD7692F72A53A2ED000E4B70 /* SessionThreadViewModelSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD7692F62A53A2ED000E4B70 /* SessionThreadViewModelSpec.swift */; }; FD7728962849E7E90018502F /* String+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD7728952849E7E90018502F /* String+Utilities.swift */; }; FD7728982849E8110018502F /* UITableView+ReusableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD7728972849E8110018502F /* UITableView+ReusableView.swift */; }; - FD77289A284AF1BD0018502F /* Sodium+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD772899284AF1BD0018502F /* Sodium+Utilities.swift */; }; FD77289E284EF1C50018502F /* Sodium+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD77289D284EF1C50018502F /* Sodium+Utilities.swift */; }; FD778B6429B189FF001BAC6B /* _014_GenerateInitialUserConfigDumps.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD778B6329B189FF001BAC6B /* _014_GenerateInitialUserConfigDumps.swift */; }; FD83B9B327CF200A005E1583 /* SessionUtilitiesKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C3C2A679255388CC00C340D1 /* SessionUtilitiesKit.framework */; platformFilter = ios; }; @@ -708,6 +721,7 @@ FD83B9C927D0487A005E1583 /* SendDirectMessageResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD83B9C827D0487A005E1583 /* SendDirectMessageResponse.swift */; }; FD83B9CC27D179BC005E1583 /* FSEndpoint.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD83B9CB27D179BC005E1583 /* FSEndpoint.swift */; }; FD83B9D227D59495005E1583 /* MockUserDefaults.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD83B9D127D59495005E1583 /* MockUserDefaults.swift */; }; + FD83DCDD2A739D350065FFAE /* RetryWithDependencies.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD83DCDC2A739D350065FFAE /* RetryWithDependencies.swift */; }; FD848B8B283DC509000E298B /* PagedDatabaseObserver.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD848B8A283DC509000E298B /* PagedDatabaseObserver.swift */; }; FD848B8D283E0B26000E298B /* MessageInputTypes.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD848B8C283E0B26000E298B /* MessageInputTypes.swift */; }; FD848B8F283EF2A8000E298B /* UIScrollView+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD848B8E283EF2A8000E298B /* UIScrollView+Utilities.swift */; }; @@ -716,11 +730,6 @@ FD848B9828422F1A000E298B /* Date+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD848B9728422F1A000E298B /* Date+Utilities.swift */; }; FD848B9A28442CE6000E298B /* StorageError.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD848B9928442CE6000E298B /* StorageError.swift */; }; FD848B9C284435D7000E298B /* AppSetup.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD848B9B284435D7000E298B /* AppSetup.swift */; }; - FD859EF427C2F49200510D0C /* MockSodium.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD859EF327C2F49200510D0C /* MockSodium.swift */; }; - FD859EF627C2F52C00510D0C /* MockSign.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD859EF527C2F52C00510D0C /* MockSign.swift */; }; - FD859EF827C2F58900510D0C /* MockAeadXChaCha20Poly1305Ietf.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD859EF727C2F58900510D0C /* MockAeadXChaCha20Poly1305Ietf.swift */; }; - FD859EFA27C2F5C500510D0C /* MockGenericHash.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD859EF927C2F5C500510D0C /* MockGenericHash.swift */; }; - FD859EFC27C2F60700510D0C /* MockEd25519.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD859EFB27C2F60700510D0C /* MockEd25519.swift */; }; FD87DCFA28B74DB300AF0F98 /* ConversationSettingsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD87DCF928B74DB300AF0F98 /* ConversationSettingsViewModel.swift */; }; FD87DCFE28B7582C00AF0F98 /* BlockedContactsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD87DCFD28B7582C00AF0F98 /* BlockedContactsViewModel.swift */; }; FD87DD0028B820F200AF0F98 /* BlockedContactCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD87DCFF28B820F200AF0F98 /* BlockedContactCell.swift */; }; @@ -745,6 +754,9 @@ FD9B30F3293EA0BF008DEE3E /* BatchResponseSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD9B30F2293EA0BF008DEE3E /* BatchResponseSpec.swift */; }; FD9BDE002A5D22B7005F1EBC /* libSessionUtil.a in Frameworks */ = {isa = PBXBuildFile; fileRef = FD9BDDF82A5D2294005F1EBC /* libSessionUtil.a */; }; FD9BDE012A5D24EA005F1EBC /* SessionUIKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C331FF1B2558F9D300070591 /* SessionUIKit.framework */; }; + FD9DD2712A72516D00ECB68E /* TestExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD9DD2702A72516D00ECB68E /* TestExtensions.swift */; }; + FD9DD2722A72516D00ECB68E /* TestExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD9DD2702A72516D00ECB68E /* TestExtensions.swift */; }; + FD9DD2732A72516D00ECB68E /* TestExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD9DD2702A72516D00ECB68E /* TestExtensions.swift */; }; FDA1E83629A5748F00C5C3BD /* ConfigUserGroupsSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDA1E83529A5748F00C5C3BD /* ConfigUserGroupsSpec.swift */; }; FDA1E83929A5771A00C5C3BD /* LibSessionSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDA1E83829A5771A00C5C3BD /* LibSessionSpec.swift */; }; FDA1E83B29A5F2D500C5C3BD /* SessionUtil+Shared.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDA1E83A29A5F2D500C5C3BD /* SessionUtil+Shared.swift */; }; @@ -769,14 +781,11 @@ FDC2909627D71252005DAE71 /* SOGSErrorSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC2909527D71252005DAE71 /* SOGSErrorSpec.swift */; }; FDC2909827D7129B005DAE71 /* PersonalizationSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC2909727D7129B005DAE71 /* PersonalizationSpec.swift */; }; FDC2909A27D71376005DAE71 /* NonceGeneratorSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC2909927D71376005DAE71 /* NonceGeneratorSpec.swift */; }; - FDC2909C27D713D2005DAE71 /* SodiumProtocolsSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC2909B27D713D2005DAE71 /* SodiumProtocolsSpec.swift */; }; FDC290A627D860CE005DAE71 /* Mock.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC290A527D860CE005DAE71 /* Mock.swift */; }; FDC290A827D9B46D005DAE71 /* NimbleExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC290A727D9B46D005DAE71 /* NimbleExtensions.swift */; }; FDC290A927D9B46D005DAE71 /* NimbleExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC290A727D9B46D005DAE71 /* NimbleExtensions.swift */; }; FDC290AA27D9B6FD005DAE71 /* Mock.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC290A527D860CE005DAE71 /* Mock.swift */; }; - FDC290B327DFF9F5005DAE71 /* TestOnionRequestAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC290B227DFF9F5005DAE71 /* TestOnionRequestAPI.swift */; }; FDC4380927B31D4E00C60D73 /* OpenGroupAPIError.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4380827B31D4E00C60D73 /* OpenGroupAPIError.swift */; }; - FDC4381527B329CE00C60D73 /* NonceGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4381427B329CE00C60D73 /* NonceGenerator.swift */; }; FDC4381727B32EC700C60D73 /* Personalization.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4381627B32EC700C60D73 /* Personalization.swift */; }; FDC4382027B36ADC00C60D73 /* SOGSEndpoint.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4381F27B36ADC00C60D73 /* SOGSEndpoint.swift */; }; FDC4382F27B383AF00C60D73 /* PushServerResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4382E27B383AF00C60D73 /* PushServerResponse.swift */; }; @@ -796,8 +805,6 @@ FDC438A627BB113A00C60D73 /* UserUnbanRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC438A527BB113A00C60D73 /* UserUnbanRequest.swift */; }; FDC438AA27BB12BB00C60D73 /* UserModeratorRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC438A927BB12BB00C60D73 /* UserModeratorRequest.swift */; }; FDC438BD27BB2AB400C60D73 /* Mockable.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC438BC27BB2AB400C60D73 /* Mockable.swift */; }; - FDC438C127BB4E6800C60D73 /* SMKDependencies.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC438C027BB4E6800C60D73 /* SMKDependencies.swift */; }; - FDC438C327BB512200C60D73 /* SodiumProtocols.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC438C227BB512200C60D73 /* SodiumProtocols.swift */; }; FDC438C727BB6DF000C60D73 /* DirectMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC438C627BB6DF000C60D73 /* DirectMessage.swift */; }; FDC438C927BB706500C60D73 /* SendDirectMessageRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC438C827BB706500C60D73 /* SendDirectMessageRequest.swift */; }; FDC438CB27BB7DB100C60D73 /* UpdateMessageRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC438CA27BB7DB100C60D73 /* UpdateMessageRequest.swift */; }; @@ -816,8 +823,8 @@ FDDCBDAB29E776BF00303C38 /* seed2-2023-2y.der in Resources */ = {isa = PBXBuildFile; fileRef = FDDCBDA529E776BF00303C38 /* seed2-2023-2y.der */; }; FDDCBDAC29E776BF00303C38 /* seed3-2023-2y.crt in Resources */ = {isa = PBXBuildFile; fileRef = FDDCBDA629E776BF00303C38 /* seed3-2023-2y.crt */; }; FDDCBDAD29E776BF00303C38 /* seed3-2023-2y.der in Resources */ = {isa = PBXBuildFile; fileRef = FDDCBDA729E776BF00303C38 /* seed3-2023-2y.der */; }; - FDDF074A29DAB36900E5E8B5 /* JobRunnerSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDDF074929DAB36900E5E8B5 /* JobRunnerSpec.swift */; }; FDDF074429C3E3D000E5E8B5 /* FetchRequest+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDDF074329C3E3D000E5E8B5 /* FetchRequest+Utilities.swift */; }; + FDDF074A29DAB36900E5E8B5 /* JobRunnerSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDDF074929DAB36900E5E8B5 /* JobRunnerSpec.swift */; }; FDE658A129418C7900A33BC1 /* CryptoKit+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE658A029418C7900A33BC1 /* CryptoKit+Utilities.swift */; }; FDE658A329418E2F00A33BC1 /* KeyPair.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE658A229418E2F00A33BC1 /* KeyPair.swift */; }; FDE6E99829F8E63A00F93C5D /* Accessibility.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE6E99729F8E63A00F93C5D /* Accessibility.swift */; }; @@ -852,7 +859,6 @@ FDF8488629405A61007DCAE5 /* Request.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDF8488529405A60007DCAE5 /* Request.swift */; }; FDF8488829405A9A007DCAE5 /* SOGSBatchRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDF8488729405A9A007DCAE5 /* SOGSBatchRequest.swift */; }; FDF8488929405B27007DCAE5 /* Data+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDFD645A27F26D4600808CA1 /* Data+Utilities.swift */; }; - FDF8488B29405BF2007DCAE5 /* SSKDependencies.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDF8488A29405BF2007DCAE5 /* SSKDependencies.swift */; }; FDF8488E29405C04007DCAE5 /* GetSnodePoolJob.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDF8488D29405C04007DCAE5 /* GetSnodePoolJob.swift */; }; FDF8489129405C13007DCAE5 /* SnodeAPINamespace.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDF8489029405C13007DCAE5 /* SnodeAPINamespace.swift */; }; FDF8489429405C1B007DCAE5 /* SnodeAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDF8489329405C1B007DCAE5 /* SnodeAPI.swift */; }; @@ -911,6 +917,7 @@ FDFDE128282D05530098B17F /* MediaPresentationContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDFDE127282D05530098B17F /* MediaPresentationContext.swift */; }; FDFDE12A282D056B0098B17F /* MediaZoomAnimationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDFDE129282D056B0098B17F /* MediaZoomAnimationController.swift */; }; FDFF61D729F2600300F95FB0 /* Identity+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDFF61D629F2600300F95FB0 /* Identity+Utilities.swift */; }; + FDFF9FDF2A787F57005E0628 /* JSONEncoder+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDFF9FDE2A787F57005E0628 /* JSONEncoder+Utilities.swift */; }; FE5FDED6D91BB4B3FA5C104D /* Pods_GlobalDependencies_FrameworkAndExtensionDependencies_ExtendedDependencies_SessionShareExtension.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 7A9C113D2086D3C8A68A371C /* Pods_GlobalDependencies_FrameworkAndExtensionDependencies_ExtendedDependencies_SessionShareExtension.framework */; }; /* End PBXBuildFile section */ @@ -1626,10 +1633,7 @@ FCB11D8B1A129A76002F93FB /* CoreMedia.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = CoreMedia.framework; path = System/Library/Frameworks/CoreMedia.framework; sourceTree = SDKROOT; }; FD078E4727E02561000769AF /* CommonMockedExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommonMockedExtensions.swift; sourceTree = ""; }; FD078E4C27E17156000769AF /* MockOGMCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockOGMCache.swift; sourceTree = ""; }; - FD078E4E27E175F1000769AF /* DependencyExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DependencyExtensions.swift; sourceTree = ""; }; - FD078E5127E1760A000769AF /* OGMDependencyExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OGMDependencyExtensions.swift; sourceTree = ""; }; - FD078E5927E29F09000769AF /* MockNonce16Generator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockNonce16Generator.swift; sourceTree = ""; }; - FD078E5B27E29F78000769AF /* MockNonce24Generator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockNonce24Generator.swift; sourceTree = ""; }; + FD0969F82A69FFE700C5C365 /* Mocked.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Mocked.swift; sourceTree = ""; }; FD09796A27F6C67500936362 /* Failable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Failable.swift; sourceTree = ""; }; FD09796D27FA6D0000936362 /* Contact.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Contact.swift; sourceTree = ""; }; FD09796F27FA6FF300936362 /* Profile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Profile.swift; sourceTree = ""; }; @@ -1685,6 +1689,15 @@ FD1A94FA2900D1C2000D73D3 /* PersistableRecord+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PersistableRecord+Utilities.swift"; sourceTree = ""; }; FD1A94FD2900D2EA000D73D3 /* PersistableRecordUtilitiesSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PersistableRecordUtilitiesSpec.swift; sourceTree = ""; }; FD1C98E3282E3C5B00B76F9E /* UINavigationBar+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UINavigationBar+Utilities.swift"; sourceTree = ""; }; + FD23CE1A2A651E6D0000B97C /* NetworkType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkType.swift; sourceTree = ""; }; + FD23CE1E2A65269C0000B97C /* Crypto.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Crypto.swift; sourceTree = ""; }; + FD23CE212A661D000000B97C /* OpenGroupAPI+Crypto.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "OpenGroupAPI+Crypto.swift"; sourceTree = ""; }; + FD23CE232A675C440000B97C /* Crypto+SessionMessagingKit.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Crypto+SessionMessagingKit.swift"; sourceTree = ""; }; + FD23CE252A676B5B0000B97C /* DependenciesSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DependenciesSpec.swift; sourceTree = ""; }; + FD23CE272A67755C0000B97C /* MockCrypto.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockCrypto.swift; sourceTree = ""; }; + FD23CE2B2A678DF80000B97C /* MockCaches.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockCaches.swift; sourceTree = ""; }; + FD23CE2F2A67B8820000B97C /* Caches.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Caches.swift; sourceTree = ""; }; + FD23CE312A67C38D0000B97C /* MockNetwork.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockNetwork.swift; sourceTree = ""; }; FD23EA6028ED0B260058676E /* CombineExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CombineExtensions.swift; sourceTree = ""; }; FD245C612850664300B966DD /* Configuration.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Configuration.swift; sourceTree = ""; }; FD28A4F527EAD44C00FF65E7 /* Storage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Storage.swift; sourceTree = ""; }; @@ -1736,9 +1749,8 @@ FD3C905B27E3FBEF00CD579F /* BatchRequestInfoSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BatchRequestInfoSpec.swift; sourceTree = ""; }; FD3C905F27E410F700CD579F /* FileUploadResponseSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileUploadResponseSpec.swift; sourceTree = ""; }; FD3C906627E416AF00CD579F /* BlindedIdLookupSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlindedIdLookupSpec.swift; sourceTree = ""; }; - FD3C906927E417CE00CD579F /* SodiumUtilitiesSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SodiumUtilitiesSpec.swift; sourceTree = ""; }; + FD3C906927E417CE00CD579F /* CryptoSMKSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CryptoSMKSpec.swift; sourceTree = ""; }; FD3C906C27E43C4B00CD579F /* MessageSenderEncryptionSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageSenderEncryptionSpec.swift; sourceTree = ""; }; - FD3C906E27E43E8700CD579F /* MockBox.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockBox.swift; sourceTree = ""; }; FD3C907027E445E500CD579F /* MessageReceiverDecryptionSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageReceiverDecryptionSpec.swift; sourceTree = ""; }; FD3C907427E83AC200CD579F /* OpenGroupServerIdLookup.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenGroupServerIdLookup.swift; sourceTree = ""; }; FD3E0C83283B5835002A425C /* SessionThreadViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionThreadViewModel.swift; sourceTree = ""; }; @@ -1754,6 +1766,7 @@ FD52090628B49738006098F6 /* ConfirmationModal.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConfirmationModal.swift; sourceTree = ""; }; FD52090828B59411006098F6 /* ScreenLockUI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScreenLockUI.swift; sourceTree = ""; }; FD52090A28B59BB4006098F6 /* ScreenLockViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScreenLockViewController.swift; sourceTree = ""; }; + FD559DF42A7368CB00C7C62A /* DispatchQueue+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DispatchQueue+Utilities.swift"; sourceTree = ""; }; FD5C72F6284F0E560029977D /* MessageReceiver+ReadReceipts.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MessageReceiver+ReadReceipts.swift"; sourceTree = ""; }; FD5C72F8284F0E880029977D /* MessageReceiver+TypingIndicators.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MessageReceiver+TypingIndicators.swift"; sourceTree = ""; }; FD5C72FA284F0EA10029977D /* MessageReceiver+DataExtractionNotification.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MessageReceiver+DataExtractionNotification.swift"; sourceTree = ""; }; @@ -1811,7 +1824,6 @@ FD7692F62A53A2ED000E4B70 /* SessionThreadViewModelSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionThreadViewModelSpec.swift; sourceTree = ""; }; FD7728952849E7E90018502F /* String+Utilities.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "String+Utilities.swift"; sourceTree = ""; }; FD7728972849E8110018502F /* UITableView+ReusableView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "UITableView+ReusableView.swift"; sourceTree = ""; }; - FD772899284AF1BD0018502F /* Sodium+Utilities.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Sodium+Utilities.swift"; sourceTree = ""; }; FD77289D284EF1C50018502F /* Sodium+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Sodium+Utilities.swift"; sourceTree = ""; }; FD778B6329B189FF001BAC6B /* _014_GenerateInitialUserConfigDumps.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = _014_GenerateInitialUserConfigDumps.swift; sourceTree = ""; }; FD83B9AF27CF200A005E1583 /* SessionUtilitiesKitTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = SessionUtilitiesKitTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -1822,6 +1834,7 @@ FD83B9C827D0487A005E1583 /* SendDirectMessageResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SendDirectMessageResponse.swift; sourceTree = ""; }; FD83B9CB27D179BC005E1583 /* FSEndpoint.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FSEndpoint.swift; sourceTree = ""; }; FD83B9D127D59495005E1583 /* MockUserDefaults.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockUserDefaults.swift; sourceTree = ""; }; + FD83DCDC2A739D350065FFAE /* RetryWithDependencies.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RetryWithDependencies.swift; sourceTree = ""; }; FD848B86283B844B000E298B /* MessageViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageViewModel.swift; sourceTree = ""; }; FD848B8A283DC509000E298B /* PagedDatabaseObserver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PagedDatabaseObserver.swift; sourceTree = ""; }; FD848B8C283E0B26000E298B /* MessageInputTypes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageInputTypes.swift; sourceTree = ""; }; @@ -1833,11 +1846,6 @@ FD859EEF27BF207700510D0C /* SessionProtos.proto */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.protobuf; path = SessionProtos.proto; sourceTree = ""; }; FD859EF027BF207C00510D0C /* WebSocketResources.proto */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.protobuf; path = WebSocketResources.proto; sourceTree = ""; }; FD859EF127BF6BA200510D0C /* Data+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Data+Utilities.swift"; sourceTree = ""; }; - FD859EF327C2F49200510D0C /* MockSodium.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockSodium.swift; sourceTree = ""; }; - FD859EF527C2F52C00510D0C /* MockSign.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockSign.swift; sourceTree = ""; }; - FD859EF727C2F58900510D0C /* MockAeadXChaCha20Poly1305Ietf.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockAeadXChaCha20Poly1305Ietf.swift; sourceTree = ""; }; - FD859EF927C2F5C500510D0C /* MockGenericHash.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockGenericHash.swift; sourceTree = ""; }; - FD859EFB27C2F60700510D0C /* MockEd25519.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockEd25519.swift; sourceTree = ""; }; FD87DCF928B74DB300AF0F98 /* ConversationSettingsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConversationSettingsViewModel.swift; sourceTree = ""; }; FD87DCFD28B7582C00AF0F98 /* BlockedContactsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlockedContactsViewModel.swift; sourceTree = ""; }; FD87DCFF28B820F200AF0F98 /* BlockedContactCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlockedContactCell.swift; sourceTree = ""; }; @@ -1858,6 +1866,7 @@ FD97B2412A3FEBF30027DD57 /* UnreadMarkerCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UnreadMarkerCell.swift; sourceTree = ""; }; FD9B30F2293EA0BF008DEE3E /* BatchResponseSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BatchResponseSpec.swift; sourceTree = ""; }; FD9BDDF82A5D2294005F1EBC /* libSessionUtil.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = libSessionUtil.a; sourceTree = BUILT_PRODUCTS_DIR; }; + FD9DD2702A72516D00ECB68E /* TestExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestExtensions.swift; sourceTree = ""; }; FDA1E83529A5748F00C5C3BD /* ConfigUserGroupsSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConfigUserGroupsSpec.swift; sourceTree = ""; }; FDA1E83829A5771A00C5C3BD /* LibSessionSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibSessionSpec.swift; sourceTree = ""; }; FDA1E83A29A5F2D500C5C3BD /* SessionUtil+Shared.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SessionUtil+Shared.swift"; sourceTree = ""; }; @@ -1882,13 +1891,10 @@ FDC2909527D71252005DAE71 /* SOGSErrorSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SOGSErrorSpec.swift; sourceTree = ""; }; FDC2909727D7129B005DAE71 /* PersonalizationSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PersonalizationSpec.swift; sourceTree = ""; }; FDC2909927D71376005DAE71 /* NonceGeneratorSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NonceGeneratorSpec.swift; sourceTree = ""; }; - FDC2909B27D713D2005DAE71 /* SodiumProtocolsSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SodiumProtocolsSpec.swift; sourceTree = ""; }; FDC2909D27D85751005DAE71 /* OpenGroupManagerSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenGroupManagerSpec.swift; sourceTree = ""; }; FDC290A527D860CE005DAE71 /* Mock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Mock.swift; sourceTree = ""; }; FDC290A727D9B46D005DAE71 /* NimbleExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NimbleExtensions.swift; sourceTree = ""; }; - FDC290B227DFF9F5005DAE71 /* TestOnionRequestAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestOnionRequestAPI.swift; sourceTree = ""; }; FDC4380827B31D4E00C60D73 /* OpenGroupAPIError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenGroupAPIError.swift; sourceTree = ""; }; - FDC4381427B329CE00C60D73 /* NonceGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NonceGenerator.swift; sourceTree = ""; }; FDC4381627B32EC700C60D73 /* Personalization.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Personalization.swift; sourceTree = ""; }; FDC4381F27B36ADC00C60D73 /* SOGSEndpoint.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SOGSEndpoint.swift; sourceTree = ""; }; FDC4382E27B383AF00C60D73 /* PushServerResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PushServerResponse.swift; sourceTree = ""; }; @@ -1910,8 +1916,6 @@ FDC438B027BB159600C60D73 /* RequestInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RequestInfo.swift; sourceTree = ""; }; FDC438B227BB15B400C60D73 /* ResponseInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ResponseInfo.swift; sourceTree = ""; }; FDC438BC27BB2AB400C60D73 /* Mockable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Mockable.swift; sourceTree = ""; }; - FDC438C027BB4E6800C60D73 /* SMKDependencies.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SMKDependencies.swift; sourceTree = ""; }; - FDC438C227BB512200C60D73 /* SodiumProtocols.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SodiumProtocols.swift; sourceTree = ""; }; FDC438C627BB6DF000C60D73 /* DirectMessage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DirectMessage.swift; sourceTree = ""; }; FDC438C827BB706500C60D73 /* SendDirectMessageRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SendDirectMessageRequest.swift; sourceTree = ""; }; FDC438CA27BB7DB100C60D73 /* UpdateMessageRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdateMessageRequest.swift; sourceTree = ""; }; @@ -1929,8 +1933,8 @@ FDDCBDA529E776BF00303C38 /* seed2-2023-2y.der */ = {isa = PBXFileReference; lastKnownFileType = file; path = "seed2-2023-2y.der"; sourceTree = ""; }; FDDCBDA629E776BF00303C38 /* seed3-2023-2y.crt */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text; path = "seed3-2023-2y.crt"; sourceTree = ""; }; FDDCBDA729E776BF00303C38 /* seed3-2023-2y.der */ = {isa = PBXFileReference; lastKnownFileType = file; path = "seed3-2023-2y.der"; sourceTree = ""; }; - FDDF074929DAB36900E5E8B5 /* JobRunnerSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JobRunnerSpec.swift; sourceTree = ""; }; FDDF074329C3E3D000E5E8B5 /* FetchRequest+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FetchRequest+Utilities.swift"; sourceTree = ""; }; + FDDF074929DAB36900E5E8B5 /* JobRunnerSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JobRunnerSpec.swift; sourceTree = ""; }; FDE658A029418C7900A33BC1 /* CryptoKit+Utilities.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "CryptoKit+Utilities.swift"; sourceTree = ""; }; FDE658A229418E2F00A33BC1 /* KeyPair.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = KeyPair.swift; sourceTree = ""; }; FDE6E99729F8E63A00F93C5D /* Accessibility.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Accessibility.swift; sourceTree = ""; }; @@ -1968,7 +1972,6 @@ FDF8488229405A12007DCAE5 /* BatchResponse.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BatchResponse.swift; sourceTree = ""; }; FDF8488529405A60007DCAE5 /* Request.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Request.swift; sourceTree = ""; }; FDF8488729405A9A007DCAE5 /* SOGSBatchRequest.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SOGSBatchRequest.swift; sourceTree = ""; }; - FDF8488A29405BF2007DCAE5 /* SSKDependencies.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SSKDependencies.swift; sourceTree = ""; }; FDF8488D29405C04007DCAE5 /* GetSnodePoolJob.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GetSnodePoolJob.swift; sourceTree = ""; }; FDF8489029405C13007DCAE5 /* SnodeAPINamespace.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SnodeAPINamespace.swift; sourceTree = ""; }; FDF8489329405C1B007DCAE5 /* SnodeAPI.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SnodeAPI.swift; sourceTree = ""; }; @@ -2026,6 +2029,7 @@ FDFDE127282D05530098B17F /* MediaPresentationContext.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaPresentationContext.swift; sourceTree = ""; }; FDFDE129282D056B0098B17F /* MediaZoomAnimationController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaZoomAnimationController.swift; sourceTree = ""; }; FDFF61D629F2600300F95FB0 /* Identity+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Identity+Utilities.swift"; sourceTree = ""; }; + FDFF9FDE2A787F57005E0628 /* JSONEncoder+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "JSONEncoder+Utilities.swift"; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -2556,6 +2560,7 @@ FDC438B227BB15B400C60D73 /* ResponseInfo.swift */, C33FDAF2255A580500E217F9 /* ProxiedContentDownloader.swift */, C3C2A5BC255385EE00C340D1 /* HTTP.swift */, + FD23CE1A2A651E6D0000B97C /* NetworkType.swift */, FDF848EE294067E4007DCAE5 /* URLResponse+Utilities.swift */, ); path = Networking; @@ -2583,14 +2588,15 @@ isa = PBXGroup; children = ( 7BD477A727EC39F5004E2822 /* Atomic.swift */, + FDC438CC27BC641200C60D73 /* Set+Utilities.swift */, 7BAF54D527ACD0E2003D12F8 /* ReusableView.swift */, C33FDB8A255A581200E217F9 /* AppContext.h */, C33FDB85255A581100E217F9 /* AppContext.m */, C3C2A5D12553860800C340D1 /* Array+Utilities.swift */, FDC4383D27B4708600C60D73 /* Atomic.swift */, - FDC438CC27BC641200C60D73 /* Set+Utilities.swift */, C33FDAA8255A57FF00E217F9 /* BuildConfiguration.swift */, B8F5F58225EC94A6003BF8D4 /* Collection+Utilities.swift */, + FD23CE2F2A67B8820000B97C /* Caches.swift */, FDFD645A27F26D4600808CA1 /* Data+Utilities.swift */, FDC6D75F2862B3F600B04575 /* Dependencies.swift */, C3C2A5D52553860A00C340D1 /* Dictionary+Utilities.swift */, @@ -3180,6 +3186,7 @@ C3A721332558BDDF0043A11F /* Open Groups */ = { isa = PBXGroup; children = ( + FD23CE202A661CE80000B97C /* Crypto */, FDC4381827B34EAD00C60D73 /* Models */, FDC4380727B31D3A00C60D73 /* Types */, FD3C907427E83AC200CD579F /* OpenGroupServerIdLookup.swift */, @@ -3204,9 +3211,10 @@ children = ( C33FDB01255A580700E217F9 /* AppReadiness.h */, C33FDB75255A581000E217F9 /* AppReadiness.m */, - FDF0B7542807C4BB004C14C5 /* Environment.swift */, + FD23CE232A675C440000B97C /* Crypto+SessionMessagingKit.swift */, FD859EF127BF6BA200510D0C /* Data+Utilities.swift */, C38EF309255B6DBE007E1867 /* DeviceSleepManager.swift */, + FDF0B7542807C4BB004C14C5 /* Environment.swift */, C3BBE0C62554F1570050F1E3 /* FixedWidthInteger+BigEndian.swift */, C3A71D0A2558989C0043A11F /* MessageWrapper.swift */, C38EF2F5255B6DBC007E1867 /* OWSAudioPlayer.h */, @@ -3220,7 +3228,6 @@ C38EF2EC255B6DBA007E1867 /* ProximityMonitoringManager.swift */, C38EEF09255B49A8007E1867 /* SNProtoEnvelope+Conversion.swift */, FDC4386827B4E6B700C60D73 /* String+Utlities.swift */, - FD772899284AF1BD0018502F /* Sodium+Utilities.swift */, FD16AB602A1DD9B60083D849 /* ProfilePictureView+Convenience.swift */, C3A3A170256E1D25004D228D /* SSKReachabilityManager.swift */, C3ECBF7A257056B700EA7FCE /* Threading.swift */, @@ -3239,7 +3246,6 @@ FDF8488C29405C04007DCAE5 /* Jobs */, FDF8489229405C1B007DCAE5 /* Networking */, C3C2A5CD255385F300C340D1 /* Utilities */, - FDF8488A29405BF2007DCAE5 /* SSKDependencies.swift */, C3C2A5B9255385ED00C340D1 /* Configuration.swift */, ); path = SessionSnodeKit; @@ -3306,7 +3312,6 @@ FD8ECF7529340F4800C0D1BB /* SessionUtil */, FD3E0C82283B581F002A425C /* Shared Models */, C3BBE0B32554F0D30050F1E3 /* Utilities */, - FDC438C027BB4E6800C60D73 /* SMKDependencies.swift */, FD245C612850664300B966DD /* Configuration.swift */, ); path = SessionMessagingKit; @@ -3552,7 +3557,10 @@ FDA8EB0F280F8238002B68E5 /* Codable+Utilities.swift */, FD3003692A3ADD6000B5A5FB /* CExceptionHelper.h */, FD30036D2A3AE26000B5A5FB /* CExceptionHelper.mm */, + FD23CE1E2A65269C0000B97C /* Crypto.swift */, + FD559DF42A7368CB00C7C62A /* DispatchQueue+Utilities.swift */, FD09796A27F6C67500936362 /* Failable.swift */, + FDFF9FDE2A787F57005E0628 /* JSONEncoder+Utilities.swift */, FD09797127FAA2F500936362 /* Optional+Utilities.swift */, FD09797C27FBDB2000936362 /* Notification+Utilities.swift */, FDF222082818D2B0000A4995 /* NSAttributedString+Utilities.swift */, @@ -3736,6 +3744,14 @@ path = Utilities; sourceTree = ""; }; + FD23CE202A661CE80000B97C /* Crypto */ = { + isa = PBXGroup; + children = ( + FD23CE212A661D000000B97C /* OpenGroupAPI+Crypto.swift */, + ); + path = Crypto; + sourceTree = ""; + }; FD29598E2A43BE5400888A17 /* Utilities */ = { isa = PBXGroup; children = ( @@ -3838,7 +3854,7 @@ FD3C906827E417B100CD579F /* Utilities */ = { isa = PBXGroup; children = ( - FD3C906927E417CE00CD579F /* SodiumUtilitiesSpec.swift */, + FD3C906927E417CE00CD579F /* CryptoSMKSpec.swift */, ); path = Utilities; sourceTree = ""; @@ -3876,6 +3892,7 @@ children = ( FD7115F628C8150D00B47552 /* Disposable Views */, FD7115FD28C8202D00B47552 /* ReplaySubject.swift */, + FD83DCDC2A739D350065FFAE /* RetryWithDependencies.swift */, FD7115FB28C8155800B47552 /* Publisher+Utilities.swift */, FD71160128C8255900B47552 /* UIControl+Combine.swift */, FD7115F928C8153400B47552 /* UIBarButtonItem+Combine.swift */, @@ -4011,6 +4028,7 @@ isa = PBXGroup; children = ( FD0B77B129B82B7A009169BA /* ArrayUtilitiesSpec.swift */, + FD23CE252A676B5B0000B97C /* DependenciesSpec.swift */, FD83B9BA27CF20AF005E1583 /* SessionIdSpec.swift */, ); path = General; @@ -4020,9 +4038,14 @@ isa = PBXGroup; children = ( FDC290A527D860CE005DAE71 /* Mock.swift */, + FD0969F82A69FFE700C5C365 /* Mocked.swift */, + FD23CE272A67755C0000B97C /* MockCrypto.swift */, + FD23CE2B2A678DF80000B97C /* MockCaches.swift */, FDFD645C27F273F300808CA1 /* MockGeneralCache.swift */, + FD23CE312A67C38D0000B97C /* MockNetwork.swift */, FD96F3A629DBD43D00401309 /* MockJobRunner.swift */, FD83B9BD27CF2243005E1583 /* TestConstants.swift */, + FD9DD2702A72516D00ECB68E /* TestExtensions.swift */, FDC290A727D9B46D005DAE71 /* NimbleExtensions.swift */, FD078E4727E02561000769AF /* CommonMockedExtensions.swift */, FD23EA6028ED0B260058676E /* CombineExtensions.swift */, @@ -4141,7 +4164,6 @@ FDC2909727D7129B005DAE71 /* PersonalizationSpec.swift */, FDC2909327D710B4005DAE71 /* SOGSEndpointSpec.swift */, FDC2909527D71252005DAE71 /* SOGSErrorSpec.swift */, - FDC2909B27D713D2005DAE71 /* SodiumProtocolsSpec.swift */, ); path = Types; sourceTree = ""; @@ -4155,8 +4177,6 @@ FDC4380827B31D4E00C60D73 /* OpenGroupAPIError.swift */, FDC4381627B32EC700C60D73 /* Personalization.swift */, FD2959912A4417A900888A17 /* PreparedSendData.swift */, - FDC4381427B329CE00C60D73 /* NonceGenerator.swift */, - FDC438C227BB512200C60D73 /* SodiumProtocols.swift */, ); path = Types; sourceTree = ""; @@ -4248,19 +4268,8 @@ isa = PBXGroup; children = ( FDC438BC27BB2AB400C60D73 /* Mockable.swift */, - FD859EF327C2F49200510D0C /* MockSodium.swift */, - FD3C906E27E43E8700CD579F /* MockBox.swift */, - FD859EF927C2F5C500510D0C /* MockGenericHash.swift */, - FD859EF527C2F52C00510D0C /* MockSign.swift */, - FD859EF727C2F58900510D0C /* MockAeadXChaCha20Poly1305Ietf.swift */, - FD859EFB27C2F60700510D0C /* MockEd25519.swift */, - FD078E5927E29F09000769AF /* MockNonce16Generator.swift */, - FD078E5B27E29F78000769AF /* MockNonce24Generator.swift */, FD83B9D127D59495005E1583 /* MockUserDefaults.swift */, FD078E4C27E17156000769AF /* MockOGMCache.swift */, - FDC290B227DFF9F5005DAE71 /* TestOnionRequestAPI.swift */, - FD078E4E27E175F1000769AF /* DependencyExtensions.swift */, - FD078E5127E1760A000769AF /* OGMDependencyExtensions.swift */, ); path = _TestUtilities; sourceTree = ""; @@ -4427,9 +4436,6 @@ C33FDDB0255A582000E217F9 /* NSURLSessionDataTask+StatusCode.h in Headers */, C33FDDD0255A582000E217F9 /* FunctionalUtil.h in Headers */, C33FDD5B255A582000E217F9 /* OWSOperation.h in Headers */, - C38EF24C255B6D67007E1867 /* NSAttributedString+OWS.h in Headers */, - C38EF32B255B6DBF007E1867 /* OWSFormat.h in Headers */, - C33FDD7C255A582000E217F9 /* SSKAsserts.h in Headers */, C33FDDCC255A582000E217F9 /* TSConstants.h in Headers */, C33FDDBD255A582000E217F9 /* ByteParser.h in Headers */, C33FDDB3255A582000E217F9 /* OWSError.h in Headers */, @@ -4631,9 +4637,9 @@ D221A085169C9E5E00537ABF /* Sources */, D221A086169C9E5E00537ABF /* Frameworks */, D221A087169C9E5E00537ABF /* Resources */, - FDD82C422A2085B900425F05 /* Add Commit Hash To Build Info Plist */, 453518771FC635DD00210559 /* Embed Foundation Extensions */, 4535189F1FC63DBF00210559 /* Embed Frameworks */, + FDD82C422A2085B900425F05 /* Add Commit Hash To Build Info Plist */, 90DF4725BB1271EBA2C66A12 /* [CP] Embed Pods Frameworks */, ); buildRules = ( @@ -5557,7 +5563,6 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - FDF8488B29405BF2007DCAE5 /* SSKDependencies.swift in Sources */, FDF8488E29405C04007DCAE5 /* GetSnodePoolJob.swift in Sources */, FDF848C329405C5A007DCAE5 /* DeleteMessagesRequest.swift in Sources */, FDF8489129405C13007DCAE5 /* SnodeAPINamespace.swift in Sources */, @@ -5629,10 +5634,13 @@ FDFD645927F26C6800808CA1 /* Array+Utilities.swift in Sources */, 7B1D74B027C365960030B423 /* Timer+MainThread.swift in Sources */, C32C5D83256DD5B6003C73A2 /* SSKKeychainStorage.swift in Sources */, + FD559DF52A7368CB00C7C62A /* DispatchQueue+Utilities.swift in Sources */, FDF8488329405A12007DCAE5 /* BatchResponse.swift in Sources */, C3D9E39B256763C20040E4F3 /* AppContext.m in Sources */, + FDFF9FDF2A787F57005E0628 /* JSONEncoder+Utilities.swift in Sources */, C3BBE0A82554D4DE0050F1E3 /* JSON.swift in Sources */, FD17D7C127F5200100122BE0 /* TypedTableDefinition.swift in Sources */, + FD23CE1B2A651E6D0000B97C /* NetworkType.swift in Sources */, FD17D7EA27F6A1C600122BE0 /* SUKLegacy.swift in Sources */, FDA8EB10280F8238002B68E5 /* Codable+Utilities.swift in Sources */, FDBB25E32988B13800F1508E /* _004_AddJobPriority.swift in Sources */, @@ -5690,8 +5698,10 @@ C3A71F892558BA9F0043A11F /* Mnemonic.swift in Sources */, B8F5F58325EC94A6003BF8D4 /* Collection+Utilities.swift in Sources */, 7BD477A827EC39F5004E2822 /* Atomic.swift in Sources */, + FD23CE1F2A65269C0000B97C /* Crypto.swift in Sources */, B8BC00C0257D90E30032E807 /* General.swift in Sources */, FDF8488629405A61007DCAE5 /* Request.swift in Sources */, + FD23CE302A67B8820000B97C /* Caches.swift in Sources */, FD17D7A127F40D2500122BE0 /* Storage.swift in Sources */, FD1A94FB2900D1C2000D73D3 /* PersistableRecord+Utilities.swift in Sources */, FD5D201E27B0D87C00FEA984 /* SessionId.swift in Sources */, @@ -5701,6 +5711,7 @@ FDF22211281B5E0B000A4995 /* TableRecord+Utilities.swift in Sources */, B88FA7FB26114EA70049422F /* Hex.swift in Sources */, FD7728962849E7E90018502F /* String+Utilities.swift in Sources */, + FD83DCDD2A739D350065FFAE /* RetryWithDependencies.swift in Sources */, C35D0DB525AE5F1200B6BF49 /* UIEdgeInsets.swift in Sources */, C3D9E4F4256778AF0040E4F3 /* NSData+Image.m in Sources */, FDF222092818D2B0000A4995 /* NSAttributedString+Utilities.swift in Sources */, @@ -5753,7 +5764,6 @@ FDF0B74928060D13004C14C5 /* QuotedReplyModel.swift in Sources */, FD3003662A25D5B300B5A5FB /* ConfigMessageReceiveJob.swift in Sources */, 7B81682C28B72F480069F315 /* PendingChange.swift in Sources */, - FD77289A284AF1BD0018502F /* Sodium+Utilities.swift in Sources */, FD5C7309285007920029977D /* BlindedIdLookup.swift in Sources */, FD71161C28D194FB00B47552 /* MentionInfo.swift in Sources */, 7B4C75CB26B37E0F0000AC89 /* UnsendRequest.swift in Sources */, @@ -5851,9 +5861,9 @@ FD432434299C6985008A0213 /* PendingReadReceipt.swift in Sources */, FDC4381727B32EC700C60D73 /* Personalization.swift in Sources */, FD245C51285065CC00B966DD /* MessageReceiver.swift in Sources */, + FD23CE222A661D000000B97C /* OpenGroupAPI+Crypto.swift in Sources */, FD245C652850665400B966DD /* ClosedGroupControlMessage.swift in Sources */, FDC4387827B5C35400C60D73 /* SendMessageRequest.swift in Sources */, - FDC4381527B329CE00C60D73 /* NonceGenerator.swift in Sources */, 7B93D07027CF194000811CB6 /* ConfigurationMessage+Convenience.swift in Sources */, FD5C72FD284F0EC90029977D /* MessageReceiver+ExpirationTimers.swift in Sources */, C32C5A88256DBCF9003C73A2 /* MessageReceiver+ClosedGroups.swift in Sources */, @@ -5882,7 +5892,7 @@ FDA1E83B29A5F2D500C5C3BD /* SessionUtil+Shared.swift in Sources */, C352A2FF25574B6300338F3E /* MessageSendJob.swift in Sources */, FD16AB612A1DD9B60083D849 /* ProfilePictureView+Convenience.swift in Sources */, - FDC438C327BB512200C60D73 /* SodiumProtocols.swift in Sources */, + FD23CE242A675C440000B97C /* Crypto+SessionMessagingKit.swift in Sources */, B8856D11256F112A001CE70E /* OWSAudioSession.swift in Sources */, C3DB66C3260ACCE6001EFC55 /* OpenGroupPoller.swift in Sources */, FD716E722850647600C96BF4 /* Data+Utilities.swift in Sources */, @@ -5914,7 +5924,6 @@ FD09798127FCFEE800936362 /* SessionThread.swift in Sources */, FD09C5EA282A1BB2000CE219 /* ThreadTypingIndicator.swift in Sources */, FDF0B75E280AAF35004C14C5 /* Preferences.swift in Sources */, - FDC438C127BB4E6800C60D73 /* SMKDependencies.swift in Sources */, FDC4383827B3863200C60D73 /* VersionResponse.swift in Sources */, B806ECA126C4A7E4008BDA44 /* WebRTCSession+UI.swift in Sources */, FD432432299C6933008A0213 /* _011_AddPendingReadReceipts.swift in Sources */, @@ -6140,14 +6149,18 @@ files = ( FD71161728D00DA400B47552 /* ThreadSettingsViewModelSpec.swift in Sources */, FD2AAAF028ED57B500A49611 /* SynchronousStorage.swift in Sources */, + FD23CE292A6775650000B97C /* MockCrypto.swift in Sources */, + FD23CE332A67C4D90000B97C /* MockNetwork.swift in Sources */, FD71161528D00D6700B47552 /* ThreadDisappearingMessagesViewModelSpec.swift in Sources */, + FD23CE2D2A678E1E0000B97C /* MockCaches.swift in Sources */, FD23EA5E28ED00FD0058676E /* NimbleExtensions.swift in Sources */, FD23EA5F28ED00FF0058676E /* CommonMockedExtensions.swift in Sources */, - FD96F3A829DBD4AD00401309 /* MockJobRunner.swift in Sources */, FD23EA5D28ED00FA0058676E /* TestConstants.swift in Sources */, FD71161A28D00E1100B47552 /* NotificationContentViewModelSpec.swift in Sources */, + FD0969FA2A6A00B000C5C365 /* Mocked.swift in Sources */, FD23EA5C28ED00F80058676E /* Mock.swift in Sources */, FD23EA6128ED0B260058676E /* CombineExtensions.swift in Sources */, + FD9DD2712A72516D00ECB68E /* TestExtensions.swift in Sources */, FD2AAAED28ED3E1000A49611 /* MockGeneralCache.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -6157,18 +6170,23 @@ buildActionMask = 2147483647; files = ( FD2AAAF228ED57B500A49611 /* SynchronousStorage.swift in Sources */, - FD96F3A929DBD4AD00401309 /* MockJobRunner.swift in Sources */, FD078E4927E02576000769AF /* CommonMockedExtensions.swift in Sources */, FD83B9BF27CF2294005E1583 /* TestConstants.swift in Sources */, + FD9DD2732A72516D00ECB68E /* TestExtensions.swift in Sources */, FD83B9BB27CF20AF005E1583 /* SessionIdSpec.swift in Sources */, FDC290A927D9B46D005DAE71 /* NimbleExtensions.swift in Sources */, FD23EA6328ED0B260058676E /* CombineExtensions.swift in Sources */, + FD23CE352A67C4DA0000B97C /* MockNetwork.swift in Sources */, + FD23CE282A67755C0000B97C /* MockCrypto.swift in Sources */, FD2AAAEE28ED3E1100A49611 /* MockGeneralCache.swift in Sources */, FD9B30F3293EA0BF008DEE3E /* BatchResponseSpec.swift in Sources */, FD37EA1528AB42CB003AE748 /* IdentitySpec.swift in Sources */, + FD23CE2C2A678DF80000B97C /* MockCaches.swift in Sources */, FD0B77B229B82B7A009169BA /* ArrayUtilitiesSpec.swift in Sources */, FD1A94FE2900D2EA000D73D3 /* PersistableRecordUtilitiesSpec.swift in Sources */, + FD23CE262A676B5B0000B97C /* DependenciesSpec.swift in Sources */, FDDF074A29DAB36900E5E8B5 /* JobRunnerSpec.swift in Sources */, + FD0969FB2A6A00B100C5C365 /* Mocked.swift in Sources */, FDC290AA27D9B6FD005DAE71 /* Mock.swift in Sources */, FD2959902A43BE5F00888A17 /* VersionSpec.swift in Sources */, ); @@ -6180,56 +6198,49 @@ files = ( FD3C905C27E3FBEF00CD579F /* BatchRequestInfoSpec.swift in Sources */, FDDC08F229A300E800BF9681 /* LibSessionTypeConversionUtilitiesSpec.swift in Sources */, - FD859EFA27C2F5C500510D0C /* MockGenericHash.swift in Sources */, FDC2909427D710B4005DAE71 /* SOGSEndpointSpec.swift in Sources */, FD96F3A529DBC3DC00401309 /* MessageSendJobSpec.swift in Sources */, - FDC290B327DFF9F5005DAE71 /* TestOnionRequestAPI.swift in Sources */, FDC2909127D709CA005DAE71 /* SOGSMessageSpec.swift in Sources */, - FD3C906A27E417CE00CD579F /* SodiumUtilitiesSpec.swift in Sources */, + FD3C906A27E417CE00CD579F /* CryptoSMKSpec.swift in Sources */, FD96F3A729DBD43D00401309 /* MockJobRunner.swift in Sources */, FDBB25E12983909300F1508E /* ConfigConvoInfoVolatileSpec.swift in Sources */, FD3C907127E445E500CD579F /* MessageReceiverDecryptionSpec.swift in Sources */, FDC2909627D71252005DAE71 /* SOGSErrorSpec.swift in Sources */, FDC2908727D7047F005DAE71 /* RoomSpec.swift in Sources */, - FD078E4F27E175F1000769AF /* DependencyExtensions.swift in Sources */, - FDC2909C27D713D2005DAE71 /* SodiumProtocolsSpec.swift in Sources */, FD3C906027E410F700CD579F /* FileUploadResponseSpec.swift in Sources */, FD83B9C727CF3F10005E1583 /* CapabilitiesSpec.swift in Sources */, FDC2909A27D71376005DAE71 /* NonceGeneratorSpec.swift in Sources */, FD2AAAF128ED57B500A49611 /* SynchronousStorage.swift in Sources */, FD078E4827E02561000769AF /* CommonMockedExtensions.swift in Sources */, - FD859EF827C2F58900510D0C /* MockAeadXChaCha20Poly1305Ietf.swift in Sources */, + FD23CE2A2A6775660000B97C /* MockCrypto.swift in Sources */, FDC2909827D7129B005DAE71 /* PersonalizationSpec.swift in Sources */, - FD859EF427C2F49200510D0C /* MockSodium.swift in Sources */, FD078E4D27E17156000769AF /* MockOGMCache.swift in Sources */, - FD078E5227E1760A000769AF /* OGMDependencyExtensions.swift in Sources */, + FD9DD2722A72516D00ECB68E /* TestExtensions.swift in Sources */, FDA1E83629A5748F00C5C3BD /* ConfigUserGroupsSpec.swift in Sources */, - FD859EFC27C2F60700510D0C /* MockEd25519.swift in Sources */, FDC290A627D860CE005DAE71 /* Mock.swift in Sources */, FD2B4AFB29429D1000AB4848 /* ConfigContactsSpec.swift in Sources */, FDA1E83D29AC71A800C5C3BD /* SessionUtilSpec.swift in Sources */, FD83B9C027CF2294005E1583 /* TestConstants.swift in Sources */, - FD3C906F27E43E8700CD579F /* MockBox.swift in Sources */, FDC4389A27BA002500C60D73 /* OpenGroupAPISpec.swift in Sources */, + FD23CE2E2A678E1E0000B97C /* MockCaches.swift in Sources */, FD23EA6228ED0B260058676E /* CombineExtensions.swift in Sources */, FD83B9C527CF3E2A005E1583 /* OpenGroupSpec.swift in Sources */, + FD23CE342A67C4D90000B97C /* MockNetwork.swift in Sources */, FDC2908B27D707F3005DAE71 /* SendMessageRequestSpec.swift in Sources */, FD8ECF822934387A00C0D1BB /* ConfigUserProfileSpec.swift in Sources */, FDC290A827D9B46D005DAE71 /* NimbleExtensions.swift in Sources */, FD7692F72A53A2ED000E4B70 /* SessionThreadViewModelSpec.swift in Sources */, + FD0969F92A69FFE700C5C365 /* Mocked.swift in Sources */, FDC2908F27D70938005DAE71 /* SendDirectMessageRequestSpec.swift in Sources */, FDC438BD27BB2AB400C60D73 /* Mockable.swift in Sources */, FDA1E83929A5771A00C5C3BD /* LibSessionSpec.swift in Sources */, - FD859EF627C2F52C00510D0C /* MockSign.swift in Sources */, FDC2908927D70656005DAE71 /* RoomPollInfoSpec.swift in Sources */, FDFD645D27F273F300808CA1 /* MockGeneralCache.swift in Sources */, - FD078E5A27E29F09000769AF /* MockNonce16Generator.swift in Sources */, FD3C906D27E43C4B00CD579F /* MessageSenderEncryptionSpec.swift in Sources */, FDC2908D27D70905005DAE71 /* UpdateMessageRequestSpec.swift in Sources */, FD078E5427E197CA000769AF /* OpenGroupManagerSpec.swift in Sources */, FD3C906727E416AF00CD579F /* BlindedIdLookupSpec.swift in Sources */, FD83B9D227D59495005E1583 /* MockUserDefaults.swift in Sources */, - FD078E5C27E29F78000769AF /* MockNonce24Generator.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/Session/Conversations/Context Menu/ContextMenuVC+Action.swift b/Session/Conversations/Context Menu/ContextMenuVC+Action.swift index f71cfde88..3fb3d0fb8 100644 --- a/Session/Conversations/Context Menu/ContextMenuVC+Action.swift +++ b/Session/Conversations/Context Menu/ContextMenuVC+Action.swift @@ -35,15 +35,15 @@ extension ContextMenuVC { // MARK: - Actions - static func info(_ cellViewModel: MessageViewModel, _ delegate: ContextMenuActionDelegate?) -> Action { + static func info(_ cellViewModel: MessageViewModel, _ delegate: ContextMenuActionDelegate?, using dependencies: Dependencies) -> Action { return Action( icon: UIImage(named: "ic_info"), title: "context_menu_info".localized(), accessibilityLabel: "Message info" - ) { delegate?.info(cellViewModel) } + ) { delegate?.info(cellViewModel, using: dependencies) } } - static func retry(_ cellViewModel: MessageViewModel, _ delegate: ContextMenuActionDelegate?) -> Action { + static func retry(_ cellViewModel: MessageViewModel, _ delegate: ContextMenuActionDelegate?, using dependencies: Dependencies) -> Action { return Action( icon: UIImage(systemName: "arrow.triangle.2.circlepath"), title: (cellViewModel.state == .failedToSync ? @@ -51,23 +51,23 @@ extension ContextMenuVC { "context_menu_resend".localized() ), accessibilityLabel: (cellViewModel.state == .failedToSync ? "Resync message" : "Resend message") - ) { delegate?.retry(cellViewModel) } + ) { delegate?.retry(cellViewModel, using: dependencies) } } - static func reply(_ cellViewModel: MessageViewModel, _ delegate: ContextMenuActionDelegate?) -> Action { + static func reply(_ cellViewModel: MessageViewModel, _ delegate: ContextMenuActionDelegate?, using dependencies: Dependencies) -> Action { return Action( icon: UIImage(named: "ic_reply"), title: "context_menu_reply".localized(), accessibilityLabel: "Reply to message" - ) { delegate?.reply(cellViewModel) } + ) { delegate?.reply(cellViewModel, using: dependencies) } } - static func copy(_ cellViewModel: MessageViewModel, _ delegate: ContextMenuActionDelegate?) -> Action { + static func copy(_ cellViewModel: MessageViewModel, _ delegate: ContextMenuActionDelegate?, using dependencies: Dependencies) -> Action { return Action( icon: UIImage(named: "ic_copy"), title: "copy".localized(), accessibilityLabel: "Copy text" - ) { delegate?.copy(cellViewModel) } + ) { delegate?.copy(cellViewModel, using: dependencies) } } static func copySessionID(_ cellViewModel: MessageViewModel, _ delegate: ContextMenuActionDelegate?) -> Action { @@ -79,50 +79,50 @@ extension ContextMenuVC { ) { delegate?.copySessionID(cellViewModel) } } - static func delete(_ cellViewModel: MessageViewModel, _ delegate: ContextMenuActionDelegate?) -> Action { + static func delete(_ cellViewModel: MessageViewModel, _ delegate: ContextMenuActionDelegate?, using dependencies: Dependencies) -> Action { return Action( icon: UIImage(named: "ic_trash"), title: "TXT_DELETE_TITLE".localized(), accessibilityLabel: "Delete message" - ) { delegate?.delete(cellViewModel) } + ) { delegate?.delete(cellViewModel, using: dependencies) } } - static func save(_ cellViewModel: MessageViewModel, _ delegate: ContextMenuActionDelegate?) -> Action { + static func save(_ cellViewModel: MessageViewModel, _ delegate: ContextMenuActionDelegate?, using dependencies: Dependencies) -> Action { return Action( icon: UIImage(named: "ic_download"), title: "context_menu_save".localized(), accessibilityLabel: "Save attachment" - ) { delegate?.save(cellViewModel) } + ) { delegate?.save(cellViewModel, using: dependencies) } } - static func ban(_ cellViewModel: MessageViewModel, _ delegate: ContextMenuActionDelegate?) -> Action { + static func ban(_ cellViewModel: MessageViewModel, _ delegate: ContextMenuActionDelegate?, using dependencies: Dependencies) -> Action { return Action( icon: UIImage(named: "ic_block"), title: "context_menu_ban_user".localized(), accessibilityLabel: "Ban user" - ) { delegate?.ban(cellViewModel) } + ) { delegate?.ban(cellViewModel, using: dependencies) } } - static func banAndDeleteAllMessages(_ cellViewModel: MessageViewModel, _ delegate: ContextMenuActionDelegate?) -> Action { + static func banAndDeleteAllMessages(_ cellViewModel: MessageViewModel, _ delegate: ContextMenuActionDelegate?, using dependencies: Dependencies) -> Action { return Action( icon: UIImage(named: "ic_block"), title: "context_menu_ban_and_delete_all".localized(), accessibilityLabel: "Ban user and delete" - ) { delegate?.banAndDeleteAllMessages(cellViewModel) } + ) { delegate?.banAndDeleteAllMessages(cellViewModel, using: dependencies) } } - static func react(_ cellViewModel: MessageViewModel, _ emoji: EmojiWithSkinTones, _ delegate: ContextMenuActionDelegate?) -> Action { + static func react(_ cellViewModel: MessageViewModel, _ emoji: EmojiWithSkinTones, _ delegate: ContextMenuActionDelegate?, using dependencies: Dependencies) -> Action { return Action( title: emoji.rawValue, isEmojiAction: true - ) { delegate?.react(cellViewModel, with: emoji) } + ) { delegate?.react(cellViewModel, with: emoji, using: dependencies) } } - static func emojiPlusButton(_ cellViewModel: MessageViewModel, _ delegate: ContextMenuActionDelegate?) -> Action { + static func emojiPlusButton(_ cellViewModel: MessageViewModel, _ delegate: ContextMenuActionDelegate?, using dependencies: Dependencies) -> Action { return Action( isEmojiPlus: true, accessibilityLabel: "Add emoji" - ) { delegate?.showFullEmojiKeyboard(cellViewModel) } + ) { delegate?.showFullEmojiKeyboard(cellViewModel, using: dependencies) } } static func dismiss(_ delegate: ContextMenuActionDelegate?) -> Action { @@ -150,7 +150,8 @@ extension ContextMenuVC { currentUserBlinded25PublicKey: String?, currentUserIsOpenGroupModerator: Bool, currentThreadIsMessageRequest: Bool, - delegate: ContextMenuActionDelegate? + delegate: ContextMenuActionDelegate?, + using dependencies: Dependencies = Dependencies() ) -> [Action]? { switch cellViewModel.variant { case .standardIncomingDeleted, .infoCall, @@ -159,7 +160,7 @@ extension ContextMenuVC { .infoClosedGroupCurrentUserLeft, .infoClosedGroupCurrentUserLeaving, .infoClosedGroupCurrentUserErrorLeaving, .infoMessageRequestAccepted, .infoDisappearingMessagesUpdate: // Let the user delete info messages and unsent messages - return [ Action.delete(cellViewModel, delegate) ] + return [ Action.delete(cellViewModel, delegate, using: dependencies) ] case .standardOutgoing, .standardIncoming: break } @@ -227,18 +228,21 @@ extension ContextMenuVC { let shouldShowInfo: Bool = (cellViewModel.attachments?.isEmpty == false) let generatedActions: [Action] = [ - (canRetry ? Action.retry(cellViewModel, delegate) : nil), - (viewModelCanReply(cellViewModel) ? Action.reply(cellViewModel, delegate) : nil), - (canCopy ? Action.copy(cellViewModel, delegate) : nil), - (canSave ? Action.save(cellViewModel, delegate) : nil), + (canRetry ? Action.retry(cellViewModel, delegate, using: dependencies) : nil), + (viewModelCanReply(cellViewModel) ? Action.reply(cellViewModel, delegate, using: dependencies) : nil), + (canCopy ? Action.copy(cellViewModel, delegate, using: dependencies) : nil), + (canSave ? Action.save(cellViewModel, delegate, using: dependencies) : nil), (canCopySessionId ? Action.copySessionID(cellViewModel, delegate) : nil), - (canDelete ? Action.delete(cellViewModel, delegate) : nil), - (canBan ? Action.ban(cellViewModel, delegate) : nil), - (canBan ? Action.banAndDeleteAllMessages(cellViewModel, delegate) : nil), - (shouldShowInfo ? Action.info(cellViewModel, delegate) : nil), + (canDelete ? Action.delete(cellViewModel, delegate, using: dependencies) : nil), + (canBan ? Action.ban(cellViewModel, delegate, using: dependencies) : nil), + (canBan ? Action.banAndDeleteAllMessages(cellViewModel, delegate, using: dependencies) : nil), + (shouldShowInfo ? Action.info(cellViewModel, delegate, using: dependencies) : nil), ] - .appending(contentsOf: (shouldShowEmojiActions ? recentEmojis : []).map { Action.react(cellViewModel, $0, delegate) }) - .appending(Action.emojiPlusButton(cellViewModel, delegate)) + .appending( + contentsOf: (shouldShowEmojiActions ? recentEmojis : []) + .map { Action.react(cellViewModel, $0, delegate, using: dependencies) } + ) + .appending(Action.emojiPlusButton(cellViewModel, delegate, using: dependencies)) .compactMap { $0 } guard !generatedActions.isEmpty else { return [] } @@ -250,16 +254,16 @@ extension ContextMenuVC { // MARK: - Delegate protocol ContextMenuActionDelegate { - func info(_ cellViewModel: MessageViewModel) - func retry(_ cellViewModel: MessageViewModel) - func reply(_ cellViewModel: MessageViewModel) - func copy(_ cellViewModel: MessageViewModel) + func info(_ cellViewModel: MessageViewModel, using dependencies: Dependencies) + func retry(_ cellViewModel: MessageViewModel, using dependencies: Dependencies) + func reply(_ cellViewModel: MessageViewModel, using dependencies: Dependencies) + func copy(_ cellViewModel: MessageViewModel, using dependencies: Dependencies) func copySessionID(_ cellViewModel: MessageViewModel) - func delete(_ cellViewModel: MessageViewModel) - func save(_ cellViewModel: MessageViewModel) - func ban(_ cellViewModel: MessageViewModel) - func banAndDeleteAllMessages(_ cellViewModel: MessageViewModel) - func react(_ cellViewModel: MessageViewModel, with emoji: EmojiWithSkinTones) - func showFullEmojiKeyboard(_ cellViewModel: MessageViewModel) + func delete(_ cellViewModel: MessageViewModel, using dependencies: Dependencies) + func save(_ cellViewModel: MessageViewModel, using dependencies: Dependencies) + func ban(_ cellViewModel: MessageViewModel, using dependencies: Dependencies) + func banAndDeleteAllMessages(_ cellViewModel: MessageViewModel, using dependencies: Dependencies) + func react(_ cellViewModel: MessageViewModel, with emoji: EmojiWithSkinTones, using dependencies: Dependencies) + func showFullEmojiKeyboard(_ cellViewModel: MessageViewModel, using dependencies: Dependencies) func contextMenuDismissed() } diff --git a/Session/Conversations/ConversationVC+Interaction.swift b/Session/Conversations/ConversationVC+Interaction.swift index f597cc418..947fc9201 100644 --- a/Session/Conversations/ConversationVC+Interaction.swift +++ b/Session/Conversations/ConversationVC+Interaction.swift @@ -173,8 +173,8 @@ extension ConversationVC: // MARK: - AttachmentApprovalViewControllerDelegate - func attachmentApproval(_ attachmentApproval: AttachmentApprovalViewController, didApproveAttachments attachments: [SignalAttachment], forThreadId threadId: String, messageText: String?) { - sendMessage(text: (messageText ?? ""), attachments: attachments) + func attachmentApproval(_ attachmentApproval: AttachmentApprovalViewController, didApproveAttachments attachments: [SignalAttachment], forThreadId threadId: String, messageText: String?, using dependencies: Dependencies) { + sendMessage(text: (messageText ?? ""), attachments: attachments, using: dependencies) resetMentions() dismiss(animated: true) { [weak self] in @@ -409,7 +409,8 @@ extension ConversationVC: attachments: [SignalAttachment] = [], linkPreviewDraft: LinkPreviewDraft? = nil, quoteModel: QuotedReplyModel? = nil, - hasPermissionToSendSeed: Bool = false + hasPermissionToSendSeed: Bool = false, + using dependencies: Dependencies = Dependencies() ) { guard !showBlockedModalIfNeeded() else { return } @@ -488,7 +489,7 @@ extension ConversationVC: let quoteThumbnailAttachment: Attachment? = quoteModel?.attachment?.cloneAsQuoteThumbnail() // Actually send the message - Storage.shared + dependencies.storage .writePublisher { [weak self] db in // Update the thread to be visible (if it isn't already) if self?.viewModel.threadData.threadShouldBeVisible == false { @@ -536,7 +537,8 @@ extension ConversationVC: db, interaction: insertedInteraction, threadId: threadId, - threadVariant: threadVariant + threadVariant: threadVariant, + using: dependencies ) } .subscribe(on: DispatchQueue.global(qos: .userInitiated)) @@ -787,10 +789,14 @@ extension ConversationVC: self.contextMenuWindow?.makeKeyAndVisible() } - func handleItemTapped(_ cellViewModel: MessageViewModel, gestureRecognizer: UITapGestureRecognizer) { + func handleItemTapped( + _ cellViewModel: MessageViewModel, + gestureRecognizer: UITapGestureRecognizer, + using dependencies: Dependencies = Dependencies() + ) { guard cellViewModel.variant != .standardOutgoing || (cellViewModel.state != .failed && cellViewModel.state != .failedToSync) else { // Show the failed message sheet - showFailedMessageSheet(for: cellViewModel) + showFailedMessageSheet(for: cellViewModel, using: dependencies) return } @@ -864,8 +870,8 @@ extension ConversationVC: let threadId: String = self.viewModel.threadData.threadId // Retry downloading the failed attachment - Storage.shared.writeAsync { db in - JobRunner.add( + dependencies.storage.writeAsync { db in + dependencies.jobRunner.add( db, job: Job( variant: .attachmentDownload, @@ -874,7 +880,9 @@ extension ConversationVC: details: AttachmentDownloadJob.Details( attachmentId: mediaView.attachment.id ) - ) + ), + canStartJob: true, + using: dependencies ) } break @@ -1013,8 +1021,8 @@ extension ConversationVC: self.present(actionSheet, animated: true) } - func handleReplyButtonTapped(for cellViewModel: MessageViewModel) { - reply(cellViewModel) + func handleReplyButtonTapped(for cellViewModel: MessageViewModel, using dependencies: Dependencies) { + reply(cellViewModel, using: dependencies) } func startThread(with sessionId: String, openGroupServer: String?, openGroupPublicKey: String?) { @@ -1123,15 +1131,15 @@ extension ConversationVC: UIView.setAnimationsEnabled(true) } - func react(_ cellViewModel: MessageViewModel, with emoji: EmojiWithSkinTones) { - react(cellViewModel, with: emoji.rawValue, remove: false) + func react(_ cellViewModel: MessageViewModel, with emoji: EmojiWithSkinTones, using dependencies: Dependencies) { + react(cellViewModel, with: emoji.rawValue, remove: false, using: dependencies) } - func removeReact(_ cellViewModel: MessageViewModel, for emoji: EmojiWithSkinTones) { - react(cellViewModel, with: emoji.rawValue, remove: true) + func removeReact(_ cellViewModel: MessageViewModel, for emoji: EmojiWithSkinTones, using dependencies: Dependencies) { + react(cellViewModel, with: emoji.rawValue, remove: true, using: dependencies) } - func removeAllReactions(_ cellViewModel: MessageViewModel, for emoji: String) { + func removeAllReactions(_ cellViewModel: MessageViewModel, for emoji: String, using dependencies: Dependencies) { guard cellViewModel.threadVariant == .community else { return } Storage.shared @@ -1208,7 +1216,7 @@ extension ConversationVC: let threadVariant: SessionThread.Variant = self.viewModel.threadData.threadVariant let openGroupRoom: String? = self.viewModel.threadData.openGroupRoomToken let sentTimestamp: Int64 = SnodeAPI.currentOffsetTimestampMs() - let recentReactionTimestamps: [Int64] = dependencies.generalCache.recentReactionTimestamps + let recentReactionTimestamps: [Int64] = dependencies.caches[.general].recentReactionTimestamps guard recentReactionTimestamps.count < 20 || @@ -1226,7 +1234,7 @@ extension ConversationVC: return } - dependencies.mutableGeneralCache.mutate { + dependencies.caches.mutate(cache: .general) { $0.recentReactionTimestamps = Array($0.recentReactionTimestamps .suffix(19)) .appending(sentTimestamp) @@ -1261,9 +1269,9 @@ extension ConversationVC: )) } } - .subscribe(on: DispatchQueue.global(qos: .userInitiated)) + .subscribe(on: DispatchQueue.global(qos: .userInitiated), using: dependencies) .flatMap { pendingChange -> AnyPublisher<(MessageSender.PreparedSendData?, OpenGroupInfo?), Error> in - Storage.shared.writePublisher { [weak self] db -> (MessageSender.PreparedSendData?, OpenGroupInfo?) in + dependencies.storage.writePublisher { [weak self] db -> (MessageSender.PreparedSendData?, OpenGroupInfo?) in // Update the thread to be visible (if it isn't already) if self?.viewModel.threadData.threadShouldBeVisible == false { _ = try SessionThread @@ -1372,7 +1380,8 @@ extension ConversationVC: namespace: try Message.Destination .from(db, threadId: cellViewModel.threadId, threadVariant: cellViewModel.threadVariant) .defaultNamespace, - interactionId: cellViewModel.id + interactionId: cellViewModel.id, + using: dependencies ) return (sendData, nil) @@ -1382,7 +1391,7 @@ extension ConversationVC: .tryFlatMap { messageSendData, openGroupInfo -> AnyPublisher in switch (messageSendData, openGroupInfo) { case (.some(let sendData), _): - return MessageSender.sendImmediate(preparedSendData: sendData) + return MessageSender.sendImmediate(data: sendData, using: dependencies) case (_, .some(let info)): return OpenGroupAPI.send(data: info.sendData) @@ -1433,14 +1442,14 @@ extension ConversationVC: } } - func showFullEmojiKeyboard(_ cellViewModel: MessageViewModel) { + func showFullEmojiKeyboard(_ cellViewModel: MessageViewModel, using dependencies: Dependencies) { hideInputAccessoryView() let emojiPicker = EmojiPickerSheet( completionHandler: { [weak self] emoji in guard let emoji: EmojiWithSkinTones = emoji else { return } - self?.react(cellViewModel, with: emoji) + self?.react(cellViewModel, with: emoji, using: dependencies) }, dismissHandler: { [weak self] in self?.showInputAccessoryView() @@ -1456,7 +1465,7 @@ extension ConversationVC: // MARK: --action handling - func showFailedMessageSheet(for cellViewModel: MessageViewModel) { + private func showFailedMessageSheet(for cellViewModel: MessageViewModel, using dependencies: Dependencies) { let sheet = UIAlertController( title: (cellViewModel.state == .failedToSync ? "MESSAGE_DELIVERY_FAILED_SYNC_TITLE".localized() : @@ -1483,7 +1492,7 @@ extension ConversationVC: "context_menu_resend".localized() ), style: .default, - handler: { [weak self] _ in self?.retry(cellViewModel) } + handler: { [weak self] _ in self?.retry(cellViewModel, using: dependencies) } )) // HACK: Extracting this info from the error string is pretty dodgy @@ -1596,7 +1605,7 @@ extension ConversationVC: // MARK: - ContextMenuActionDelegate - func info(_ cellViewModel: MessageViewModel) { + func info(_ cellViewModel: MessageViewModel, using dependencies: Dependencies) { let mediaInfoVC = MediaInfoVC( attachments: (cellViewModel.attachments ?? []), isOutgoing: (cellViewModel.variant == .standardOutgoing), @@ -1607,8 +1616,8 @@ extension ConversationVC: navigationController?.pushViewController(mediaInfoVC, animated: true) } - func retry(_ cellViewModel: MessageViewModel) { - Storage.shared.writeAsync { [weak self] db in + func retry(_ cellViewModel: MessageViewModel, using dependencies: Dependencies) { + dependencies.storage.writeAsync { [weak self] db in guard let threadId: String = self?.viewModel.threadData.threadId, let threadVariant: SessionThread.Variant = self?.viewModel.threadData.threadVariant, @@ -1649,12 +1658,13 @@ extension ConversationVC: interaction: interaction, threadId: threadId, threadVariant: threadVariant, - isSyncMessage: (cellViewModel.state == .failedToSync) + isSyncMessage: (cellViewModel.state == .failedToSync), + using: dependencies ) } } - func reply(_ cellViewModel: MessageViewModel) { + func reply(_ cellViewModel: MessageViewModel, using dependencies: Dependencies) { let maybeQuoteDraft: QuotedReplyModel? = QuotedReplyModel.quotedReplyForSending( threadId: self.viewModel.threadData.threadId, authorId: cellViewModel.authorId, @@ -1677,7 +1687,7 @@ extension ConversationVC: snInputView.becomeFirstResponder() } - func copy(_ cellViewModel: MessageViewModel) { + func copy(_ cellViewModel: MessageViewModel, using dependencies: Dependencies) { switch cellViewModel.cellType { case .typingIndicator, .dateHeader, .unreadMarker: break @@ -1715,7 +1725,7 @@ extension ConversationVC: UIPasteboard.general.string = cellViewModel.authorId } - func delete(_ cellViewModel: MessageViewModel) { + func delete(_ cellViewModel: MessageViewModel, using dependencies: Dependencies) { switch cellViewModel.variant { case .standardIncomingDeleted, .infoCall, .infoScreenshotNotification, .infoMediaSavedNotification, @@ -1911,7 +1921,8 @@ extension ConversationVC: message: unsendRequest, threadId: cellViewModel.threadId, interactionId: nil, - to: .contact(publicKey: userPublicKey) + to: .contact(publicKey: userPublicKey), + using: dependencies ) } return @@ -1934,7 +1945,8 @@ extension ConversationVC: message: unsendRequest, threadId: cellViewModel.threadId, interactionId: nil, - to: .contact(publicKey: userPublicKey) + to: .contact(publicKey: userPublicKey), + using: dependencies ) } self?.showInputAccessoryView() @@ -1962,7 +1974,8 @@ extension ConversationVC: message: unsendRequest, interactionId: nil, threadId: cellViewModel.threadId, - threadVariant: cellViewModel.threadVariant + threadVariant: cellViewModel.threadVariant, + using: dependencies ) } @@ -1996,7 +2009,7 @@ extension ConversationVC: } } - func save(_ cellViewModel: MessageViewModel) { + func save(_ cellViewModel: MessageViewModel, using dependencies: Dependencies) { guard cellViewModel.cellType == .mediaMessage else { return } let mediaAttachments: [(Attachment, String)] = (cellViewModel.attachments ?? []) @@ -2038,24 +2051,10 @@ extension ConversationVC: return } - let threadId: String = self.viewModel.threadData.threadId - let threadVariant: SessionThread.Variant = self.viewModel.threadData.threadVariant - - Storage.shared.writeAsync { db in - try MessageSender.send( - db, - message: DataExtractionNotification( - kind: .mediaSaved(timestamp: UInt64(cellViewModel.timestampMs)), - sentTimestamp: UInt64(SnodeAPI.currentOffsetTimestampMs()) - ), - interactionId: nil, - threadId: threadId, - threadVariant: threadVariant - ) - } + sendDataExtraction(kind: .mediaSaved(timestamp: UInt64(cellViewModel.timestampMs))) } - func ban(_ cellViewModel: MessageViewModel) { + func ban(_ cellViewModel: MessageViewModel, using dependencies: Dependencies) { guard cellViewModel.threadVariant == .community else { return } let threadId: String = self.viewModel.threadData.threadId @@ -2111,7 +2110,7 @@ extension ConversationVC: self.present(modal, animated: true) } - func banAndDeleteAllMessages(_ cellViewModel: MessageViewModel) { + func banAndDeleteAllMessages(_ cellViewModel: MessageViewModel, using dependencies: Dependencies) { guard cellViewModel.threadVariant == .community else { return } let threadId: String = self.viewModel.threadData.threadId @@ -2303,23 +2302,29 @@ extension ConversationVC: // MARK: - Data Extraction Notifications - @objc func sendScreenshotNotification() { + @objc func sendScreenshotNotification() { sendDataExtraction(kind: .screenshot) } + + func sendDataExtraction( + kind: DataExtractionNotification.Kind, + using dependencies: Dependencies = Dependencies() + ) { // Only send screenshot notifications to one-to-one conversations guard self.viewModel.threadData.threadVariant == .contact else { return } let threadId: String = self.viewModel.threadData.threadId let threadVariant: SessionThread.Variant = self.viewModel.threadData.threadVariant - Storage.shared.writeAsync { db in + dependencies.storage.writeAsync { db in try MessageSender.send( db, message: DataExtractionNotification( - kind: .screenshot, + kind: kind, sentTimestamp: UInt64(SnodeAPI.currentOffsetTimestampMs()) ), interactionId: nil, threadId: threadId, - threadVariant: threadVariant + threadVariant: threadVariant, + using: dependencies ) } } @@ -2355,7 +2360,8 @@ extension ConversationVC { for threadId: String, threadVariant: SessionThread.Variant, isNewThread: Bool, - timestampMs: Int64 + timestampMs: Int64, + using dependencies: Dependencies = Dependencies() ) { guard threadVariant == .contact else { return } @@ -2396,7 +2402,8 @@ extension ConversationVC { ), interactionId: nil, threadId: threadId, - threadVariant: threadVariant + threadVariant: threadVariant, + using: dependencies ) } diff --git a/Session/Conversations/Message Cells/MessageCell.swift b/Session/Conversations/Message Cells/MessageCell.swift index dde344352..63ccdf71d 100644 --- a/Session/Conversations/Message Cells/MessageCell.swift +++ b/Session/Conversations/Message Cells/MessageCell.swift @@ -87,12 +87,18 @@ public class MessageCell: UITableViewCell { protocol MessageCellDelegate: ReactionDelegate { func handleItemLongPressed(_ cellViewModel: MessageViewModel) - func handleItemTapped(_ cellViewModel: MessageViewModel, gestureRecognizer: UITapGestureRecognizer) + func handleItemTapped(_ cellViewModel: MessageViewModel, gestureRecognizer: UITapGestureRecognizer, using dependencies: Dependencies) func handleItemDoubleTapped(_ cellViewModel: MessageViewModel) func handleItemSwiped(_ cellViewModel: MessageViewModel, state: SwipeState) func openUrl(_ urlString: String) - func handleReplyButtonTapped(for cellViewModel: MessageViewModel) + func handleReplyButtonTapped(for cellViewModel: MessageViewModel, using dependencies: Dependencies) func startThread(with sessionId: String, openGroupServer: String?, openGroupPublicKey: String?) func showReactionList(_ cellViewModel: MessageViewModel, selectedReaction: EmojiWithSkinTones?) func needsLayout(for cellViewModel: MessageViewModel, expandingReactions: Bool) } + +extension MessageCellDelegate { + func handleItemTapped(_ cellViewModel: MessageViewModel, gestureRecognizer: UITapGestureRecognizer) { + handleItemTapped(cellViewModel, gestureRecognizer: gestureRecognizer, using: Dependencies()) + } +} diff --git a/Session/Conversations/Message Cells/VisibleMessageCell.swift b/Session/Conversations/Message Cells/VisibleMessageCell.swift index 6019d5a4d..3a016fa51 100644 --- a/Session/Conversations/Message Cells/VisibleMessageCell.swift +++ b/Session/Conversations/Message Cells/VisibleMessageCell.swift @@ -861,7 +861,9 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate { isHandlingLongPress = true } - @objc private func handleTap(_ gestureRecognizer: UITapGestureRecognizer) { + @objc private func handleTap(_ gestureRecognizer: UITapGestureRecognizer) { onTap(gestureRecognizer) } + + private func onTap(_ gestureRecognizer: UITapGestureRecognizer, using dependencies: Dependencies = Dependencies()) { guard let cellViewModel: MessageViewModel = self.viewModel else { return } let location = gestureRecognizer.location(in: self) @@ -897,10 +899,10 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate { if reactionContainerView.convert(reactionView.frame, from: reactionView.superview).contains(convertedLocation) { if reactionView.viewModel.showBorder { - delegate?.removeReact(cellViewModel, for: reactionView.viewModel.emoji) + delegate?.removeReact(cellViewModel, for: reactionView.viewModel.emoji, using: dependencies) } else { - delegate?.react(cellViewModel, with: reactionView.viewModel.emoji) + delegate?.react(cellViewModel, with: reactionView.viewModel.emoji, using: dependencies) } return } @@ -917,7 +919,7 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate { } } else if snContentView.bounds.contains(snContentView.convert(location, from: self)) { - delegate?.handleItemTapped(cellViewModel, gestureRecognizer: gestureRecognizer) + delegate?.handleItemTapped(cellViewModel, gestureRecognizer: gestureRecognizer, using: dependencies) } } @@ -985,11 +987,11 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate { } } - private func reply() { + private func reply(using dependencies: Dependencies = Dependencies()) { guard let cellViewModel: MessageViewModel = self.viewModel else { return } resetReply() - delegate?.handleReplyButtonTapped(for: cellViewModel) + delegate?.handleReplyButtonTapped(for: cellViewModel, using: dependencies) } // MARK: - Convenience diff --git a/Session/Conversations/Settings/ThreadDisappearingMessagesSettingsViewModel.swift b/Session/Conversations/Settings/ThreadDisappearingMessagesSettingsViewModel.swift index 86a5ac164..633dfee2c 100644 --- a/Session/Conversations/Settings/ThreadDisappearingMessagesSettingsViewModel.swift +++ b/Session/Conversations/Settings/ThreadDisappearingMessagesSettingsViewModel.swift @@ -39,10 +39,10 @@ class ThreadDisappearingMessagesSettingsViewModel: SessionTableViewModel [SectionModel] in - let userPublicKey: String = getUserHexEncodedPublicKey(db, dependencies: dependencies) + let userPublicKey: String = getUserHexEncodedPublicKey(db, using: dependencies) let maybeThreadViewModel: SessionThreadViewModel? = try SessionThreadViewModel .conversationSettingsQuery(threadId: threadId, userPublicKey: userPublicKey) .fetchOne(db) @@ -156,7 +156,7 @@ class ThreadDisappearingMessagesSettingsViewModel: SessionTableViewModel () + didTriggerSearch: @escaping () -> (), + using dependencies: Dependencies = Dependencies() ) { self.dependencies = dependencies self.threadId = threadId @@ -196,7 +196,7 @@ class ThreadSettingsViewModel: SessionTableViewModel [SectionModel] in - let userPublicKey: String = getUserHexEncodedPublicKey(db, dependencies: dependencies) + let userPublicKey: String = getUserHexEncodedPublicKey(db, using: dependencies) let maybeThreadViewModel: SessionThreadViewModel? = try SessionThreadViewModel .conversationSettingsQuery(threadId: threadId, userPublicKey: userPublicKey) .fetchOne(db) @@ -755,7 +755,7 @@ class ThreadSettingsViewModel: SessionTableViewModel AnyPublisher { guard let threadId = userInfo[AppNotificationUserInfoKey.threadId] as? String else { return Fail(error: NotificationError.failDebug("threadId was unexpectedly nil")) @@ -599,10 +600,11 @@ class NotificationActionHandler { db, interaction: interaction, threadId: threadId, - threadVariant: thread.variant + threadVariant: thread.variant, + using: dependencies ) } - .flatMap { MessageSender.sendImmediate(preparedSendData: $0) } + .flatMap { MessageSender.sendImmediate(data: $0, using: dependencies) } .handleEvents( receiveCompletion: { result in switch result { diff --git a/Session/Notifications/SyncPushTokensJob.swift b/Session/Notifications/SyncPushTokensJob.swift index 8f6b3c261..ba1a0a5f2 100644 --- a/Session/Notifications/SyncPushTokensJob.swift +++ b/Session/Notifications/SyncPushTokensJob.swift @@ -20,7 +20,7 @@ public enum SyncPushTokensJob: JobExecutor { success: @escaping (Job, Bool, Dependencies) -> (), failure: @escaping (Job, Error?, Bool, Dependencies) -> (), deferred: @escaping (Job, Dependencies) -> (), - dependencies: Dependencies = Dependencies() + using dependencies: Dependencies = Dependencies() ) { // Don't run when inactive or not in main app or if the user doesn't exist yet guard (UserDefaults.sharedLokiProject?[.isMainAppActive]).defaulting(to: false) else { diff --git a/Session/Onboarding/Onboarding.swift b/Session/Onboarding/Onboarding.swift index 97607724d..123b2fd33 100644 --- a/Session/Onboarding/Onboarding.swift +++ b/Session/Onboarding/Onboarding.swift @@ -26,7 +26,10 @@ enum Onboarding { return existingPublisher } - private static func createProfileNameRetrievalPublisher(_ requestId: UUID) -> AnyPublisher { + private static func createProfileNameRetrievalPublisher( + _ requestId: UUID, + using dependencies: Dependencies = Dependencies() + ) -> AnyPublisher { // FIXME: Remove this once `useSharedUtilForUserConfig` is permanent guard SessionUtil.userConfigsEnabled else { return Just(nil) @@ -99,7 +102,8 @@ enum Onboarding { ) }(), sentTimestamp: TimeInterval((message.sentTimestamp ?? 0) / 1000), - calledFromConfigHandling: false + calledFromConfigHandling: false, + using: dependencies ) } return () diff --git a/Session/Settings/NukeDataModal.swift b/Session/Settings/NukeDataModal.swift index bda18bf9c..8b2a3b548 100644 --- a/Session/Settings/NukeDataModal.swift +++ b/Session/Settings/NukeDataModal.swift @@ -244,7 +244,7 @@ final class NukeDataModal: Modal { UserDefaults.removeAll() // Remove the cached key so it gets re-cached on next access - dependencies.mutableGeneralCache.mutate { + dependencies.caches.mutate(cache: .general) { $0.encodedPublicKey = nil $0.recentReactionTimestamps = [] } diff --git a/Session/Utilities/BackgroundPoller.swift b/Session/Utilities/BackgroundPoller.swift index df82c5ad7..817d76ee9 100644 --- a/Session/Utilities/BackgroundPoller.swift +++ b/Session/Utilities/BackgroundPoller.swift @@ -13,10 +13,7 @@ public final class BackgroundPoller { public static func poll( completionHandler: @escaping (UIBackgroundFetchResult) -> Void, - dependencies: OpenGroupManager.OGMDependencies = OpenGroupManager.OGMDependencies( - subscribeQueue: .global(qos: .background), - receiveQueue: .main - ) + using dependencies: Dependencies = Dependencies() ) { Publishers .MergeMany( @@ -55,8 +52,8 @@ public final class BackgroundPoller { } ) ) - .subscribe(on: dependencies.subscribeQueue) - .receive(on: dependencies.receiveQueue) + .subscribe(on: DispatchQueue.global(qos: .background), using: dependencies) + .receive(on: DispatchQueue.main, using: dependencies) .collect() .sinkUntilComplete( receiveCompletion: { result in @@ -74,7 +71,7 @@ public final class BackgroundPoller { } private static func pollForMessages( - using dependencies: OpenGroupManager.OGMDependencies + using dependencies: Dependencies ) -> AnyPublisher { let userPublicKey: String = getUserHexEncodedPublicKey() @@ -94,7 +91,7 @@ public final class BackgroundPoller { } private static func pollForClosedGroupMessages( - using dependencies: OpenGroupManager.OGMDependencies + using dependencies: Dependencies ) -> [AnyPublisher] { // Fetch all closed groups (excluding any don't contain the current user as a // GroupMemeber as the user is no longer a member of those) diff --git a/SessionMessagingKit/Calls/WebRTCSession.swift b/SessionMessagingKit/Calls/WebRTCSession.swift index 71e098ae2..9db449481 100644 --- a/SessionMessagingKit/Calls/WebRTCSession.swift +++ b/SessionMessagingKit/Calls/WebRTCSession.swift @@ -124,13 +124,14 @@ public final class WebRTCSession : NSObject, RTCPeerConnectionDelegate { _ db: Database, message: CallMessage, interactionId: Int64?, - in thread: SessionThread + in thread: SessionThread, + using dependencies: Dependencies = Dependencies() ) throws -> AnyPublisher { SNLog("[Calls] Sending pre-offer message.") return MessageSender .sendImmediate( - preparedSendData: try MessageSender + data: try MessageSender .preparedSendData( db, message: message, @@ -138,8 +139,10 @@ public final class WebRTCSession : NSObject, RTCPeerConnectionDelegate { namespace: try Message.Destination .from(db, threadId: thread.id, threadVariant: thread.variant) .defaultNamespace, - interactionId: interactionId - ) + interactionId: interactionId, + using: dependencies + ), + using: dependencies ) .handleEvents(receiveOutput: { _ in SNLog("[Calls] Pre-offer message has been sent.") }) .eraseToAnyPublisher() @@ -147,7 +150,8 @@ public final class WebRTCSession : NSObject, RTCPeerConnectionDelegate { public func sendOffer( to thread: SessionThread, - isRestartingICEConnection: Bool = false + isRestartingICEConnection: Bool = false, + using dependencies: Dependencies = Dependencies() ) -> AnyPublisher { SNLog("[Calls] Sending offer message.") let uuid: String = self.uuid @@ -172,7 +176,7 @@ public final class WebRTCSession : NSObject, RTCPeerConnectionDelegate { } } - Storage.shared + dependencies.storage .writePublisher { db in try MessageSender .preparedSendData( @@ -188,10 +192,11 @@ public final class WebRTCSession : NSObject, RTCPeerConnectionDelegate { namespace: try Message.Destination .from(db, threadId: thread.id, threadVariant: thread.variant) .defaultNamespace, - interactionId: nil + interactionId: nil, + using: dependencies ) } - .flatMap { MessageSender.sendImmediate(preparedSendData: $0) } + .flatMap { MessageSender.sendImmediate(data: $0, using: dependencies) } .subscribe(on: DispatchQueue.global(qos: .userInitiated)) .sinkUntilComplete( receiveCompletion: { result in @@ -207,12 +212,15 @@ public final class WebRTCSession : NSObject, RTCPeerConnectionDelegate { .eraseToAnyPublisher() } - public func sendAnswer(to sessionId: String) -> AnyPublisher { + public func sendAnswer( + to sessionId: String, + using dependencies: Dependencies = Dependencies() + ) -> AnyPublisher { SNLog("[Calls] Sending answer message.") let uuid: String = self.uuid let mediaConstraints: RTCMediaConstraints = mediaConstraints(false) - return Storage.shared + return dependencies.storage .readPublisher { db -> SessionThread in guard let thread: SessionThread = try? SessionThread.fetchOne(db, id: sessionId) else { throw WebRTCSessionError.noThread @@ -239,7 +247,7 @@ public final class WebRTCSession : NSObject, RTCPeerConnectionDelegate { } } - Storage.shared + dependencies.storage .writePublisher { db in try MessageSender .preparedSendData( @@ -254,10 +262,11 @@ public final class WebRTCSession : NSObject, RTCPeerConnectionDelegate { namespace: try Message.Destination .from(db, threadId: thread.id, threadVariant: thread.variant) .defaultNamespace, - interactionId: nil + interactionId: nil, + using: dependencies ) } - .flatMap { MessageSender.sendImmediate(preparedSendData: $0) } + .flatMap { MessageSender.sendImmediate(data: $0, using: dependencies) } .subscribe(on: DispatchQueue.global(qos: .userInitiated)) .sinkUntilComplete( receiveCompletion: { result in @@ -283,7 +292,7 @@ public final class WebRTCSession : NSObject, RTCPeerConnectionDelegate { } } - private func sendICECandidates() { + private func sendICECandidates(using dependencies: Dependencies = Dependencies()) { let candidates: [RTCIceCandidate] = self.queuedICECandidates let uuid: String = self.uuid let contactSessionId: String = self.contactSessionId @@ -291,7 +300,7 @@ public final class WebRTCSession : NSObject, RTCPeerConnectionDelegate { // Empty the queue self.queuedICECandidates.removeAll() - Storage.shared + dependencies.storage .writePublisher { db in guard let thread: SessionThread = try SessionThread.fetchOne(db, id: contactSessionId) else { throw WebRTCSessionError.noThread @@ -315,15 +324,20 @@ public final class WebRTCSession : NSObject, RTCPeerConnectionDelegate { namespace: try Message.Destination .from(db, threadId: thread.id, threadVariant: thread.variant) .defaultNamespace, - interactionId: nil + interactionId: nil, + using: dependencies ) } .subscribe(on: DispatchQueue.global(qos: .userInitiated)) - .flatMap { MessageSender.sendImmediate(preparedSendData: $0) } + .flatMap { MessageSender.sendImmediate(data: $0, using: dependencies) } .sinkUntilComplete() } - public func endCall(_ db: Database, with sessionId: String) throws { + public func endCall( + _ db: Database, + with sessionId: String, + using dependencies: Dependencies = Dependencies() + ) throws { guard let thread: SessionThread = try SessionThread.fetchOne(db, id: sessionId) else { return } SNLog("[Calls] Sending end call message.") @@ -340,11 +354,12 @@ public final class WebRTCSession : NSObject, RTCPeerConnectionDelegate { namespace: try Message.Destination .from(db, threadId: thread.id, threadVariant: thread.variant) .defaultNamespace, - interactionId: nil + interactionId: nil, + using: dependencies ) MessageSender - .sendImmediate(preparedSendData: preparedSendData) + .sendImmediate(data: preparedSendData, using: dependencies) .subscribe(on: DispatchQueue.global(qos: .userInitiated)) .sinkUntilComplete() } diff --git a/SessionMessagingKit/Database/Models/Attachment.swift b/SessionMessagingKit/Database/Models/Attachment.swift index bb575a6fa..bedc9fc31 100644 --- a/SessionMessagingKit/Database/Models/Attachment.swift +++ b/SessionMessagingKit/Database/Models/Attachment.swift @@ -1039,7 +1039,10 @@ extension Attachment { } } - internal func upload(to destination: Attachment.Destination) -> AnyPublisher { + internal func upload( + to destination: Attachment.Destination, + using dependencies: Dependencies + ) -> AnyPublisher { // This can occur if an AttachmnetUploadJob was explicitly created for a message // dependant on the attachment being uploaded (in this case the attachment has // already been uploaded so just succeed) diff --git a/SessionMessagingKit/Database/Models/BlindedIdLookup.swift b/SessionMessagingKit/Database/Models/BlindedIdLookup.swift index 3a3d07498..2500b0d09 100644 --- a/SessionMessagingKit/Database/Models/BlindedIdLookup.swift +++ b/SessionMessagingKit/Database/Models/BlindedIdLookup.swift @@ -76,7 +76,7 @@ public extension BlindedIdLookup { openGroupServer: String, openGroupPublicKey: String, isCheckingForOutbox: Bool, - dependencies: SMKDependencies = SMKDependencies() + using dependencies: Dependencies = Dependencies() ) throws -> BlindedIdLookup { var lookup: BlindedIdLookup = (try? BlindedIdLookup .fetchOne(db, id: blindedId)) @@ -94,11 +94,13 @@ public extension BlindedIdLookup { // If we we given a sessionId then validate it is correct and if so save it if let sessionId: String = sessionId, - dependencies.sodium.sessionId( - sessionId, - matchesBlindedId: blindedId, - serverPublicKey: openGroupPublicKey, - genericHash: dependencies.genericHash + dependencies.crypto.verify( + .sessionId( + sessionId, + matchesBlindedId: blindedId, + serverPublicKey: openGroupPublicKey, + using: dependencies + ) ) { lookup = try lookup @@ -115,9 +117,16 @@ public extension BlindedIdLookup { .fetchCursor(db) while let contact: Contact = try contactsThatApprovedMeCursor.next() { - guard dependencies.sodium.sessionId(contact.id, matchesBlindedId: blindedId, serverPublicKey: openGroupPublicKey, genericHash: dependencies.genericHash) else { - continue - } + guard + dependencies.crypto.verify( + .sessionId( + contact.id, + matchesBlindedId: blindedId, + serverPublicKey: openGroupPublicKey, + using: dependencies + ) + ) + else { continue } // We found a match so update the lookup and leave the loop lookup = try lookup @@ -151,11 +160,13 @@ public extension BlindedIdLookup { while let otherLookup: BlindedIdLookup = try blindedIdLookupCursor.next() { guard let sessionId: String = otherLookup.sessionId, - dependencies.sodium.sessionId( - sessionId, - matchesBlindedId: blindedId, - serverPublicKey: openGroupPublicKey, - genericHash: dependencies.genericHash + dependencies.crypto.verify( + .sessionId( + sessionId, + matchesBlindedId: blindedId, + serverPublicKey: openGroupPublicKey, + using: dependencies + ) ) else { continue } diff --git a/SessionMessagingKit/Database/Models/Contact.swift b/SessionMessagingKit/Database/Models/Contact.swift index 6f3b05c47..51f77ab8f 100644 --- a/SessionMessagingKit/Database/Models/Contact.swift +++ b/SessionMessagingKit/Database/Models/Contact.swift @@ -55,12 +55,12 @@ public struct Contact: Codable, Identifiable, Equatable, FetchableRecord, Persis isBlocked: Bool = false, didApproveMe: Bool = false, hasBeenBlocked: Bool = false, - dependencies: Dependencies = Dependencies() + using dependencies: Dependencies = Dependencies() ) { self.id = id self.isTrusted = ( isTrusted || - id == getUserHexEncodedPublicKey(dependencies: dependencies) // Always trust ourselves + id == getUserHexEncodedPublicKey(using: dependencies) // Always trust ourselves ) self.isApproved = isApproved self.isBlocked = isBlocked diff --git a/SessionMessagingKit/Database/Models/Interaction.swift b/SessionMessagingKit/Database/Models/Interaction.swift index 21b29295b..c50645ca0 100644 --- a/SessionMessagingKit/Database/Models/Interaction.swift +++ b/SessionMessagingKit/Database/Models/Interaction.swift @@ -797,22 +797,19 @@ public extension Interaction { _ db: Database, threadId: String, body: String?, - quoteAuthorId: String? = nil + quoteAuthorId: String? = nil, + using dependencies: Dependencies = Dependencies() ) -> Bool { var publicKeysToCheck: [String] = [ - getUserHexEncodedPublicKey(db) + getUserHexEncodedPublicKey(db, using: dependencies) ] // If the thread is an open group then add the blinded id as a key to check if let openGroup: OpenGroup = try? OpenGroup.fetchOne(db, id: threadId) { - let sodium: Sodium = Sodium() - if let userEd25519KeyPair: KeyPair = Identity.fetchUserEd25519KeyPair(db), - let blindedKeyPair: KeyPair = sodium.blindedKeyPair( - serverPublicKey: openGroup.publicKey, - edKeyPair: userEd25519KeyPair, - genericHash: sodium.genericHash + let blindedKeyPair: KeyPair = dependencies.crypto.generate( + .blindedKeyPair(serverPublicKey: openGroup.publicKey, edKeyPair: userEd25519KeyPair, using: dependencies) ) { publicKeysToCheck.append(SessionId(.blinded15, publicKey: blindedKeyPair.publicKey).hexString) diff --git a/SessionMessagingKit/Database/Models/Profile.swift b/SessionMessagingKit/Database/Models/Profile.swift index 007a6f10c..cc4e4bef6 100644 --- a/SessionMessagingKit/Database/Models/Profile.swift +++ b/SessionMessagingKit/Database/Models/Profile.swift @@ -250,7 +250,7 @@ public extension Profile { /// /// **Note:** This method intentionally does **not** save the newly created Profile, /// it will need to be explicitly saved after calling - static func fetchOrCreateCurrentUser(_ db: Database? = nil, dependencies: Dependencies = Dependencies()) -> Profile { + static func fetchOrCreateCurrentUser(_ db: Database? = nil, using dependencies: Dependencies = Dependencies()) -> Profile { let userPublicKey: String = getUserHexEncodedPublicKey(db) guard let db: Database = db else { diff --git a/SessionMessagingKit/Database/Models/SessionThread.swift b/SessionMessagingKit/Database/Models/SessionThread.swift index 958e7fc30..163d38038 100644 --- a/SessionMessagingKit/Database/Models/SessionThread.swift +++ b/SessionMessagingKit/Database/Models/SessionThread.swift @@ -525,12 +525,19 @@ public extension SessionThread { _ db: Database? = nil, threadId: String, threadVariant: Variant, - blindingPrefix: SessionId.Prefix + blindingPrefix: SessionId.Prefix, + using dependencies: Dependencies = Dependencies() ) -> String? { guard threadVariant == .community else { return nil } guard let db: Database = db else { - return Storage.shared.read { db in - getUserHexEncodedBlindedKey(db, threadId: threadId, threadVariant: threadVariant, blindingPrefix: blindingPrefix) + return dependencies.storage.read { db in + getUserHexEncodedBlindedKey( + db, + threadId: threadId, + threadVariant: threadVariant, + blindingPrefix: blindingPrefix, + using: dependencies + ) } } @@ -559,12 +566,8 @@ public extension SessionThread { guard capabilities.isEmpty || capabilities.contains(.blind) else { return nil } - let sodium: Sodium = Sodium() - - let blindedKeyPair: KeyPair? = sodium.blindedKeyPair( - serverPublicKey: openGroupInfo.publicKey, - edKeyPair: userEdKeyPair, - genericHash: sodium.getGenericHash() + let blindedKeyPair: KeyPair? = dependencies.crypto.generate( + .blindedKeyPair(serverPublicKey: openGroupInfo.publicKey, edKeyPair: userEdKeyPair, using: dependencies) ) return blindedKeyPair.map { keyPair -> String in diff --git a/SessionMessagingKit/Jobs/Types/AttachmentDownloadJob.swift b/SessionMessagingKit/Jobs/Types/AttachmentDownloadJob.swift index 1b64721d5..3eb523085 100644 --- a/SessionMessagingKit/Jobs/Types/AttachmentDownloadJob.swift +++ b/SessionMessagingKit/Jobs/Types/AttachmentDownloadJob.swift @@ -17,7 +17,7 @@ public enum AttachmentDownloadJob: JobExecutor { success: @escaping (Job, Bool, Dependencies) -> (), failure: @escaping (Job, Error?, Bool, Dependencies) -> (), deferred: @escaping (Job, Dependencies) -> (), - dependencies: Dependencies = Dependencies() + using dependencies: Dependencies ) { guard let threadId: String = job.threadId, diff --git a/SessionMessagingKit/Jobs/Types/AttachmentUploadJob.swift b/SessionMessagingKit/Jobs/Types/AttachmentUploadJob.swift index 5ed000623..99a7a6bbd 100644 --- a/SessionMessagingKit/Jobs/Types/AttachmentUploadJob.swift +++ b/SessionMessagingKit/Jobs/Types/AttachmentUploadJob.swift @@ -17,7 +17,7 @@ public enum AttachmentUploadJob: JobExecutor { success: @escaping (Job, Bool, Dependencies) -> (), failure: @escaping (Job, Error?, Bool, Dependencies) -> (), deferred: @escaping (Job, Dependencies) -> (), - dependencies: Dependencies = Dependencies() + using dependencies: Dependencies ) { guard let threadId: String = job.threadId, @@ -72,7 +72,7 @@ public enum AttachmentUploadJob: JobExecutor { // reentrancy issues when the success/failure closures get called before the upload as the JobRunner // will attempt to update the state of the job immediately attachment - .upload(to: (openGroup.map { .openGroup($0) } ?? .fileServer)) + .upload(to: (openGroup.map { .openGroup($0) } ?? .fileServer), using: dependencies) .subscribe(on: queue) .receive(on: queue) .sinkUntilComplete( @@ -95,7 +95,8 @@ public enum AttachmentUploadJob: JobExecutor { message: details.message, with: .other(error), interactionId: interactionId, - isSyncMessage: details.isSyncMessage + isSyncMessage: details.isSyncMessage, + using: dependencies ) } diff --git a/SessionMessagingKit/Jobs/Types/ConfigMessageReceiveJob.swift b/SessionMessagingKit/Jobs/Types/ConfigMessageReceiveJob.swift index 38a3b2dcb..ee5c00c9c 100644 --- a/SessionMessagingKit/Jobs/Types/ConfigMessageReceiveJob.swift +++ b/SessionMessagingKit/Jobs/Types/ConfigMessageReceiveJob.swift @@ -15,7 +15,7 @@ public enum ConfigMessageReceiveJob: JobExecutor { success: @escaping (Job, Bool, Dependencies) -> (), failure: @escaping (Job, Error?, Bool, Dependencies) -> (), deferred: @escaping (Job, Dependencies) -> (), - dependencies: Dependencies = Dependencies() + using dependencies: Dependencies = Dependencies() ) { /// When the `configMessageReceive` job fails we want to unblock any `messageReceive` jobs it was blocking /// to ensure the user isn't losing any messages - this generally _shouldn't_ happen but if it does then having a temporary diff --git a/SessionMessagingKit/Jobs/Types/ConfigurationSyncJob.swift b/SessionMessagingKit/Jobs/Types/ConfigurationSyncJob.swift index 8c471b4db..f19caf473 100644 --- a/SessionMessagingKit/Jobs/Types/ConfigurationSyncJob.swift +++ b/SessionMessagingKit/Jobs/Types/ConfigurationSyncJob.swift @@ -18,7 +18,7 @@ public enum ConfigurationSyncJob: JobExecutor { success: @escaping (Job, Bool, Dependencies) -> (), failure: @escaping (Job, Error?, Bool, Dependencies) -> (), deferred: @escaping (Job, Dependencies) -> (), - dependencies: Dependencies = Dependencies() + using dependencies: Dependencies ) { guard SessionUtil.userConfigsEnabled, @@ -43,7 +43,7 @@ public enum ConfigurationSyncJob: JobExecutor { // it again immediately which is pointless) let updatedJob: Job? = dependencies.storage.write { db in try job - .with(nextRunTimestamp: Date().timeIntervalSince1970 + maxRunFrequency) + .with(nextRunTimestamp: dependencies.dateNow.timeIntervalSince1970 + maxRunFrequency) .saved(db) } @@ -79,7 +79,7 @@ public enum ConfigurationSyncJob: JobExecutor { .map { $0.obsoleteHashes } .reduce([], +) .asSet() - let jobStartTimestamp: TimeInterval = Date().timeIntervalSince1970 + let jobStartTimestamp: TimeInterval = dependencies.dateNow.timeIntervalSince1970 SNLog("[ConfigurationSyncJob] For \(publicKey) started with \(pendingConfigChanges.count) change\(pendingConfigChanges.count == 1 ? "" : "s")") dependencies.storage @@ -105,7 +105,8 @@ public enum ConfigurationSyncJob: JobExecutor { return (snodeMessage, namespace) }, - allObsoleteHashes: Array(allObsoleteHashes) + allObsoleteHashes: Array(allObsoleteHashes), + using: dependencies ) } .subscribe(on: queue) @@ -223,7 +224,7 @@ public extension ConfigurationSyncJob { ) ), canStartJob: true, - dependencies: dependencies + using: dependencies ) return } @@ -231,16 +232,16 @@ public extension ConfigurationSyncJob { // Upsert a config sync job if needed dependencies.jobRunner.upsert( db, - job: ConfigurationSyncJob.createIfNeeded(db, publicKey: publicKey, dependencies: dependencies), + job: ConfigurationSyncJob.createIfNeeded(db, publicKey: publicKey, using: dependencies), canStartJob: true, - dependencies: dependencies + using: dependencies ) } @discardableResult static func createIfNeeded( _ db: Database, publicKey: String, - dependencies: Dependencies = Dependencies() + using dependencies: Dependencies = Dependencies() ) -> Job? { /// The ConfigurationSyncJob will automatically reschedule itself to run again after 3 seconds so if there is an existing /// job then there is no need to create another instance @@ -266,7 +267,7 @@ public extension ConfigurationSyncJob { ) } - static func run() -> AnyPublisher { + static func run(using dependencies: Dependencies = Dependencies()) -> AnyPublisher { // FIXME: Remove this once `useSharedUtilForUserConfig` is permanent guard SessionUtil.userConfigsEnabled else { return Storage.shared @@ -276,17 +277,18 @@ public extension ConfigurationSyncJob { // fresh install due to the migrations getting run) guard Identity.userCompletedRequiredOnboarding(db) else { throw StorageError.generic } - let publicKey: String = getUserHexEncodedPublicKey(db) + let publicKey: String = getUserHexEncodedPublicKey(db, using: dependencies) return try MessageSender.preparedSendData( db, message: try ConfigurationMessage.getCurrent(db), to: Message.Destination.contact(publicKey: publicKey), namespace: .default, - interactionId: nil + interactionId: nil, + using: dependencies ) } - .flatMap { MessageSender.sendImmediate(preparedSendData: $0) } + .flatMap { MessageSender.sendImmediate(data: $0, using: dependencies) } .eraseToAnyPublisher() } @@ -298,7 +300,8 @@ public extension ConfigurationSyncJob { queue: .global(qos: .userInitiated), success: { _, _, _ in resolver(Result.success(())) }, failure: { _, error, _, _ in resolver(Result.failure(error ?? HTTPError.generic)) }, - deferred: { _, _ in } + deferred: { _, _ in }, + using: dependencies ) } } diff --git a/SessionMessagingKit/Jobs/Types/DisappearingMessagesJob.swift b/SessionMessagingKit/Jobs/Types/DisappearingMessagesJob.swift index 6434b9b52..ac0c55a12 100644 --- a/SessionMessagingKit/Jobs/Types/DisappearingMessagesJob.swift +++ b/SessionMessagingKit/Jobs/Types/DisappearingMessagesJob.swift @@ -16,7 +16,7 @@ public enum DisappearingMessagesJob: JobExecutor { success: @escaping (Job, Bool, Dependencies) -> (), failure: @escaping (Job, Error?, Bool, Dependencies) -> (), deferred: @escaping (Job, Dependencies) -> (), - dependencies: Dependencies = Dependencies() + using dependencies: Dependencies ) { // The 'backgroundTask' gets captured and cleared within the 'completion' block let timestampNowMs: TimeInterval = TimeInterval(SnodeAPI.currentOffsetTimestampMs()) diff --git a/SessionMessagingKit/Jobs/Types/FailedAttachmentDownloadsJob.swift b/SessionMessagingKit/Jobs/Types/FailedAttachmentDownloadsJob.swift index 44b5e1921..20eac0a93 100644 --- a/SessionMessagingKit/Jobs/Types/FailedAttachmentDownloadsJob.swift +++ b/SessionMessagingKit/Jobs/Types/FailedAttachmentDownloadsJob.swift @@ -16,7 +16,7 @@ public enum FailedAttachmentDownloadsJob: JobExecutor { success: @escaping (Job, Bool, Dependencies) -> (), failure: @escaping (Job, Error?, Bool, Dependencies) -> (), deferred: @escaping (Job, Dependencies) -> (), - dependencies: Dependencies = Dependencies() + using dependencies: Dependencies ) { var changeCount: Int = -1 diff --git a/SessionMessagingKit/Jobs/Types/FailedMessageSendsJob.swift b/SessionMessagingKit/Jobs/Types/FailedMessageSendsJob.swift index 9ec106631..e458de633 100644 --- a/SessionMessagingKit/Jobs/Types/FailedMessageSendsJob.swift +++ b/SessionMessagingKit/Jobs/Types/FailedMessageSendsJob.swift @@ -15,7 +15,7 @@ public enum FailedMessageSendsJob: JobExecutor { success: @escaping (Job, Bool, Dependencies) -> (), failure: @escaping (Job, Error?, Bool, Dependencies) -> (), deferred: @escaping (Job, Dependencies) -> (), - dependencies: Dependencies = Dependencies() + using dependencies: Dependencies ) { var changeCount: Int = -1 var attachmentChangeCount: Int = -1 diff --git a/SessionMessagingKit/Jobs/Types/GarbageCollectionJob.swift b/SessionMessagingKit/Jobs/Types/GarbageCollectionJob.swift index b35bf8621..7b5989579 100644 --- a/SessionMessagingKit/Jobs/Types/GarbageCollectionJob.swift +++ b/SessionMessagingKit/Jobs/Types/GarbageCollectionJob.swift @@ -23,7 +23,7 @@ public enum GarbageCollectionJob: JobExecutor { success: @escaping (Job, Bool, Dependencies) -> (), failure: @escaping (Job, Error?, Bool, Dependencies) -> (), deferred: @escaping (Job, Dependencies) -> (), - dependencies: Dependencies = Dependencies() + using dependencies: Dependencies ) { /// Determine what types of data we want to collect (if we didn't provide any then assume we want to collect everything) /// @@ -33,18 +33,18 @@ public enum GarbageCollectionJob: JobExecutor { .map { try? JSONDecoder().decode(Details.self, from: $0) }? .typesToCollect) .defaulting(to: Types.allCases) - let timestampNow: TimeInterval = Date().timeIntervalSince1970 + let timestampNow: TimeInterval = dependencies.dateNow.timeIntervalSince1970 /// Only do a full collection if the job isn't the recurring one or it's been 23 hours since it last ran (23 hours so a user who opens the /// app at about the same time every day will trigger the garbage collection) - since this runs when the app becomes active we /// want to prevent it running to frequently (the app becomes active if a system alert, the notification center or the control panel /// are shown) - let lastGarbageCollection: Date = UserDefaults.standard[.lastGarbageCollection] + let lastGarbageCollection: Date = dependencies.standardUserDefaults[.lastGarbageCollection] .defaulting(to: Date.distantPast) let finalTypesToCollect: Set = { guard job.behaviour != .recurringOnActive || - Date().timeIntervalSince(lastGarbageCollection) > (23 * 60 * 60) + dependencies.dateNow.timeIntervalSince(lastGarbageCollection) > (23 * 60 * 60) else { // Note: This should only contain the `Types` which are unlikely to ever cause // a startup delay (ie. avoid mass deletions and file management) @@ -450,8 +450,8 @@ public enum GarbageCollectionJob: JobExecutor { // If we did a full collection then update the 'lastGarbageCollection' date to // prevent a full collection from running again in the next 23 hours - if job.behaviour == .recurringOnActive && Date().timeIntervalSince(lastGarbageCollection) > (23 * 60 * 60) { - UserDefaults.standard[.lastGarbageCollection] = Date() + if job.behaviour == .recurringOnActive && dependencies.dateNow.timeIntervalSince(lastGarbageCollection) > (23 * 60 * 60) { + dependencies.standardUserDefaults[.lastGarbageCollection] = dependencies.dateNow } success(job, false, dependencies) diff --git a/SessionMessagingKit/Jobs/Types/GroupLeavingJob.swift b/SessionMessagingKit/Jobs/Types/GroupLeavingJob.swift index 9eb36cccf..65d2601d6 100644 --- a/SessionMessagingKit/Jobs/Types/GroupLeavingJob.swift +++ b/SessionMessagingKit/Jobs/Types/GroupLeavingJob.swift @@ -18,7 +18,7 @@ public enum GroupLeavingJob: JobExecutor { success: @escaping (Job, Bool, Dependencies) -> (), failure: @escaping (Job, Error?, Bool, Dependencies) -> (), deferred: @escaping (Job, Dependencies) -> (), - dependencies: Dependencies = Dependencies() + using dependencies: Dependencies = Dependencies() ) { guard let detailsData: Data = job.details, @@ -51,10 +51,11 @@ public enum GroupLeavingJob: JobExecutor { to: destination, namespace: destination.defaultNamespace, interactionId: job.interactionId, - isSyncMessage: false + isSyncMessage: false, + using: dependencies ) } - .flatMap { MessageSender.sendImmediate(preparedSendData: $0) } + .flatMap { MessageSender.sendImmediate(data: $0, using: dependencies) } .subscribe(on: queue) .receive(on: queue) .sinkUntilComplete( diff --git a/SessionMessagingKit/Jobs/Types/MessageReceiveJob.swift b/SessionMessagingKit/Jobs/Types/MessageReceiveJob.swift index 696442bd8..fabae4e97 100644 --- a/SessionMessagingKit/Jobs/Types/MessageReceiveJob.swift +++ b/SessionMessagingKit/Jobs/Types/MessageReceiveJob.swift @@ -15,7 +15,7 @@ public enum MessageReceiveJob: JobExecutor { success: @escaping (Job, Bool, Dependencies) -> (), failure: @escaping (Job, Error?, Bool, Dependencies) -> (), deferred: @escaping (Job, Dependencies) -> (), - dependencies: Dependencies = Dependencies() + using dependencies: Dependencies = Dependencies() ) { guard let threadId: String = job.threadId, diff --git a/SessionMessagingKit/Jobs/Types/MessageSendJob.swift b/SessionMessagingKit/Jobs/Types/MessageSendJob.swift index 3ff522544..df411c1e0 100644 --- a/SessionMessagingKit/Jobs/Types/MessageSendJob.swift +++ b/SessionMessagingKit/Jobs/Types/MessageSendJob.swift @@ -18,7 +18,7 @@ public enum MessageSendJob: JobExecutor { success: @escaping (Job, Bool, Dependencies) -> (), failure: @escaping (Job, Error?, Bool, Dependencies) -> (), deferred: @escaping (Job, Dependencies) -> (), - dependencies: Dependencies = Dependencies() + using dependencies: Dependencies ) { guard let detailsData: Data = job.details, @@ -90,6 +90,10 @@ public enum MessageSendJob: JobExecutor { switch attachment.state { case .uploading, .pendingDownload, .downloading, .failedUpload, .downloaded: return true + + // If we've somehow got an attachment that is in an 'uploaded' state but doesn't + // have a 'downloadUrl' then it's invalid and needs to be re-uploaded + case .uploaded: return (attachment.downloadUrl == nil) default: return false } @@ -122,7 +126,7 @@ public enum MessageSendJob: JobExecutor { ) } .compactMap { attachmentId -> (jobId: Int64, job: Job)? in - JobRunner + dependencies.jobRunner .insert( db, job: Job( @@ -171,13 +175,14 @@ public enum MessageSendJob: JobExecutor { to: details.destination, namespace: details.destination.defaultNamespace, interactionId: job.interactionId, - isSyncMessage: details.isSyncMessage + isSyncMessage: details.isSyncMessage, + using: dependencies ) } .map { sendData in sendData.with(fileIds: messageFileIds) } - .flatMap { MessageSender.sendImmediate(preparedSendData: $0) } - .subscribe(on: queue) - .receive(on: queue) + .flatMap { MessageSender.sendImmediate(data: $0, using: dependencies) } + .subscribe(on: queue, using: dependencies) + .receive(on: queue, using: dependencies) .sinkUntilComplete( receiveCompletion: { result in switch result { diff --git a/SessionMessagingKit/Jobs/Types/NotifyPushServerJob.swift b/SessionMessagingKit/Jobs/Types/NotifyPushServerJob.swift index a128f459d..01500f714 100644 --- a/SessionMessagingKit/Jobs/Types/NotifyPushServerJob.swift +++ b/SessionMessagingKit/Jobs/Types/NotifyPushServerJob.swift @@ -16,7 +16,7 @@ public enum NotifyPushServerJob: JobExecutor { success: @escaping (Job, Bool, Dependencies) -> (), failure: @escaping (Job, Error?, Bool, Dependencies) -> (), deferred: @escaping (Job, Dependencies) -> (), - dependencies: Dependencies = Dependencies() + using dependencies: Dependencies ) { guard let detailsData: Data = job.details, diff --git a/SessionMessagingKit/Jobs/Types/RetrieveDefaultOpenGroupRoomsJob.swift b/SessionMessagingKit/Jobs/Types/RetrieveDefaultOpenGroupRoomsJob.swift index d16e8ce9b..7310f120e 100644 --- a/SessionMessagingKit/Jobs/Types/RetrieveDefaultOpenGroupRoomsJob.swift +++ b/SessionMessagingKit/Jobs/Types/RetrieveDefaultOpenGroupRoomsJob.swift @@ -15,7 +15,7 @@ public enum RetrieveDefaultOpenGroupRoomsJob: JobExecutor { success: @escaping (Job, Bool, Dependencies) -> (), failure: @escaping (Job, Error?, Bool, Dependencies) -> (), deferred: @escaping (Job, Dependencies) -> (), - dependencies: Dependencies = Dependencies() + using dependencies: Dependencies ) { // Don't run when inactive or not in main app guard (UserDefaults.sharedLokiProject?[.isMainAppActive]).defaulting(to: false) else { diff --git a/SessionMessagingKit/Jobs/Types/SendReadReceiptsJob.swift b/SessionMessagingKit/Jobs/Types/SendReadReceiptsJob.swift index 77097df92..8b5090afb 100644 --- a/SessionMessagingKit/Jobs/Types/SendReadReceiptsJob.swift +++ b/SessionMessagingKit/Jobs/Types/SendReadReceiptsJob.swift @@ -17,7 +17,7 @@ public enum SendReadReceiptsJob: JobExecutor { success: @escaping (Job, Bool, Dependencies) -> (), failure: @escaping (Job, Error?, Bool, Dependencies) -> (), deferred: @escaping (Job, Dependencies) -> (), - dependencies: Dependencies = Dependencies() + using dependencies: Dependencies ) { guard let threadId: String = job.threadId, @@ -47,7 +47,7 @@ public enum SendReadReceiptsJob: JobExecutor { isSyncMessage: false ) } - .flatMap { MessageSender.sendImmediate(preparedSendData: $0) } + .flatMap { MessageSender.sendImmediate(data: $0, using: dependencies) } .subscribe(on: queue) .receive(on: queue) .sinkUntilComplete( @@ -59,7 +59,7 @@ public enum SendReadReceiptsJob: JobExecutor { // another one for the same thread but with a 'nextRunTimestamp' set to the // 'maxRunFrequency' value to throttle the read receipt requests var shouldFinishCurrentJob: Bool = false - let nextRunTimestamp: TimeInterval = (Date().timeIntervalSince1970 + maxRunFrequency) + let nextRunTimestamp: TimeInterval = (dependencies.dateNow.timeIntervalSince1970 + maxRunFrequency) let updatedJob: Job? = Storage.shared.write { db in // If another 'sendReadReceipts' job was scheduled then update that one diff --git a/SessionMessagingKit/Jobs/Types/UpdateProfilePictureJob.swift b/SessionMessagingKit/Jobs/Types/UpdateProfilePictureJob.swift index c29ff6200..21afcc6de 100644 --- a/SessionMessagingKit/Jobs/Types/UpdateProfilePictureJob.swift +++ b/SessionMessagingKit/Jobs/Types/UpdateProfilePictureJob.swift @@ -15,7 +15,7 @@ public enum UpdateProfilePictureJob: JobExecutor { success: @escaping (Job, Bool, Dependencies) -> (), failure: @escaping (Job, Error?, Bool, Dependencies) -> (), deferred: @escaping (Job, Dependencies) -> (), - dependencies: Dependencies = Dependencies() + using dependencies: Dependencies ) { // Don't run when inactive or not in main app guard (UserDefaults.sharedLokiProject?[.isMainAppActive]).defaulting(to: false) else { @@ -24,8 +24,8 @@ public enum UpdateProfilePictureJob: JobExecutor { // Only re-upload the profile picture if enough time has passed since the last upload guard - let lastProfilePictureUpload: Date = UserDefaults.standard[.lastProfilePictureUpload], - Date().timeIntervalSince(lastProfilePictureUpload) > (14 * 24 * 60 * 60) + let lastProfilePictureUpload: Date = dependencies.standardUserDefaults[.lastProfilePictureUpload], + dependencies.dateNow.timeIntervalSince(lastProfilePictureUpload) > (14 * 24 * 60 * 60) else { // Reset the `nextRunTimestamp` value just in case the last run failed so we don't get stuck // in a loop endlessly deferring the job @@ -42,7 +42,7 @@ public enum UpdateProfilePictureJob: JobExecutor { } // Note: The user defaults flag is updated in ProfileManager - let profile: Profile = Profile.fetchOrCreateCurrentUser(dependencies: dependencies) + let profile: Profile = Profile.fetchOrCreateCurrentUser(using: dependencies) let profilePictureData: Data? = profile.profilePictureFileName .map { ProfileManager.loadProfileData(with: $0) } diff --git a/SessionMessagingKit/Messages/Message.swift b/SessionMessagingKit/Messages/Message.swift index 7de4f560e..d9d6e3caa 100644 --- a/SessionMessagingKit/Messages/Message.swift +++ b/SessionMessagingKit/Messages/Message.swift @@ -230,7 +230,8 @@ public extension Message { static func processRawReceivedMessage( _ db: Database, - rawMessage: SnodeReceivedMessage + rawMessage: SnodeReceivedMessage, + using dependencies: Dependencies = Dependencies() ) throws -> ProcessedMessage? { guard let envelope = SNProtoEnvelope.from(rawMessage) else { throw MessageReceiverError.invalidMessage @@ -242,7 +243,8 @@ public extension Message { envelope: envelope, serverExpirationTimestamp: (TimeInterval(rawMessage.info.expirationDateMs) / 1000), serverHash: rawMessage.info.hash, - handleClosedGroupKeyUpdateMessages: true + handleClosedGroupKeyUpdateMessages: true, + using: dependencies ) // Ensure we actually want to de-dupe messages for this namespace, otherwise just @@ -289,7 +291,8 @@ public extension Message { static func processRawReceivedMessage( _ db: Database, serializedData: Data, - serverHash: String? + serverHash: String?, + using dependencies: Dependencies = Dependencies() ) throws -> ProcessedMessage? { guard let envelope = try? SNProtoEnvelope.parseData(serializedData) else { throw MessageReceiverError.invalidMessage @@ -303,7 +306,8 @@ public extension Message { ControlMessageProcessRecord.defaultExpirationSeconds ), serverHash: serverHash, - handleClosedGroupKeyUpdateMessages: true + handleClosedGroupKeyUpdateMessages: true, + using: dependencies ) } @@ -312,7 +316,8 @@ public extension Message { /// closed group key update messages (the `NotificationServiceExtension` does this itself) static func processRawReceivedMessageAsNotification( _ db: Database, - envelope: SNProtoEnvelope + envelope: SNProtoEnvelope, + using dependencies: Dependencies = Dependencies() ) throws -> ProcessedMessage? { let processedMessage: ProcessedMessage? = try processRawReceivedMessage( db, @@ -322,7 +327,8 @@ public extension Message { ControlMessageProcessRecord.defaultExpirationSeconds ), serverHash: nil, - handleClosedGroupKeyUpdateMessages: false + handleClosedGroupKeyUpdateMessages: false, + using: dependencies ) return processedMessage @@ -334,7 +340,7 @@ public extension Message { openGroupServerPublicKey: String, message: OpenGroupAPI.Message, data: Data, - dependencies: SMKDependencies = SMKDependencies() + using dependencies: Dependencies = Dependencies() ) throws -> ProcessedMessage? { // Need a sender in order to process the message guard let sender: String = message.sender, let timestamp = message.posted else { return nil } @@ -357,7 +363,7 @@ public extension Message { openGroupMessageServerId: message.id, openGroupServerPublicKey: openGroupServerPublicKey, handleClosedGroupKeyUpdateMessages: false, - dependencies: dependencies + using: dependencies ) } @@ -368,7 +374,7 @@ public extension Message { data: Data, isOutgoing: Bool? = nil, otherBlindedPublicKey: String? = nil, - dependencies: SMKDependencies = SMKDependencies() + using dependencies: Dependencies = Dependencies() ) throws -> ProcessedMessage? { // Note: The `posted` value is in seconds but all messages in the database use milliseconds for timestamps let envelopeBuilder = SNProtoEnvelope.builder(type: .sessionMessage, timestamp: UInt64(floor(message.posted * 1000))) @@ -390,7 +396,7 @@ public extension Message { isOutgoing: isOutgoing, otherBlindedPublicKey: otherBlindedPublicKey, handleClosedGroupKeyUpdateMessages: false, - dependencies: dependencies + using: dependencies ) } @@ -399,24 +405,26 @@ public extension Message { openGroupId: String, message: OpenGroupAPI.Message, associatedPendingChanges: [OpenGroupAPI.PendingChange], - dependencies: SMKDependencies = SMKDependencies() + using dependencies: Dependencies = Dependencies() ) -> [Reaction] { var results: [Reaction] = [] guard let reactions = message.reactions else { return results } - let userPublicKey: String = getUserHexEncodedPublicKey(db) + let userPublicKey: String = getUserHexEncodedPublicKey(db, using: dependencies) let blinded15UserPublicKey: String? = SessionThread .getUserHexEncodedBlindedKey( db, threadId: openGroupId, threadVariant: .community, - blindingPrefix: .blinded15 + blindingPrefix: .blinded15, + using: dependencies ) let blinded25UserPublicKey: String? = SessionThread .getUserHexEncodedBlindedKey( db, threadId: openGroupId, threadVariant: .community, - blindingPrefix: .blinded25 + blindingPrefix: .blinded25, + using: dependencies ) for (encodedEmoji, rawReaction) in reactions { @@ -536,7 +544,7 @@ public extension Message { isOutgoing: Bool? = nil, otherBlindedPublicKey: String? = nil, handleClosedGroupKeyUpdateMessages: Bool, - dependencies: SMKDependencies = SMKDependencies() + using dependencies: Dependencies ) throws -> ProcessedMessage? { let (message, proto, threadId, threadVariant) = try MessageReceiver.parse( db, @@ -547,7 +555,7 @@ public extension Message { openGroupServerPublicKey: openGroupServerPublicKey, isOutgoing: isOutgoing, otherBlindedPublicKey: otherBlindedPublicKey, - dependencies: dependencies + using: dependencies ) message.serverHash = serverHash @@ -568,7 +576,8 @@ public extension Message { db, threadId: threadId, threadVariant: threadVariant, - message: closedGroupControlMessage + message: closedGroupControlMessage, + using: dependencies ) return nil diff --git a/SessionMessagingKit/Open Groups/Crypto/OpenGroupAPI+Crypto.swift b/SessionMessagingKit/Open Groups/Crypto/OpenGroupAPI+Crypto.swift new file mode 100644 index 000000000..b4cdb41c5 --- /dev/null +++ b/SessionMessagingKit/Open Groups/Crypto/OpenGroupAPI+Crypto.swift @@ -0,0 +1,403 @@ +// Copyright © 2023 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import CryptoKit +import Sodium +import Clibsodium +import Curve25519Kit +import SessionUtilitiesKit + +// MARK: - Nonce + +internal extension OpenGroupAPI { + class NonceGenerator16Byte: NonceGenerator { + public var NonceBytes: Int { 16 } + } + + class NonceGenerator24Byte: NonceGenerator { + public var NonceBytes: Int { 24 } + } +} + +public extension Crypto.Size { + static let nonce16: Crypto.Size = Crypto.Size(id: "nonce16") { OpenGroupAPI.NonceGenerator16Byte().NonceBytes } + static let nonce24: Crypto.Size = Crypto.Size(id: "nonce24") { OpenGroupAPI.NonceGenerator24Byte().NonceBytes } +} + +public extension Crypto.Action { + static func generateNonce16() -> Crypto.Action { + return Crypto.Action(id: "generateNonce16") { OpenGroupAPI.NonceGenerator16Byte().nonce() } + } + + static func generateNonce24() -> Crypto.Action { + return Crypto.Action(id: "generateNonce24") { OpenGroupAPI.NonceGenerator24Byte().nonce() } + } +} + +// MARK: - AeadXChaCha20Poly1305Ietf + +public extension Crypto.Size { + static let aeadXChaCha20KeyBytes: Crypto.Size = Crypto.Size(id: "aeadXChaCha20KeyBytes") { + Sodium().aead.xchacha20poly1305ietf.KeyBytes + } + static let aeadXChaCha20ABytes: Crypto.Size = Crypto.Size(id: "aeadXChaCha20ABytes") { + Sodium().aead.xchacha20poly1305ietf.ABytes + } +} + +public extension Crypto.Action { + /// This method is the same as the standard AeadXChaCha20Poly1305Ietf `encrypt` method except it allows the + /// specification of a nonce which allows for deterministic behaviour with unit testing + static func encryptAeadXChaCha20( + message: Bytes, + secretKey: Bytes, + nonce: Bytes, + additionalData: Bytes? = nil, + using dependencies: Dependencies + ) -> Crypto.Action { + return Crypto.Action( + id: "encryptAeadXChaCha20", + args: [message, secretKey, nonce, additionalData] + ) { + guard secretKey.count == dependencies.crypto.size(.aeadXChaCha20KeyBytes) else { return nil } + + var authenticatedCipherText = Bytes( + repeating: 0, + count: message.count + dependencies.crypto.size(.aeadXChaCha20ABytes) + ) + var authenticatedCipherTextLen: UInt64 = 0 + + let result = crypto_aead_xchacha20poly1305_ietf_encrypt( + &authenticatedCipherText, &authenticatedCipherTextLen, + message, UInt64(message.count), + additionalData, UInt64(additionalData?.count ?? 0), + nil, nonce, secretKey + ) + + guard result == 0 else { return nil } + + return authenticatedCipherText + } + } + + static func decryptAeadXChaCha20( + authenticatedCipherText: Bytes, + secretKey: Bytes, + nonce: Bytes, + additionalData: Bytes? = nil + ) -> Crypto.Action { + return Crypto.Action( + id: "decryptAeadXChaCha20", + args: [authenticatedCipherText, secretKey, nonce, additionalData] + ) { + return Sodium().aead.xchacha20poly1305ietf.decrypt( + authenticatedCipherText: authenticatedCipherText, + secretKey: secretKey, + nonce: nonce, + additionalData: additionalData + ) + } + } +} + +// MARK: - Blinding + +/// These extenion methods are used to generate a sign "blinded" messages +/// +/// According to the Swift engineers the only situation when `UnsafeRawBufferPointer.baseAddress` is nil is when it's an +/// empty collection; as such our guard cases wihch return `-1` when unwrapping this value should never be hit and we can ignore +/// them as possible results. +/// +/// For more information see: +/// https://forums.swift.org/t/when-is-unsafemutablebufferpointer-baseaddress-nil/32136/5 +/// https://github.com/apple/swift-evolution/blob/master/proposals/0055-optional-unsafe-pointers.md#unsafebufferpointer +public extension Crypto.Action { + private static let scalarLength: Int = Int(crypto_core_ed25519_scalarbytes()) // 32 + private static let noClampLength: Int = Int(Sodium.lib_crypto_scalarmult_ed25519_bytes()) // 32 + private static let scalarMultLength: Int = Int(crypto_scalarmult_bytes()) // 32 + fileprivate static let publicKeyLength: Int = Int(crypto_scalarmult_bytes()) // 32 + fileprivate static let secretKeyLength: Int = Int(crypto_sign_secretkeybytes()) // 64 + + /// 64-byte blake2b hash then reduce to get the blinding factor + static func generateBlindingFactor( + serverPublicKey: String, + using dependencies: Dependencies + ) -> Crypto.Action { + return Crypto.Action( + id: "generateBlindingFactor", + args: [serverPublicKey] + ) { + /// k = salt.crypto_core_ed25519_scalar_reduce(blake2b(server_pk, digest_size=64).digest()) + let serverPubKeyData: Data = Data(hex: serverPublicKey) + + guard + !serverPubKeyData.isEmpty, + let serverPublicKeyHashBytes: Bytes = try? dependencies.crypto.perform( + .hash(message: [UInt8](serverPubKeyData), outputLength: 64) + ) + else { return nil } + + /// Reduce the server public key into an ed25519 scalar (`k`) + let kPtr: UnsafeMutablePointer = UnsafeMutablePointer.allocate(capacity: Crypto.Action.scalarLength) + + _ = serverPublicKeyHashBytes.withUnsafeBytes { (serverPublicKeyHashPtr: UnsafeRawBufferPointer) -> Int32 in + guard let serverPublicKeyHashBaseAddress: UnsafePointer = serverPublicKeyHashPtr.baseAddress?.assumingMemoryBound(to: UInt8.self) else { + return -1 // Impossible case (refer to comments at top of extension) + } + + Sodium.lib_crypto_core_ed25519_scalar_reduce(kPtr, serverPublicKeyHashBaseAddress) + return 0 + } + + return Data(bytes: kPtr, count: Crypto.Action.scalarLength).bytes + } + } + + /// Calculate k*a. To get 'a' (the Ed25519 private key scalar) we call the sodium function to + /// convert to an *x* secret key, which seems wrong--but isn't because converted keys use the + /// same secret scalar secret (and so this is just the most convenient way to get 'a' out of + /// a sodium Ed25519 secret key) + fileprivate static func generatePrivateKeyScalar(secretKey: Bytes) -> Bytes { + /// a = s.to_curve25519_private_key().encode() + let aPtr: UnsafeMutablePointer = UnsafeMutablePointer.allocate(capacity: Crypto.Action.scalarMultLength) + + /// Looks like the `crypto_sign_ed25519_sk_to_curve25519` function can't actually fail so no need to verify the result + /// See: https://github.com/jedisct1/libsodium/blob/master/src/libsodium/crypto_sign/ed25519/ref10/keypair.c#L70 + _ = secretKey.withUnsafeBytes { (secretKeyPtr: UnsafeRawBufferPointer) -> Int32 in + guard let secretKeyBaseAddress: UnsafePointer = secretKeyPtr.baseAddress?.assumingMemoryBound(to: UInt8.self) else { + return -1 // Impossible case (refer to comments at top of extension) + } + + return crypto_sign_ed25519_sk_to_curve25519(aPtr, secretKeyBaseAddress) + } + + return Data(bytes: aPtr, count: Crypto.Action.scalarMultLength).bytes + } + + /// Constructs an Ed25519 signature from a root Ed25519 key and a blinded scalar/pubkey pair, with one tweak to the + /// construction: we add kA into the hashed value that yields r so that we have domain separation for different blinded + /// pubkeys (this doesn't affect verification at all) + static func sogsSignature( + message: Bytes, + secretKey: Bytes, + blindedSecretKey ka: Bytes, + blindedPublicKey kA: Bytes + ) -> Crypto.Action { + return Crypto.Action( + id: "sogsSignature", + args: [message, secretKey, ka, kA] + ) { + /// H_rh = sha512(s.encode()).digest()[32:] + let H_rh: Bytes = Bytes(SHA512.hash(data: secretKey).suffix(32)) + + /// r = salt.crypto_core_ed25519_scalar_reduce(sha512_multipart(H_rh, kA, message_parts)) + let combinedHashBytes: Bytes = SHA512.hash(data: H_rh + kA + message).bytes + let rPtr: UnsafeMutablePointer = UnsafeMutablePointer.allocate(capacity: Crypto.Action.scalarLength) + + _ = combinedHashBytes.withUnsafeBytes { (combinedHashPtr: UnsafeRawBufferPointer) -> Int32 in + guard let combinedHashBaseAddress: UnsafePointer = combinedHashPtr.baseAddress?.assumingMemoryBound(to: UInt8.self) else { + return -1 // Impossible case (refer to comments at top of extension) + } + + Sodium.lib_crypto_core_ed25519_scalar_reduce(rPtr, combinedHashBaseAddress) + return 0 + } + + /// sig_R = salt.crypto_scalarmult_ed25519_base_noclamp(r) + let sig_RPtr: UnsafeMutablePointer = UnsafeMutablePointer.allocate(capacity: Crypto.Action.noClampLength) + guard crypto_scalarmult_ed25519_base_noclamp(sig_RPtr, rPtr) == 0 else { return nil } + + /// HRAM = salt.crypto_core_ed25519_scalar_reduce(sha512_multipart(sig_R, kA, message_parts)) + let sig_RBytes: Bytes = Data(bytes: sig_RPtr, count: Crypto.Action.noClampLength).bytes + let HRAMHashBytes: Bytes = SHA512.hash(data: sig_RBytes + kA + message).bytes + let HRAMPtr: UnsafeMutablePointer = UnsafeMutablePointer.allocate(capacity: Crypto.Action.scalarLength) + + _ = HRAMHashBytes.withUnsafeBytes { (HRAMHashPtr: UnsafeRawBufferPointer) -> Int32 in + guard let HRAMHashBaseAddress: UnsafePointer = HRAMHashPtr.baseAddress?.assumingMemoryBound(to: UInt8.self) else { + return -1 // Impossible case (refer to comments at top of extension) + } + + Sodium.lib_crypto_core_ed25519_scalar_reduce(HRAMPtr, HRAMHashBaseAddress) + return 0 + } + + /// sig_s = salt.crypto_core_ed25519_scalar_add(r, salt.crypto_core_ed25519_scalar_mul(HRAM, ka)) + let sig_sMulPtr: UnsafeMutablePointer = UnsafeMutablePointer.allocate(capacity: Crypto.Action.scalarLength) + let sig_sPtr: UnsafeMutablePointer = UnsafeMutablePointer.allocate(capacity: Crypto.Action.scalarLength) + + _ = ka.withUnsafeBytes { (kaPtr: UnsafeRawBufferPointer) -> Int32 in + guard let kaBaseAddress: UnsafePointer = kaPtr.baseAddress?.assumingMemoryBound(to: UInt8.self) else { + return -1 // Impossible case (refer to comments at top of extension) + } + + Sodium.lib_crypto_core_ed25519_scalar_mul(sig_sMulPtr, HRAMPtr, kaBaseAddress) + Sodium.lib_crypto_core_ed25519_scalar_add(sig_sPtr, rPtr, sig_sMulPtr) + return 0 + } + + /// full_sig = sig_R + sig_s + return (Data(bytes: sig_RPtr, count: Crypto.Action.noClampLength).bytes + Data(bytes: sig_sPtr, count: Crypto.Action.scalarLength).bytes) + } + } + + /// Combines two keys (`kA`) + static func combineKeys( + lhsKeyBytes: Bytes, + rhsKeyBytes: Bytes + ) -> Crypto.Action { + return Crypto.Action( + id: "combineKeys", + args: [lhsKeyBytes, rhsKeyBytes] + ) { + let combinedPtr: UnsafeMutablePointer = UnsafeMutablePointer.allocate(capacity: Crypto.Action.noClampLength) + + let result = rhsKeyBytes.withUnsafeBytes { (rhsKeyBytesPtr: UnsafeRawBufferPointer) -> Int32 in + return lhsKeyBytes.withUnsafeBytes { (lhsKeyBytesPtr: UnsafeRawBufferPointer) -> Int32 in + guard let lhsKeyBytesBaseAddress: UnsafePointer = lhsKeyBytesPtr.baseAddress?.assumingMemoryBound(to: UInt8.self) else { + return -1 // Impossible case (refer to comments at top of extension) + } + guard let rhsKeyBytesBaseAddress: UnsafePointer = rhsKeyBytesPtr.baseAddress?.assumingMemoryBound(to: UInt8.self) else { + return -1 // Impossible case (refer to comments at top of extension) + } + + return Sodium.lib_crypto_scalarmult_ed25519_noclamp(combinedPtr, lhsKeyBytesBaseAddress, rhsKeyBytesBaseAddress) + } + } + + /// Ensure the above worked + guard result == 0 else { return nil } + + return Data(bytes: combinedPtr, count: Crypto.Action.noClampLength).bytes + } + } + + /// Calculate a shared secret for a message from A to B: + /// + /// BLAKE2b(a kB || kA || kB) + /// + /// The receiver can calulate the same value via: + /// + /// BLAKE2b(b kA || kA || kB) + static func sharedBlindedEncryptionKey( + secretKey: Bytes, + otherBlindedPublicKey: Bytes, + fromBlindedPublicKey kA: Bytes, + toBlindedPublicKey kB: Bytes, + using dependencies: Dependencies + ) -> Crypto.Action { + return Crypto.Action( + id: "sharedBlindedEncryptionKey", + args: [secretKey, otherBlindedPublicKey, kA, kB] + ) { + let aBytes: Bytes = generatePrivateKeyScalar(secretKey: secretKey) + let combinedKeyBytes: Bytes = try dependencies.crypto.perform( + .combineKeys(lhsKeyBytes: aBytes, rhsKeyBytes: otherBlindedPublicKey) + ) + + return try dependencies.crypto.perform( + .hash(message: (combinedKeyBytes + kA + kB), outputLength: 32) + ) + } + } +} + +public extension Crypto.KeyPairType { + /// Constructs a "blinded" key pair (`ka, kA`) based on an open group server `publicKey` and an ed25519 `keyPair` + static func blindedKeyPair( + serverPublicKey: String, + edKeyPair: KeyPair, + using dependencies: Dependencies + ) -> Crypto.KeyPairType { + return Crypto.KeyPairType( + id: "blindedKeyPair", + args: [serverPublicKey, edKeyPair] + ) { + guard + edKeyPair.publicKey.count == Crypto.Action.publicKeyLength, + edKeyPair.secretKey.count == Crypto.Action.secretKeyLength, + let kBytes: Bytes = try? dependencies.crypto.perform( + .generateBlindingFactor(serverPublicKey: serverPublicKey, using: dependencies) + ) + else { return nil } + + let aBytes: Bytes = Crypto.Action.generatePrivateKeyScalar(secretKey: edKeyPair.secretKey) + + /// Generate the blinded key pair `ka`, `kA` + let kaPtr: UnsafeMutablePointer = UnsafeMutablePointer.allocate(capacity: Crypto.Action.secretKeyLength) + let kAPtr: UnsafeMutablePointer = UnsafeMutablePointer.allocate(capacity: Crypto.Action.publicKeyLength) + + _ = aBytes.withUnsafeBytes { (aPtr: UnsafeRawBufferPointer) -> Int32 in + return kBytes.withUnsafeBytes { (kPtr: UnsafeRawBufferPointer) -> Int32 in + guard let kBaseAddress: UnsafePointer = kPtr.baseAddress?.assumingMemoryBound(to: UInt8.self) else { + return -1 // Impossible case (refer to comments at top of extension) + } + guard let aBaseAddress: UnsafePointer = aPtr.baseAddress?.assumingMemoryBound(to: UInt8.self) else { + return -1 // Impossible case (refer to comments at top of extension) + } + + Sodium.lib_crypto_core_ed25519_scalar_mul(kaPtr, kBaseAddress, aBaseAddress) + return 0 + } + } + + guard crypto_scalarmult_ed25519_base_noclamp(kAPtr, kaPtr) == 0 else { return nil } + + return KeyPair( + publicKey: Data(bytes: kAPtr, count: Crypto.Action.publicKeyLength).bytes, + secretKey: Data(bytes: kaPtr, count: Crypto.Action.secretKeyLength).bytes + ) + } + } +} + +public extension Crypto.Verification { + /// This method should be used to check if a users standard sessionId matches a blinded one + static func sessionId( + _ standardSessionId: String, + matchesBlindedId blindedSessionId: String, + serverPublicKey: String, + using dependencies: Dependencies + ) -> Crypto.Verification { + return Crypto.Verification( + id: "sessionId", + args: [standardSessionId, blindedSessionId, serverPublicKey] + ) { + // Only support generating blinded keys for standard session ids + guard + let sessionId: SessionId = SessionId(from: standardSessionId), + sessionId.prefix == .standard, + let blindedId: SessionId = SessionId(from: blindedSessionId), + ( + blindedId.prefix == .blinded15 || + blindedId.prefix == .blinded25 + ), + let kBytes: Bytes = try? dependencies.crypto.perform( + .generateBlindingFactor(serverPublicKey: serverPublicKey, using: dependencies) + ) + else { return false } + + /// From the session id (ignoring 05 prefix) we have two possible ed25519 pubkeys; the first is the positive (which is what + /// Signal's XEd25519 conversion always uses) + /// + /// Note: The below method is code we have exposed from the `curve25519_verify` method within the Curve25519 library + /// rather than custom code we have written + guard let xEd25519Key: Data = try? Ed25519.publicKey(from: Data(hex: sessionId.publicKey)) else { return false } + + /// Blind the positive public key + guard + let pk1: Bytes = try? dependencies.crypto.perform( + .combineKeys(lhsKeyBytes: kBytes, rhsKeyBytes: xEd25519Key.bytes) + ) + else { return false } + + /// For the negative, what we're going to get out of the above is simply the negative of pk1, so flip the sign bit to get pk2 + /// pk2 = pk1[0:31] + bytes([pk1[31] ^ 0b1000_0000]) + let pk2: Bytes = (pk1[0..<31] + [(pk1[31] ^ 0b1000_0000)]) + + return ( + SessionId(.blinded15, publicKey: pk1).publicKey == blindedId.publicKey || + SessionId(.blinded15, publicKey: pk2).publicKey == blindedId.publicKey + ) + } + } +} diff --git a/SessionMessagingKit/Open Groups/Models/SOGSBatchRequest.swift b/SessionMessagingKit/Open Groups/Models/SOGSBatchRequest.swift index efe990e89..073f63816 100644 --- a/SessionMessagingKit/Open Groups/Models/SOGSBatchRequest.swift +++ b/SessionMessagingKit/Open Groups/Models/SOGSBatchRequest.swift @@ -69,7 +69,6 @@ public extension OpenGroupAPI { info = HTTP.ResponseInfo(code: 0, headers: [:]) data = [:] #endif - } } } diff --git a/SessionMessagingKit/Open Groups/Models/SOGSMessage.swift b/SessionMessagingKit/Open Groups/Models/SOGSMessage.swift index b266c26e1..7adecefea 100644 --- a/SessionMessagingKit/Open Groups/Models/SOGSMessage.swift +++ b/SessionMessagingKit/Open Groups/Models/SOGSMessage.swift @@ -69,7 +69,7 @@ extension OpenGroupAPI.Message { guard let sender: String = maybeSender, let data = Data(base64Encoded: base64EncodedData), let signature = Data(base64Encoded: base64EncodedSignature) else { throw HTTPError.parsingFailed } - guard let dependencies: SMKDependencies = decoder.userInfo[Dependencies.userInfoKey] as? SMKDependencies else { + guard let dependencies: Dependencies = decoder.userInfo[Dependencies.userInfoKey] as? Dependencies else { throw HTTPError.parsingFailed } @@ -78,13 +78,21 @@ extension OpenGroupAPI.Message { switch SessionId.Prefix(from: sender) { case .blinded15, .blinded25: - guard dependencies.sign.verify(message: data.bytes, publicKey: publicKey.bytes, signature: signature.bytes) else { + guard + dependencies.crypto.verify( + .signature(message: data.bytes, publicKey: publicKey.bytes, signature: signature.bytes) + ) + else { SNLog("Ignoring message with invalid signature.") throw HTTPError.parsingFailed } case .standard, .unblinded: - guard (try? dependencies.ed25519.verifySignature(signature, publicKey: publicKey, data: data)) == true else { + guard + dependencies.crypto.verify( + .signatureEd25519(signature, publicKey: publicKey, data: data) + ) + else { SNLog("Ignoring message with invalid signature.") throw HTTPError.parsingFailed } diff --git a/SessionMessagingKit/Open Groups/OpenGroupAPI.swift b/SessionMessagingKit/Open Groups/OpenGroupAPI.swift index 3797dd755..4cd74f821 100644 --- a/SessionMessagingKit/Open Groups/OpenGroupAPI.swift +++ b/SessionMessagingKit/Open Groups/OpenGroupAPI.swift @@ -31,7 +31,7 @@ public enum OpenGroupAPI { server: String, hasPerformedInitialPoll: Bool, timeSinceLastPoll: TimeInterval, - using dependencies: SMKDependencies = SMKDependencies() + using dependencies: Dependencies = Dependencies() ) throws -> PreparedSendData { let lastInboxMessageId: Int64 = (try? OpenGroup .select(.inboxLatestMessageId) @@ -143,7 +143,7 @@ public enum OpenGroupAPI { _ db: Database, server: String, requests: [ErasedPreparedSendData], - using dependencies: SMKDependencies = SMKDependencies() + using dependencies: Dependencies = Dependencies() ) throws -> PreparedSendData { return try OpenGroupAPI .prepareSendData( @@ -173,7 +173,7 @@ public enum OpenGroupAPI { _ db: Database, server: String, requests: [ErasedPreparedSendData], - using dependencies: SMKDependencies = SMKDependencies() + using dependencies: Dependencies = Dependencies() ) throws -> PreparedSendData { return try OpenGroupAPI .prepareSendData( @@ -202,7 +202,7 @@ public enum OpenGroupAPI { _ db: Database, server: String, forceBlinded: Bool = false, - using dependencies: SMKDependencies = SMKDependencies() + using dependencies: Dependencies = Dependencies() ) throws -> PreparedSendData { return try OpenGroupAPI .prepareSendData( @@ -225,7 +225,7 @@ public enum OpenGroupAPI { public static func preparedRooms( _ db: Database, server: String, - using dependencies: SMKDependencies = SMKDependencies() + using dependencies: Dependencies = Dependencies() ) throws -> PreparedSendData<[Room]> { return try OpenGroupAPI .prepareSendData( @@ -244,7 +244,7 @@ public enum OpenGroupAPI { _ db: Database, for roomToken: String, on server: String, - using dependencies: SMKDependencies = SMKDependencies() + using dependencies: Dependencies = Dependencies() ) throws -> PreparedSendData { return try OpenGroupAPI .prepareSendData( @@ -267,7 +267,7 @@ public enum OpenGroupAPI { lastUpdated: Int64, for roomToken: String, on server: String, - using dependencies: SMKDependencies = SMKDependencies() + using dependencies: Dependencies = Dependencies() ) throws -> PreparedSendData { return try OpenGroupAPI .prepareSendData( @@ -292,7 +292,7 @@ public enum OpenGroupAPI { _ db: Database, for roomToken: String, on server: String, - using dependencies: SMKDependencies = SMKDependencies() + using dependencies: Dependencies = Dependencies() ) throws -> PreparedSendData { return try OpenGroupAPI .preparedSequence( @@ -332,13 +332,18 @@ public enum OpenGroupAPI { } } + public typealias CapabilitiesAndRoomsResponse = ( + capabilities: (info: ResponseInfoType, data: Capabilities), + rooms: (info: ResponseInfoType, data: [Room]) + ) + /// This is a convenience method which constructs a `/sequence` of the `capabilities` and `rooms` requests, refer to those /// methods for the documented behaviour of each method public static func preparedCapabilitiesAndRooms( _ db: Database, on server: String, - using dependencies: SMKDependencies = SMKDependencies() - ) throws -> PreparedSendData<(capabilities: (info: ResponseInfoType, data: Capabilities), rooms: (info: ResponseInfoType, data: [Room]))> { + using dependencies: Dependencies = Dependencies() + ) throws -> PreparedSendData { return try OpenGroupAPI .preparedSequence( db, @@ -351,7 +356,7 @@ public enum OpenGroupAPI { ], using: dependencies ) - .map { (info: ResponseInfoType, response: BatchResponse) -> (capabilities: (info: ResponseInfoType, data: Capabilities), rooms: (info: ResponseInfoType, data: [Room])) in + .map { (info: ResponseInfoType, response: BatchResponse) -> CapabilitiesAndRoomsResponse in let maybeCapabilities: HTTP.BatchSubResponse? = (response[.capabilities] as? HTTP.BatchSubResponse) let maybeRooms: HTTP.BatchSubResponse<[Room]>? = response.data .first(where: { key, _ in @@ -387,7 +392,7 @@ public enum OpenGroupAPI { whisperTo: String?, whisperMods: Bool, fileIds: [String]?, - using dependencies: SMKDependencies = SMKDependencies() + using dependencies: Dependencies = Dependencies() ) throws -> PreparedSendData { guard let signResult: (publicKey: String, signature: Bytes) = sign(db, messageBytes: plaintext.bytes, for: server, fallbackSigningType: .standard, using: dependencies) else { throw OpenGroupAPIError.signingFailed @@ -419,7 +424,7 @@ public enum OpenGroupAPI { id: Int64, in roomToken: String, on server: String, - using dependencies: SMKDependencies = SMKDependencies() + using dependencies: Dependencies = Dependencies() ) throws -> PreparedSendData { return try OpenGroupAPI .prepareSendData( @@ -443,7 +448,7 @@ public enum OpenGroupAPI { fileIds: [Int64]?, in roomToken: String, on server: String, - using dependencies: SMKDependencies = SMKDependencies() + using dependencies: Dependencies = Dependencies() ) throws -> PreparedSendData { guard let signResult: (publicKey: String, signature: Bytes) = sign(db, messageBytes: plaintext.bytes, for: server, fallbackSigningType: .standard, using: dependencies) else { throw OpenGroupAPIError.signingFailed @@ -473,7 +478,7 @@ public enum OpenGroupAPI { id: Int64, in roomToken: String, on server: String, - using dependencies: SMKDependencies = SMKDependencies() + using dependencies: Dependencies = Dependencies() ) throws -> PreparedSendData { return try OpenGroupAPI .prepareSendData( @@ -497,7 +502,7 @@ public enum OpenGroupAPI { _ db: Database, in roomToken: String, on server: String, - using dependencies: SMKDependencies = SMKDependencies() + using dependencies: Dependencies = Dependencies() ) throws -> PreparedSendData<[Failable]> { return try OpenGroupAPI .prepareSendData( @@ -526,7 +531,7 @@ public enum OpenGroupAPI { messageId: Int64, in roomToken: String, on server: String, - using dependencies: SMKDependencies = SMKDependencies() + using dependencies: Dependencies = Dependencies() ) throws -> PreparedSendData<[Failable]> { return try OpenGroupAPI .prepareSendData( @@ -555,7 +560,7 @@ public enum OpenGroupAPI { seqNo: Int64, in roomToken: String, on server: String, - using dependencies: SMKDependencies = SMKDependencies() + using dependencies: Dependencies = Dependencies() ) throws -> PreparedSendData<[Failable]> { return try OpenGroupAPI .prepareSendData( @@ -591,7 +596,7 @@ public enum OpenGroupAPI { sessionId: String, in roomToken: String, on server: String, - using dependencies: SMKDependencies = SMKDependencies() + using dependencies: Dependencies = Dependencies() ) throws -> PreparedSendData { return try OpenGroupAPI .prepareSendData( @@ -615,7 +620,7 @@ public enum OpenGroupAPI { id: Int64, in roomToken: String, on server: String, - using dependencies: SMKDependencies = SMKDependencies() + using dependencies: Dependencies = Dependencies() ) throws -> PreparedSendData { /// URL(String:) won't convert raw emojis, so need to do a little encoding here. /// The raw emoji will come back when calling url.path @@ -646,7 +651,7 @@ public enum OpenGroupAPI { id: Int64, in roomToken: String, on server: String, - using dependencies: SMKDependencies = SMKDependencies() + using dependencies: Dependencies = Dependencies() ) throws -> PreparedSendData { /// URL(String:) won't convert raw emojis, so need to do a little encoding here. /// The raw emoji will come back when calling url.path @@ -675,7 +680,7 @@ public enum OpenGroupAPI { id: Int64, in roomToken: String, on server: String, - using dependencies: SMKDependencies = SMKDependencies() + using dependencies: Dependencies = Dependencies() ) throws -> PreparedSendData { /// URL(String:) won't convert raw emojis, so need to do a little encoding here. /// The raw emoji will come back when calling url.path @@ -705,7 +710,7 @@ public enum OpenGroupAPI { id: Int64, in roomToken: String, on server: String, - using dependencies: SMKDependencies = SMKDependencies() + using dependencies: Dependencies = Dependencies() ) throws -> PreparedSendData { /// URL(String:) won't convert raw emojis, so need to do a little encoding here. /// The raw emoji will come back when calling url.path @@ -743,7 +748,7 @@ public enum OpenGroupAPI { id: Int64, in roomToken: String, on server: String, - using dependencies: SMKDependencies = SMKDependencies() + using dependencies: Dependencies = Dependencies() ) throws -> PreparedSendData { return try OpenGroupAPI .prepareSendData( @@ -766,7 +771,7 @@ public enum OpenGroupAPI { id: Int64, in roomToken: String, on server: String, - using dependencies: SMKDependencies = SMKDependencies() + using dependencies: Dependencies = Dependencies() ) throws -> PreparedSendData { return try OpenGroupAPI .prepareSendData( @@ -788,7 +793,7 @@ public enum OpenGroupAPI { _ db: Database, in roomToken: String, on server: String, - using dependencies: SMKDependencies = SMKDependencies() + using dependencies: Dependencies = Dependencies() ) throws -> PreparedSendData { return try OpenGroupAPI .prepareSendData( @@ -817,7 +822,7 @@ public enum OpenGroupAPI { fileName: String? = nil, to roomToken: String, on server: String, - using dependencies: SMKDependencies = SMKDependencies() + using dependencies: Dependencies = Dependencies() ) throws -> PreparedSendData { return try OpenGroupAPI .prepareSendData( @@ -849,7 +854,7 @@ public enum OpenGroupAPI { fileId: String, from roomToken: String, on server: String, - using dependencies: SMKDependencies = SMKDependencies() + using dependencies: Dependencies = Dependencies() ) throws -> PreparedSendData { return try OpenGroupAPI .prepareSendData( @@ -872,7 +877,7 @@ public enum OpenGroupAPI { public static func preparedInbox( _ db: Database, on server: String, - using dependencies: SMKDependencies = SMKDependencies() + using dependencies: Dependencies = Dependencies() ) throws -> PreparedSendData<[DirectMessage]?> { return try OpenGroupAPI .prepareSendData( @@ -893,7 +898,7 @@ public enum OpenGroupAPI { _ db: Database, id: Int64, on server: String, - using dependencies: SMKDependencies = SMKDependencies() + using dependencies: Dependencies = Dependencies() ) throws -> PreparedSendData<[DirectMessage]?> { return try OpenGroupAPI .prepareSendData( @@ -915,7 +920,7 @@ public enum OpenGroupAPI { ciphertext: Data, toInboxFor blindedSessionId: String, on server: String, - using dependencies: SMKDependencies = SMKDependencies() + using dependencies: Dependencies ) throws -> PreparedSendData { return try OpenGroupAPI .prepareSendData( @@ -939,7 +944,7 @@ public enum OpenGroupAPI { public static func preparedOutbox( _ db: Database, on server: String, - using dependencies: SMKDependencies = SMKDependencies() + using dependencies: Dependencies = Dependencies() ) throws -> PreparedSendData<[DirectMessage]?> { return try OpenGroupAPI .prepareSendData( @@ -960,7 +965,7 @@ public enum OpenGroupAPI { _ db: Database, id: Int64, on server: String, - using dependencies: SMKDependencies = SMKDependencies() + using dependencies: Dependencies = Dependencies() ) throws -> PreparedSendData<[DirectMessage]?> { return try OpenGroupAPI .prepareSendData( @@ -1013,7 +1018,7 @@ public enum OpenGroupAPI { for timeout: TimeInterval? = nil, from roomTokens: [String]? = nil, on server: String, - using dependencies: SMKDependencies = SMKDependencies() + using dependencies: Dependencies = Dependencies() ) throws -> PreparedSendData { return try OpenGroupAPI .prepareSendData( @@ -1062,7 +1067,7 @@ public enum OpenGroupAPI { sessionId: String, from roomTokens: [String]?, on server: String, - using dependencies: SMKDependencies = SMKDependencies() + using dependencies: Dependencies = Dependencies() ) throws -> PreparedSendData { return try OpenGroupAPI .prepareSendData( @@ -1140,7 +1145,7 @@ public enum OpenGroupAPI { visible: Bool, for roomTokens: [String]?, on server: String, - using dependencies: SMKDependencies = SMKDependencies() + using dependencies: Dependencies = Dependencies() ) throws -> PreparedSendData { guard (moderator != nil && admin == nil) || (moderator == nil && admin != nil) else { throw HTTPError.generic @@ -1173,7 +1178,7 @@ public enum OpenGroupAPI { sessionId: String, in roomToken: String, on server: String, - using dependencies: SMKDependencies = SMKDependencies() + using dependencies: Dependencies = Dependencies() ) throws -> PreparedSendData { return try OpenGroupAPI .preparedSequence( @@ -1208,7 +1213,7 @@ public enum OpenGroupAPI { for serverName: String, fallbackSigningType signingType: SessionId.Prefix, forceBlinded: Bool = false, - using dependencies: SMKDependencies = SMKDependencies() + using dependencies: Dependencies ) -> (publicKey: String, signature: Bytes)? { guard let userEdKeyPair: KeyPair = Identity.fetchUserEd25519KeyPair(db), @@ -1228,13 +1233,14 @@ public enum OpenGroupAPI { // If we have no capabilities or if the server supports blinded keys then sign using the blinded key if forceBlinded || capabilities.isEmpty || capabilities.contains(.blind) { - guard let blindedKeyPair: KeyPair = dependencies.sodium.blindedKeyPair(serverPublicKey: serverPublicKey, edKeyPair: userEdKeyPair, genericHash: dependencies.genericHash) else { - return nil - } - - guard let signatureResult: Bytes = dependencies.sodium.sogsSignature(message: messageBytes, secretKey: userEdKeyPair.secretKey, blindedSecretKey: blindedKeyPair.secretKey, blindedPublicKey: blindedKeyPair.publicKey) else { - return nil - } + guard + let blindedKeyPair: KeyPair = dependencies.crypto.generate( + .blindedKeyPair(serverPublicKey: serverPublicKey, edKeyPair: userEdKeyPair, using: dependencies) + ), + let signatureResult: Bytes = try? dependencies.crypto.perform( + .sogsSignature(message: messageBytes, secretKey: userEdKeyPair.secretKey, blindedSecretKey: blindedKeyPair.secretKey, blindedPublicKey: blindedKeyPair.publicKey) + ) + else { return nil } return ( publicKey: SessionId(.blinded15, publicKey: blindedKeyPair.publicKey).hexString, @@ -1245,9 +1251,11 @@ public enum OpenGroupAPI { // Otherwise sign using the fallback type switch signingType { case .unblinded: - guard let signatureResult: Bytes = dependencies.sign.signature(message: messageBytes, secretKey: userEdKeyPair.secretKey) else { - return nil - } + guard + let signatureResult: Bytes = try? dependencies.crypto.perform( + .signature(message: messageBytes, secretKey: userEdKeyPair.secretKey) + ) + else { return nil } return ( publicKey: SessionId(.unblinded, publicKey: userEdKeyPair.publicKey).hexString, @@ -1256,10 +1264,12 @@ public enum OpenGroupAPI { // Default to using the 'standard' key default: - guard let userKeyPair: KeyPair = Identity.fetchUserKeyPair(db) else { return nil } - guard let signatureResult: Bytes = try? dependencies.ed25519.sign(data: messageBytes, keyPair: userKeyPair) else { - return nil - } + guard + let userKeyPair: KeyPair = Identity.fetchUserKeyPair(db), + let signatureResult: Bytes = try? dependencies.crypto.perform( + .signEd25519(data: messageBytes, keyPair: userKeyPair) + ) + else { return nil } return ( publicKey: SessionId(.standard, publicKey: userKeyPair.publicKey).hexString, @@ -1275,7 +1285,7 @@ public enum OpenGroupAPI { for serverName: String, with serverPublicKey: String, forceBlinded: Bool = false, - using dependencies: SMKDependencies = SMKDependencies() + using dependencies: Dependencies = Dependencies() ) -> URLRequest? { guard let url: URL = request.url else { return nil } @@ -1283,12 +1293,12 @@ public enum OpenGroupAPI { let path: String = url.path .appending(url.query.map { value in "?\(value)" }) let method: String = (request.httpMethod ?? "GET") - let timestamp: Int = Int(floor(dependencies.date.timeIntervalSince1970)) - let nonce: Data = Data(dependencies.nonceGenerator16.nonce()) + let timestamp: Int = Int(floor(dependencies.dateNow.timeIntervalSince1970)) let serverPublicKeyData: Data = Data(hex: serverPublicKey) guard !serverPublicKeyData.isEmpty, + let nonce: Data = (try? dependencies.crypto.perform(.generateNonce16())).map({ Data($0) }), let timestampBytes: Bytes = "\(timestamp)".data(using: .ascii)?.bytes else { return nil } @@ -1296,7 +1306,7 @@ public enum OpenGroupAPI { let bodyHash: Bytes? = { guard let body: Data = request.httpBody else { return nil } - return dependencies.genericHash.hash(message: body.bytes, outputLength: 64) + return try? dependencies.crypto.perform(.hash(message: body.bytes, outputLength: 64)) }() /// Generate the signature message @@ -1341,7 +1351,7 @@ public enum OpenGroupAPI { responseType: R.Type, forceBlinded: Bool = false, timeout: TimeInterval = HTTP.defaultTimeout, - using dependencies: SMKDependencies = SMKDependencies() + using dependencies: Dependencies = Dependencies() ) throws -> PreparedSendData { let urlRequest: URLRequest = try request.generateUrlRequest() let maybePublicKey: String? = try? OpenGroup @@ -1369,19 +1379,21 @@ public enum OpenGroupAPI { /// This method takes in the `PreparedSendData` and actually sends it to the API public static func send( data: PreparedSendData?, - using dependencies: SMKDependencies = SMKDependencies() + using dependencies: Dependencies = Dependencies() ) -> AnyPublisher<(ResponseInfoType, R), Error> { guard let validData: PreparedSendData = data else { return Fail(error: OpenGroupAPIError.invalidPreparedData) .eraseToAnyPublisher() } - return dependencies.onionApi - .sendOnionRequest( - validData.request, - to: validData.server, - with: validData.publicKey, - timeout: validData.timeout + return dependencies.network + .send( + .onionRequest( + validData.request, + to: validData.server, + with: validData.publicKey, + timeout: validData.timeout + ) ) .decoded(with: validData, using: dependencies) .eraseToAnyPublisher() diff --git a/SessionMessagingKit/Open Groups/OpenGroupManager.swift b/SessionMessagingKit/Open Groups/OpenGroupManager.swift index 72d3e023b..25b6c93df 100644 --- a/SessionMessagingKit/Open Groups/OpenGroupManager.swift +++ b/SessionMessagingKit/Open Groups/OpenGroupManager.swift @@ -12,48 +12,17 @@ import SessionSnodeKit public final class OpenGroupManager { public typealias DefaultRoomInfo = (room: OpenGroupAPI.Room, existingImageData: Data?) - // MARK: - Cache - - public class Cache: OGMMutableCacheType { - public var defaultRoomsPublisher: AnyPublisher<[DefaultRoomInfo], Error>? - public var groupImagePublishers: [String: AnyPublisher] = [:] - - public var pollers: [String: OpenGroupAPI.Poller] = [:] // One for each server - public var isPolling: Bool = false - - /// Server URL to value - public var hasPerformedInitialPoll: [String: Bool] = [:] - public var timeSinceLastPoll: [String: TimeInterval] = [:] - - fileprivate var _timeSinceLastOpen: TimeInterval? - public func getTimeSinceLastOpen(using dependencies: Dependencies) -> TimeInterval { - if let storedTimeSinceLastOpen: TimeInterval = _timeSinceLastOpen { - return storedTimeSinceLastOpen - } - - guard let lastOpen: Date = dependencies.standardUserDefaults[.lastOpen] else { - _timeSinceLastOpen = .greatestFiniteMagnitude - return .greatestFiniteMagnitude - } - - _timeSinceLastOpen = dependencies.date.timeIntervalSince(lastOpen) - return dependencies.date.timeIntervalSince(lastOpen) - } - - public var pendingChanges: [OpenGroupAPI.PendingChange] = [] - } - // MARK: - Variables public static let shared: OpenGroupManager = OpenGroupManager() // MARK: - Polling - public func startPolling(using dependencies: OGMDependencies = OGMDependencies()) { + public func startPolling(using dependencies: Dependencies = Dependencies()) { // Run on the 'workQueue' to ensure any 'Atomic' access doesn't block the main thread // on startup - OpenGroupAPI.workQueue.async { - guard !dependencies.cache.isPolling else { return } + OpenGroupAPI.workQueue.async(using: dependencies) { + guard !dependencies.caches[.openGroupManager].isPolling else { return } let servers: Set = dependencies.storage .read { db in @@ -70,7 +39,7 @@ public final class OpenGroupManager { .defaulting(to: []) // Update the cache state and re-create all of the pollers - dependencies.mutableCache.mutate { cache in + dependencies.caches.mutate(cache: .openGroupManager) { cache in cache.isPolling = true cache.pollers = servers .reduce(into: [:]) { result, server in @@ -80,13 +49,14 @@ public final class OpenGroupManager { } // Now that the pollers have been created actually start them - dependencies.cache.pollers.forEach { _, poller in poller.startIfNeeded(using: dependencies) } + dependencies.caches[.openGroupManager].pollers + .forEach { _, poller in poller.startIfNeeded(using: dependencies) } } } - public func stopPolling(using dependencies: OGMDependencies = OGMDependencies()) { - dependencies.mutableCache.mutate { - $0.pollers.forEach { (_, openGroupPoller) in openGroupPoller.stop() } + public func stopPolling(using dependencies: Dependencies = Dependencies()) { + dependencies.caches.mutate(cache: .openGroupManager) { + $0.pollers.forEach { _, openGroupPoller in openGroupPoller.stop() } $0.pollers.removeAll() $0.isPolling = false } @@ -132,7 +102,13 @@ public final class OpenGroupManager { return options.contains(serverHost) } - public func hasExistingOpenGroup(_ db: Database, roomToken: String, server: String, publicKey: String, dependencies: OGMDependencies = OGMDependencies()) -> Bool { + public func hasExistingOpenGroup( + _ db: Database, + roomToken: String, + server: String, + publicKey: String, + using dependencies: Dependencies = Dependencies() + ) -> Bool { guard let serverUrl: URL = URL(string: server.lowercased()) else { return false } let serverPort: String = OpenGroupManager.port(for: server, serverUrl: serverUrl) @@ -164,7 +140,7 @@ public final class OpenGroupManager { } // First check if there is no poller for the specified server - if Set(dependencies.cache.pollers.keys).intersection(serverOptions).isEmpty { + if Set(dependencies.caches[.openGroupManager].pollers.keys).intersection(serverOptions).isEmpty { return false } @@ -187,10 +163,10 @@ public final class OpenGroupManager { server: String, publicKey: String, calledFromConfigHandling: Bool, - dependencies: OGMDependencies = OGMDependencies() + using dependencies: Dependencies = Dependencies() ) -> Bool { // If we are currently polling for this server and already have a TSGroupThread for this room the do nothing - if hasExistingOpenGroup(db, roomToken: roomToken, server: server, publicKey: publicKey, dependencies: dependencies) { + if hasExistingOpenGroup(db, roomToken: roomToken, server: server, publicKey: publicKey, using: dependencies) { SNLog("Ignoring join open group attempt (already joined), user initiated: \(!calledFromConfigHandling)") return false } @@ -256,7 +232,7 @@ public final class OpenGroupManager { server: String, publicKey: String, calledFromConfigHandling: Bool, - dependencies: OGMDependencies = OGMDependencies() + using dependencies: Dependencies = Dependencies() ) -> AnyPublisher { guard successfullyAddedGroup else { return Just(()) @@ -311,7 +287,7 @@ public final class OpenGroupManager { publicKey: publicKey, for: roomToken, on: targetServer, - dependencies: dependencies + using: dependencies ) { resolver(Result.success(())) } @@ -322,7 +298,7 @@ public final class OpenGroupManager { receiveCompletion: { result in switch result { case .finished: break - case .failure: SNLog("Failed to join open group.") + case .failure(let error): SNLog("Failed to join open group with error: \(error).") } } ) @@ -333,7 +309,7 @@ public final class OpenGroupManager { _ db: Database, openGroupId: String, calledFromConfigHandling: Bool, - using dependencies: OGMDependencies = OGMDependencies() + using dependencies: Dependencies = Dependencies() ) { let server: String? = try? OpenGroup .select(.server) @@ -358,9 +334,9 @@ public final class OpenGroupManager { .defaulting(to: 1) if numActiveRooms == 1, let server: String = server?.lowercased() { - let poller = dependencies.cache.pollers[server] + let poller = dependencies.caches[.openGroupManager].pollers[server] poller?.stop() - dependencies.mutableCache.mutate { $0.pollers[server] = nil } + dependencies.caches.mutate(cache: .openGroupManager) { $0.pollers[server] = nil } } // Remove all the data (everything should cascade delete) @@ -435,7 +411,7 @@ public final class OpenGroupManager { for roomToken: String, on server: String, waitForImageToComplete: Bool = false, - dependencies: OGMDependencies = OGMDependencies(), + using dependencies: Dependencies, completion: (() -> ())? = nil ) throws { // Create the open group model and get or create the thread @@ -521,18 +497,19 @@ public final class OpenGroupManager { } } - db.afterNextTransactionNested { _ in + db.afterNextTransactionNested { reentrantDb in // Dispatch async to the workQueue to prevent holding up the DBWrite thread from the // above transaction - OpenGroupAPI.workQueue.async { + OpenGroupAPI.workQueue.async(using: dependencies) { // Start the poller if needed - if dependencies.cache.pollers[server.lowercased()] == nil { - dependencies.mutableCache.mutate { + if dependencies.caches[.openGroupManager].pollers[server.lowercased()] == nil { + dependencies.caches.mutate(cache: .openGroupManager) { $0.pollers[server.lowercased()]?.stop() $0.pollers[server.lowercased()] = OpenGroupAPI.Poller(for: server.lowercased()) } - dependencies.cache.pollers[server.lowercased()]?.startIfNeeded(using: dependencies) + dependencies.caches[.openGroupManager].pollers[server.lowercased()]? + .startIfNeeded(using: dependencies) } /// Start downloading the room image (if we don't have one or it's been updated) @@ -553,8 +530,8 @@ public final class OpenGroupManager { ) // Note: We need to subscribe and receive on different threads to ensure the // logic in 'receiveValue' doesn't result in a reentrancy database issue - .subscribe(on: OpenGroupAPI.workQueue) - .receive(on: DispatchQueue.global(qos: .default)) + .subscribe(on: OpenGroupAPI.workQueue, using: dependencies) + .receive(on: DispatchQueue.global(qos: .default), using: dependencies) .sinkUntilComplete( receiveCompletion: { _ in if waitForImageToComplete { @@ -562,7 +539,7 @@ public final class OpenGroupManager { } }, receiveValue: { data in - dependencies.storage.write { db in + dependencies.storage.write(using: dependencies) { db in _ = try OpenGroup .filter(id: threadId) .updateAll(db, OpenGroup.Columns.imageData.set(to: data)) @@ -588,7 +565,7 @@ public final class OpenGroupManager { messages: [OpenGroupAPI.Message], for roomToken: String, on server: String, - dependencies: OGMDependencies = OGMDependencies() + using dependencies: Dependencies ) { guard let openGroup: OpenGroup = try? OpenGroup.fetchOne(db, id: OpenGroup.idFor(roomToken: roomToken, server: server)) else { SNLog("Couldn't handle open group messages.") @@ -623,7 +600,7 @@ public final class OpenGroupManager { openGroupServerPublicKey: openGroup.publicKey, message: message, data: data, - dependencies: dependencies + using: dependencies ) if let messageInfo: MessageReceiveJob.Details.MessageInfo = processedMessage?.messageInfo { @@ -634,7 +611,7 @@ public final class OpenGroupManager { message: messageInfo.message, serverExpirationTimestamp: messageInfo.serverExpirationTimestamp, associatedWithProto: try SNProtoContent.parseData(messageInfo.serializedProtoData), - dependencies: dependencies + using: dependencies ) largestValidSeqNo = max(largestValidSeqNo, message.seqNo) } @@ -661,7 +638,7 @@ public final class OpenGroupManager { db, openGroupId: openGroup.id, message: message, - associatedPendingChanges: dependencies.cache.pendingChanges + associatedPendingChanges: dependencies.caches[.openGroupManager].pendingChanges .filter { guard $0.server == server && $0.room == roomToken && $0.changeType == .reaction else { return false @@ -672,7 +649,7 @@ public final class OpenGroupManager { } return false }, - dependencies: dependencies + using: dependencies ) try MessageReceiver.handleOpenGroupReactions( @@ -708,7 +685,7 @@ public final class OpenGroupManager { .updateAll(db, OpenGroup.Columns.sequenceNumber.set(to: largestValidSeqNo)) // Update pendingChange cache based on the `largestValidSeqNo` value - dependencies.mutableCache.mutate { + dependencies.caches.mutate(cache: .openGroupManager) { $0.pendingChanges = $0.pendingChanges .filter { $0.seqNo == nil || $0.seqNo! > largestValidSeqNo } } @@ -719,7 +696,7 @@ public final class OpenGroupManager { messages: [OpenGroupAPI.DirectMessage], fromOutbox: Bool, on server: String, - dependencies: OGMDependencies = OGMDependencies() + using dependencies: Dependencies ) { // Don't need to do anything if we have no messages (it's a valid case) guard !messages.isEmpty else { return } @@ -762,7 +739,7 @@ public final class OpenGroupManager { data: messageData, isOutgoing: fromOutbox, otherBlindedPublicKey: (fromOutbox ? message.recipient : message.sender), - dependencies: dependencies + using: dependencies ) // We want to update the BlindedIdLookup cache with the message info so we can avoid using the @@ -788,7 +765,7 @@ public final class OpenGroupManager { openGroupServer: server.lowercased(), openGroupPublicKey: openGroup.publicKey, isCheckingForOutbox: fromOutbox, - dependencies: dependencies + using: dependencies ) }() lookupCache[message.recipient] = lookup @@ -817,7 +794,7 @@ public final class OpenGroupManager { message: messageInfo.message, serverExpirationTimestamp: messageInfo.serverExpirationTimestamp, associatedWithProto: try SNProtoContent.parseData(messageInfo.serializedProtoData), - dependencies: dependencies + using: dependencies ) } } @@ -846,7 +823,7 @@ public final class OpenGroupManager { in roomToken: String, on server: String, type: OpenGroupAPI.PendingChange.ReactAction, - using dependencies: OGMDependencies = OGMDependencies() + using dependencies: Dependencies = Dependencies() ) -> OpenGroupAPI.PendingChange { let pendingChange = OpenGroupAPI.PendingChange( server: server, @@ -859,7 +836,7 @@ public final class OpenGroupManager { ) ) - dependencies.mutableCache.mutate { + dependencies.caches.mutate(cache: .openGroupManager) { $0.pendingChanges.append(pendingChange) } @@ -869,9 +846,9 @@ public final class OpenGroupManager { public static func updatePendingChange( _ pendingChange: OpenGroupAPI.PendingChange, seqNo: Int64?, - using dependencies: OGMDependencies = OGMDependencies() + using dependencies: Dependencies = Dependencies() ) { - dependencies.mutableCache.mutate { + dependencies.caches.mutate(cache: .openGroupManager) { if let index = $0.pendingChanges.firstIndex(of: pendingChange) { $0.pendingChanges[index].seqNo = seqNo } @@ -880,9 +857,9 @@ public final class OpenGroupManager { public static func removePendingChange( _ pendingChange: OpenGroupAPI.PendingChange, - using dependencies: OGMDependencies = OGMDependencies() + using dependencies: Dependencies = Dependencies() ) { - dependencies.mutableCache.mutate { + dependencies.caches.mutate(cache: .openGroupManager) { if let index = $0.pendingChanges.firstIndex(of: pendingChange) { $0.pendingChanges.remove(at: index) } @@ -894,7 +871,7 @@ public final class OpenGroupManager { _ db: Database? = nil, capability: Capability.Variant, on server: String?, - using dependencies: OGMDependencies = OGMDependencies() + using dependencies: Dependencies = Dependencies() ) -> Bool { guard let server: String = server else { return false } guard let db: Database = db else { @@ -919,7 +896,7 @@ public final class OpenGroupManager { _ publicKey: String, for roomToken: String?, on server: String?, - using dependencies: OGMDependencies = OGMDependencies() + using dependencies: Dependencies = Dependencies() ) -> Bool { guard let roomToken: String = roomToken, let server: String = server else { return false } @@ -943,7 +920,7 @@ public final class OpenGroupManager { // Conveniently the logic for these different cases works in order so we can fallthrough each // case with only minor efficiency losses - let userPublicKey: String = getUserHexEncodedPublicKey(db, dependencies: dependencies) + let userPublicKey: String = getUserHexEncodedPublicKey(db, using: dependencies) switch sessionId.prefix { case .standard: @@ -967,10 +944,12 @@ public final class OpenGroupManager { .filter(id: groupId) .asRequest(of: String.self) .fetchOne(db), - let blindedKeyPair: KeyPair = dependencies.sodium.blindedKeyPair( - serverPublicKey: openGroupPublicKey, - edKeyPair: userEdKeyPair, - genericHash: dependencies.genericHash + let blindedKeyPair: KeyPair = dependencies.crypto.generate( + .blindedKeyPair( + serverPublicKey: openGroupPublicKey, + edKeyPair: userEdKeyPair, + using: dependencies + ) ) else { return false } guard @@ -1003,19 +982,16 @@ public final class OpenGroupManager { } @discardableResult public static func getDefaultRoomsIfNeeded( - using dependencies: OGMDependencies = OGMDependencies( - subscribeQueue: OpenGroupAPI.workQueue, - receiveQueue: OpenGroupAPI.workQueue - ) + using dependencies: Dependencies = Dependencies() ) -> AnyPublisher<[DefaultRoomInfo], Error> { // Note: If we already have a 'defaultRoomsPromise' then there is no need to get it again - if let existingPublisher: AnyPublisher<[DefaultRoomInfo], Error> = dependencies.cache.defaultRoomsPublisher { + if let existingPublisher: AnyPublisher<[DefaultRoomInfo], Error> = dependencies.caches[.openGroupManager].defaultRoomsPublisher { return existingPublisher } // Try to retrieve the default rooms 8 times let publisher: AnyPublisher<[DefaultRoomInfo], Error> = dependencies.storage - .readPublisher { db in + .readPublisher { db -> OpenGroupAPI.PreparedSendData in try OpenGroupAPI.preparedCapabilitiesAndRooms( db, on: OpenGroupAPI.defaultServer, @@ -1023,9 +999,9 @@ public final class OpenGroupManager { ) } .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } - .subscribe(on: dependencies.subscribeQueue) - .receive(on: dependencies.receiveQueue) - .retry(8) + .subscribe(on: OpenGroupAPI.workQueue, using: dependencies) + .receive(on: OpenGroupAPI.workQueue, using: dependencies) + .retry(8, using: dependencies) .map { info, response -> [DefaultRoomInfo]? in dependencies.storage.write { db -> [DefaultRoomInfo] in // Store the capabilities first @@ -1090,7 +1066,7 @@ public final class OpenGroupManager { switch result { case .finished: break case .failure: - dependencies.mutableCache.mutate { cache in + dependencies.caches.mutate(cache: .openGroupManager) { cache in cache.defaultRoomsPublisher = nil } } @@ -1099,7 +1075,7 @@ public final class OpenGroupManager { .shareReplay(1) .eraseToAnyPublisher() - dependencies.mutableCache.mutate { cache in + dependencies.caches.mutate(cache: .openGroupManager) { cache in cache.defaultRoomsPublisher = publisher } @@ -1114,9 +1090,7 @@ public final class OpenGroupManager { for roomToken: String, on server: String, existingData: Data?, - using dependencies: OGMDependencies = OGMDependencies( - subscribeQueue: .global(qos: .background) - ) + using dependencies: Dependencies = Dependencies() ) -> AnyPublisher { // Normally the image for a given group is stored with the group thread, so it's only // fetched once. However, on the join open group screen we show images for groups the @@ -1129,7 +1103,7 @@ public final class OpenGroupManager { // there is one. let threadId: String = OpenGroup.idFor(roomToken: roomToken, server: server) let lastOpenGroupImageUpdate: Date? = dependencies.standardUserDefaults[.lastOpenGroupImageUpdate] - let now: Date = dependencies.date + let now: Date = dependencies.dateNow let timeSinceLastUpdate: TimeInterval = (lastOpenGroupImageUpdate.map { now.timeIntervalSince($0) } ?? .greatestFiniteMagnitude) let updateInterval: TimeInterval = (7 * 24 * 60 * 60) let canUseExistingImage: Bool = ( @@ -1143,14 +1117,14 @@ public final class OpenGroupManager { .eraseToAnyPublisher() } - if let publisher: AnyPublisher = dependencies.cache.groupImagePublishers[threadId] { + if let publisher: AnyPublisher = dependencies.caches[.openGroupManager].groupImagePublishers[threadId] { return publisher } // Defer the actual download and run it on a separate thread to avoid blocking the calling thread let publisher: AnyPublisher = Deferred { Future { resolver in - dependencies.subscribeQueue.async { + DispatchQueue.global(qos: .background).async(using: dependencies) { // Hold on to the publisher until it has completed at least once dependencies.storage .readPublisher { db -> (Data?, OpenGroupAPI.PreparedSendData?) in @@ -1224,10 +1198,10 @@ public final class OpenGroupManager { // Automatically subscribe for the roomImage download (want to download regardless of // whether the upstream subscribes) publisher - .subscribe(on: dependencies.subscribeQueue) + .subscribe(on: DispatchQueue.global(qos: .background), using: dependencies) .sinkUntilComplete() - dependencies.mutableCache.mutate { cache in + dependencies.caches.mutate(cache: .openGroupManager) { cache in cache.groupImagePublishers[threadId] = publisher } @@ -1235,9 +1209,65 @@ public final class OpenGroupManager { } } +// MARK: - OpenGroupManager Cache + +public extension OpenGroupManager { + class Cache: OGMCacheType { + public var defaultRoomsPublisher: AnyPublisher<[DefaultRoomInfo], Error>? + public var groupImagePublishers: [String: AnyPublisher] = [:] + + public var pollers: [String: OpenGroupAPI.Poller] = [:] // One for each server + public var isPolling: Bool = false + + /// Server URL to value + public var hasPerformedInitialPoll: [String: Bool] = [:] + public var timeSinceLastPoll: [String: TimeInterval] = [:] + + fileprivate var _timeSinceLastOpen: TimeInterval? + public func getTimeSinceLastOpen(using dependencies: Dependencies) -> TimeInterval { + if let storedTimeSinceLastOpen: TimeInterval = _timeSinceLastOpen { + return storedTimeSinceLastOpen + } + + guard let lastOpen: Date = dependencies.standardUserDefaults[.lastOpen] else { + _timeSinceLastOpen = .greatestFiniteMagnitude + return .greatestFiniteMagnitude + } + + _timeSinceLastOpen = dependencies.dateNow.timeIntervalSince(lastOpen) + return dependencies.dateNow.timeIntervalSince(lastOpen) + } + + public var pendingChanges: [OpenGroupAPI.PendingChange] = [] + } +} + +public extension Cache { + static let openGroupManager: CacheInfo.Config = CacheInfo.create( + createInstance: { OpenGroupManager.Cache() }, + mutableInstance: { $0 }, + immutableInstance: { $0 } + ) +} + // MARK: - OGMCacheType -public protocol OGMMutableCacheType: OGMCacheType { +/// This is a read-only version of the `OpenGroupManager.Cache` designed to avoid unintentionally mutating the instance in a +/// non-thread-safe way +public protocol OGMImmutableCacheType: ImmutableCacheType { + var defaultRoomsPublisher: AnyPublisher<[OpenGroupManager.DefaultRoomInfo], Error>? { get } + var groupImagePublishers: [String: AnyPublisher] { get } + + var pollers: [String: OpenGroupAPI.Poller] { get } + var isPolling: Bool { get } + + var hasPerformedInitialPoll: [String: Bool] { get } + var timeSinceLastPoll: [String: TimeInterval] { get } + + var pendingChanges: [OpenGroupAPI.PendingChange] { get } +} + +public protocol OGMCacheType: OGMImmutableCacheType, MutableCacheType { var defaultRoomsPublisher: AnyPublisher<[OpenGroupManager.DefaultRoomInfo], Error>? { get set } var groupImagePublishers: [String: AnyPublisher] { get set } @@ -1251,90 +1281,3 @@ public protocol OGMMutableCacheType: OGMCacheType { func getTimeSinceLastOpen(using dependencies: Dependencies) -> TimeInterval } - -/// This is a read-only version of the `OGMMutableCacheType` designed to avoid unintentionally mutating the instance in a -/// non-thread-safe way -public protocol OGMCacheType { - var defaultRoomsPublisher: AnyPublisher<[OpenGroupManager.DefaultRoomInfo], Error>? { get } - var groupImagePublishers: [String: AnyPublisher] { get } - - var pollers: [String: OpenGroupAPI.Poller] { get } - var isPolling: Bool { get } - - var hasPerformedInitialPoll: [String: Bool] { get } - var timeSinceLastPoll: [String: TimeInterval] { get } - - var pendingChanges: [OpenGroupAPI.PendingChange] { get } -} - -// MARK: - OGMDependencies - -extension OpenGroupManager { - public class OGMDependencies: SMKDependencies { - /// These should not be accessed directly but rather via an instance of this type - private static let _cacheInstance: OGMMutableCacheType = OpenGroupManager.Cache() - private static let _cacheInstanceAccessQueue = DispatchQueue(label: "OGMCacheInstanceAccess") - - internal var _mutableCache: Atomic - public var mutableCache: Atomic { - get { - Dependencies.getMutableValueSettingIfNull(&_mutableCache) { - OGMDependencies._cacheInstanceAccessQueue.sync { OGMDependencies._cacheInstance } - } - } - } - public var cache: OGMCacheType { - get { - Dependencies.getValueSettingIfNull(&_mutableCache) { - OGMDependencies._cacheInstanceAccessQueue.sync { OGMDependencies._cacheInstance } - } - } - set { - guard let mutableValue: OGMMutableCacheType = newValue as? OGMMutableCacheType else { return } - - _mutableCache.mutate { $0 = mutableValue } - } - } - - public init( - subscribeQueue: DispatchQueue? = nil, - receiveQueue: DispatchQueue? = nil, - cache: OGMMutableCacheType? = nil, - onionApi: OnionRequestAPIType.Type? = nil, - generalCache: MutableGeneralCacheType? = nil, - storage: Storage? = nil, - scheduler: ValueObservationScheduler? = nil, - sodium: SodiumType? = nil, - box: BoxType? = nil, - genericHash: GenericHashType? = nil, - sign: SignType? = nil, - aeadXChaCha20Poly1305Ietf: AeadXChaCha20Poly1305IetfType? = nil, - ed25519: Ed25519Type? = nil, - nonceGenerator16: NonceGenerator16ByteType? = nil, - nonceGenerator24: NonceGenerator24ByteType? = nil, - standardUserDefaults: UserDefaultsType? = nil, - date: Date? = nil - ) { - _mutableCache = Atomic(cache) - - super.init( - subscribeQueue: subscribeQueue, - receiveQueue: receiveQueue, - onionApi: onionApi, - generalCache: generalCache, - storage: storage, - scheduler: scheduler, - sodium: sodium, - box: box, - genericHash: genericHash, - sign: sign, - aeadXChaCha20Poly1305Ietf: aeadXChaCha20Poly1305Ietf, - ed25519: ed25519, - nonceGenerator16: nonceGenerator16, - nonceGenerator24: nonceGenerator24, - standardUserDefaults: standardUserDefaults, - date: date - ) - } - } -} diff --git a/SessionMessagingKit/Open Groups/Types/NonceGenerator.swift b/SessionMessagingKit/Open Groups/Types/NonceGenerator.swift deleted file mode 100644 index 50bcf5db9..000000000 --- a/SessionMessagingKit/Open Groups/Types/NonceGenerator.swift +++ /dev/null @@ -1,25 +0,0 @@ -// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. - -import Sodium - -public protocol NonceGenerator16ByteType { - var NonceBytes: Int { get } - - func nonce() -> Array -} - -public protocol NonceGenerator24ByteType { - var NonceBytes: Int { get } - - func nonce() -> Array -} - -extension OpenGroupAPI { - public class NonceGenerator16Byte: NonceGenerator, NonceGenerator16ByteType { - public var NonceBytes: Int { 16 } - } - - public class NonceGenerator24Byte: NonceGenerator, NonceGenerator24ByteType { - public var NonceBytes: Int { 24 } - } -} diff --git a/SessionMessagingKit/Open Groups/Types/PreparedSendData.swift b/SessionMessagingKit/Open Groups/Types/PreparedSendData.swift index c8b7c4d00..d8bb312f7 100644 --- a/SessionMessagingKit/Open Groups/Types/PreparedSendData.swift +++ b/SessionMessagingKit/Open Groups/Types/PreparedSendData.swift @@ -29,7 +29,7 @@ public extension OpenGroupAPI { private let method: HTTPMethod private let path: String public let endpoint: Endpoint - fileprivate let batchEndpoints: [Endpoint] + internal let batchEndpoints: [Endpoint] public let batchResponseTypes: [Decodable.Type] /// The `jsonBodyEncoder` is used to simplify the encoding for `BatchRequest` @@ -185,7 +185,7 @@ public extension OpenGroupAPI.PreparedSendData { public extension Publisher where Output == (ResponseInfoType, Data?), Failure == Error { func decoded( with preparedData: OpenGroupAPI.PreparedSendData, - using dependencies: Dependencies = Dependencies() + using dependencies: Dependencies ) -> AnyPublisher<(ResponseInfoType, R), Error> { self .tryMap { responseInfo, maybeData -> (ResponseInfoType, R) in diff --git a/SessionMessagingKit/Open Groups/Types/SodiumProtocols.swift b/SessionMessagingKit/Open Groups/Types/SodiumProtocols.swift deleted file mode 100644 index 223a42e44..000000000 --- a/SessionMessagingKit/Open Groups/Types/SodiumProtocols.swift +++ /dev/null @@ -1,108 +0,0 @@ -// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. - -import Foundation -import Sodium -import Curve25519Kit -import SessionUtilitiesKit - -public protocol SodiumType { - func getBox() -> BoxType - func getGenericHash() -> GenericHashType - func getSign() -> SignType - func getAeadXChaCha20Poly1305Ietf() -> AeadXChaCha20Poly1305IetfType - - func generateBlindingFactor(serverPublicKey: String, genericHash: GenericHashType) -> Bytes? - func blindedKeyPair(serverPublicKey: String, edKeyPair: KeyPair, genericHash: GenericHashType) -> KeyPair? - func sogsSignature(message: Bytes, secretKey: Bytes, blindedSecretKey ka: Bytes, blindedPublicKey kA: Bytes) -> Bytes? - - func combineKeys(lhsKeyBytes: Bytes, rhsKeyBytes: Bytes) -> Bytes? - func sharedBlindedEncryptionKey(secretKey a: Bytes, otherBlindedPublicKey: Bytes, fromBlindedPublicKey kA: Bytes, toBlindedPublicKey kB: Bytes, genericHash: GenericHashType) -> Bytes? - - func sessionId(_ sessionId: String, matchesBlindedId blindedSessionId: String, serverPublicKey: String, genericHash: GenericHashType) -> Bool -} - -public protocol AeadXChaCha20Poly1305IetfType { - var KeyBytes: Int { get } - var ABytes: Int { get } - - func encrypt(message: Bytes, secretKey: Bytes, nonce: Bytes, additionalData: Bytes?) -> Bytes? - func decrypt(authenticatedCipherText: Bytes, secretKey: Bytes, nonce: Bytes, additionalData: Bytes?) -> Bytes? -} - -public protocol Ed25519Type { - func sign(data: Bytes, keyPair: KeyPair) throws -> Bytes? - func verifySignature(_ signature: Data, publicKey: Data, data: Data) throws -> Bool -} - -public protocol BoxType { - func seal(message: Bytes, recipientPublicKey: Bytes) -> Bytes? - func open(anonymousCipherText: Bytes, recipientPublicKey: Bytes, recipientSecretKey: Bytes) -> Bytes? -} - -public protocol GenericHashType { - func hash(message: Bytes, key: Bytes?) -> Bytes? - func hash(message: Bytes, outputLength: Int) -> Bytes? - func hashSaltPersonal(message: Bytes, outputLength: Int, key: Bytes?, salt: Bytes, personal: Bytes) -> Bytes? -} - -public protocol SignType { - var Bytes: Int { get } - var PublicKeyBytes: Int { get } - - func toX25519(ed25519PublicKey: Bytes) -> Bytes? - func signature(message: Bytes, secretKey: Bytes) -> Bytes? - func verify(message: Bytes, publicKey: Bytes, signature: Bytes) -> Bool -} - -// MARK: - Default Values - -extension GenericHashType { - func hash(message: Bytes) -> Bytes? { return hash(message: message, key: nil) } - - func hashSaltPersonal(message: Bytes, outputLength: Int, salt: Bytes, personal: Bytes) -> Bytes? { - return hashSaltPersonal(message: message, outputLength: outputLength, key: nil, salt: salt, personal: personal) - } -} - -extension AeadXChaCha20Poly1305IetfType { - func encrypt(message: Bytes, secretKey: Bytes, nonce: Bytes) -> Bytes? { - return encrypt(message: message, secretKey: secretKey, nonce: nonce, additionalData: nil) - } - - func decrypt(authenticatedCipherText: Bytes, secretKey: Bytes, nonce: Bytes) -> Bytes? { - return decrypt(authenticatedCipherText: authenticatedCipherText, secretKey: secretKey, nonce: nonce, additionalData: nil) - } -} - -// MARK: - Conformance - -extension Sodium: SodiumType { - public func getBox() -> BoxType { return box } - public func getGenericHash() -> GenericHashType { return genericHash } - public func getSign() -> SignType { return sign } - public func getAeadXChaCha20Poly1305Ietf() -> AeadXChaCha20Poly1305IetfType { return aead.xchacha20poly1305ietf } - - public func blindedKeyPair(serverPublicKey: String, edKeyPair: KeyPair) -> KeyPair? { - return blindedKeyPair(serverPublicKey: serverPublicKey, edKeyPair: edKeyPair, genericHash: getGenericHash()) - } -} - -extension Box: BoxType {} -extension GenericHash: GenericHashType {} -extension Sign: SignType {} -extension Aead.XChaCha20Poly1305Ietf: AeadXChaCha20Poly1305IetfType {} - -struct Ed25519Wrapper: Ed25519Type { - func sign(data: Bytes, keyPair: KeyPair) throws -> Bytes? { - let ecKeyPair: ECKeyPair = try ECKeyPair( - publicKeyData: Data(keyPair.publicKey), - privateKeyData: Data(keyPair.secretKey) - ) - - return try Ed25519.sign(Data(data), with: ecKeyPair).bytes - } - - func verifySignature(_ signature: Data, publicKey: Data, data: Data) throws -> Bool { - return try Ed25519.verifySignature(signature, publicKey: publicKey, data: data) - } -} diff --git a/SessionMessagingKit/SMKDependencies.swift b/SessionMessagingKit/SMKDependencies.swift deleted file mode 100644 index 34b9b9e6d..000000000 --- a/SessionMessagingKit/SMKDependencies.swift +++ /dev/null @@ -1,98 +0,0 @@ -// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. - -import Foundation -import GRDB -import Sodium -import SessionSnodeKit -import SessionUtilitiesKit - -public class SMKDependencies: SSKDependencies { - internal var _sodium: Atomic - public var sodium: SodiumType { - get { Dependencies.getValueSettingIfNull(&_sodium) { Sodium() } } - set { _sodium.mutate { $0 = newValue } } - } - - internal var _box: Atomic - public var box: BoxType { - get { Dependencies.getValueSettingIfNull(&_box) { sodium.getBox() } } - set { _box.mutate { $0 = newValue } } - } - - internal var _genericHash: Atomic - public var genericHash: GenericHashType { - get { Dependencies.getValueSettingIfNull(&_genericHash) { sodium.getGenericHash() } } - set { _genericHash.mutate { $0 = newValue } } - } - - internal var _sign: Atomic - public var sign: SignType { - get { Dependencies.getValueSettingIfNull(&_sign) { sodium.getSign() } } - set { _sign.mutate { $0 = newValue } } - } - - internal var _aeadXChaCha20Poly1305Ietf: Atomic - public var aeadXChaCha20Poly1305Ietf: AeadXChaCha20Poly1305IetfType { - get { Dependencies.getValueSettingIfNull(&_aeadXChaCha20Poly1305Ietf) { sodium.getAeadXChaCha20Poly1305Ietf() } } - set { _aeadXChaCha20Poly1305Ietf.mutate { $0 = newValue } } - } - - internal var _ed25519: Atomic - public var ed25519: Ed25519Type { - get { Dependencies.getValueSettingIfNull(&_ed25519) { Ed25519Wrapper() } } - set { _ed25519.mutate { $0 = newValue } } - } - - internal var _nonceGenerator16: Atomic - public var nonceGenerator16: NonceGenerator16ByteType { - get { Dependencies.getValueSettingIfNull(&_nonceGenerator16) { OpenGroupAPI.NonceGenerator16Byte() } } - set { _nonceGenerator16.mutate { $0 = newValue } } - } - - internal var _nonceGenerator24: Atomic - public var nonceGenerator24: NonceGenerator24ByteType { - get { Dependencies.getValueSettingIfNull(&_nonceGenerator24) { OpenGroupAPI.NonceGenerator24Byte() } } - set { _nonceGenerator24.mutate { $0 = newValue } } - } - - // MARK: - Initialization - - public init( - subscribeQueue: DispatchQueue? = nil, - receiveQueue: DispatchQueue? = nil, - onionApi: OnionRequestAPIType.Type? = nil, - generalCache: MutableGeneralCacheType? = nil, - storage: Storage? = nil, - scheduler: ValueObservationScheduler? = nil, - sodium: SodiumType? = nil, - box: BoxType? = nil, - genericHash: GenericHashType? = nil, - sign: SignType? = nil, - aeadXChaCha20Poly1305Ietf: AeadXChaCha20Poly1305IetfType? = nil, - ed25519: Ed25519Type? = nil, - nonceGenerator16: NonceGenerator16ByteType? = nil, - nonceGenerator24: NonceGenerator24ByteType? = nil, - standardUserDefaults: UserDefaultsType? = nil, - date: Date? = nil - ) { - _sodium = Atomic(sodium) - _box = Atomic(box) - _genericHash = Atomic(genericHash) - _sign = Atomic(sign) - _aeadXChaCha20Poly1305Ietf = Atomic(aeadXChaCha20Poly1305Ietf) - _ed25519 = Atomic(ed25519) - _nonceGenerator16 = Atomic(nonceGenerator16) - _nonceGenerator24 = Atomic(nonceGenerator24) - - super.init( - subscribeQueue: subscribeQueue, - receiveQueue: receiveQueue, - onionApi: onionApi, - generalCache: generalCache, - storage: storage, - scheduler: scheduler, - standardUserDefaults: standardUserDefaults, - date: date - ) - } -} diff --git a/SessionMessagingKit/Sending & Receiving/Errors/MessageSenderError.swift b/SessionMessagingKit/Sending & Receiving/Errors/MessageSenderError.swift index 12cb06900..b2408a869 100644 --- a/SessionMessagingKit/Sending & Receiving/Errors/MessageSenderError.swift +++ b/SessionMessagingKit/Sending & Receiving/Errors/MessageSenderError.swift @@ -11,6 +11,7 @@ public enum MessageSenderError: LocalizedError, Equatable { case encryptionFailed case noUsername case attachmentsNotUploaded + case blindingFailed // Closed groups case noThread @@ -21,7 +22,10 @@ public enum MessageSenderError: LocalizedError, Equatable { internal var isRetryable: Bool { switch self { - case .invalidMessage, .protoConversionFailed, .invalidClosedGroupUpdate, .signingFailed, .encryptionFailed: return false + case .invalidMessage, .protoConversionFailed, .invalidClosedGroupUpdate, + .signingFailed, .encryptionFailed, .blindingFailed: + return false + default: return true } } @@ -36,6 +40,7 @@ public enum MessageSenderError: LocalizedError, Equatable { case .encryptionFailed: return "Couldn't encrypt message." case .noUsername: return "Missing username." case .attachmentsNotUploaded: return "Attachments for this message have not been uploaded." + case .blindingFailed: return "Couldn't blind the sender" // Closed groups case .noThread: return "Couldn't find a thread associated with the given group public key." @@ -58,6 +63,7 @@ public enum MessageSenderError: LocalizedError, Equatable { case (.noThread, .noThread): return true case (.noKeyPair, .noKeyPair): return true case (.invalidClosedGroupUpdate, .invalidClosedGroupUpdate): return true + case (.blindingFailed, .blindingFailed): return true case (.other(let lhsError), .other(let rhsError)): // Not ideal but the best we can do diff --git a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+Calls.swift b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+Calls.swift index cd85c095b..270e27d41 100644 --- a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+Calls.swift +++ b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+Calls.swift @@ -194,7 +194,11 @@ extension MessageReceiver { // MARK: - Convenience - public static func handleIncomingCallOfferInBusyState(_ db: Database, message: CallMessage) throws { + public static func handleIncomingCallOfferInBusyState( + _ db: Database, + message: CallMessage, + using dependencies: Dependencies = Dependencies() + ) throws { let messageInfo: CallMessage.MessageInfo = CallMessage.MessageInfo(state: .missed) guard @@ -229,7 +233,7 @@ extension MessageReceiver { .inserted(db) MessageSender.sendImmediate( - preparedSendData: try MessageSender + data: try MessageSender .preparedSendData( db, message: CallMessage( @@ -242,8 +246,10 @@ extension MessageReceiver { namespace: try Message.Destination .from(db, threadId: thread.id, threadVariant: thread.variant) .defaultNamespace, - interactionId: nil // Explicitly nil as it's a separate message from above - ) + interactionId: nil, // Explicitly nil as it's a separate message from above + using: dependencies + ), + using: dependencies ) .subscribe(on: DispatchQueue.global(qos: .userInitiated)) .sinkUntilComplete() diff --git a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+ClosedGroups.swift b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+ClosedGroups.swift index 1a13665af..22a28658c 100644 --- a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+ClosedGroups.swift +++ b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+ClosedGroups.swift @@ -12,10 +12,11 @@ extension MessageReceiver { _ db: Database, threadId: String, threadVariant: SessionThread.Variant, - message: ClosedGroupControlMessage + message: ClosedGroupControlMessage, + using dependencies: Dependencies = Dependencies() ) throws { switch message.kind { - case .new: try handleNewClosedGroup(db, message: message) + case .new: try handleNewClosedGroup(db, message: message, using: dependencies) case .encryptionKeyPair: try handleClosedGroupEncryptionKeyPair( @@ -65,7 +66,11 @@ extension MessageReceiver { // MARK: - Specific Handling - private static func handleNewClosedGroup(_ db: Database, message: ClosedGroupControlMessage) throws { + private static func handleNewClosedGroup( + _ db: Database, + message: ClosedGroupControlMessage, + using dependencies: Dependencies + ) throws { guard case let .new(publicKeyAsData, name, encryptionKeyPair, membersAsData, adminsAsData, expirationTimer) = message.kind else { return } @@ -112,7 +117,8 @@ extension MessageReceiver { admins: adminsAsData.map { $0.toHexString() }, expirationTimer: expirationTimer, formationTimestampMs: sentTimestamp, - calledFromConfigHandling: false + calledFromConfigHandling: false, + using: dependencies ) } @@ -125,7 +131,8 @@ extension MessageReceiver { admins: [String], expirationTimer: UInt32, formationTimestampMs: UInt64, - calledFromConfigHandling: Bool + calledFromConfigHandling: Bool, + using dependencies: Dependencies ) throws { // With new closed groups we only want to create them if the admin creating the closed group is an // approved contact (to prevent spam via closed groups getting around message requests if users are @@ -222,7 +229,7 @@ extension MessageReceiver { } // Start polling - ClosedGroupPoller.shared.startIfNeeded(for: groupPublicKey) + ClosedGroupPoller.shared.startIfNeeded(for: groupPublicKey, using: dependencies) // Notify the PN server let _ = PushNotificationAPI.performOperation(.subscribe, for: groupPublicKey, publicKey: getUserHexEncodedPublicKey(db)) diff --git a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+ConfigurationMessages.swift b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+ConfigurationMessages.swift index 1935d7619..390bf8381 100644 --- a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+ConfigurationMessages.swift +++ b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+ConfigurationMessages.swift @@ -7,7 +7,11 @@ import SessionUIKit import SessionUtilitiesKit extension MessageReceiver { - internal static func handleLegacyConfigurationMessage(_ db: Database, message: ConfigurationMessage) throws { + internal static func handleLegacyConfigurationMessage( + _ db: Database, + message: ConfigurationMessage, + using dependencies: Dependencies + ) throws { // FIXME: Remove this once `useSharedUtilForUserConfig` is permanent guard !SessionUtil.userConfigsEnabled(db) else { TopBannerController.show(warning: .outdatedUserConfig) @@ -46,7 +50,8 @@ extension MessageReceiver { ) }(), sentTimestamp: messageSentTimestamp, - calledFromConfigHandling: true + calledFromConfigHandling: true, + using: dependencies ) // Create a contact for the current user if needed (also force-approve the current user @@ -192,7 +197,8 @@ extension MessageReceiver { admins: [String](closedGroup.admins), expirationTimer: closedGroup.expirationTimer, formationTimestampMs: message.sentTimestamp!, - calledFromConfigHandling: false // Legacy config isn't an issue + calledFromConfigHandling: false, // Legacy config isn't an issue + using: dependencies ) } } diff --git a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+MessageRequests.swift b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+MessageRequests.swift index 1582d5896..10dc0dde4 100644 --- a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+MessageRequests.swift +++ b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+MessageRequests.swift @@ -10,9 +10,9 @@ extension MessageReceiver { internal static func handleMessageRequestResponse( _ db: Database, message: MessageRequestResponse, - dependencies: SMKDependencies + using dependencies: Dependencies ) throws { - let userPublicKey = getUserHexEncodedPublicKey(db, dependencies: dependencies) + let userPublicKey = getUserHexEncodedPublicKey(db, using: dependencies) var blindedContactIds: [String] = [] // Ignore messages which were sent from the current user @@ -42,7 +42,8 @@ extension MessageReceiver { fileName: nil ) }(), - sentTimestamp: messageSentTimestamp + sentTimestamp: messageSentTimestamp, + using: dependencies ) } @@ -73,11 +74,13 @@ extension MessageReceiver { // If the sessionId matches the blindedId then this thread needs to be converted to an // un-blinded thread guard - dependencies.sodium.sessionId( - senderId, - matchesBlindedId: blindedIdLookup.blindedId, - serverPublicKey: blindedIdLookup.openGroupPublicKey, - genericHash: dependencies.genericHash + dependencies.crypto.verify( + .sessionId( + senderId, + matchesBlindedId: blindedIdLookup.blindedId, + serverPublicKey: blindedIdLookup.openGroupPublicKey, + using: dependencies + ) ) else { return } diff --git a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+VisibleMessages.swift b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+VisibleMessages.swift index 7f972a4c3..a1a959306 100644 --- a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+VisibleMessages.swift +++ b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+VisibleMessages.swift @@ -13,7 +13,7 @@ extension MessageReceiver { threadVariant: SessionThread.Variant, message: VisibleMessage, associatedWithProto proto: SNProtoContent, - dependencies: Dependencies = Dependencies() + using dependencies: Dependencies = Dependencies() ) throws -> Int64 { guard let sender: String = message.sender, let dataMessage = proto.dataMessage else { throw MessageReceiverError.invalidMessage @@ -43,7 +43,8 @@ extension MessageReceiver { fileName: nil ) }(), - sentTimestamp: messageSentTimestamp + sentTimestamp: messageSentTimestamp, + using: dependencies ) } @@ -64,7 +65,7 @@ extension MessageReceiver { } // Store the message variant so we can run variant-specific behaviours - let currentUserPublicKey: String = getUserHexEncodedPublicKey(db, dependencies: dependencies) + let currentUserPublicKey: String = getUserHexEncodedPublicKey(db, using: dependencies) let thread: SessionThread = try SessionThread .fetchOrCreate(db, id: threadId, variant: threadVariant, shouldBeVisible: nil) let maybeOpenGroup: OpenGroup? = { @@ -90,10 +91,12 @@ extension MessageReceiver { guard let userEdKeyPair: KeyPair = Identity.fetchUserEd25519KeyPair(db), - let blindedKeyPair: KeyPair = sodium.blindedKeyPair( - serverPublicKey: openGroup.publicKey, - edKeyPair: userEdKeyPair, - genericHash: sodium.genericHash + let blindedKeyPair: KeyPair = try? dependencies.crypto.generate( + .blindedKeyPair( + serverPublicKey: openGroup.publicKey, + edKeyPair: userEdKeyPair, + using: dependencies + ) ) else { return .standardIncoming } @@ -163,7 +166,8 @@ extension MessageReceiver { db, threadId: thread.id, body: message.text, - quoteAuthorId: dataMessage.quote?.author + quoteAuthorId: dataMessage.quote?.author, + using: dependencies ), // Note: Ensure we don't ever expire open group messages expiresInSeconds: (disappearingMessagesConfiguration.isEnabled && message.openGroupServerMessageId == nil ? @@ -294,7 +298,7 @@ extension MessageReceiver { .appending(quote?.attachmentId) .appending(linkPreview?.attachmentId) .forEach { attachmentId in - JobRunner.add( + dependencies.jobRunner.add( db, job: Job( variant: .attachmentDownload, @@ -304,7 +308,8 @@ extension MessageReceiver { attachmentId: attachmentId ) ), - canStartJob: isMainAppActive + canStartJob: isMainAppActive, + using: dependencies ) } } diff --git a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageSender+ClosedGroups.swift b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageSender+ClosedGroups.swift index 5cecfca71..19841fd95 100644 --- a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageSender+ClosedGroups.swift +++ b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageSender+ClosedGroups.swift @@ -4,7 +4,6 @@ import Foundation import Combine import GRDB import Sodium -import Curve25519Kit import SessionUtilitiesKit import SessionSnodeKit @@ -13,21 +12,21 @@ extension MessageSender { public static func createClosedGroup( name: String, - members: Set + members: Set, + using dependencies: Dependencies = Dependencies() ) -> AnyPublisher { - Storage.shared + dependencies.storage .writePublisher { db -> (String, SessionThread, [MessageSender.PreparedSendData]) in - let userPublicKey: String = getUserHexEncodedPublicKey(db) - var members: Set = members + // Generate the group's two keys + guard + let groupKeyPair: KeyPair = dependencies.crypto.generate(.x25519KeyPair()), + let encryptionKeyPair: KeyPair = dependencies.crypto.generate(.x25519KeyPair()) + else { throw MessageSenderError.noKeyPair } - // Generate the group's public key - let groupKeyPair: ECKeyPair = Curve25519.generateKeyPair() - let groupPublicKey: String = KeyPair( - publicKey: groupKeyPair.publicKey.bytes, - secretKey: groupKeyPair.privateKey.bytes - ).hexEncodedPublicKey // Includes the 'SessionId.Prefix.standard' prefix - // Generate the key pair that'll be used for encryption and decryption - let encryptionKeyPair: ECKeyPair = Curve25519.generateKeyPair() + // Includes the 'SessionId.Prefix.standard' prefix + let groupPublicKey: String = groupKeyPair.hexEncodedPublicKey + let userPublicKey: String = getUserHexEncodedPublicKey(db, using: dependencies) + var members: Set = members // Create the group members.insert(userPublicKey) // Ensure the current user is included in the member list @@ -49,8 +48,8 @@ extension MessageSender { let latestKeyPairReceivedTimestamp: TimeInterval = (TimeInterval(SnodeAPI.currentOffsetTimestampMs()) / 1000) try ClosedGroupKeyPair( threadId: groupPublicKey, - publicKey: encryptionKeyPair.publicKey, - secretKey: encryptionKeyPair.privateKey, + publicKey: Data(encryptionKeyPair.publicKey), + secretKey: Data(encryptionKeyPair.secretKey), receivedTimestamp: latestKeyPairReceivedTimestamp ).insert(db) @@ -78,8 +77,8 @@ extension MessageSender { db, groupPublicKey: groupPublicKey, name: name, - latestKeyPairPublicKey: encryptionKeyPair.publicKey, - latestKeyPairSecretKey: encryptionKeyPair.privateKey, + latestKeyPairPublicKey: Data(encryptionKeyPair.publicKey), + latestKeyPairSecretKey: Data(encryptionKeyPair.secretKey), latestKeyPairReceivedTimestamp: latestKeyPairReceivedTimestamp, disappearingConfig: DisappearingMessagesConfiguration.defaultWith(groupPublicKey), members: members, @@ -94,10 +93,7 @@ extension MessageSender { kind: .new( publicKey: Data(hex: groupPublicKey), name: name, - encryptionKeyPair: KeyPair( - publicKey: encryptionKeyPair.publicKey.bytes, - secretKey: encryptionKeyPair.privateKey.bytes - ), + encryptionKeyPair: encryptionKeyPair, members: membersAsData, admins: adminsAsData, expirationTimer: 0 @@ -108,7 +104,8 @@ extension MessageSender { ), to: .contact(publicKey: memberId), namespace: Message.Destination.contact(publicKey: memberId).defaultNamespace, - interactionId: nil + interactionId: nil, + using: dependencies ) } @@ -119,7 +116,7 @@ extension MessageSender { .MergeMany( // Send a closed group update message to all members individually memberSendData - .map { MessageSender.sendImmediate(preparedSendData: $0) } + .map { MessageSender.sendImmediate(data: $0, using: dependencies) } .appending( // Notify the PN server PushNotificationAPI.performOperation( @@ -135,7 +132,7 @@ extension MessageSender { .handleEvents( receiveOutput: { thread in // Start polling - ClosedGroupPoller.shared.startIfNeeded(for: thread.id) + ClosedGroupPoller.shared.startIfNeeded(for: thread.id, using: dependencies) } ) .eraseToAnyPublisher() @@ -151,21 +148,25 @@ extension MessageSender { targetMembers: Set, userPublicKey: String, allGroupMembers: [GroupMember], - closedGroup: ClosedGroup + closedGroup: ClosedGroup, + using dependencies: Dependencies ) -> AnyPublisher { guard allGroupMembers.contains(where: { $0.role == .admin && $0.profileId == userPublicKey }) else { return Fail(error: MessageSenderError.invalidClosedGroupUpdate) .eraseToAnyPublisher() } - return Storage.shared + return dependencies.storage .readPublisher { db -> (ClosedGroupKeyPair, MessageSender.PreparedSendData) in // Generate the new encryption key pair - let legacyNewKeyPair: ECKeyPair = Curve25519.generateKeyPair() + guard let legacyNewKeyPair: KeyPair = dependencies.crypto.generate(.x25519KeyPair()) else { + throw MessageSenderError.noKeyPair + } + let newKeyPair: ClosedGroupKeyPair = ClosedGroupKeyPair( threadId: closedGroup.threadId, - publicKey: legacyNewKeyPair.publicKey, - secretKey: legacyNewKeyPair.privateKey, + publicKey: Data(legacyNewKeyPair.publicKey), + secretKey: Data(legacyNewKeyPair.secretKey), receivedTimestamp: (TimeInterval(SnodeAPI.currentOffsetTimestampMs()) / 1000) ) @@ -193,7 +194,8 @@ extension MessageSender { encryptedKeyPair: try MessageSender.encryptWithSessionProtocol( db, plaintext: plaintext, - for: memberPublicKey + for: memberPublicKey, + using: dependencies ) ) } @@ -204,20 +206,21 @@ extension MessageSender { namespace: try Message.Destination .from(db, threadId: closedGroup.threadId, threadVariant: .legacyGroup) .defaultNamespace, - interactionId: nil + interactionId: nil, + using: dependencies ) return (newKeyPair, sendData) } .flatMap { newKeyPair, sendData -> AnyPublisher in - MessageSender.sendImmediate(preparedSendData: sendData) + MessageSender.sendImmediate(data: sendData, using: dependencies) .map { _ in newKeyPair } .eraseToAnyPublisher() } .handleEvents( receiveOutput: { newKeyPair in /// Store it **after** having sent out the message to the group - Storage.shared.write { db in + dependencies.storage.write { db in try newKeyPair.insert(db) // Update libSession @@ -251,11 +254,12 @@ extension MessageSender { public static func update( groupPublicKey: String, with members: Set, - name: String + name: String, + using dependencies: Dependencies = Dependencies() ) -> AnyPublisher { return Storage.shared .writePublisher { db -> (String, ClosedGroup, [GroupMember], Set) in - let userPublicKey: String = getUserHexEncodedPublicKey(db) + let userPublicKey: String = getUserHexEncodedPublicKey(db, using: dependencies) // Get the group, check preconditions & prepare guard (try? SessionThread.exists(db, id: groupPublicKey)) == true else { @@ -292,7 +296,8 @@ extension MessageSender { message: ClosedGroupControlMessage(kind: .nameChange(name: name)), interactionId: interactionId, threadId: groupPublicKey, - threadVariant: .legacyGroup + threadVariant: .legacyGroup, + using: dependencies ) // Update libSession @@ -321,7 +326,8 @@ extension MessageSender { addedMembers: addedMembers, userPublicKey: userPublicKey, allGroupMembers: allGroupMembers, - closedGroup: closedGroup + closedGroup: closedGroup, + using: dependencies ) } catch { @@ -348,7 +354,8 @@ extension MessageSender { removedMembers: removedMembers, userPublicKey: userPublicKey, allGroupMembers: allGroupMembers, - closedGroup: closedGroup + closedGroup: closedGroup, + using: dependencies ) .catch { _ in Fail(error: MessageSenderError.invalidClosedGroupUpdate).eraseToAnyPublisher() } .eraseToAnyPublisher() @@ -364,7 +371,8 @@ extension MessageSender { addedMembers: Set, userPublicKey: String, allGroupMembers: [GroupMember], - closedGroup: ClosedGroup + closedGroup: ClosedGroup, + using dependencies: Dependencies ) throws { guard let disappearingMessagesConfig: DisappearingMessagesConfiguration = try DisappearingMessagesConfiguration.fetchOne(db, id: closedGroup.threadId) else { throw StorageError.objectNotFound @@ -419,7 +427,8 @@ extension MessageSender { ), interactionId: interactionId, threadId: closedGroup.threadId, - threadVariant: .legacyGroup + threadVariant: .legacyGroup, + using: dependencies ) try addedMembers.forEach { member in @@ -446,7 +455,8 @@ extension MessageSender { ), interactionId: nil, threadId: member, - threadVariant: .contact + threadVariant: .contact, + using: dependencies ) // Add the users to the group @@ -469,7 +479,8 @@ extension MessageSender { removedMembers: Set, userPublicKey: String, allGroupMembers: [GroupMember], - closedGroup: ClosedGroup + closedGroup: ClosedGroup, + using dependencies: Dependencies = Dependencies() ) -> AnyPublisher { guard !removedMembers.contains(userPublicKey) else { SNLog("Invalid closed group update.") @@ -490,7 +501,7 @@ extension MessageSender { .map { $0.profileId } let members: Set = Set(groupMemberIds).subtracting(removedMembers) - return Storage.shared + return dependencies.storage .writePublisher { db in // Update zombie & member list try GroupMember @@ -535,16 +546,18 @@ extension MessageSender { namespace: try Message.Destination .from(db, threadId: closedGroup.threadId, threadVariant: .legacyGroup) .defaultNamespace, - interactionId: interactionId + interactionId: interactionId, + using: dependencies ) } - .flatMap { MessageSender.sendImmediate(preparedSendData: $0) } + .flatMap { MessageSender.sendImmediate(data: $0, using: dependencies) } .flatMap { _ -> AnyPublisher in MessageSender.generateAndSendNewEncryptionKeyPair( targetMembers: members, userPublicKey: userPublicKey, allGroupMembers: allGroupMembers, - closedGroup: closedGroup + closedGroup: closedGroup, + using: dependencies ) } .eraseToAnyPublisher() @@ -561,9 +574,10 @@ extension MessageSender { public static func leave( _ db: Database, groupPublicKey: String, - deleteThread: Bool + deleteThread: Bool, + using dependencies: Dependencies = Dependencies() ) throws { - let userPublicKey: String = getUserHexEncodedPublicKey(db) + let userPublicKey: String = getUserHexEncodedPublicKey(db, using: dependencies) // Notify the user let interaction: Interaction = try Interaction( @@ -574,7 +588,7 @@ extension MessageSender { timestampMs: SnodeAPI.currentOffsetTimestampMs() ).inserted(db) - JobRunner.upsert( + dependencies.jobRunner.upsert( db, job: Job( variant: .groupLeaving, @@ -583,14 +597,17 @@ extension MessageSender { details: GroupLeavingJob.Details( deleteThread: deleteThread ) - ) + ), + canStartJob: true, + using: dependencies ) } public static func sendLatestEncryptionKeyPair( _ db: Database, to publicKey: String, - for groupPublicKey: String + for groupPublicKey: String, + using dependencies: Dependencies = Dependencies() ) { guard let thread: SessionThread = try? SessionThread.fetchOne(db, id: groupPublicKey) else { return SNLog("Couldn't send key pair for nonexistent closed group.") @@ -626,7 +643,8 @@ extension MessageSender { let ciphertext = try MessageSender.encryptWithSessionProtocol( db, plaintext: plaintext, - for: publicKey + for: publicKey, + using: dependencies ) SNLog("Sending latest encryption key pair to: \(publicKey).") @@ -645,7 +663,8 @@ extension MessageSender { ), interactionId: nil, threadId: thread.id, - threadVariant: thread.variant + threadVariant: thread.variant, + using: dependencies ) } catch {} diff --git a/SessionMessagingKit/Sending & Receiving/MessageReceiver+Decryption.swift b/SessionMessagingKit/Sending & Receiving/MessageReceiver+Decryption.swift index 06780bcaf..08449a494 100644 --- a/SessionMessagingKit/Sending & Receiving/MessageReceiver+Decryption.swift +++ b/SessionMessagingKit/Sending & Receiving/MessageReceiver+Decryption.swift @@ -6,18 +6,24 @@ import Sodium import SessionUtilitiesKit extension MessageReceiver { - internal static func decryptWithSessionProtocol(ciphertext: Data, using x25519KeyPair: KeyPair, dependencies: SMKDependencies = SMKDependencies()) throws -> (plaintext: Data, senderX25519PublicKey: String) { - let recipientX25519PrivateKey = x25519KeyPair.secretKey - let recipientX25519PublicKey = x25519KeyPair.publicKey - let signatureSize = dependencies.sign.Bytes - let ed25519PublicKeySize = dependencies.sign.PublicKeyBytes + internal static func decryptWithSessionProtocol( + ciphertext: Data, + using x25519KeyPair: KeyPair, + using dependencies: Dependencies = Dependencies() + ) throws -> (plaintext: Data, senderX25519PublicKey: String) { + let recipientX25519PrivateKey: Bytes = x25519KeyPair.secretKey + let recipientX25519PublicKey: Bytes = x25519KeyPair.publicKey + let signatureSize: Int = dependencies.crypto.size(.signature) + let ed25519PublicKeySize: Int = dependencies.crypto.size(.publicKey) // 1. ) Decrypt the message guard - let plaintextWithMetadata = dependencies.box.open( - anonymousCipherText: Bytes(ciphertext), - recipientPublicKey: Box.PublicKey(Bytes(recipientX25519PublicKey)), - recipientSecretKey: Bytes(recipientX25519PrivateKey) + let plaintextWithMetadata = try? dependencies.crypto.perform( + .open( + anonymousCipherText: Bytes(ciphertext), + recipientPublicKey: Box.PublicKey(Bytes(recipientX25519PublicKey)), + recipientSecretKey: Bytes(recipientX25519PrivateKey) + ) ), plaintextWithMetadata.count > (signatureSize + ed25519PublicKeySize) else { @@ -32,79 +38,100 @@ extension MessageReceiver { // 3. ) Verify the signature let verificationData = plaintext + senderED25519PublicKey + recipientX25519PublicKey - guard dependencies.sign.verify(message: verificationData, publicKey: senderED25519PublicKey, signature: signature) else { - throw MessageReceiverError.invalidSignature - } + guard + dependencies.crypto.verify( + .signature(message: verificationData, publicKey: senderED25519PublicKey, signature: signature) + ) + else { throw MessageReceiverError.invalidSignature } // 4. ) Get the sender's X25519 public key - guard let senderX25519PublicKey = dependencies.sign.toX25519(ed25519PublicKey: senderED25519PublicKey) else { - throw MessageReceiverError.decryptionFailed - } + guard + let senderX25519PublicKey = try? dependencies.crypto.perform( + .toX25519(ed25519PublicKey: senderED25519PublicKey) + ) + else { throw MessageReceiverError.decryptionFailed } return (Data(plaintext), SessionId(.standard, publicKey: senderX25519PublicKey).hexString) } - internal static func decryptWithSessionBlindingProtocol(data: Data, isOutgoing: Bool, otherBlindedPublicKey: String, with openGroupPublicKey: String, userEd25519KeyPair: KeyPair, using dependencies: SMKDependencies = SMKDependencies()) throws -> (plaintext: Data, senderX25519PublicKey: String) { + internal static func decryptWithSessionBlindingProtocol( + data: Data, + isOutgoing: Bool, + otherBlindedPublicKey: String, + with openGroupPublicKey: String, + userEd25519KeyPair: KeyPair, + using dependencies: Dependencies = Dependencies() + ) throws -> (plaintext: Data, senderX25519PublicKey: String) { /// Ensure the data is at least long enough to have the required components guard - data.count > (dependencies.nonceGenerator24.NonceBytes + 2), - let blindedKeyPair = dependencies.sodium.blindedKeyPair( - serverPublicKey: openGroupPublicKey, - edKeyPair: userEd25519KeyPair, - genericHash: dependencies.genericHash + data.count > (dependencies.crypto.size(.nonce24) + 2), + let blindedKeyPair = dependencies.crypto.generate( + .blindedKeyPair(serverPublicKey: openGroupPublicKey, edKeyPair: userEd25519KeyPair, using: dependencies) ) else { throw MessageReceiverError.decryptionFailed } /// Step one: calculate the shared encryption key, receiving from A to B let otherKeyBytes: Bytes = Data(hex: otherBlindedPublicKey.removingIdPrefixIfNeeded()).bytes let kA: Bytes = (isOutgoing ? blindedKeyPair.publicKey : otherKeyBytes) - guard let dec_key: Bytes = dependencies.sodium.sharedBlindedEncryptionKey( - secretKey: userEd25519KeyPair.secretKey, - otherBlindedPublicKey: otherKeyBytes, - fromBlindedPublicKey: kA, - toBlindedPublicKey: (isOutgoing ? otherKeyBytes : blindedKeyPair.publicKey), - genericHash: dependencies.genericHash - ) else { - throw MessageReceiverError.decryptionFailed - } + guard + let dec_key: Bytes = try? dependencies.crypto.perform( + .sharedBlindedEncryptionKey( + secretKey: userEd25519KeyPair.secretKey, + otherBlindedPublicKey: otherKeyBytes, + fromBlindedPublicKey: kA, + toBlindedPublicKey: (isOutgoing ? otherKeyBytes : blindedKeyPair.publicKey), + using: dependencies + ) + ) + else { throw MessageReceiverError.decryptionFailed } /// v, ct, nc = data[0], data[1:-24], data[-24:] let version: UInt8 = data.bytes[0] - let ciphertext: Bytes = Bytes(data.bytes[1..<(data.count - dependencies.nonceGenerator24.NonceBytes)]) - let nonce: Bytes = Bytes(data.bytes[(data.count - dependencies.nonceGenerator24.NonceBytes).. dependencies.sign.PublicKeyBytes else { throw MessageReceiverError.decryptionFailed } + guard innerBytes.count > dependencies.crypto.size(.publicKey) else { throw MessageReceiverError.decryptionFailed } /// Split up: the last 32 bytes are the sender's *unblinded* ed25519 key let plaintext: Bytes = Bytes(innerBytes[ - 0...(innerBytes.count - 1 - dependencies.sign.PublicKeyBytes) + 0...(innerBytes.count - 1 - dependencies.crypto.size(.publicKey)) ]) let sender_edpk: Bytes = Bytes(innerBytes[ - (innerBytes.count - dependencies.sign.PublicKeyBytes)...(innerBytes.count - 1) + (innerBytes.count - dependencies.crypto.size(.publicKey))...(innerBytes.count - 1) ]) /// Verify that the inner sender_edpk (A) yields the same outer kA we got with the message - guard let blindingFactor: Bytes = dependencies.sodium.generateBlindingFactor(serverPublicKey: openGroupPublicKey, genericHash: dependencies.genericHash) else { - throw MessageReceiverError.invalidSignature - } - guard let sharedSecret: Bytes = dependencies.sodium.combineKeys(lhsKeyBytes: blindingFactor, rhsKeyBytes: sender_edpk) else { - throw MessageReceiverError.invalidSignature - } - guard kA == sharedSecret else { throw MessageReceiverError.invalidSignature } + guard + let blindingFactor: Bytes = try? dependencies.crypto.perform( + .generateBlindingFactor(serverPublicKey: openGroupPublicKey, using: dependencies) + ), + let sharedSecret: Bytes = try? dependencies.crypto.perform( + .combineKeys(lhsKeyBytes: blindingFactor, rhsKeyBytes: sender_edpk) + ), + kA == sharedSecret + else { throw MessageReceiverError.invalidSignature } /// Get the sender's X25519 public key - guard let senderSessionIdBytes: Bytes = dependencies.sign.toX25519(ed25519PublicKey: sender_edpk) else { - throw MessageReceiverError.decryptionFailed - } + guard + let senderSessionIdBytes: Bytes = try? dependencies.crypto.perform( + .toX25519(ed25519PublicKey: sender_edpk) + ) + else { throw MessageReceiverError.decryptionFailed } return (Data(plaintext), SessionId(.standard, publicKey: senderSessionIdBytes).hexString) } diff --git a/SessionMessagingKit/Sending & Receiving/MessageReceiver.swift b/SessionMessagingKit/Sending & Receiving/MessageReceiver.swift index 5eec855eb..e1d1b8be8 100644 --- a/SessionMessagingKit/Sending & Receiving/MessageReceiver.swift +++ b/SessionMessagingKit/Sending & Receiving/MessageReceiver.swift @@ -18,9 +18,9 @@ public enum MessageReceiver { openGroupServerPublicKey: String?, isOutgoing: Bool? = nil, otherBlindedPublicKey: String? = nil, - dependencies: SMKDependencies = SMKDependencies() + using dependencies: Dependencies = Dependencies() ) throws -> (Message, SNProtoContent, String, SessionThread.Variant) { - let userPublicKey: String = getUserHexEncodedPublicKey(db, dependencies: dependencies) + let userPublicKey: String = getUserHexEncodedPublicKey(db, using: dependencies) let isOpenGroupMessage: Bool = (openGroupId != nil) // Decrypt the contents @@ -183,7 +183,7 @@ public enum MessageReceiver { message: Message, serverExpirationTimestamp: TimeInterval?, associatedWithProto proto: SNProtoContent, - dependencies: SMKDependencies = SMKDependencies() + using dependencies: Dependencies = Dependencies() ) throws { // Check if the message requires an existing conversation (if it does and the conversation isn't in // the config then the message will be dropped) @@ -198,7 +198,7 @@ public enum MessageReceiver { message: message, threadId: threadId, threadVariant: threadVariant, - dependencies: dependencies + using: dependencies ) switch message { @@ -222,7 +222,8 @@ public enum MessageReceiver { db, threadId: threadId, threadVariant: threadVariant, - message: message + message: message, + using: dependencies ) case let message as DataExtractionNotification: @@ -242,7 +243,11 @@ public enum MessageReceiver { ) case let message as ConfigurationMessage: - try MessageReceiver.handleLegacyConfigurationMessage(db, message: message) + try MessageReceiver.handleLegacyConfigurationMessage( + db, + message: message, + using: dependencies + ) case let message as UnsendRequest: try MessageReceiver.handleUnsendRequest( @@ -264,7 +269,7 @@ public enum MessageReceiver { try MessageReceiver.handleMessageRequestResponse( db, message: message, - dependencies: dependencies + using: dependencies ) case let message as VisibleMessage: @@ -360,7 +365,7 @@ public enum MessageReceiver { message: Message, threadId: String, threadVariant: SessionThread.Variant, - dependencies: SMKDependencies = SMKDependencies() + using dependencies: Dependencies = Dependencies() ) throws { switch message { case is ReadReceipt: return // No visible artifact created so better to keep for more reliable read states @@ -369,7 +374,7 @@ public enum MessageReceiver { } // Determine the state of the conversation and the validity of the message - let currentUserPublicKey: String = getUserHexEncodedPublicKey(db, dependencies: dependencies) + let currentUserPublicKey: String = getUserHexEncodedPublicKey(db, using: dependencies) let conversationVisibleInConfig: Bool = SessionUtil.conversationInConfig( db, threadId: threadId, diff --git a/SessionMessagingKit/Sending & Receiving/MessageSender+Convenience.swift b/SessionMessagingKit/Sending & Receiving/MessageSender+Convenience.swift index f0e139d35..5ca3e6333 100644 --- a/SessionMessagingKit/Sending & Receiving/MessageSender+Convenience.swift +++ b/SessionMessagingKit/Sending & Receiving/MessageSender+Convenience.swift @@ -14,7 +14,8 @@ extension MessageSender { interaction: Interaction, threadId: String, threadVariant: SessionThread.Variant, - isSyncMessage: Bool = false + isSyncMessage: Bool = false, + using dependencies: Dependencies ) throws { // Only 'VisibleMessage' types can be sent via this method guard interaction.variant == .standardOutgoing else { throw MessageSenderError.invalidMessage } @@ -26,7 +27,8 @@ extension MessageSender { threadId: threadId, interactionId: interactionId, to: try Message.Destination.from(db, threadId: threadId, threadVariant: threadVariant), - isSyncMessage: isSyncMessage + isSyncMessage: isSyncMessage, + using: dependencies ) } @@ -36,7 +38,8 @@ extension MessageSender { interactionId: Int64?, threadId: String, threadVariant: SessionThread.Variant, - isSyncMessage: Bool = false + isSyncMessage: Bool = false, + using dependencies: Dependencies ) throws { send( db, @@ -44,7 +47,8 @@ extension MessageSender { threadId: threadId, interactionId: interactionId, to: try Message.Destination.from(db, threadId: threadId, threadVariant: threadVariant), - isSyncMessage: isSyncMessage + isSyncMessage: isSyncMessage, + using: dependencies ) } @@ -54,7 +58,8 @@ extension MessageSender { threadId: String?, interactionId: Int64?, to destination: Message.Destination, - isSyncMessage: Bool = false + isSyncMessage: Bool = false, + using dependencies: Dependencies ) { // If it's a sync message then we need to make some slight tweaks before sending so use the proper // sync message sending process instead of the standard process @@ -65,12 +70,13 @@ extension MessageSender { destination: destination, threadId: threadId, interactionId: interactionId, - isAlreadySyncMessage: false + isAlreadySyncMessage: false, + using: dependencies ) return } - JobRunner.add( + dependencies.jobRunner.add( db, job: Job( variant: .messageSend, @@ -81,7 +87,9 @@ extension MessageSender { message: message, isSyncMessage: isSyncMessage ) - ) + ), + canStartJob: true, + using: dependencies ) } @@ -91,7 +99,8 @@ extension MessageSender { _ db: Database, interaction: Interaction, threadId: String, - threadVariant: SessionThread.Variant + threadVariant: SessionThread.Variant, + using dependencies: Dependencies ) throws -> PreparedSendData { // Only 'VisibleMessage' types can be sent via this method guard interaction.variant == .standardOutgoing else { throw MessageSenderError.invalidMessage } @@ -104,11 +113,15 @@ extension MessageSender { namespace: try Message.Destination .from(db, threadId: threadId, threadVariant: threadVariant) .defaultNamespace, - interactionId: interactionId + interactionId: interactionId, + using: dependencies ) } - public static func performUploadsIfNeeded(preparedSendData: PreparedSendData) -> AnyPublisher { + public static func performUploadsIfNeeded( + preparedSendData: PreparedSendData, + using dependencies: Dependencies + ) -> AnyPublisher { // We need an interactionId in order for a message to have uploads guard let interactionId: Int64 = preparedSendData.interactionId else { return Just(preparedSendData) @@ -127,7 +140,7 @@ extension MessageSender { } }() - return Storage.shared + return dependencies.storage .readPublisher { db -> (attachments: [Attachment], openGroup: OpenGroup?) in let attachmentStateInfo: [Attachment.StateInfo] = (try? Attachment .stateInfo(interactionId: interactionId, state: .uploading) @@ -162,7 +175,8 @@ extension MessageSender { to: ( openGroup.map { Attachment.Destination.openGroup($0) } ?? .fileServer - ) + ), + using: dependencies ) } ) diff --git a/SessionMessagingKit/Sending & Receiving/MessageSender+Encryption.swift b/SessionMessagingKit/Sending & Receiving/MessageSender+Encryption.swift index a9d4dca47..287c2737a 100644 --- a/SessionMessagingKit/Sending & Receiving/MessageSender+Encryption.swift +++ b/SessionMessagingKit/Sending & Receiving/MessageSender+Encryption.swift @@ -10,7 +10,7 @@ extension MessageSender { _ db: Database, plaintext: Data, for recipientHexEncodedX25519PublicKey: String, - using dependencies: SMKDependencies = SMKDependencies() + using dependencies: Dependencies ) throws -> Data { guard let userEd25519KeyPair: KeyPair = Identity.fetchUserEd25519KeyPair(db) else { throw MessageSenderError.noUserED25519KeyPair @@ -19,14 +19,21 @@ extension MessageSender { let recipientX25519PublicKey = Data(hex: recipientHexEncodedX25519PublicKey.removingIdPrefixIfNeeded()) let verificationData = plaintext + Data(userEd25519KeyPair.publicKey) + recipientX25519PublicKey - guard let signature = dependencies.sign.signature(message: Bytes(verificationData), secretKey: userEd25519KeyPair.secretKey) else { - throw MessageSenderError.signingFailed - } + guard + let signature = try? dependencies.crypto.perform( + .signature(message: Bytes(verificationData), secretKey: userEd25519KeyPair.secretKey) + ) + else { throw MessageSenderError.signingFailed } let plaintextWithMetadata = plaintext + Data(userEd25519KeyPair.publicKey) + Data(signature) - guard let ciphertext = dependencies.box.seal(message: Bytes(plaintextWithMetadata), recipientPublicKey: Bytes(recipientX25519PublicKey)) else { - throw MessageSenderError.encryptionFailed - } + guard + let ciphertext = try? dependencies.crypto.perform( + .seal( + message: Bytes(plaintextWithMetadata), + recipientPublicKey: Bytes(recipientX25519PublicKey) + ) + ) + else { throw MessageSenderError.encryptionFailed } return Data(ciphertext) } @@ -36,7 +43,7 @@ extension MessageSender { plaintext: Data, for recipientBlindedId: String, openGroupPublicKey: String, - using dependencies: SMKDependencies = SMKDependencies() + using dependencies: Dependencies ) throws -> Data { guard SessionId.Prefix(from: recipientBlindedId) == .blinded15 || @@ -45,32 +52,37 @@ extension MessageSender { guard let userEd25519KeyPair: KeyPair = Identity.fetchUserEd25519KeyPair(db) else { throw MessageSenderError.noUserED25519KeyPair } - guard let blindedKeyPair = dependencies.sodium.blindedKeyPair(serverPublicKey: openGroupPublicKey, edKeyPair: userEd25519KeyPair, genericHash: dependencies.genericHash) else { - throw MessageSenderError.signingFailed - } + guard + let blindedKeyPair = dependencies.crypto.generate( + .blindedKeyPair(serverPublicKey: openGroupPublicKey, edKeyPair: userEd25519KeyPair, using: dependencies) + ) + else { throw MessageSenderError.signingFailed } let recipientBlindedPublicKey = Data(hex: recipientBlindedId.removingIdPrefixIfNeeded()) /// Step one: calculate the shared encryption key, sending from A to B - guard let enc_key: Bytes = dependencies.sodium.sharedBlindedEncryptionKey( - secretKey: userEd25519KeyPair.secretKey, - otherBlindedPublicKey: recipientBlindedPublicKey.bytes, - fromBlindedPublicKey: blindedKeyPair.publicKey, - toBlindedPublicKey: recipientBlindedPublicKey.bytes, - genericHash: dependencies.genericHash - ) else { - throw MessageSenderError.signingFailed - } + guard + let enc_key: Bytes = try? dependencies.crypto.perform( + .sharedBlindedEncryptionKey( + secretKey: userEd25519KeyPair.secretKey, + otherBlindedPublicKey: recipientBlindedPublicKey.bytes, + fromBlindedPublicKey: blindedKeyPair.publicKey, + toBlindedPublicKey: recipientBlindedPublicKey.bytes, + using: dependencies + ) + ), + let nonce: Bytes = try? dependencies.crypto.perform(.generateNonce24()) + else { throw MessageSenderError.signingFailed } /// Inner data: msg || A (i.e. the sender's ed25519 master pubkey, *not* kA blinded pubkey) let innerBytes: Bytes = (plaintext.bytes + userEd25519KeyPair.publicKey) /// Encrypt using xchacha20-poly1305 - let nonce: Bytes = dependencies.nonceGenerator24.nonce() - - guard let ciphertext = dependencies.aeadXChaCha20Poly1305Ietf.encrypt(message: innerBytes, secretKey: enc_key, nonce: nonce) else { - throw MessageSenderError.encryptionFailed - } + guard + let ciphertext = try? dependencies.crypto.perform( + .encryptAeadXChaCha20(message: innerBytes, secretKey: enc_key, nonce: nonce, using: dependencies) + ) + else { throw MessageSenderError.encryptionFailed } /// data = b'\x00' + ciphertext + nonce return Data(Bytes(arrayLiteral: 0) + ciphertext + nonce) diff --git a/SessionMessagingKit/Sending & Receiving/MessageSender.swift b/SessionMessagingKit/Sending & Receiving/MessageSender.swift index a40c3a426..9b968bdab 100644 --- a/SessionMessagingKit/Sending & Receiving/MessageSender.swift +++ b/SessionMessagingKit/Sending & Receiving/MessageSender.swift @@ -140,10 +140,10 @@ public final class MessageSender { namespace: SnodeAPI.Namespace?, interactionId: Int64?, isSyncMessage: Bool = false, - using dependencies: SMKDependencies = SMKDependencies() + using dependencies: Dependencies = Dependencies() ) throws -> PreparedSendData { // Common logic for all destinations - let currentUserPublicKey: String = getUserHexEncodedPublicKey(db, dependencies: dependencies) + let currentUserPublicKey: String = getUserHexEncodedPublicKey(db, using: dependencies) let messageSendTimestamp: Int64 = SnodeAPI.currentOffsetTimestampMs() let updatedMessage: Message = message @@ -199,7 +199,7 @@ public final class MessageSender { userPublicKey: String, messageSendTimestamp: Int64, isSyncMessage: Bool = false, - using dependencies: SMKDependencies = SMKDependencies() + using dependencies: Dependencies ) throws -> PreparedSendData { message.sender = userPublicKey message.recipient = { @@ -276,7 +276,7 @@ public final class MessageSender { do { switch destination { case .contact(let publicKey): - ciphertext = try encryptWithSessionProtocol(db, plaintext: plaintext, for: publicKey) + ciphertext = try encryptWithSessionProtocol(db, plaintext: plaintext, for: publicKey, using: dependencies) case .closedGroup(let groupPublicKey): guard let encryptionKeyPair: ClosedGroupKeyPair = try? ClosedGroupKeyPair.fetchLatestKeyPair(db, threadId: groupPublicKey) else { @@ -286,7 +286,8 @@ public final class MessageSender { ciphertext = try encryptWithSessionProtocol( db, plaintext: plaintext, - for: SessionId(.standard, publicKey: encryptionKeyPair.publicKey.bytes).hexString + for: SessionId(.standard, publicKey: encryptionKeyPair.publicKey.bytes).hexString, + using: dependencies ) case .openGroup, .openGroupInbox: preconditionFailure() @@ -365,7 +366,7 @@ public final class MessageSender { to destination: Message.Destination, interactionId: Int64?, messageSendTimestamp: Int64, - using dependencies: SMKDependencies = SMKDependencies() + using dependencies: Dependencies ) throws -> PreparedSendData { let threadId: String @@ -394,7 +395,7 @@ public final class MessageSender { throw MessageSenderError.invalidMessage } - message.sender = { + message.sender = try { let capabilities: [Capability.Variant] = (try? Capability .select(.variant) .filter(Capability.Columns.openGroupServer == server) @@ -407,9 +408,11 @@ public final class MessageSender { guard capabilities.isEmpty || capabilities.contains(.blind) else { return SessionId(.unblinded, publicKey: userEdKeyPair.publicKey).hexString } - guard let blindedKeyPair: KeyPair = dependencies.sodium.blindedKeyPair(serverPublicKey: openGroup.publicKey, edKeyPair: userEdKeyPair, genericHash: dependencies.genericHash) else { - preconditionFailure() - } + guard + let blindedKeyPair: KeyPair = dependencies.crypto.generate( + .blindedKeyPair(serverPublicKey: openGroup.publicKey, edKeyPair: userEdKeyPair, using: dependencies) + ) + else { throw MessageSenderError.signingFailed } return SessionId(.blinded15, publicKey: blindedKeyPair.publicKey).hexString }() @@ -492,7 +495,7 @@ public final class MessageSender { interactionId: Int64?, userPublicKey: String, messageSendTimestamp: Int64, - using dependencies: SMKDependencies = SMKDependencies() + using dependencies: Dependencies ) throws -> PreparedSendData { guard case .openGroupInbox(_, let openGroupPublicKey, let recipientBlindedPublicKey) = destination else { throw MessageSenderError.invalidMessage @@ -582,10 +585,10 @@ public final class MessageSender { // MARK: - Sending public static func sendImmediate( - preparedSendData: PreparedSendData, - using dependencies: SMKDependencies = SMKDependencies() + data: PreparedSendData, + using dependencies: Dependencies ) -> AnyPublisher { - guard preparedSendData.shouldSend else { + guard data.shouldSend else { return Just(()) .setFailureType(to: Error.self) .eraseToAnyPublisher() @@ -597,7 +600,7 @@ public final class MessageSender { // // If you see this error then you need to call // `MessageSender.performUploadsIfNeeded(queue:preparedSendData:)` before calling this function - switch preparedSendData.message { + switch data.message { case let visibleMessage as VisibleMessage: let expectedAttachmentUploadCount: Int = ( visibleMessage.attachmentIds.count + @@ -605,17 +608,17 @@ public final class MessageSender { (visibleMessage.quote?.attachmentId != nil ? 1 : 0) ) - guard expectedAttachmentUploadCount == preparedSendData.totalAttachmentsUploaded else { + guard expectedAttachmentUploadCount == data.totalAttachmentsUploaded else { // Make sure to actually handle this as a failure (if we don't then the message // won't go into an error state correctly) - if let message: Message = preparedSendData.message { + if let message: Message = data.message { dependencies.storage.read { db in MessageSender.handleFailedMessageSend( db, message: message, with: .attachmentsNotUploaded, - interactionId: preparedSendData.interactionId, - isSyncMessage: (preparedSendData.isSyncMessage == true), + interactionId: data.interactionId, + isSyncMessage: (data.isSyncMessage == true), using: dependencies ) } @@ -630,10 +633,10 @@ public final class MessageSender { default: break } - switch preparedSendData.destination { - case .contact, .closedGroup: return sendToSnodeDestination(data: preparedSendData, using: dependencies) - case .openGroup: return sendToOpenGroupDestination(data: preparedSendData, using: dependencies) - case .openGroupInbox: return sendToOpenGroupInbox(data: preparedSendData, using: dependencies) + switch data.destination { + case .contact, .closedGroup: return sendToSnodeDestination(data: data, using: dependencies) + case .openGroup: return sendToOpenGroupDestination(data: data, using: dependencies) + case .openGroupInbox: return sendToOpenGroupInbox(data: data, using: dependencies) } } @@ -641,7 +644,7 @@ public final class MessageSender { private static func sendToSnodeDestination( data: PreparedSendData, - using dependencies: SMKDependencies = SMKDependencies() + using dependencies: Dependencies ) -> AnyPublisher { guard let message: Message = data.message, @@ -653,14 +656,11 @@ public final class MessageSender { .eraseToAnyPublisher() } - return SnodeAPI - .sendMessage( - snodeMessage, - in: namespace - ) - .flatMap { response -> AnyPublisher in + return dependencies.network + .send(.message(snodeMessage, in: namespace, using: dependencies)) + .flatMap { info, response -> AnyPublisher in let updatedMessage: Message = message - updatedMessage.serverHash = response.1.hash + updatedMessage.serverHash = response.hash let job: Job? = Job( variant: .notifyPushServer, @@ -695,7 +695,7 @@ public final class MessageSender { guard shouldNotify else { return () } - JobRunner.add(db, job: job) + dependencies.jobRunner.add(db, job: job, canStartJob: true, using: dependencies) return () } .flatMap { _ -> AnyPublisher in @@ -726,7 +726,8 @@ public final class MessageSender { deferred: { _, _ in // Always fulfill because the notify PN server job isn't critical. resolver(Result.success(())) - } + }, + using: dependencies ) } } @@ -761,7 +762,7 @@ public final class MessageSender { private static func sendToOpenGroupDestination( data: PreparedSendData, - using dependencies: SMKDependencies = SMKDependencies() + using dependencies: Dependencies ) -> AnyPublisher { guard let message: Message = data.message, @@ -829,7 +830,7 @@ public final class MessageSender { private static func sendToOpenGroupInbox( data: PreparedSendData, - using dependencies: SMKDependencies = SMKDependencies() + using dependencies: Dependencies ) -> AnyPublisher { guard let message: Message = data.message, @@ -926,7 +927,7 @@ public final class MessageSender { interactionId: Int64?, serverTimestampMs: UInt64? = nil, isSyncMessage: Bool = false, - using dependencies: SMKDependencies = SMKDependencies() + using dependencies: Dependencies ) throws { // If the message was a reaction then we want to update the reaction instead of the original // interaction (which the 'interactionId' is pointing to @@ -964,13 +965,15 @@ public final class MessageSender { .updateAll(db, RecipientState.Columns.state.set(to: RecipientState.State.sent)) // Start the disappearing messages timer if needed - JobRunner.upsert( + dependencies.jobRunner.upsert( db, job: DisappearingMessagesJob.updateNextRunIfNeeded( db, interaction: interaction, startedAtMs: TimeInterval(SnodeAPI.currentOffsetTimestampMs()) - ) + ), + canStartJob: true, + using: dependencies ) } } @@ -995,7 +998,8 @@ public final class MessageSender { destination: destination, threadId: threadId, interactionId: interactionId, - isAlreadySyncMessage: isSyncMessage + isAlreadySyncMessage: isSyncMessage, + using: dependencies ) } @@ -1005,7 +1009,7 @@ public final class MessageSender { with error: MessageSenderError, interactionId: Int64?, isSyncMessage: Bool = false, - using dependencies: SMKDependencies = SMKDependencies() + using dependencies: Dependencies ) -> Error { // If the message was a reaction then we don't want to do anything to the original // interaciton (which the 'interactionId' is pointing to @@ -1072,11 +1076,12 @@ public final class MessageSender { destination: Message.Destination, threadId: String?, interactionId: Int64?, - isAlreadySyncMessage: Bool + isAlreadySyncMessage: Bool, + using dependencies: Dependencies ) { // Sync the message if it's not a sync message, wasn't already sent to the current user and // it's a message type which should be synced - let currentUserPublicKey = getUserHexEncodedPublicKey(db) + let currentUserPublicKey = getUserHexEncodedPublicKey(db, using: dependencies) if case .contact(let publicKey) = destination, @@ -1087,7 +1092,7 @@ public final class MessageSender { if let message = message as? VisibleMessage { message.syncTarget = publicKey } if let message = message as? ExpirationTimerUpdate { message.syncTarget = publicKey } - JobRunner.add( + dependencies.jobRunner.add( db, job: Job( variant: .messageSend, @@ -1098,7 +1103,9 @@ public final class MessageSender { message: message, isSyncMessage: true ) - ) + ), + canStartJob: true, + using: dependencies ) } } diff --git a/SessionMessagingKit/Sending & Receiving/Pollers/ClosedGroupPoller.swift b/SessionMessagingKit/Sending & Receiving/Pollers/ClosedGroupPoller.swift index 7f7a798b6..c4c77c7fa 100644 --- a/SessionMessagingKit/Sending & Receiving/Pollers/ClosedGroupPoller.swift +++ b/SessionMessagingKit/Sending & Receiving/Pollers/ClosedGroupPoller.swift @@ -23,23 +23,23 @@ public final class ClosedGroupPoller: Poller { // MARK: - Public API - public func start() { + public func start(using dependencies: Dependencies = Dependencies()) { // Fetch all closed groups (excluding any don't contain the current user as a // GroupMemeber as the user is no longer a member of those) - Storage.shared + dependencies.storage .read { db in try ClosedGroup .select(.threadId) .joining( required: ClosedGroup.members - .filter(GroupMember.Columns.profileId == getUserHexEncodedPublicKey(db)) + .filter(GroupMember.Columns.profileId == getUserHexEncodedPublicKey(db, using: dependencies)) ) .asRequest(of: String.self) .fetchAll(db) } .defaulting(to: []) .forEach { [weak self] publicKey in - self?.startIfNeeded(for: publicKey) + self?.startIfNeeded(for: publicKey, using: dependencies) } } @@ -49,7 +49,7 @@ public final class ClosedGroupPoller: Poller { return "closed group with public key: \(publicKey)" } - override func nextPollDelay(for publicKey: String) -> TimeInterval { + override func nextPollDelay(for publicKey: String, using dependencies: Dependencies) -> TimeInterval { // Get the received date of the last message in the thread. If we don't have // any messages yet, pick some reasonable fake time interval to use instead let lastMessageDate: Date = Storage.shared @@ -68,7 +68,7 @@ public final class ClosedGroupPoller: Poller { } .defaulting(to: Date().addingTimeInterval(-5 * 60)) - let timeSinceLastMessage: TimeInterval = Date().timeIntervalSince(lastMessageDate) + let timeSinceLastMessage: TimeInterval = dependencies.dateNow.timeIntervalSince(lastMessageDate) let minPollInterval: Double = ClosedGroupPoller.minPollInterval let limit: Double = (12 * 60 * 60) let a: TimeInterval = ((ClosedGroupPoller.maxPollInterval - minPollInterval) / limit) @@ -78,11 +78,7 @@ public final class ClosedGroupPoller: Poller { return nextPollInterval } - override func handlePollError( - _ error: Error, - for publicKey: String, - using dependencies: SMKDependencies = SMKDependencies() - ) -> Bool { + override func handlePollError(_ error: Error, for publicKey: String, using dependencies: Dependencies) -> Bool { SNLog("Polling failed for closed group with public key: \(publicKey) due to error: \(error).") return true } diff --git a/SessionMessagingKit/Sending & Receiving/Pollers/CurrentUserPoller.swift b/SessionMessagingKit/Sending & Receiving/Pollers/CurrentUserPoller.swift index 3936baf4f..3e62ad28c 100644 --- a/SessionMessagingKit/Sending & Receiving/Pollers/CurrentUserPoller.swift +++ b/SessionMessagingKit/Sending & Receiving/Pollers/CurrentUserPoller.swift @@ -33,13 +33,13 @@ public final class CurrentUserPoller: Poller { // MARK: - Convenience Functions - public func start() { - let publicKey: String = getUserHexEncodedPublicKey() + public func start(using dependencies: Dependencies = Dependencies()) { + let publicKey: String = getUserHexEncodedPublicKey(using: dependencies) guard isPolling.wrappedValue[publicKey] != true else { return } SNLog("Started polling.") - super.startIfNeeded(for: publicKey) + super.startIfNeeded(for: publicKey, using: dependencies) } public func stop() { @@ -53,7 +53,7 @@ public final class CurrentUserPoller: Poller { return "Main Poller" } - override func nextPollDelay(for publicKey: String) -> TimeInterval { + override func nextPollDelay(for publicKey: String, using dependencies: Dependencies) -> TimeInterval { let failureCount: TimeInterval = TimeInterval(failureCount.wrappedValue[publicKey] ?? 0) // If there have been no failures then just use the 'minPollInterval' @@ -65,11 +65,7 @@ public final class CurrentUserPoller: Poller { return min(maxRetryInterval, nextDelay) } - override func handlePollError( - _ error: Error, - for publicKey: String, - using dependencies: SMKDependencies = SMKDependencies() - ) -> Bool { + override func handlePollError(_ error: Error, for publicKey: String, using dependencies: Dependencies) -> Bool { if UserDefaults.sharedLokiProject?[.isMainAppActive] != true { // Do nothing when an error gets throws right after returning from the background (happens frequently) } diff --git a/SessionMessagingKit/Sending & Receiving/Pollers/OpenGroupPoller.swift b/SessionMessagingKit/Sending & Receiving/Pollers/OpenGroupPoller.swift index 48cb16f64..0192286ea 100644 --- a/SessionMessagingKit/Sending & Receiving/Pollers/OpenGroupPoller.swift +++ b/SessionMessagingKit/Sending & Receiving/Pollers/OpenGroupPoller.swift @@ -35,7 +35,7 @@ extension OpenGroupAPI { self.server = server } - public func startIfNeeded(using dependencies: OpenGroupManager.OGMDependencies = OpenGroupManager.OGMDependencies()) { + public func startIfNeeded(using dependencies: Dependencies) { guard !hasStarted else { return } hasStarted = true @@ -49,20 +49,15 @@ extension OpenGroupAPI { // MARK: - Polling - private func pollRecursively( - using dependencies: OpenGroupManager.OGMDependencies = OpenGroupManager.OGMDependencies( - subscribeQueue: Threading.pollerQueue, - receiveQueue: OpenGroupAPI.workQueue - ) - ) { + private func pollRecursively(using dependencies: Dependencies) { guard hasStarted else { return } let server: String = self.server - let lastPollStart: TimeInterval = Date().timeIntervalSince1970 + let lastPollStart: TimeInterval = dependencies.dateNow.timeIntervalSince1970 poll(using: dependencies) - .subscribe(on: dependencies.subscribeQueue) - .receive(on: dependencies.receiveQueue) + .subscribe(on: Threading.pollerQueue, using: dependencies) + .receive(on: OpenGroupAPI.workQueue, using: dependencies) .sinkUntilComplete( receiveCompletion: { [weak self] _ in let minPollFailureCount: Int64 = dependencies.storage @@ -76,7 +71,7 @@ extension OpenGroupAPI { .defaulting(to: 0) // Calculate the remaining poll delay - let currentTime: TimeInterval = Date().timeIntervalSince1970 + let currentTime: TimeInterval = dependencies.dateNow.timeIntervalSince1970 let nextPollInterval: TimeInterval = Poller.getInterval( for: TimeInterval(minPollFailureCount), minInterval: Poller.minPollInterval, @@ -86,12 +81,12 @@ extension OpenGroupAPI { // Schedule the next poll guard remainingInterval > 0 else { - return dependencies.subscribeQueue.async { + return Threading.pollerQueue.async(using: dependencies) { self?.pollRecursively(using: dependencies) } } - dependencies.subscribeQueue.asyncAfter(deadline: .now() + .milliseconds(Int(remainingInterval * 1000)), qos: .default) { + Threading.pollerQueue.asyncAfter(deadline: .now() + .milliseconds(Int(remainingInterval * 1000)), qos: .default, using: dependencies) { self?.pollRecursively(using: dependencies) } } @@ -99,7 +94,7 @@ extension OpenGroupAPI { } public func poll( - using dependencies: OpenGroupManager.OGMDependencies = OpenGroupManager.OGMDependencies() + using dependencies: Dependencies = Dependencies() ) -> AnyPublisher { return poll( calledFromBackgroundPoller: false, @@ -112,7 +107,7 @@ extension OpenGroupAPI { calledFromBackgroundPoller: Bool, isBackgroundPollerValid: @escaping (() -> Bool) = { true }, isPostCapabilitiesRetry: Bool, - using dependencies: OpenGroupManager.OGMDependencies = OpenGroupManager.OGMDependencies() + using dependencies: Dependencies = Dependencies() ) -> AnyPublisher { guard !self.isPolling else { return Just(()) @@ -122,10 +117,12 @@ extension OpenGroupAPI { self.isPolling = true let server: String = self.server - let hasPerformedInitialPoll: Bool = (dependencies.cache.hasPerformedInitialPoll[server] == true) + let hasPerformedInitialPoll: Bool = (dependencies.caches[.openGroupManager].hasPerformedInitialPoll[server] == true) let timeSinceLastPoll: TimeInterval = ( - dependencies.cache.timeSinceLastPoll[server] ?? - dependencies.mutableCache.mutate { $0.getTimeSinceLastOpen(using: dependencies) } + dependencies.caches[.openGroupManager].timeSinceLastPoll[server] ?? + dependencies.caches.mutate(cache: .openGroupManager) { cache in + cache.getTimeSinceLastOpen(using: dependencies) + } ) return dependencies.storage @@ -170,10 +167,11 @@ extension OpenGroupAPI { using: dependencies ) - dependencies.mutableCache.mutate { cache in + + dependencies.caches.mutate(cache: .openGroupManager) { cache in cache.hasPerformedInitialPoll[server] = true - cache.timeSinceLastPoll[server] = Date().timeIntervalSince1970 - UserDefaults.standard[.lastOpen] = Date() + cache.timeSinceLastPoll[server] = dependencies.dateNow.timeIntervalSince1970 + dependencies.standardUserDefaults[.lastOpen] = dependencies.dateNow } SNLog("Open group polling finished for \(server).") @@ -303,7 +301,7 @@ extension OpenGroupAPI { isBackgroundPollerValid: @escaping (() -> Bool) = { true }, isPostCapabilitiesRetry: Bool, error: Error, - using dependencies: OpenGroupManager.OGMDependencies = OpenGroupManager.OGMDependencies() + using dependencies: Dependencies = Dependencies() ) -> AnyPublisher { /// We want to custom handle a '400' error code due to not having blinded auth as it likely means that we join the /// OpenGroup before blinding was enabled and need to update it's capabilities @@ -376,7 +374,7 @@ extension OpenGroupAPI { info: ResponseInfoType, response: BatchResponse, failureCount: Int64, - using dependencies: OpenGroupManager.OGMDependencies = OpenGroupManager.OGMDependencies() + using dependencies: Dependencies ) { let server: String = self.server let validResponses: [OpenGroupAPI.Endpoint: Decodable] = response.data @@ -550,7 +548,7 @@ extension OpenGroupAPI { publicKey: nil, for: roomToken, on: server, - dependencies: dependencies + using: dependencies ) case .roomMessagesRecent(let roomToken), .roomMessagesBefore(let roomToken, _), .roomMessagesSince(let roomToken, _): @@ -564,7 +562,7 @@ extension OpenGroupAPI { messages: responseBody.compactMap { $0.value }, for: roomToken, on: server, - dependencies: dependencies + using: dependencies ) case .inbox, .inboxSince, .outbox, .outboxSince: @@ -587,7 +585,7 @@ extension OpenGroupAPI { messages: messages, fromOutbox: fromOutbox, on: server, - dependencies: dependencies + using: dependencies ) default: break // No custom handling needed diff --git a/SessionMessagingKit/Sending & Receiving/Pollers/Poller.swift b/SessionMessagingKit/Sending & Receiving/Pollers/Poller.swift index bd984c6ea..b4c89e410 100644 --- a/SessionMessagingKit/Sending & Receiving/Pollers/Poller.swift +++ b/SessionMessagingKit/Sending & Receiving/Pollers/Poller.swift @@ -53,18 +53,18 @@ public class Poller { } /// Calculate the delay which should occur before the next poll - internal func nextPollDelay(for publicKey: String) -> TimeInterval { + internal func nextPollDelay(for publicKey: String, using dependencies: Dependencies) -> TimeInterval { preconditionFailure("abstract class - override in subclass") } /// Perform and logic which should occur when the poll errors, will stop polling if `false` is returned - internal func handlePollError(_ error: Error, for publicKey: String, using dependencies: SMKDependencies) -> Bool { + internal func handlePollError(_ error: Error, for publicKey: String, using dependencies: Dependencies) -> Bool { preconditionFailure("abstract class - override in subclass") } // MARK: - Private API - internal func startIfNeeded(for publicKey: String) { + internal func startIfNeeded(for publicKey: String, using dependencies: Dependencies) { // Run on the 'pollerQueue' to ensure any 'Atomic' access doesn't block the main thread // on startup Threading.pollerQueue.async { [weak self] in @@ -74,13 +74,13 @@ public class Poller { // and the timer is not created, if we mark the group as is polling // after setUpPolling. So the poller may not work, thus misses messages self?.isPolling.mutate { $0[publicKey] = true } - self?.pollRecursively(for: publicKey) + self?.pollRecursively(for: publicKey, using: dependencies) } } internal func getSnodeForPolling( for publicKey: String, - using dependencies: SMKDependencies = SMKDependencies() + using dependencies: Dependencies ) -> AnyPublisher { // If we don't want to poll a snode multiple times then just grab a random one from the swarm guard maxNodePollCount > 0 else { @@ -135,14 +135,14 @@ public class Poller { private func pollRecursively( for publicKey: String, - using dependencies: SMKDependencies = SMKDependencies() + using dependencies: Dependencies ) { guard isPolling.wrappedValue[publicKey] == true else { return } let namespaces: [SnodeAPI.Namespace] = self.namespaces - let lastPollStart: TimeInterval = Date().timeIntervalSince1970 - let lastPollInterval: TimeInterval = nextPollDelay(for: publicKey) - let getSnodePublisher: AnyPublisher = getSnodeForPolling(for: publicKey) + let lastPollStart: TimeInterval = dependencies.dateNow.timeIntervalSince1970 + let lastPollInterval: TimeInterval = nextPollDelay(for: publicKey, using: dependencies) + let getSnodePublisher: AnyPublisher = getSnodeForPolling(for: publicKey, using: dependencies) // Store the publisher intp the cancellables dictionary cancellables.mutate { [weak self] cancellables in @@ -156,8 +156,8 @@ public class Poller { using: dependencies ) } - .subscribe(on: dependencies.subscribeQueue) - .receive(on: dependencies.receiveQueue) + .subscribe(on: Threading.pollerQueue, using: dependencies) + .receive(on: Threading.pollerQueue, using: dependencies) .sink( receiveCompletion: { result in switch result { @@ -174,21 +174,21 @@ public class Poller { self?.incrementPollCount(publicKey: publicKey) // Calculate the remaining poll delay - let currentTime: TimeInterval = Date().timeIntervalSince1970 + let currentTime: TimeInterval = dependencies.dateNow.timeIntervalSince1970 let nextPollInterval: TimeInterval = ( - self?.nextPollDelay(for: publicKey) ?? + self?.nextPollDelay(for: publicKey, using: dependencies) ?? lastPollInterval ) let remainingInterval: TimeInterval = max(0, nextPollInterval - (currentTime - lastPollStart)) // Schedule the next poll guard remainingInterval > 0 else { - return dependencies.subscribeQueue.async { + return Threading.pollerQueue.async(using: dependencies) { self?.pollRecursively(for: publicKey, using: dependencies) } } - dependencies.subscribeQueue.asyncAfter(deadline: .now() + .milliseconds(Int(remainingInterval * 1000)), qos: .default) { + Threading.pollerQueue.asyncAfter(deadline: .now() + .milliseconds(Int(remainingInterval * 1000)), qos: .default, using: dependencies) { self?.pollRecursively(for: publicKey, using: dependencies) } }, @@ -209,10 +209,7 @@ public class Poller { calledFromBackgroundPoller: Bool = false, isBackgroundPollValid: @escaping (() -> Bool) = { true }, poller: Poller? = nil, - using dependencies: SMKDependencies = SMKDependencies( - subscribeQueue: Threading.pollerQueue, - receiveQueue: Threading.pollerQueue - ) + using dependencies: Dependencies = Dependencies() ) -> AnyPublisher<[Message], Error> { // If the polling has been cancelled then don't continue guard @@ -276,7 +273,7 @@ public class Poller { var standardMessageJobsToRun: [Job] = [] var pollerLogOutput: String = "\(pollerName) failed to process any messages" - Storage.shared.write { db in + dependencies.storage.write { db in let allProcessedMessages: [ProcessedMessage] = allMessages .compactMap { message -> ProcessedMessage? in do { @@ -338,7 +335,7 @@ public class Poller { db, job: jobToRun, canStartJob: !calledFromBackgroundPoller, - dependencies: dependencies + using: dependencies ) return updatedJob?.id @@ -372,7 +369,7 @@ public class Poller { db, job: jobToRun, canStartJob: !calledFromBackgroundPoller, - dependencies: dependencies + using: dependencies ) // Create the dependency between the jobs @@ -429,10 +426,11 @@ public class Poller { // Note: In the background we just want jobs to fail silently ConfigMessageReceiveJob.run( job, - queue: dependencies.receiveQueue, + queue: Threading.pollerQueue, success: { _, _, _ in resolver(Result.success(())) }, failure: { _, _, _, _ in resolver(Result.success(())) }, - deferred: { _, _ in resolver(Result.success(())) } + deferred: { _, _ in resolver(Result.success(())) }, + using: dependencies ) } } @@ -449,10 +447,11 @@ public class Poller { // Note: In the background we just want jobs to fail silently MessageReceiveJob.run( job, - queue: dependencies.receiveQueue, + queue: Threading.pollerQueue, success: { _, _, _ in resolver(Result.success(())) }, failure: { _, _, _, _ in resolver(Result.success(())) }, - deferred: { _, _ in resolver(Result.success(())) } + deferred: { _, _ in resolver(Result.success(())) }, + using: dependencies ) } } diff --git a/SessionMessagingKit/Sending & Receiving/Typing Indicators/TypingIndicators.swift b/SessionMessagingKit/Sending & Receiving/Typing Indicators/TypingIndicators.swift index b8c4d3b49..e10970955 100644 --- a/SessionMessagingKit/Sending & Receiving/Typing Indicators/TypingIndicators.swift +++ b/SessionMessagingKit/Sending & Receiving/Typing Indicators/TypingIndicators.swift @@ -54,11 +54,11 @@ public class TypingIndicators { self.timestampMs = (timestampMs ?? SnodeAPI.currentOffsetTimestampMs()) } - fileprivate func start(_ db: Database) { + fileprivate func start(_ db: Database, using dependencies: Dependencies = Dependencies()) { // Start the typing indicator switch direction { case .outgoing: - scheduleRefreshCallback(db, shouldSend: (refreshTimer == nil)) + scheduleRefreshCallback(db, shouldSend: (refreshTimer == nil), using: dependencies) case .incoming: try? ThreadTypingIndicator( @@ -72,7 +72,7 @@ public class TypingIndicators { refreshTimeout() } - fileprivate func stop(_ db: Database) { + fileprivate func stop(_ db: Database, using dependencies: Dependencies = Dependencies()) { self.refreshTimer?.invalidate() self.refreshTimer = nil self.stopTimer?.invalidate() @@ -85,7 +85,8 @@ public class TypingIndicators { message: TypingIndicator(kind: .stopped), interactionId: nil, threadId: threadId, - threadVariant: threadVariant + threadVariant: threadVariant, + using: dependencies ) case .incoming: @@ -111,14 +112,19 @@ public class TypingIndicators { } } - private func scheduleRefreshCallback(_ db: Database, shouldSend: Bool = true) { + private func scheduleRefreshCallback( + _ db: Database, + shouldSend: Bool = true, + using dependencies: Dependencies + ) { if shouldSend { try? MessageSender.send( db, message: TypingIndicator(kind: .started), interactionId: nil, threadId: threadId, - threadVariant: threadVariant + threadVariant: threadVariant, + using: dependencies ) } @@ -127,8 +133,8 @@ public class TypingIndicators { withTimeInterval: 10, repeats: false ) { [weak self] _ in - Storage.shared.writeAsync { db in - self?.scheduleRefreshCallback(db) + dependencies.storage.writeAsync { db in + self?.scheduleRefreshCallback(db, using: dependencies) } } } diff --git a/SessionMessagingKit/SessionUtil/Config Handling/SessionUtil+UserGroups.swift b/SessionMessagingKit/SessionUtil/Config Handling/SessionUtil+UserGroups.swift index a10e617be..114914f9d 100644 --- a/SessionMessagingKit/SessionUtil/Config Handling/SessionUtil+UserGroups.swift +++ b/SessionMessagingKit/SessionUtil/Config Handling/SessionUtil+UserGroups.swift @@ -29,7 +29,8 @@ internal extension SessionUtil { _ db: Database, in conf: UnsafeMutablePointer?, mergeNeedsDump: Bool, - latestConfigSentTimestampMs: Int64 + latestConfigSentTimestampMs: Int64, + using dependencies: Dependencies = Dependencies() ) throws { guard mergeNeedsDump else { return } guard conf != nil else { throw SessionUtilError.nilConfigObject } @@ -236,7 +237,8 @@ internal extension SessionUtil { admins: updatedAdmins.map { $0.profileId }, expirationTimer: UInt32(group.disappearingConfig?.durationSeconds ?? 0), formationTimestampMs: UInt64((group.joinedAt.map { $0 * 1000 } ?? latestConfigSentTimestampMs)), - calledFromConfigHandling: true + calledFromConfigHandling: true, + using: dependencies ) } else { diff --git a/SessionMessagingKit/SessionUtil/Config Handling/SessionUtil+UserProfile.swift b/SessionMessagingKit/SessionUtil/Config Handling/SessionUtil+UserProfile.swift index 2bbe3cc4d..ed522930e 100644 --- a/SessionMessagingKit/SessionUtil/Config Handling/SessionUtil+UserProfile.swift +++ b/SessionMessagingKit/SessionUtil/Config Handling/SessionUtil+UserProfile.swift @@ -18,7 +18,8 @@ internal extension SessionUtil { _ db: Database, in conf: UnsafeMutablePointer?, mergeNeedsDump: Bool, - latestConfigSentTimestampMs: Int64 + latestConfigSentTimestampMs: Int64, + using dependencies: Dependencies = Dependencies() ) throws { typealias ProfileData = (profileName: String, profilePictureUrl: String?, profilePictureKey: Data?) @@ -51,7 +52,8 @@ internal extension SessionUtil { ) }(), sentTimestamp: (TimeInterval(latestConfigSentTimestampMs) / 1000), - calledFromConfigHandling: true + calledFromConfigHandling: true, + using: dependencies ) // Update the 'Note to Self' visibility and priority diff --git a/SessionMessagingKit/Utilities/Crypto+SessionMessagingKit.swift b/SessionMessagingKit/Utilities/Crypto+SessionMessagingKit.swift new file mode 100644 index 000000000..d2a974ef5 --- /dev/null +++ b/SessionMessagingKit/Utilities/Crypto+SessionMessagingKit.swift @@ -0,0 +1,144 @@ +// Copyright © 2023 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import Sodium +import Clibsodium +import Curve25519Kit +import SessionUtilitiesKit + +// MARK: - Generic Hash + +public extension Crypto.Action { + static func hash(message: Bytes, key: Bytes?) -> Crypto.Action { + return Crypto.Action(id: "hash", args: [message, key]) { + Sodium().genericHash.hash(message: message, key: key) + } + } + + static func hash(message: Bytes, outputLength: Int) -> Crypto.Action { + return Crypto.Action(id: "hashOutputLength", args: [message, outputLength]) { + Sodium().genericHash.hash(message: message, outputLength: outputLength) + } + } + + static func hashSaltPersonal( + message: Bytes, + outputLength: Int, + key: Bytes? = nil, + salt: Bytes, + personal: Bytes + ) -> Crypto.Action { + return Crypto.Action( + id: "hashSaltPersonal", + args: [message, outputLength, key, salt, personal] + ) { + var output: [UInt8] = [UInt8](repeating: 0, count: outputLength) + + let result = crypto_generichash_blake2b_salt_personal( + &output, + outputLength, + message, + UInt64(message.count), + key, + (key?.count ?? 0), + salt, + personal + ) + + guard result == 0 else { return nil } + + return output + } + } +} + +// MARK: - Sign + +public extension Crypto.Action { + static func toX25519(ed25519PublicKey: Bytes) -> Crypto.Action { + return Crypto.Action(id: "toX25519", args: [ed25519PublicKey]) { + Sodium().sign.toX25519(ed25519PublicKey: ed25519PublicKey) + } + } + + static func toX25519(ed25519SecretKey: Bytes) -> Crypto.Action { + return Crypto.Action(id: "toX25519", args: [ed25519SecretKey]) { + Sodium().sign.toX25519(ed25519SecretKey: ed25519SecretKey) + } + } + + static func signature(message: Bytes, secretKey: Bytes) -> Crypto.Action { + return Crypto.Action(id: "signature", args: [message, secretKey]) { + Sodium().sign.signature(message: message, secretKey: secretKey) + } + } +} + +public extension Crypto.Verification { + static func signature(message: Bytes, publicKey: Bytes, signature: Bytes) -> Crypto.Verification { + return Crypto.Verification(id: "signature", args: [message, publicKey, signature]) { + Sodium().sign.verify(message: message, publicKey: publicKey, signature: signature) + } + } +} + +// MARK: - Box + +public extension Crypto.Size { + static let signature: Crypto.Size = Crypto.Size(id: "signature") { Sodium().sign.Bytes } + static let publicKey: Crypto.Size = Crypto.Size(id: "publicKey") { Sodium().sign.PublicKeyBytes } +} + +public extension Crypto.Action { + static func seal(message: Bytes, recipientPublicKey: Bytes) -> Crypto.Action { + return Crypto.Action(id: "seal", args: [message, recipientPublicKey]) { + Sodium().box.seal(message: message, recipientPublicKey: recipientPublicKey) + } + } + + static func open(anonymousCipherText: Bytes, recipientPublicKey: Bytes, recipientSecretKey: Bytes) -> Crypto.Action { + return Crypto.Action( + id: "open", + args: [anonymousCipherText, recipientPublicKey, recipientSecretKey] + ) { + Sodium().box.open( + anonymousCipherText: anonymousCipherText, + recipientPublicKey: recipientPublicKey, + recipientSecretKey: recipientSecretKey + ) + } + } +} + +// MARK: - Ed25519 + +public extension Crypto.Action { + static func signEd25519(data: Bytes, keyPair: KeyPair) -> Crypto.Action { + return Crypto.Action(id: "signEd25519", args: [data, keyPair]) { + let ecKeyPair: ECKeyPair = try ECKeyPair( + publicKeyData: Data(keyPair.publicKey), + privateKeyData: Data(keyPair.secretKey) + ) + + return try Ed25519.sign(Data(data), with: ecKeyPair).bytes + } + } +} + +public extension Crypto.Verification { + static func signatureEd25519(_ signature: Data, publicKey: Data, data: Data) -> Crypto.Verification { + return Crypto.Verification(id: "signatureEd25519", args: [signature, publicKey, data]) { + return ((try? Ed25519.verifySignature(signature, publicKey: publicKey, data: data)) == true) + } + } +} + +public extension Crypto.KeyPairType { + static func x25519KeyPair() -> Crypto.KeyPairType { + return Crypto.KeyPairType(id: "x25519KeyPair") { + let keyPair: ECKeyPair = Curve25519.generateKeyPair() + + return KeyPair(publicKey: Array(keyPair.publicKey), secretKey: Array(keyPair.privateKey)) + } + } +} diff --git a/SessionMessagingKit/Utilities/ProfileManager.swift b/SessionMessagingKit/Utilities/ProfileManager.swift index 5ffc3d937..7266b30f6 100644 --- a/SessionMessagingKit/Utilities/ProfileManager.swift +++ b/SessionMessagingKit/Utilities/ProfileManager.swift @@ -286,9 +286,10 @@ public struct ProfileManager { profileName: String, avatarUpdate: AvatarUpdate = .none, success: ((Database) throws -> ())? = nil, - failure: ((ProfileManagerError) -> ())? = nil + failure: ((ProfileManagerError) -> ())? = nil, + using dependencies: Dependencies = Dependencies() ) { - let userPublicKey: String = getUserHexEncodedPublicKey() + let userPublicKey: String = getUserHexEncodedPublicKey(using: dependencies) let isRemovingAvatar: Bool = { switch avatarUpdate { case .remove: return true @@ -298,7 +299,7 @@ public struct ProfileManager { switch avatarUpdate { case .none, .remove, .updateTo: - Storage.shared.writeAsync { db in + dependencies.storage.writeAsync { db in if isRemovingAvatar { let existingProfileUrl: String? = try Profile .filter(id: userPublicKey) @@ -327,7 +328,8 @@ public struct ProfileManager { publicKey: userPublicKey, name: profileName, avatarUpdate: avatarUpdate, - sentTimestamp: Date().timeIntervalSince1970 + sentTimestamp: dependencies.dateNow.timeIntervalSince1970, + using: dependencies ) SNLog("Successfully updated service with profile.") @@ -345,7 +347,8 @@ public struct ProfileManager { publicKey: userPublicKey, name: profileName, avatarUpdate: .updateTo(url: downloadUrl, key: newProfileKey, fileName: fileName), - sentTimestamp: Date().timeIntervalSince1970 + sentTimestamp: dependencies.dateNow.timeIntervalSince1970, + using: dependencies ) SNLog("Successfully updated service with profile.") @@ -498,9 +501,9 @@ public struct ProfileManager { avatarUpdate: AvatarUpdate, sentTimestamp: TimeInterval, calledFromConfigHandling: Bool = false, - dependencies: Dependencies = Dependencies() + using dependencies: Dependencies ) throws { - let isCurrentUser = (publicKey == getUserHexEncodedPublicKey(db, dependencies: dependencies)) + let isCurrentUser = (publicKey == getUserHexEncodedPublicKey(db, using: dependencies)) let profile: Profile = Profile.fetchOrCreate(db, id: publicKey) var profileChanges: [ConfigColumnAssignment] = [] @@ -604,7 +607,7 @@ public struct ProfileManager { let targetProfile: Profile = Profile.fetchOrCreate(db, id: publicKey) // FIXME: Refactor avatar downloading to be a proper Job so we can avoid this - JobRunner.afterBlockingQueue { + dependencies.jobRunner.afterBlockingQueue { ProfileManager.downloadAvatar(for: targetProfile) } } diff --git a/SessionMessagingKit/Utilities/SessionCrypto.swift b/SessionMessagingKit/Utilities/SessionCrypto.swift deleted file mode 100644 index e785ef186..000000000 --- a/SessionMessagingKit/Utilities/SessionCrypto.swift +++ /dev/null @@ -1,285 +0,0 @@ -// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. - -import Foundation -import CryptoKit -import Clibsodium -import Sodium -import Curve25519Kit -import SessionUtilitiesKit - -/// These extenion methods are used to generate a sign "blinded" messages -/// -/// According to the Swift engineers the only situation when `UnsafeRawBufferPointer.baseAddress` is nil is when it's an -/// empty collection; as such our guard cases wihch return `-1` when unwrapping this value should never be hit and we can ignore -/// them as possible results. -/// -/// For more information see: -/// https://forums.swift.org/t/when-is-unsafemutablebufferpointer-baseaddress-nil/32136/5 -/// https://github.com/apple/swift-evolution/blob/master/proposals/0055-optional-unsafe-pointers.md#unsafebufferpointer -extension Sodium { - private static let scalarLength: Int = Int(crypto_core_ed25519_scalarbytes()) // 32 - private static let noClampLength: Int = Int(Sodium.lib_crypto_scalarmult_ed25519_bytes()) // 32 - private static let scalarMultLength: Int = Int(crypto_scalarmult_bytes()) // 32 - private static let publicKeyLength: Int = Int(crypto_scalarmult_bytes()) // 32 - private static let secretKeyLength: Int = Int(crypto_sign_secretkeybytes()) // 64 - - /// 64-byte blake2b hash then reduce to get the blinding factor - public func generateBlindingFactor(serverPublicKey: String, genericHash: GenericHashType) -> Bytes? { - /// k = salt.crypto_core_ed25519_scalar_reduce(blake2b(server_pk, digest_size=64).digest()) - let serverPubKeyData: Data = Data(hex: serverPublicKey) - - guard !serverPubKeyData.isEmpty, let serverPublicKeyHashBytes: Bytes = genericHash.hash(message: [UInt8](serverPubKeyData), outputLength: 64) else { - return nil - } - - /// Reduce the server public key into an ed25519 scalar (`k`) - let kPtr: UnsafeMutablePointer = UnsafeMutablePointer.allocate(capacity: Sodium.scalarLength) - - _ = serverPublicKeyHashBytes.withUnsafeBytes { (serverPublicKeyHashPtr: UnsafeRawBufferPointer) -> Int32 in - guard let serverPublicKeyHashBaseAddress: UnsafePointer = serverPublicKeyHashPtr.baseAddress?.assumingMemoryBound(to: UInt8.self) else { - return -1 // Impossible case (refer to comments at top of extension) - } - - Sodium.lib_crypto_core_ed25519_scalar_reduce(kPtr, serverPublicKeyHashBaseAddress) - return 0 - } - - return Data(bytes: kPtr, count: Sodium.scalarLength).bytes - } - - /// Calculate k*a. To get 'a' (the Ed25519 private key scalar) we call the sodium function to - /// convert to an *x* secret key, which seems wrong--but isn't because converted keys use the - /// same secret scalar secret (and so this is just the most convenient way to get 'a' out of - /// a sodium Ed25519 secret key) - func generatePrivateKeyScalar(secretKey: Bytes) -> Bytes { - /// a = s.to_curve25519_private_key().encode() - let aPtr: UnsafeMutablePointer = UnsafeMutablePointer.allocate(capacity: Sodium.scalarMultLength) - - /// Looks like the `crypto_sign_ed25519_sk_to_curve25519` function can't actually fail so no need to verify the result - /// See: https://github.com/jedisct1/libsodium/blob/master/src/libsodium/crypto_sign/ed25519/ref10/keypair.c#L70 - _ = secretKey.withUnsafeBytes { (secretKeyPtr: UnsafeRawBufferPointer) -> Int32 in - guard let secretKeyBaseAddress: UnsafePointer = secretKeyPtr.baseAddress?.assumingMemoryBound(to: UInt8.self) else { - return -1 // Impossible case (refer to comments at top of extension) - } - - return crypto_sign_ed25519_sk_to_curve25519(aPtr, secretKeyBaseAddress) - } - - return Data(bytes: aPtr, count: Sodium.scalarMultLength).bytes - } - - /// Constructs a "blinded" key pair (`ka, kA`) based on an open group server `publicKey` and an ed25519 `keyPair` - public func blindedKeyPair(serverPublicKey: String, edKeyPair: KeyPair, genericHash: GenericHashType) -> KeyPair? { - guard edKeyPair.publicKey.count == Sodium.publicKeyLength && edKeyPair.secretKey.count == Sodium.secretKeyLength else { - return nil - } - guard let kBytes: Bytes = generateBlindingFactor(serverPublicKey: serverPublicKey, genericHash: genericHash) else { - return nil - } - let aBytes: Bytes = generatePrivateKeyScalar(secretKey: edKeyPair.secretKey) - - /// Generate the blinded key pair `ka`, `kA` - let kaPtr: UnsafeMutablePointer = UnsafeMutablePointer.allocate(capacity: Sodium.secretKeyLength) - let kAPtr: UnsafeMutablePointer = UnsafeMutablePointer.allocate(capacity: Sodium.publicKeyLength) - - _ = aBytes.withUnsafeBytes { (aPtr: UnsafeRawBufferPointer) -> Int32 in - return kBytes.withUnsafeBytes { (kPtr: UnsafeRawBufferPointer) -> Int32 in - guard let kBaseAddress: UnsafePointer = kPtr.baseAddress?.assumingMemoryBound(to: UInt8.self) else { - return -1 // Impossible case (refer to comments at top of extension) - } - guard let aBaseAddress: UnsafePointer = aPtr.baseAddress?.assumingMemoryBound(to: UInt8.self) else { - return -1 // Impossible case (refer to comments at top of extension) - } - - Sodium.lib_crypto_core_ed25519_scalar_mul(kaPtr, kBaseAddress, aBaseAddress) - return 0 - } - } - - guard crypto_scalarmult_ed25519_base_noclamp(kAPtr, kaPtr) == 0 else { return nil } - - return KeyPair( - publicKey: Data(bytes: kAPtr, count: Sodium.publicKeyLength).bytes, - secretKey: Data(bytes: kaPtr, count: Sodium.secretKeyLength).bytes - ) - } - - /// Constructs an Ed25519 signature from a root Ed25519 key and a blinded scalar/pubkey pair, with one tweak to the - /// construction: we add kA into the hashed value that yields r so that we have domain separation for different blinded - /// pubkeys (this doesn't affect verification at all) - public func sogsSignature(message: Bytes, secretKey: Bytes, blindedSecretKey ka: Bytes, blindedPublicKey kA: Bytes) -> Bytes? { - /// H_rh = sha512(s.encode()).digest()[32:] - let H_rh: Bytes = Bytes(SHA512.hash(data: secretKey).suffix(32)) - - /// r = salt.crypto_core_ed25519_scalar_reduce(sha512_multipart(H_rh, kA, message_parts)) - let combinedHashBytes: Bytes = SHA512.hash(data: H_rh + kA + message).bytes - let rPtr: UnsafeMutablePointer = UnsafeMutablePointer.allocate(capacity: Sodium.scalarLength) - - _ = combinedHashBytes.withUnsafeBytes { (combinedHashPtr: UnsafeRawBufferPointer) -> Int32 in - guard let combinedHashBaseAddress: UnsafePointer = combinedHashPtr.baseAddress?.assumingMemoryBound(to: UInt8.self) else { - return -1 // Impossible case (refer to comments at top of extension) - } - - Sodium.lib_crypto_core_ed25519_scalar_reduce(rPtr, combinedHashBaseAddress) - return 0 - } - - /// sig_R = salt.crypto_scalarmult_ed25519_base_noclamp(r) - let sig_RPtr: UnsafeMutablePointer = UnsafeMutablePointer.allocate(capacity: Sodium.noClampLength) - guard crypto_scalarmult_ed25519_base_noclamp(sig_RPtr, rPtr) == 0 else { return nil } - - /// HRAM = salt.crypto_core_ed25519_scalar_reduce(sha512_multipart(sig_R, kA, message_parts)) - let sig_RBytes: Bytes = Data(bytes: sig_RPtr, count: Sodium.noClampLength).bytes - let HRAMHashBytes: Bytes = SHA512.hash(data: sig_RBytes + kA + message).bytes - let HRAMPtr: UnsafeMutablePointer = UnsafeMutablePointer.allocate(capacity: Sodium.scalarLength) - - _ = HRAMHashBytes.withUnsafeBytes { (HRAMHashPtr: UnsafeRawBufferPointer) -> Int32 in - guard let HRAMHashBaseAddress: UnsafePointer = HRAMHashPtr.baseAddress?.assumingMemoryBound(to: UInt8.self) else { - return -1 // Impossible case (refer to comments at top of extension) - } - - Sodium.lib_crypto_core_ed25519_scalar_reduce(HRAMPtr, HRAMHashBaseAddress) - return 0 - } - - /// sig_s = salt.crypto_core_ed25519_scalar_add(r, salt.crypto_core_ed25519_scalar_mul(HRAM, ka)) - let sig_sMulPtr: UnsafeMutablePointer = UnsafeMutablePointer.allocate(capacity: Sodium.scalarLength) - let sig_sPtr: UnsafeMutablePointer = UnsafeMutablePointer.allocate(capacity: Sodium.scalarLength) - - _ = ka.withUnsafeBytes { (kaPtr: UnsafeRawBufferPointer) -> Int32 in - guard let kaBaseAddress: UnsafePointer = kaPtr.baseAddress?.assumingMemoryBound(to: UInt8.self) else { - return -1 // Impossible case (refer to comments at top of extension) - } - - Sodium.lib_crypto_core_ed25519_scalar_mul(sig_sMulPtr, HRAMPtr, kaBaseAddress) - Sodium.lib_crypto_core_ed25519_scalar_add(sig_sPtr, rPtr, sig_sMulPtr) - return 0 - } - - /// full_sig = sig_R + sig_s - return (Data(bytes: sig_RPtr, count: Sodium.noClampLength).bytes + Data(bytes: sig_sPtr, count: Sodium.scalarLength).bytes) - } - - /// Combines two keys (`kA`) - public func combineKeys(lhsKeyBytes: Bytes, rhsKeyBytes: Bytes) -> Bytes? { - let combinedPtr: UnsafeMutablePointer = UnsafeMutablePointer.allocate(capacity: Sodium.noClampLength) - - let result = rhsKeyBytes.withUnsafeBytes { (rhsKeyBytesPtr: UnsafeRawBufferPointer) -> Int32 in - return lhsKeyBytes.withUnsafeBytes { (lhsKeyBytesPtr: UnsafeRawBufferPointer) -> Int32 in - guard let lhsKeyBytesBaseAddress: UnsafePointer = lhsKeyBytesPtr.baseAddress?.assumingMemoryBound(to: UInt8.self) else { - return -1 // Impossible case (refer to comments at top of extension) - } - guard let rhsKeyBytesBaseAddress: UnsafePointer = rhsKeyBytesPtr.baseAddress?.assumingMemoryBound(to: UInt8.self) else { - return -1 // Impossible case (refer to comments at top of extension) - } - - return Sodium.lib_crypto_scalarmult_ed25519_noclamp(combinedPtr, lhsKeyBytesBaseAddress, rhsKeyBytesBaseAddress) - } - } - - /// Ensure the above worked - guard result == 0 else { return nil } - - return Data(bytes: combinedPtr, count: Sodium.noClampLength).bytes - } - - /// Calculate a shared secret for a message from A to B: - /// - /// BLAKE2b(a kB || kA || kB) - /// - /// The receiver can calulate the same value via: - /// - /// BLAKE2b(b kA || kA || kB) - public func sharedBlindedEncryptionKey(secretKey: Bytes, otherBlindedPublicKey: Bytes, fromBlindedPublicKey kA: Bytes, toBlindedPublicKey kB: Bytes, genericHash: GenericHashType) -> Bytes? { - let aBytes: Bytes = generatePrivateKeyScalar(secretKey: secretKey) - - guard let combinedKeyBytes: Bytes = combineKeys(lhsKeyBytes: aBytes, rhsKeyBytes: otherBlindedPublicKey) else { - return nil - } - - return genericHash.hash(message: (combinedKeyBytes + kA + kB), outputLength: 32) - } - - /// This method should be used to check if a users standard sessionId matches a blinded one - public func sessionId(_ standardSessionId: String, matchesBlindedId blindedSessionId: String, serverPublicKey: String, genericHash: GenericHashType) -> Bool { - // Only support generating blinded keys for standard session ids - guard - let sessionId: SessionId = SessionId(from: standardSessionId), - sessionId.prefix == .standard, - let blindedId: SessionId = SessionId(from: blindedSessionId), - ( - blindedId.prefix == .blinded15 || - blindedId.prefix == .blinded25 - ), - let kBytes: Bytes = generateBlindingFactor(serverPublicKey: serverPublicKey, genericHash: genericHash) - else { return false } - - /// From the session id (ignoring 05 prefix) we have two possible ed25519 pubkeys; the first is the positive (which is what - /// Signal's XEd25519 conversion always uses) - /// - /// Note: The below method is code we have exposed from the `curve25519_verify` method within the Curve25519 library - /// rather than custom code we have written - guard let xEd25519Key: Data = try? Ed25519.publicKey(from: Data(hex: sessionId.publicKey)) else { return false } - - /// Blind the positive public key - guard let pk1: Bytes = combineKeys(lhsKeyBytes: kBytes, rhsKeyBytes: xEd25519Key.bytes) else { return false } - - /// For the negative, what we're going to get out of the above is simply the negative of pk1, so flip the sign bit to get pk2 - /// pk2 = pk1[0:31] + bytes([pk1[31] ^ 0b1000_0000]) - let pk2: Bytes = (pk1[0..<31] + [(pk1[31] ^ 0b1000_0000)]) - - return ( - SessionId(.blinded15, publicKey: pk1).publicKey == blindedId.publicKey || - SessionId(.blinded15, publicKey: pk2).publicKey == blindedId.publicKey - ) - } -} - -extension GenericHash { - public func hashSaltPersonal( - message: Bytes, - outputLength: Int, - key: Bytes? = nil, - salt: Bytes, - personal: Bytes - ) -> Bytes? { - var output: [UInt8] = [UInt8](repeating: 0, count: outputLength) - - let result = crypto_generichash_blake2b_salt_personal( - &output, - outputLength, - message, - UInt64(message.count), - key, - (key?.count ?? 0), - salt, - personal - ) - - guard result == 0 else { return nil } - - return output - } -} - -extension AeadXChaCha20Poly1305IetfType { - /// This method is the same as the standard AeadXChaCha20Poly1305IetfType `encrypt` method except it allows the - /// specification of a nonce which allows for deterministic behaviour with unit testing - public func encrypt(message: Bytes, secretKey: Bytes, nonce: Bytes, additionalData: Bytes? = nil) -> Bytes? { - guard secretKey.count == KeyBytes else { return nil } - - var authenticatedCipherText = Bytes(repeating: 0, count: message.count + ABytes) - var authenticatedCipherTextLen: UInt64 = 0 - - let result = crypto_aead_xchacha20poly1305_ietf_encrypt( - &authenticatedCipherText, &authenticatedCipherTextLen, - message, UInt64(message.count), - additionalData, UInt64(additionalData?.count ?? 0), - nil, nonce, secretKey - ) - - guard result == 0 else { return nil } - - return authenticatedCipherText - } -} diff --git a/SessionMessagingKitTests/Jobs/Types/MessageSendJobSpec.swift b/SessionMessagingKitTests/Jobs/Types/MessageSendJobSpec.swift index f8b824165..3de0762cf 100644 --- a/SessionMessagingKitTests/Jobs/Types/MessageSendJobSpec.swift +++ b/SessionMessagingKitTests/Jobs/Types/MessageSendJobSpec.swift @@ -15,8 +15,8 @@ class MessageSendJobSpec: QuickSpec { override func spec() { var job: Job! var interaction: Interaction! - var attachment1: Attachment! - var interactionAttachment1: InteractionAttachment! + var attachment: Attachment! + var interactionAttachment: InteractionAttachment! var mockStorage: Storage! var mockJobRunner: MockJobRunner! var dependencies: Dependencies! @@ -24,8 +24,10 @@ class MessageSendJobSpec: QuickSpec { // MARK: - JobRunner describe("a MessageSendJob") { + // MARK: - Configuration + beforeEach { - mockStorage = Storage( + mockStorage = SynchronousStorage( customWriter: try! DatabaseQueue(), customMigrationTargets: [ SNUtilitiesKit.self, @@ -36,9 +38,9 @@ class MessageSendJobSpec: QuickSpec { dependencies = Dependencies( storage: mockStorage, jobRunner: mockJobRunner, - date: Date(timeIntervalSince1970: 1234567890) + dateNow: Date(timeIntervalSince1970: 1234567890) ) - attachment1 = Attachment( + attachment = Attachment( id: "200", variant: .standard, state: .failedDownload, @@ -60,7 +62,7 @@ class MessageSendJobSpec: QuickSpec { } .thenReturn([:]) mockJobRunner - .when { $0.insert(any(), job: any(), before: any(), dependencies: dependencies) } + .when { $0.insert(any(), job: any(), before: any()) } .then { args in let db: Database = args[0] as! Database var job: Job = args[1] as! Job @@ -77,6 +79,7 @@ class MessageSendJobSpec: QuickSpec { dependencies = nil } + // MARK: - fails when not given any details it("fails when not given any details") { job = Job(variant: .messageSend) @@ -92,14 +95,15 @@ class MessageSendJobSpec: QuickSpec { permanentFailure = runPermanentFailure }, deferred: { _, _ in }, - dependencies: dependencies + using: dependencies ) expect(error).to(matchError(JobRunnerError.missingRequiredDetails)) expect(permanentFailure).to(beTrue()) } - it("fails when not given incorrect details") { + // MARK: - fails when given incorrect details + it("fails when given incorrect details") { job = Job( variant: .messageSend, details: MessageReceiveJob.Details(messages: [], calledFromBackgroundPoller: false) @@ -117,13 +121,14 @@ class MessageSendJobSpec: QuickSpec { permanentFailure = runPermanentFailure }, deferred: { _, _ in }, - dependencies: dependencies + using: dependencies ) expect(error).to(matchError(JobRunnerError.missingRequiredDetails)) expect(permanentFailure).to(beTrue()) } + // MARK: - of VisibleMessage context("of VisibleMessage") { beforeEach { interaction = Interaction( @@ -162,6 +167,7 @@ class MessageSendJobSpec: QuickSpec { } } + // MARK: -- fails when there is no job id it("fails when there is no job id") { job = Job( variant: .messageSend, @@ -186,13 +192,14 @@ class MessageSendJobSpec: QuickSpec { permanentFailure = runPermanentFailure }, deferred: { _, _ in }, - dependencies: dependencies + using: dependencies ) expect(error).to(matchError(JobRunnerError.missingRequiredDetails)) expect(permanentFailure).to(beTrue()) } + // MARK: -- fails when there is no interaction id it("fails when there is no interaction id") { job = Job( variant: .messageSend, @@ -216,13 +223,14 @@ class MessageSendJobSpec: QuickSpec { permanentFailure = runPermanentFailure }, deferred: { _, _ in }, - dependencies: dependencies + using: dependencies ) expect(error).to(matchError(JobRunnerError.missingRequiredDetails)) expect(permanentFailure).to(beTrue()) } + // MARK: -- fails when there is no interaction for the provided interaction id it("fails when there is no interaction for the provided interaction id") { job = Job( variant: .messageSend, @@ -248,29 +256,32 @@ class MessageSendJobSpec: QuickSpec { permanentFailure = runPermanentFailure }, deferred: { _, _ in }, - dependencies: dependencies + using: dependencies ) expect(error).to(matchError(StorageError.objectNotFound)) expect(permanentFailure).to(beTrue()) } + + // MARK: -- with an attachment context("with an attachment") { beforeEach { - interactionAttachment1 = InteractionAttachment( + interactionAttachment = InteractionAttachment( albumIndex: 0, interactionId: interaction.id!, - attachmentId: attachment1.id + attachmentId: attachment.id ) mockStorage.write { db in - try attachment1.insert(db) - try interactionAttachment1.insert(db) + try attachment.insert(db) + try interactionAttachment.insert(db) } } + // MARK: ---- it fails when trying to send with an attachment which previously failed to download it("it fails when trying to send with an attachment which previously failed to download") { mockStorage.write { db in - try attachment1.with(state: .failedDownload).save(db) + try attachment.with(state: .failedDownload).save(db) } var error: Error? = nil @@ -285,54 +296,27 @@ class MessageSendJobSpec: QuickSpec { permanentFailure = runPermanentFailure }, deferred: { _, _ in }, - dependencies: dependencies - ) - - expect(error).to(matchError(AttachmentError.notUploaded)) - expect(permanentFailure).to(beTrue()) - } - - it("it fails when trying to send with an attachment that has an invalid downloadUrl") { - mockStorage.write { db in - try attachment1 - .with( - state: .uploaded, - downloadUrl: nil - ) - .save(db) - } - - var error: Error? = nil - var permanentFailure: Bool = false - - MessageSendJob.run( - job, - queue: .main, - success: { _, _, _ in }, - failure: { _, runError, runPermanentFailure, _ in - error = runError - permanentFailure = runPermanentFailure - }, - deferred: { _, _ in }, - dependencies: dependencies + using: dependencies ) expect(error).to(matchError(AttachmentError.notUploaded)) expect(permanentFailure).to(beTrue()) } + // MARK: ---- with a pending upload context("with a pending upload") { beforeEach { mockStorage.write { db in - try attachment1.with(state: .uploading).save(db) + try attachment.with(state: .uploading).save(db) } } + // MARK: ------ it defers when trying to send with an attachment which is still pending upload it("it defers when trying to send with an attachment which is still pending upload") { var didDefer: Bool = false mockStorage.write { db in - try attachment1.with(state: .uploading).save(db) + try attachment.with(state: .uploading).save(db) } MessageSendJob.run( @@ -341,12 +325,38 @@ class MessageSendJobSpec: QuickSpec { success: { _, _, _ in }, failure: { _, _, _, _ in }, deferred: { _, _ in didDefer = true }, - dependencies: dependencies + using: dependencies ) expect(didDefer).to(beTrue()) } + // MARK: ------ it defers when trying to send with an uploaded attachment that has an invalid downloadUrl + it("it defers when trying to send with an uploaded attachment that has an invalid downloadUrl") { + var didDefer: Bool = false + + mockStorage.write { db in + try attachment + .with( + state: .uploaded, + downloadUrl: nil + ) + .save(db) + } + + MessageSendJob.run( + job, + queue: .main, + success: { _, _, _ in }, + failure: { _, _, _, _ in }, + deferred: { _, _ in didDefer = true }, + using: dependencies + ) + + expect(didDefer).to(beTrue()) + } + + // MARK: ------ inserts an attachment upload job before the message send job it("inserts an attachment upload job before the message send job") { mockJobRunner .when { @@ -356,17 +366,7 @@ class MessageSendJobSpec: QuickSpec { variant: .attachmentUpload ) } - .thenReturn([ - 2: JobRunner.JobInfo( - variant: .attachmentUpload, - threadId: nil, - interactionId: 100, - detailsData: try! JSONEncoder().encode(AttachmentUploadJob.Details( - messageSendJobId: 1, - attachmentId: "200" - )) - ) - ]) + .thenReturn([:]) MessageSendJob.run( job, @@ -374,7 +374,7 @@ class MessageSendJobSpec: QuickSpec { success: { _, _, _ in }, failure: { _, _, _, _ in }, deferred: { _, _ in }, - dependencies: dependencies + using: dependencies ) expect(mockJobRunner) @@ -392,12 +392,12 @@ class MessageSendJobSpec: QuickSpec { attachmentId: "200" ) ), - before: job, - dependencies: dependencies + before: job ) }) } + // MARK: ------ creates a dependency between the new job and the existing one it("creates a dependency between the new job and the existing one") { MessageSendJob.run( job, @@ -405,7 +405,7 @@ class MessageSendJobSpec: QuickSpec { success: { _, _, _ in }, failure: { _, _, _, _ in }, deferred: { _, _ in }, - dependencies: dependencies + using: dependencies ) expect(mockStorage.read { db in try JobDependencies.fetchOne(db) }) diff --git a/SessionMessagingKitTests/Open Groups/Models/BatchRequestInfoSpec.swift b/SessionMessagingKitTests/Open Groups/Models/BatchRequestInfoSpec.swift index 345b3044d..1d8508abe 100644 --- a/SessionMessagingKitTests/Open Groups/Models/BatchRequestInfoSpec.swift +++ b/SessionMessagingKitTests/Open Groups/Models/BatchRequestInfoSpec.swift @@ -44,11 +44,13 @@ class BatchRequestInfoSpec: QuickSpec { ) ] ) - let requestData: Data = try! JSONEncoder().encode(request) - let requestString: String? = String(data: requestData, encoding: .utf8) - expect(requestString) - .to(equal("[{\"path\":\"\\/batch\",\"method\":\"GET\",\"b64\":\"testBody\"}]")) + let requestData: Data? = try? JSONEncoder().encode(request) + let requestJson: [[String: Any]]? = requestData + .map { try? JSONSerialization.jsonObject(with: $0) as? [[String: Any]] } + expect(requestJson?.first?["path"] as? String).to(equal("/batch")) + expect(requestJson?.first?["method"] as? String).to(equal("GET")) + expect(requestJson?.first?["b64"] as? String).to(equal("testBody")) } it("successfully encodes a byte body") { @@ -70,11 +72,13 @@ class BatchRequestInfoSpec: QuickSpec { ) ] ) - let requestData: Data = try! JSONEncoder().encode(request) - let requestString: String? = String(data: requestData, encoding: .utf8) - expect(requestString) - .to(equal("[{\"path\":\"\\/batch\",\"method\":\"GET\",\"bytes\":[1,2,3]}]")) + let requestData: Data? = try? JSONEncoder().encode(request) + let requestJson: [[String: Any]]? = requestData + .map { try? JSONSerialization.jsonObject(with: $0) as? [[String: Any]] } + expect(requestJson?.first?["path"] as? String).to(equal("/batch")) + expect(requestJson?.first?["method"] as? String).to(equal("GET")) + expect(requestJson?.first?["bytes"] as? [Int]).to(equal([1, 2, 3])) } it("successfully encodes a JSON body") { @@ -96,11 +100,13 @@ class BatchRequestInfoSpec: QuickSpec { ) ] ) - let requestData: Data = try! JSONEncoder().encode(request) - let requestString: String? = String(data: requestData, encoding: .utf8) - expect(requestString) - .to(equal("[{\"path\":\"\\/batch\",\"method\":\"GET\",\"json\":{\"stringValue\":\"testValue\"}}]")) + let requestData: Data? = try? JSONEncoder().encode(request) + let requestJson: [[String: Any]]? = requestData + .map { try? JSONSerialization.jsonObject(with: $0) as? [[String: Any]] } + expect(requestJson?.first?["path"] as? String).to(equal("/batch")) + expect(requestJson?.first?["method"] as? String).to(equal("GET")) + expect(requestJson?.first?["json"] as? [String: String]).to(equal(["stringValue": "testValue"])) } it("strips authentication headers") { diff --git a/SessionMessagingKitTests/Open Groups/Models/SOGSMessageSpec.swift b/SessionMessagingKitTests/Open Groups/Models/SOGSMessageSpec.swift index 176201e26..040785fe8 100644 --- a/SessionMessagingKitTests/Open Groups/Models/SOGSMessageSpec.swift +++ b/SessionMessagingKitTests/Open Groups/Models/SOGSMessageSpec.swift @@ -16,9 +16,8 @@ class SOGSMessageSpec: QuickSpec { var messageJson: String! var messageData: Data! var decoder: JSONDecoder! - var mockSign: MockSign! - var mockEd25519: MockEd25519! - var dependencies: SMKDependencies! + var mockCrypto: MockCrypto! + var dependencies: Dependencies! beforeEach { messageJson = """ @@ -35,18 +34,16 @@ class SOGSMessageSpec: QuickSpec { } """ messageData = messageJson.data(using: .utf8)! - mockSign = MockSign() - mockEd25519 = MockEd25519() - dependencies = SMKDependencies( - sign: mockSign, - ed25519: mockEd25519 + mockCrypto = MockCrypto() + dependencies = Dependencies( + crypto: mockCrypto ) decoder = JSONDecoder() decoder.userInfo = [ Dependencies.userInfoKey: dependencies as Any ] } afterEach { - mockSign = nil + mockCrypto = nil } context("when decoding") { @@ -204,8 +201,10 @@ class SOGSMessageSpec: QuickSpec { } it("succeeds if it succeeds verification") { - mockSign - .when { $0.verify(message: anyArray(), publicKey: anyArray(), signature: anyArray()) } + mockCrypto + .when { + $0.verify(.signature(message: anyArray(), publicKey: anyArray(), signature: anyArray())) + } .thenReturn(true) expect { @@ -215,25 +214,31 @@ class SOGSMessageSpec: QuickSpec { } it("provides the correct values as parameters") { - mockSign - .when { $0.verify(message: anyArray(), publicKey: anyArray(), signature: anyArray()) } + mockCrypto + .when { + $0.verify(.signature(message: anyArray(), publicKey: anyArray(), signature: anyArray())) + } .thenReturn(true) _ = try? decoder.decode(OpenGroupAPI.Message.self, from: messageData) - expect(mockSign) + expect(mockCrypto) .to(call(matchingParameters: true) { $0.verify( - message: Data(base64Encoded: "VGVzdERhdGE=")!.bytes, - publicKey: Data(hex: TestConstants.publicKey).bytes, - signature: Data(base64Encoded: "VGVzdFNpZ25hdHVyZQ==")!.bytes + .signature( + message: Data(base64Encoded: "VGVzdERhdGE=")!.bytes, + publicKey: Data(hex: TestConstants.publicKey).bytes, + signature: Data(base64Encoded: "VGVzdFNpZ25hdHVyZQ==")!.bytes + ) ) }) } it("throws if it fails verification") { - mockSign - .when { $0.verify(message: anyArray(), publicKey: anyArray(), signature: anyArray()) } + mockCrypto + .when { + $0.verify(.signature(message: anyArray(), publicKey: anyArray(), signature: anyArray())) + } .thenReturn(false) expect { @@ -245,7 +250,9 @@ class SOGSMessageSpec: QuickSpec { context("that is unblinded") { it("succeeds if it succeeds verification") { - mockEd25519.when { try $0.verifySignature(any(), publicKey: any(), data: any()) }.thenReturn(true) + mockCrypto + .when { $0.verify(.signatureEd25519(any(), publicKey: any(), data: any())) } + .thenReturn(true) expect { try decoder.decode(OpenGroupAPI.Message.self, from: messageData) @@ -254,22 +261,28 @@ class SOGSMessageSpec: QuickSpec { } it("provides the correct values as parameters") { - mockEd25519.when { try $0.verifySignature(any(), publicKey: any(), data: any()) }.thenReturn(true) + mockCrypto + .when { $0.verify(.signatureEd25519(any(), publicKey: any(), data: any())) } + .thenReturn(true) _ = try? decoder.decode(OpenGroupAPI.Message.self, from: messageData) - expect(mockEd25519) + expect(mockCrypto) .to(call(matchingParameters: true) { - try $0.verifySignature( - Data(base64Encoded: "VGVzdFNpZ25hdHVyZQ==")!, - publicKey: Data(hex: TestConstants.publicKey), - data: Data(base64Encoded: "VGVzdERhdGE=")! + $0.verify( + .signatureEd25519( + Data(base64Encoded: "VGVzdFNpZ25hdHVyZQ==")!, + publicKey: Data(hex: TestConstants.publicKey), + data: Data(base64Encoded: "VGVzdERhdGE=")! + ) ) }) } it("throws if it fails verification") { - mockEd25519.when { try $0.verifySignature(any(), publicKey: any(), data: any()) }.thenReturn(false) + mockCrypto + .when { $0.verify(.signatureEd25519(any(), publicKey: any(), data: any())) } + .thenReturn(false) expect { try decoder.decode(OpenGroupAPI.Message.self, from: messageData) diff --git a/SessionMessagingKitTests/Open Groups/OpenGroupAPISpec.swift b/SessionMessagingKitTests/Open Groups/OpenGroupAPISpec.swift index d87bfdca3..8f815c2eb 100644 --- a/SessionMessagingKitTests/Open Groups/OpenGroupAPISpec.swift +++ b/SessionMessagingKitTests/Open Groups/OpenGroupAPISpec.swift @@ -17,49 +17,31 @@ class OpenGroupAPISpec: QuickSpec { override func spec() { var mockStorage: Storage! - var mockSodium: MockSodium! - var mockAeadXChaCha20Poly1305Ietf: MockAeadXChaCha20Poly1305Ietf! - var mockSign: MockSign! - var mockGenericHash: MockGenericHash! - var mockEd25519: MockEd25519! - var mockNonce16Generator: MockNonce16Generator! - var mockNonce24Generator: MockNonce24Generator! - var dependencies: SMKDependencies! + var mockNetwork: MockNetwork! + var mockCrypto: MockCrypto! + var dependencies: Dependencies! var disposables: [AnyCancellable] = [] - var response: (ResponseInfoType, Codable)? = nil - var pollResponse: (info: ResponseInfoType, data: OpenGroupAPI.BatchResponse)? var error: Error? describe("an OpenGroupAPI") { // MARK: - Configuration beforeEach { - mockStorage = Storage( + mockStorage = SynchronousStorage( customWriter: try! DatabaseQueue(), customMigrationTargets: [ SNUtilitiesKit.self, SNMessagingKit.self ] ) - mockSodium = MockSodium() - mockAeadXChaCha20Poly1305Ietf = MockAeadXChaCha20Poly1305Ietf() - mockSign = MockSign() - mockGenericHash = MockGenericHash() - mockNonce16Generator = MockNonce16Generator() - mockNonce24Generator = MockNonce24Generator() - mockEd25519 = MockEd25519() - dependencies = SMKDependencies( - onionApi: TestOnionRequestAPI.self, + mockNetwork = MockNetwork() + mockCrypto = MockCrypto() + dependencies = Dependencies( storage: mockStorage, - sodium: mockSodium, - genericHash: mockGenericHash, - sign: mockSign, - aeadXChaCha20Poly1305Ietf: mockAeadXChaCha20Poly1305Ietf, - ed25519: mockEd25519, - nonceGenerator16: mockNonce16Generator, - nonceGenerator24: mockNonce24Generator, - date: Date(timeIntervalSince1970: 1234567890) + network: mockNetwork, + crypto: mockCrypto, + dateNow: Date(timeIntervalSince1970: 1234567890) ) mockStorage.write { db in @@ -85,33 +67,42 @@ class OpenGroupAPISpec: QuickSpec { try Capability(openGroupServer: "testserver", variant: .sogs, isMissing: false).insert(db) } - mockGenericHash.when { $0.hash(message: anyArray(), outputLength: any()) }.thenReturn([]) - mockSodium - .when { $0.blindedKeyPair(serverPublicKey: any(), edKeyPair: any(), genericHash: mockGenericHash) } + mockCrypto + .when { try $0.perform(.hash(message: anyArray(), outputLength: any())) } + .thenReturn([]) + mockCrypto + .when { [dependencies = dependencies!] crypto in + crypto.generate(.blindedKeyPair(serverPublicKey: any(), edKeyPair: any(), using: dependencies)) + } .thenReturn( KeyPair( publicKey: Data.data(fromHex: TestConstants.publicKey)!.bytes, secretKey: Data.data(fromHex: TestConstants.edSecretKey)!.bytes ) ) - mockSodium + mockCrypto .when { - $0.sogsSignature( - message: anyArray(), - secretKey: anyArray(), - blindedSecretKey: anyArray(), - blindedPublicKey: anyArray() + try $0.perform( + .sogsSignature( + message: anyArray(), + secretKey: anyArray(), + blindedSecretKey: anyArray(), + blindedPublicKey: anyArray() + ) ) } .thenReturn("TestSogsSignature".bytes) - mockSign.when { $0.signature(message: anyArray(), secretKey: anyArray()) }.thenReturn("TestSignature".bytes) - mockEd25519.when { try $0.sign(data: anyArray(), keyPair: any()) }.thenReturn("TestStandardSignature".bytes) - - mockNonce16Generator - .when { $0.nonce() } + mockCrypto + .when { try $0.perform(.signature(message: anyArray(), secretKey: anyArray())) } + .thenReturn("TestSignature".bytes) + mockCrypto + .when { try $0.perform(.signEd25519(data: anyArray(), keyPair: any())) } + .thenReturn("TestStandardSignature".bytes) + mockCrypto + .when { try $0.perform(.generateNonce16()) } .thenReturn(Data(base64Encoded: "pK6YRtQApl4NhECGizF0Cg==")!.bytes) - mockNonce24Generator - .when { $0.nonce() } + mockCrypto + .when { try $0.perform(.generateNonce24()) } .thenReturn(Data(base64Encoded: "pbTUizreT0sqJ2R2LloseQDyVL2RYztD")!.bytes) } @@ -119,733 +110,307 @@ class OpenGroupAPISpec: QuickSpec { disposables.forEach { $0.cancel() } mockStorage = nil - mockSodium = nil - mockAeadXChaCha20Poly1305Ietf = nil - mockSign = nil - mockGenericHash = nil - mockEd25519 = nil + mockNetwork = nil + mockCrypto = nil dependencies = nil disposables = [] - response = nil - pollResponse = nil error = nil } - // MARK: - Batching & Polling - - context("when polling") { - context("and given a correct response") { - beforeEach { - class TestApi: TestOnionRequestAPI { - override class var mockResponse: Data? { - let responses: [Data] = [ - try! JSONEncoder().encode( - HTTP.BatchSubResponse( - code: 200, - headers: [:], - body: OpenGroupAPI.Capabilities(capabilities: [], missing: nil), - failedToParseBody: false - ) - ), - try! JSONEncoder().encode( - HTTP.BatchSubResponse( - code: 200, - headers: [:], - body: try! JSONDecoder().decode( - OpenGroupAPI.RoomPollInfo.self, - from: """ - { - \"token\":\"test\", - \"active_users\":1, - \"read\":true, - \"write\":true, - \"upload\":true - } - """.data(using: .utf8)! - ), - failedToParseBody: false - ) - ), - try! JSONEncoder().encode( - HTTP.BatchSubResponse( - code: 200, - headers: [:], - body: [OpenGroupAPI.Message](), - failedToParseBody: false - ) - ) - ] - - return "[\(responses.map { String(data: $0, encoding: .utf8)! }.joined(separator: ","))]".data(using: .utf8) - } - } - - dependencies = dependencies.with(onionApi: TestApi.self) + // MARK: - when preparing a poll request + context("when preparing a poll request") { + // MARK: -- generates the correct request + it("generates the correct request") { + let preparedRequest: OpenGroupAPI.PreparedSendData? = mockStorage.read { db in + try OpenGroupAPI.preparedPoll( + db, + server: "testserver", + hasPerformedInitialPoll: false, + timeSinceLastPoll: 0, + using: dependencies + ) } - it("generates the correct request") { - mockStorage - .readPublisher { db in - try OpenGroupAPI.preparedPoll( - db, - server: "testserver", - hasPerformedInitialPoll: false, - timeSinceLastPoll: 0, - using: dependencies - ) - } - .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } - .handleEvents(receiveOutput: { result in pollResponse = result }) - .mapError { error.setting(to: $0) } - .sinkAndStore(in: &disposables) - - expect(pollResponse) - .toEventuallyNot( - beNil(), - timeout: .milliseconds(100) - ) - expect(error?.localizedDescription).to(beNil()) - - // Validate the response data - expect(pollResponse?.data.count).to(equal(3)) - expect(pollResponse?.data.keys).to(contain(.capabilities)) - expect(pollResponse?.data.keys).to(contain(.roomPollInfo("testRoom", 0))) - expect(pollResponse?.data.keys).to(contain(.roomMessagesRecent("testRoom"))) - - // Validate request data - let requestData: TestOnionRequestAPI.RequestData? = (pollResponse?.info as? TestOnionRequestAPI.ResponseInfo)?.requestData - expect(requestData?.urlString).to(equal("testserver/batch")) - expect(requestData?.httpMethod).to(equal("POST")) - expect(requestData?.publicKey).to(equal("88672ccb97f40bb57238989226cf429b575ba355443f47bc76c5ab144a96c65b")) + expect(preparedRequest?.request.url?.absoluteString).to(equal("testserver/batch")) + expect(preparedRequest?.request.httpMethod).to(equal("POST")) + expect(preparedRequest?.batchEndpoints.count).to(equal(3)) + expect(preparedRequest?.batchEndpoints[test: 0]).to(equal(.capabilities)) + expect(preparedRequest?.batchEndpoints[test: 1]).to(equal(.roomPollInfo("testRoom", 0))) + expect(preparedRequest?.batchEndpoints[test: 2]).to(equal(.roomMessagesRecent("testRoom"))) + } + + // MARK: -- retrieves recent messages if there was no last message + it("retrieves recent messages if there was no last message") { + let preparedRequest: OpenGroupAPI.PreparedSendData? = mockStorage.read { db in + try OpenGroupAPI.preparedPoll( + db, + server: "testserver", + hasPerformedInitialPoll: false, + timeSinceLastPoll: 0, + using: dependencies + ) } - it("retrieves recent messages if there was no last message") { - mockStorage - .readPublisher { db in - try OpenGroupAPI.preparedPoll( - db, - server: "testserver", - hasPerformedInitialPoll: false, - timeSinceLastPoll: 0, - using: dependencies - ) - } - .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } - .handleEvents(receiveOutput: { result in pollResponse = result }) - .mapError { error.setting(to: $0) } - .sinkAndStore(in: &disposables) - - expect(pollResponse) - .toEventuallyNot( - beNil(), - timeout: .milliseconds(100) - ) - expect(error?.localizedDescription).to(beNil()) - expect(pollResponse?.data.keys).to(contain(.roomMessagesRecent("testRoom"))) + expect(preparedRequest?.batchEndpoints[test: 2]).to(equal(.roomMessagesRecent("testRoom"))) + } + + // MARK: -- retrieves recent messages if there was a last message and it has not performed the initial poll and the last message was too long ago + it("retrieves recent messages if there was a last message and it has not performed the initial poll and the last message was too long ago") { + mockStorage.write { db in + try OpenGroup + .updateAll(db, OpenGroup.Columns.sequenceNumber.set(to: 121)) } - it("retrieves recent messages if there was a last message and it has not performed the initial poll and the last message was too long ago") { - mockStorage.write { db in - try OpenGroup - .updateAll(db, OpenGroup.Columns.sequenceNumber.set(to: 123)) - } - - mockStorage - .readPublisher { db in - try OpenGroupAPI.preparedPoll( - db, - server: "testserver", - hasPerformedInitialPoll: false, - timeSinceLastPoll: (OpenGroupAPI.Poller.maxInactivityPeriod + 1), - using: dependencies - ) - } - .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } - .handleEvents(receiveOutput: { result in pollResponse = result }) - .mapError { error.setting(to: $0) } - .sinkAndStore(in: &disposables) - - expect(pollResponse) - .toEventuallyNot( - beNil(), - timeout: .milliseconds(100) - ) - expect(error?.localizedDescription).to(beNil()) - expect(pollResponse?.data.keys).to(contain(.roomMessagesRecent("testRoom"))) + let preparedRequest: OpenGroupAPI.PreparedSendData? = mockStorage.read { db in + try OpenGroupAPI.preparedPoll( + db, + server: "testserver", + hasPerformedInitialPoll: false, + timeSinceLastPoll: (OpenGroupAPI.Poller.maxInactivityPeriod + 1), + using: dependencies + ) } - it("retrieves recent messages if there was a last message and it has performed an initial poll but it was not too long ago") { - mockStorage.write { db in - try OpenGroup - .updateAll(db, OpenGroup.Columns.sequenceNumber.set(to: 123)) - } - - mockStorage - .readPublisher { db in - try OpenGroupAPI.preparedPoll( - db, - server: "testserver", - hasPerformedInitialPoll: false, - timeSinceLastPoll: 0, - using: dependencies - ) - } - .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } - .handleEvents(receiveOutput: { result in pollResponse = result }) - .mapError { error.setting(to: $0) } - .sinkAndStore(in: &disposables) - - expect(pollResponse) - .toEventuallyNot( - beNil(), - timeout: .milliseconds(100) - ) - expect(error?.localizedDescription).to(beNil()) - expect(pollResponse?.data.keys).to(contain(.roomMessagesSince("testRoom", seqNo: 123))) + expect(preparedRequest?.batchEndpoints[test: 2]).to(equal(.roomMessagesRecent("testRoom"))) + } + + // MARK: -- retrieves recent messages if there was a last message and it has performed an initial poll but it was not too long ago + it("retrieves recent messages if there was a last message and it has performed an initial poll but it was not too long ago") { + mockStorage.write { db in + try OpenGroup + .updateAll(db, OpenGroup.Columns.sequenceNumber.set(to: 122)) } - it("retrieves recent messages if there was a last message and there has already been a poll this session") { - mockStorage.write { db in - try OpenGroup - .updateAll(db, OpenGroup.Columns.sequenceNumber.set(to: 123)) - } + let preparedRequest: OpenGroupAPI.PreparedSendData? = mockStorage.read { db in + try OpenGroupAPI.preparedPoll( + db, + server: "testserver", + hasPerformedInitialPoll: false, + timeSinceLastPoll: 0, + using: dependencies + ) + } + + expect(preparedRequest?.batchEndpoints[test: 2]) + .to(equal(.roomMessagesSince("testRoom", seqNo: 122))) + } + + // MARK: -- retrieves recent messages if there was a last message and there has already been a poll this session + it("retrieves recent messages if there was a last message and there has already been a poll this session") { + mockStorage.write { db in + try OpenGroup + .updateAll(db, OpenGroup.Columns.sequenceNumber.set(to: 123)) + } - mockStorage - .readPublisher { db in - try OpenGroupAPI.preparedPoll( - db, - server: "testserver", - hasPerformedInitialPoll: true, - timeSinceLastPoll: 0, - using: dependencies - ) - } - .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } - .handleEvents(receiveOutput: { result in pollResponse = result }) - .mapError { error.setting(to: $0) } - .sinkAndStore(in: &disposables) - - expect(pollResponse) - .toEventuallyNot( - beNil(), - timeout: .milliseconds(100) + let preparedRequest: OpenGroupAPI.PreparedSendData? = mockStorage.read { db in + try OpenGroupAPI.preparedPoll( + db, + server: "testserver", + hasPerformedInitialPoll: true, + timeSinceLastPoll: 0, + using: dependencies + ) + } + + expect(preparedRequest?.batchEndpoints[test: 2]) + .to(equal(.roomMessagesSince("testRoom", seqNo: 123))) + } + + // MARK: -- when unblinded + context("when unblinded") { + beforeEach { + mockStorage.write { db in + _ = try Capability.deleteAll(db) + try Capability(openGroupServer: "testserver", variant: .sogs, isMissing: false).insert(db) + } + } + + // MARK: ---- does not call the inbox and outbox endpoints + it("does not call the inbox and outbox endpoints") { + let preparedRequest: OpenGroupAPI.PreparedSendData? = mockStorage.read { db in + try OpenGroupAPI.preparedPoll( + db, + server: "testserver", + hasPerformedInitialPoll: false, + timeSinceLastPoll: 0, + using: dependencies ) - expect(error?.localizedDescription).to(beNil()) - expect(pollResponse?.data.keys).to(contain(.roomMessagesSince("testRoom", seqNo: 123))) - } - - context("when unblinded") { - beforeEach { - mockStorage.write { db in - _ = try Capability.deleteAll(db) - try Capability(openGroupServer: "testserver", variant: .sogs, isMissing: false).insert(db) - } - } - - it("does not call the inbox and outbox endpoints") { - mockStorage - .readPublisher { db in - try OpenGroupAPI.preparedPoll( - db, - server: "testserver", - hasPerformedInitialPoll: false, - timeSinceLastPoll: 0, - using: dependencies - ) - } - .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } - .handleEvents(receiveOutput: { result in pollResponse = result }) - .mapError { error.setting(to: $0) } - .sinkAndStore(in: &disposables) - - expect(pollResponse) - .toEventuallyNot( - beNil(), - timeout: .milliseconds(100) - ) - expect(error?.localizedDescription).to(beNil()) - - // Validate the response data - expect(pollResponse?.data.keys).toNot(contain(.inbox)) - expect(pollResponse?.data.keys).toNot(contain(.outbox)) - } - } - - context("when blinded") { - beforeEach { - class TestApi: TestOnionRequestAPI { - override class var mockResponse: Data? { - let responses: [Data] = [ - try! JSONEncoder().encode( - HTTP.BatchSubResponse( - code: 200, - headers: [:], - body: OpenGroupAPI.Capabilities(capabilities: [], missing: nil), - failedToParseBody: false - ) - ), - try! JSONEncoder().encode( - HTTP.BatchSubResponse( - code: 200, - headers: [:], - body: try! JSONDecoder().decode( - OpenGroupAPI.RoomPollInfo.self, - from: """ - { - \"token\":\"test\", - \"active_users\":1, - \"read\":true, - \"write\":true, - \"upload\":true - } - """.data(using: .utf8)! - ), - failedToParseBody: false - ) - ), - try! JSONEncoder().encode( - HTTP.BatchSubResponse( - code: 200, - headers: [:], - body: [OpenGroupAPI.Message](), - failedToParseBody: false - ) - ), - try! JSONEncoder().encode( - HTTP.BatchSubResponse( - code: 200, - headers: [:], - body: [OpenGroupAPI.DirectMessage](), - failedToParseBody: false - ) - ), - try! JSONEncoder().encode( - HTTP.BatchSubResponse( - code: 200, - headers: [:], - body: [OpenGroupAPI.DirectMessage](), - failedToParseBody: false - ) - ) - ] - - return "[\(responses.map { String(data: $0, encoding: .utf8)! }.joined(separator: ","))]".data(using: .utf8) - } - } - - dependencies = dependencies.with(onionApi: TestApi.self) - - mockStorage.write { db in - _ = try Capability.deleteAll(db) - try Capability(openGroupServer: "testserver", variant: .sogs, isMissing: false).insert(db) - try Capability(openGroupServer: "testserver", variant: .blind, isMissing: false).insert(db) - } - } - - it("includes the inbox and outbox endpoints") { - mockStorage - .readPublisher { db in - try OpenGroupAPI.preparedPoll( - db, - server: "testserver", - hasPerformedInitialPoll: false, - timeSinceLastPoll: 0, - using: dependencies - ) - } - .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } - .handleEvents(receiveOutput: { result in pollResponse = result }) - .mapError { error.setting(to: $0) } - .sinkAndStore(in: &disposables) - - expect(pollResponse) - .toEventuallyNot( - beNil(), - timeout: .milliseconds(100) - ) - expect(error?.localizedDescription).to(beNil()) - - // Validate the response data - expect(pollResponse?.data.keys).to(contain(.inbox)) - expect(pollResponse?.data.keys).to(contain(.outbox)) } - it("retrieves recent inbox messages if there was no last message") { - mockStorage - .readPublisher { db in - try OpenGroupAPI.preparedPoll( - db, - server: "testserver", - hasPerformedInitialPoll: true, - timeSinceLastPoll: 0, - using: dependencies - ) - } - .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } - .handleEvents(receiveOutput: { result in pollResponse = result }) - .mapError { error.setting(to: $0) } - .sinkAndStore(in: &disposables) - - expect(pollResponse) - .toEventuallyNot( - beNil(), - timeout: .milliseconds(100) - ) - expect(error?.localizedDescription).to(beNil()) - expect(pollResponse?.data.keys).to(contain(.inbox)) - } - - it("retrieves inbox messages since the last message if there was one") { - mockStorage.write { db in - try OpenGroup - .updateAll(db, OpenGroup.Columns.inboxLatestMessageId.set(to: 124)) - } - - mockStorage - .readPublisher { db in - try OpenGroupAPI.preparedPoll( - db, - server: "testserver", - hasPerformedInitialPoll: true, - timeSinceLastPoll: 0, - using: dependencies - ) - } - .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } - .handleEvents(receiveOutput: { result in pollResponse = result }) - .mapError { error.setting(to: $0) } - .sinkAndStore(in: &disposables) - - expect(pollResponse) - .toEventuallyNot( - beNil(), - timeout: .milliseconds(100) - ) - expect(error?.localizedDescription).to(beNil()) - expect(pollResponse?.data.keys).to(contain(.inboxSince(id: 124))) - } - - it("retrieves recent outbox messages if there was no last message") { - mockStorage - .readPublisher { db in - try OpenGroupAPI.preparedPoll( - db, - server: "testserver", - hasPerformedInitialPoll: true, - timeSinceLastPoll: 0, - using: dependencies - ) - } - .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } - .handleEvents(receiveOutput: { result in pollResponse = result }) - .mapError { error.setting(to: $0) } - .sinkAndStore(in: &disposables) - - expect(pollResponse) - .toEventuallyNot( - beNil(), - timeout: .milliseconds(100) - ) - expect(error?.localizedDescription).to(beNil()) - expect(pollResponse?.data.keys).to(contain(.outbox)) - } - - it("retrieves outbox messages since the last message if there was one") { - mockStorage.write { db in - try OpenGroup - .updateAll(db, OpenGroup.Columns.outboxLatestMessageId.set(to: 125)) - } - - mockStorage - .readPublisher { db in - try OpenGroupAPI.preparedPoll( - db, - server: "testserver", - hasPerformedInitialPoll: true, - timeSinceLastPoll: 0, - using: dependencies - ) - } - .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } - .handleEvents(receiveOutput: { result in pollResponse = result }) - .mapError { error.setting(to: $0) } - .sinkAndStore(in: &disposables) - - expect(pollResponse) - .toEventuallyNot( - beNil(), - timeout: .milliseconds(100) - ) - expect(error?.localizedDescription).to(beNil()) - expect(pollResponse?.data.keys).to(contain(.outboxSince(id: 125))) - } + expect(preparedRequest?.batchEndpoints).toNot(contain(.inbox)) + expect(preparedRequest?.batchEndpoints).toNot(contain(.outbox)) } } - context("and given an invalid response") { - it("succeeds but flags the bodies it failed to parse when an unexpected response is returned") { - class TestApi: TestOnionRequestAPI { - override class var mockResponse: Data? { - let responses: [Data] = [ - try! JSONEncoder().encode( - HTTP.BatchSubResponse( - code: 200, - headers: [:], - body: OpenGroupAPI.Capabilities(capabilities: [], missing: nil), - failedToParseBody: false - ) - ), - try! JSONEncoder().encode( - HTTP.BatchSubResponse( - code: 200, - headers: [:], - body: OpenGroupAPI.PinnedMessage(id: 1, pinnedAt: 1, pinnedBy: ""), - failedToParseBody: false - ) - ), - try! JSONEncoder().encode( - HTTP.BatchSubResponse( - code: 200, - headers: [:], - body: OpenGroupAPI.PinnedMessage(id: 1, pinnedAt: 1, pinnedBy: ""), - failedToParseBody: false - ) - ) - ] - - return "[\(responses.map { String(data: $0, encoding: .utf8)! }.joined(separator: ","))]".data(using: .utf8) - } + // MARK: -- when blinded + context("when blinded") { + beforeEach { + mockStorage.write { db in + _ = try Capability.deleteAll(db) + try Capability(openGroupServer: "testserver", variant: .sogs, isMissing: false).insert(db) + try Capability(openGroupServer: "testserver", variant: .blind, isMissing: false).insert(db) } - dependencies = dependencies.with(onionApi: TestApi.self) - - mockStorage - .readPublisher { db in - try OpenGroupAPI.preparedPoll( - db, - server: "testserver", - hasPerformedInitialPoll: false, - timeSinceLastPoll: 0, - using: dependencies - ) - } - .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } - .handleEvents(receiveOutput: { result in pollResponse = result }) - .mapError { error.setting(to: $0) } - .sinkAndStore(in: &disposables) - - expect(pollResponse) - .toEventuallyNot( - beNil(), - timeout: .milliseconds(100) + } + + // MARK: ---- includes the inbox and outbox endpoints + it("includes the inbox and outbox endpoints") { + let preparedRequest: OpenGroupAPI.PreparedSendData? = mockStorage.read { db in + try OpenGroupAPI.preparedPoll( + db, + server: "testserver", + hasPerformedInitialPoll: false, + timeSinceLastPoll: 0, + using: dependencies ) - expect(error?.localizedDescription).to(beNil()) + } - let capabilitiesResponse: HTTP.BatchSubResponse? = (pollResponse?.data[.capabilities] as? HTTP.BatchSubResponse) - let pollInfoResponse: HTTP.BatchSubResponse? = (pollResponse?.data[.roomPollInfo("testRoom", 0)] as? HTTP.BatchSubResponse) - let messagesResponse: HTTP.BatchSubResponse<[Failable]>? = (pollResponse?.data[.roomMessagesRecent("testRoom")] as? HTTP.BatchSubResponse<[Failable]>) - expect(capabilitiesResponse?.failedToParseBody).to(beFalse()) - expect(pollInfoResponse?.failedToParseBody).to(beTrue()) - expect(messagesResponse?.failedToParseBody).to(beTrue()) + expect(preparedRequest?.batchEndpoints).to(contain(.inbox)) + expect(preparedRequest?.batchEndpoints).to(contain(.outbox)) } - it("errors when no data is returned") { - mockStorage - .readPublisher { db in - try OpenGroupAPI.preparedPoll( - db, - server: "testserver", - hasPerformedInitialPoll: false, - timeSinceLastPoll: 0, - using: dependencies - ) - } - .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } - .handleEvents(receiveOutput: { result in pollResponse = result }) - .mapError { error.setting(to: $0) } - .sinkAndStore(in: &disposables) - - expect(error?.localizedDescription) - .toEventually( - equal(HTTPError.parsingFailed.localizedDescription), - timeout: .milliseconds(100) + // MARK: ---- retrieves recent inbox messages if there was no last message + it("retrieves recent inbox messages if there was no last message") { + let preparedRequest: OpenGroupAPI.PreparedSendData? = mockStorage.read { db in + try OpenGroupAPI.preparedPoll( + db, + server: "testserver", + hasPerformedInitialPoll: true, + timeSinceLastPoll: 0, + using: dependencies ) + } - expect(pollResponse).to(beNil()) + expect(preparedRequest?.batchEndpoints).to(contain(.inbox)) } - it("errors when invalid data is returned") { - class TestApi: TestOnionRequestAPI { - override class var mockResponse: Data? { return Data() } + // MARK: ---- retrieves inbox messages since the last message if there was one + it("retrieves inbox messages since the last message if there was one") { + mockStorage.write { db in + try OpenGroup + .updateAll(db, OpenGroup.Columns.inboxLatestMessageId.set(to: 124)) } - dependencies = dependencies.with(onionApi: TestApi.self) - mockStorage - .readPublisher { db in - try OpenGroupAPI.preparedPoll( - db, - server: "testserver", - hasPerformedInitialPoll: false, - timeSinceLastPoll: 0, - using: dependencies - ) - } - .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } - .handleEvents(receiveOutput: { result in pollResponse = result }) - .mapError { error.setting(to: $0) } - .sinkAndStore(in: &disposables) - - expect(error?.localizedDescription) - .toEventually( - equal(HTTPError.parsingFailed.localizedDescription), - timeout: .milliseconds(100) + let preparedRequest: OpenGroupAPI.PreparedSendData? = mockStorage.read { db in + try OpenGroupAPI.preparedPoll( + db, + server: "testserver", + hasPerformedInitialPoll: true, + timeSinceLastPoll: 0, + using: dependencies ) + } - expect(pollResponse).to(beNil()) + expect(preparedRequest?.batchEndpoints).to(contain(.inboxSince(id: 124))) } - it("errors when an empty array is returned") { - class TestApi: TestOnionRequestAPI { - override class var mockResponse: Data? { return "[]".data(using: .utf8) } - } - dependencies = dependencies.with(onionApi: TestApi.self) - - mockStorage - .readPublisher { db in - try OpenGroupAPI.preparedPoll( - db, - server: "testserver", - hasPerformedInitialPoll: false, - timeSinceLastPoll: 0, - using: dependencies - ) - } - .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } - .handleEvents(receiveOutput: { result in pollResponse = result }) - .mapError { error.setting(to: $0) } - .sinkAndStore(in: &disposables) - - expect(error?.localizedDescription) - .toEventually( - equal(HTTPError.parsingFailed.localizedDescription), - timeout: .milliseconds(100) + // MARK: ---- retrieves recent outbox messages if there was no last message + it("retrieves recent outbox messages if there was no last message") { + let preparedRequest: OpenGroupAPI.PreparedSendData? = mockStorage.read { db in + try OpenGroupAPI.preparedPoll( + db, + server: "testserver", + hasPerformedInitialPoll: true, + timeSinceLastPoll: 0, + using: dependencies ) + } - expect(pollResponse).to(beNil()) + expect(preparedRequest?.batchEndpoints).to(contain(.outbox)) } - it("errors when an empty object is returned") { - class TestApi: TestOnionRequestAPI { - override class var mockResponse: Data? { return "{}".data(using: .utf8) } + // MARK: ---- retrieves outbox messages since the last message if there was one + it("retrieves outbox messages since the last message if there was one") { + mockStorage.write { db in + try OpenGroup + .updateAll(db, OpenGroup.Columns.outboxLatestMessageId.set(to: 125)) } - dependencies = dependencies.with(onionApi: TestApi.self) - mockStorage - .readPublisher { db in - try OpenGroupAPI.preparedPoll( - db, - server: "testserver", - hasPerformedInitialPoll: false, - timeSinceLastPoll: 0, - using: dependencies - ) - } - .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } - .handleEvents(receiveOutput: { result in pollResponse = result }) - .mapError { error.setting(to: $0) } - .sinkAndStore(in: &disposables) - - expect(error?.localizedDescription) - .toEventually( - equal(HTTPError.parsingFailed.localizedDescription), - timeout: .milliseconds(100) + let preparedRequest: OpenGroupAPI.PreparedSendData? = mockStorage.read { db in + try OpenGroupAPI.preparedPoll( + db, + server: "testserver", + hasPerformedInitialPoll: true, + timeSinceLastPoll: 0, + using: dependencies ) - - expect(pollResponse).to(beNil()) - } - - it("errors when a different number of responses are returned") { - class TestApi: TestOnionRequestAPI { - override class var mockResponse: Data? { - let responses: [Data] = [ - try! JSONEncoder().encode( - HTTP.BatchSubResponse( - code: 200, - headers: [:], - body: OpenGroupAPI.Capabilities(capabilities: [], missing: nil), - failedToParseBody: false - ) - ), - try! JSONEncoder().encode( - HTTP.BatchSubResponse( - code: 200, - headers: [:], - body: try! JSONDecoder().decode( - OpenGroupAPI.RoomPollInfo.self, - from: """ - { - \"token\":\"test\", - \"active_users\":1, - \"read\":true, - \"write\":true, - \"upload\":true - } - """.data(using: .utf8)! - ), - failedToParseBody: false - ) - ) - ] - - return "[\(responses.map { String(data: $0, encoding: .utf8)! }.joined(separator: ","))]".data(using: .utf8) - } } - dependencies = dependencies.with(onionApi: TestApi.self) - mockStorage - .readPublisher { db in - try OpenGroupAPI.preparedPoll( - db, - server: "testserver", - hasPerformedInitialPoll: false, - timeSinceLastPoll: 0, - using: dependencies - ) - } - .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } - .handleEvents(receiveOutput: { result in pollResponse = result }) - .mapError { error.setting(to: $0) } - .sinkAndStore(in: &disposables) - - expect(error?.localizedDescription) - .toEventually( - equal(HTTPError.parsingFailed.localizedDescription), - timeout: .milliseconds(100) - ) - - expect(pollResponse).to(beNil()) + expect(preparedRequest?.batchEndpoints).to(contain(.outboxSince(id: 125))) } } } - // MARK: - Capabilities - - context("when doing a capabilities request") { + // MARK: - when preparing a capabilities request + context("when preparing a capabilities request") { + // MARK: -- generates the request correctly it("generates the request and handles the response correctly") { - class TestApi: TestOnionRequestAPI { - static let data: OpenGroupAPI.Capabilities = OpenGroupAPI.Capabilities(capabilities: [.sogs], missing: nil) - - override class var mockResponse: Data? { try! JSONEncoder().encode(data) } + let preparedRequest: OpenGroupAPI.PreparedSendData? = mockStorage.read { db in + try OpenGroupAPI.preparedCapabilities( + db, + server: "testserver", + using: dependencies + ) } - dependencies = dependencies.with(onionApi: TestApi.self) - var response: (info: ResponseInfoType, data: OpenGroupAPI.Capabilities)? + expect(preparedRequest?.request.url?.absoluteString).to(equal("testserver/capabilities")) + expect(preparedRequest?.request.httpMethod).to(equal("GET")) + } + } + + // MARK: - when preparing a rooms request + context("when preparing a rooms request") { + + // MARK: -- generates the request correctly + it("generates the request correctly") { + let preparedRequest: OpenGroupAPI.PreparedSendData<[OpenGroupAPI.Room]>? = mockStorage.read { db in + try OpenGroupAPI.preparedRooms( + db, + server: "testserver", + using: dependencies + ) + } + + expect(preparedRequest?.request.url?.absoluteString).to(equal("testserver/rooms")) + expect(preparedRequest?.request.httpMethod).to(equal("GET")) + } + } + + // MARK: - when preparing a capabilitiesAndRoom request + context("when preparing a capabilitiesAndRoom request") { + // MARK: -- generates the request correctly + it("generates the request correctly") { + let preparedRequest: OpenGroupAPI.PreparedSendData? = mockStorage.read { db in + try OpenGroupAPI.preparedCapabilitiesAndRoom( + db, + for: "testRoom", + on: "testserver", + using: dependencies + ) + } + + expect(preparedRequest?.batchEndpoints.count).to(equal(2)) + expect(preparedRequest?.batchEndpoints[test: 0]).to(equal(.capabilities)) + expect(preparedRequest?.batchEndpoints[test: 1]).to(equal(.room("testRoom"))) + + expect(preparedRequest?.request.url?.absoluteString).to(equal("testserver/sequence")) + expect(preparedRequest?.request.httpMethod).to(equal("POST")) + } + + // MARK: -- processes a valid response correctly + it("processes a valid response correctly") { + mockNetwork + .when { $0.send(.onionRequest(any(), to: any(), with: any())) } + .thenReturn(OpenGroupAPI.BatchResponse.mockCapabilitiesAndRoomResponse) + + var response: (info: ResponseInfoType, data: OpenGroupAPI.CapabilitiesAndRoomResponse)? mockStorage .readPublisher { db in - try OpenGroupAPI.preparedCapabilities( + try OpenGroupAPI.preparedCapabilitiesAndRoom( db, - server: "testserver", + for: "testRoom", + on: "testserver", using: dependencies ) } @@ -854,62 +419,1419 @@ class OpenGroupAPISpec: QuickSpec { .mapError { error.setting(to: $0) } .sinkAndStore(in: &disposables) - expect(response) - .toEventuallyNot( - beNil(), - timeout: .milliseconds(100) - ) - expect(error?.localizedDescription).to(beNil()) + expect(response).toNot(beNil()) + expect(error).to(beNil()) + } + + // MARK: -- and given an invalid response + + context("and given an invalid response") { + // MARK: ---- errors when not given a room response + it("errors when not given a room response") { + mockNetwork + .when { $0.send(.onionRequest(any(), to: any(), with: any())) } + .thenReturn(OpenGroupAPI.BatchResponse.mockCapabilitiesAndBanResponse) + + var response: (info: ResponseInfoType, data: OpenGroupAPI.CapabilitiesAndRoomResponse)? + + mockStorage + .readPublisher { db in + try OpenGroupAPI.preparedCapabilitiesAndRoom( + db, + for: "testRoom", + on: "testserver", + using: dependencies + ) + } + .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } + .handleEvents(receiveOutput: { result in response = result }) + .mapError { error.setting(to: $0) } + .sinkAndStore(in: &disposables) + + expect(error).to(matchError(HTTPError.parsingFailed)) + expect(response).to(beNil()) + } - // Validate the response data - expect(response?.data).to(equal(TestApi.data)) - - // Validate request data - let requestData: TestOnionRequestAPI.RequestData? = (response?.info as? TestOnionRequestAPI.ResponseInfo)?.requestData - expect(requestData?.httpMethod).to(equal("GET")) - expect(requestData?.urlString).to(equal("testserver/capabilities")) + // MARK: ---- errors when not given a capabilities response + it("errors when not given a capabilities response") { + mockNetwork + .when { $0.send(.onionRequest(any(), to: any(), with: any())) } + .thenReturn(OpenGroupAPI.BatchResponse.mockBanAndRoomResponse) + + var response: (info: ResponseInfoType, data: OpenGroupAPI.CapabilitiesAndRoomResponse)? + + mockStorage + .readPublisher { db in + try OpenGroupAPI.preparedCapabilitiesAndRoom( + db, + for: "testRoom", + on: "testserver", + using: dependencies + ) + } + .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } + .handleEvents(receiveOutput: { result in response = result }) + .mapError { error.setting(to: $0) } + .sinkAndStore(in: &disposables) + + expect(error).to(matchError(HTTPError.parsingFailed)) + expect(response).to(beNil()) + } } } - // MARK: - Rooms - - context("when doing a rooms request") { - it("generates the request and handles the response correctly") { - class TestApi: TestOnionRequestAPI { - static let data: [OpenGroupAPI.Room] = [ - OpenGroupAPI.Room( - token: "test", - name: "test", - roomDescription: nil, - infoUpdates: 0, - messageSequence: 0, - created: 0, - activeUsers: 0, - activeUsersCutoff: 0, - imageId: nil, - pinnedMessages: nil, - admin: false, - globalAdmin: false, - admins: [], - hiddenAdmins: nil, - moderator: false, - globalModerator: false, - moderators: [], - hiddenModerators: nil, - read: false, - defaultRead: nil, - defaultAccessible: nil, - write: false, - defaultWrite: nil, - upload: false, - defaultUpload: nil - ) - ] - - override class var mockResponse: Data? { return try! JSONEncoder().encode(data) } + // MARK: - when preparing a capabilitiesAndRooms request + context("when preparing a capabilitiesAndRooms request") { + // MARK: -- generates the request correctly + it("generates the request correctly") { + let preparedRequest: OpenGroupAPI.PreparedSendData? = mockStorage.read { db in + try OpenGroupAPI.preparedCapabilitiesAndRooms( + db, + on: "testserver", + using: dependencies + ) } - dependencies = dependencies.with(onionApi: TestApi.self) + expect(preparedRequest?.batchEndpoints.count).to(equal(2)) + expect(preparedRequest?.batchEndpoints[test: 0]).to(equal(.capabilities)) + expect(preparedRequest?.batchEndpoints[test: 1]).to(equal(.rooms)) + + expect(preparedRequest?.request.url?.absoluteString).to(equal("testserver/sequence")) + expect(preparedRequest?.request.httpMethod).to(equal("POST")) + } + + // MARK: -- processes a valid response correctly + it("processes a valid response correctly") { + mockNetwork + .when { $0.send(.onionRequest(any(), to: any(), with: any())) } + .thenReturn(OpenGroupAPI.BatchResponse.mockCapabilitiesAndRoomsResponse) + + var response: (info: ResponseInfoType, data: OpenGroupAPI.CapabilitiesAndRoomsResponse)? + + mockStorage + .readPublisher { db in + try OpenGroupAPI.preparedCapabilitiesAndRooms( + db, + on: "testserver", + using: dependencies + ) + } + .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } + .handleEvents(receiveOutput: { result in response = result }) + .mapError { error.setting(to: $0) } + .sinkAndStore(in: &disposables) + + expect(response).toNot(beNil()) + expect(error).to(beNil()) + } + + // MARK: -- and given an invalid response + + context("and given an invalid response") { + // MARK: ---- errors when not given a room response + it("errors when not given a room response") { + mockNetwork + .when { $0.send(.onionRequest(any(), to: any(), with: any())) } + .thenReturn(OpenGroupAPI.BatchResponse.mockCapabilitiesAndBanResponse) + + var response: (info: ResponseInfoType, data: OpenGroupAPI.CapabilitiesAndRoomsResponse)? + + mockStorage + .readPublisher { db in + try OpenGroupAPI.preparedCapabilitiesAndRooms( + db, + on: "testserver", + using: dependencies + ) + } + .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } + .handleEvents(receiveOutput: { result in response = result }) + .mapError { error.setting(to: $0) } + .sinkAndStore(in: &disposables) + + expect(error).to(matchError(HTTPError.parsingFailed)) + expect(response).to(beNil()) + } + + // MARK: ---- errors when not given a capabilities response + it("errors when not given a capabilities response") { + mockNetwork + .when { $0.send(.onionRequest(any(), to: any(), with: any())) } + .thenReturn(OpenGroupAPI.BatchResponse.mockBanAndRoomsResponse) + + var response: (info: ResponseInfoType, data: OpenGroupAPI.CapabilitiesAndRoomsResponse)? + + mockStorage + .readPublisher { db in + try OpenGroupAPI.preparedCapabilitiesAndRooms( + db, + on: "testserver", + using: dependencies + ) + } + .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } + .handleEvents(receiveOutput: { result in response = result }) + .mapError { error.setting(to: $0) } + .sinkAndStore(in: &disposables) + + expect(error).to(matchError(HTTPError.parsingFailed)) + expect(response).to(beNil()) + } + } + } + + // MARK: - when preparing a send message request + context("when preparing a send message request") { + // MARK: -- generates the request correctly + it("generates the request correctly") { + let preparedRequest: OpenGroupAPI.PreparedSendData? = mockStorage.read { db in + try OpenGroupAPI.preparedSend( + db, + plaintext: "test".data(using: .utf8)!, + to: "testRoom", + on: "testServer", + whisperTo: nil, + whisperMods: false, + fileIds: nil, + using: dependencies + ) + } + + expect(preparedRequest?.request.url?.absoluteString).to(equal("testServer/room/testRoom/message")) + expect(preparedRequest?.request.httpMethod).to(equal("POST")) + } + + // MARK: -- when unblinded + context("when unblinded") { + beforeEach { + mockStorage.write { db in + _ = try Capability.deleteAll(db) + try Capability(openGroupServer: "testserver", variant: .sogs, isMissing: false).insert(db) + } + } + + // MARK: ---- signs the message correctly + it("signs the message correctly") { + let preparedRequest: OpenGroupAPI.PreparedSendData? = mockStorage.read { db in + try OpenGroupAPI.preparedSend( + db, + plaintext: "test".data(using: .utf8)!, + to: "testRoom", + on: "testServer", + whisperTo: nil, + whisperMods: false, + fileIds: nil, + using: dependencies + ) + } + + let requestBody: OpenGroupAPI.SendMessageRequest? = try? preparedRequest?.request.httpBody? + .decoded(as: OpenGroupAPI.SendMessageRequest.self, using: dependencies) + expect(requestBody?.data).to(equal("test".data(using: .utf8))) + expect(requestBody?.signature).to(equal("TestStandardSignature".data(using: .utf8))) + } + + // MARK: ---- fails to sign if there is no open group + it("fails to sign if there is no open group") { + mockStorage.write { db in + _ = try OpenGroup.deleteAll(db) + } + + var preparationError: Error? + let preparedRequest: OpenGroupAPI.PreparedSendData? = mockStorage.read { db in + do { + return try OpenGroupAPI.preparedSend( + db, + plaintext: "test".data(using: .utf8)!, + to: "testRoom", + on: "testserver", + whisperTo: nil, + whisperMods: false, + fileIds: nil, + using: dependencies + ) + } + catch { + preparationError = error + throw error + } + } + + expect(preparationError).to(matchError(OpenGroupAPIError.signingFailed)) + expect(preparedRequest).to(beNil()) + } + + // MARK: ---- fails to sign if there is no user key pair + it("fails to sign if there is no user key pair") { + mockStorage.write { db in + _ = try Identity.filter(id: .x25519PublicKey).deleteAll(db) + _ = try Identity.filter(id: .x25519PrivateKey).deleteAll(db) + } + + var preparationError: Error? + let preparedRequest: OpenGroupAPI.PreparedSendData? = mockStorage.read { db in + do { + return try OpenGroupAPI.preparedSend( + db, + plaintext: "test".data(using: .utf8)!, + to: "testRoom", + on: "testserver", + whisperTo: nil, + whisperMods: false, + fileIds: nil, + using: dependencies + ) + } + catch { + preparationError = error + throw error + } + } + + expect(preparationError).to(matchError(OpenGroupAPIError.signingFailed)) + expect(preparedRequest).to(beNil()) + } + + // MARK: ---- fails to sign if no signature is generated + it("fails to sign if no signature is generated") { + mockCrypto.reset() // The 'keyPair' value doesn't equate so have to explicitly reset + mockCrypto + .when { try $0.perform(.signEd25519(data: anyArray(), keyPair: any())) } + .thenReturn(nil) + + var preparationError: Error? + let preparedRequest: OpenGroupAPI.PreparedSendData? = mockStorage.read { db in + do { + return try OpenGroupAPI.preparedSend( + db, + plaintext: "test".data(using: .utf8)!, + to: "testRoom", + on: "testserver", + whisperTo: nil, + whisperMods: false, + fileIds: nil, + using: dependencies + ) + } + catch { + preparationError = error + throw error + } + } + + expect(preparationError).to(matchError(OpenGroupAPIError.signingFailed)) + expect(preparedRequest).to(beNil()) + } + } + + // MARK: -- when blinded + context("when blinded") { + beforeEach { + mockStorage.write { db in + _ = try Capability.deleteAll(db) + try Capability(openGroupServer: "testserver", variant: .sogs, isMissing: false).insert(db) + try Capability(openGroupServer: "testserver", variant: .blind, isMissing: false).insert(db) + } + } + + // MARK: ---- signs the message correctly + it("signs the message correctly") { + let preparedRequest: OpenGroupAPI.PreparedSendData? = mockStorage.read { db in + try OpenGroupAPI.preparedSend( + db, + plaintext: "test".data(using: .utf8)!, + to: "testRoom", + on: "testserver", + whisperTo: nil, + whisperMods: false, + fileIds: nil, + using: dependencies + ) + } + + let requestBody: OpenGroupAPI.SendMessageRequest? = try? preparedRequest?.request.httpBody? + .decoded(as: OpenGroupAPI.SendMessageRequest.self, using: dependencies) + expect(requestBody?.data).to(equal("test".data(using: .utf8))) + expect(requestBody?.signature).to(equal("TestSogsSignature".data(using: .utf8))) + } + + // MARK: ---- fails to sign if there is no open group + it("fails to sign if there is no open group") { + mockStorage.write { db in + _ = try OpenGroup.deleteAll(db) + } + + var preparationError: Error? + let preparedRequest: OpenGroupAPI.PreparedSendData? = mockStorage.read { db in + do { + return try OpenGroupAPI.preparedSend( + db, + plaintext: "test".data(using: .utf8)!, + to: "testRoom", + on: "testServer", + whisperTo: nil, + whisperMods: false, + fileIds: nil, + using: dependencies + ) + } + catch { + preparationError = error + throw error + } + } + + expect(preparationError).to(matchError(OpenGroupAPIError.signingFailed)) + expect(preparedRequest).to(beNil()) + } + + // MARK: ---- fails to sign if there is no ed key pair key + it("fails to sign if there is no ed key pair key") { + mockStorage.write { db in + _ = try Identity.filter(id: .ed25519PublicKey).deleteAll(db) + _ = try Identity.filter(id: .ed25519SecretKey).deleteAll(db) + } + + var preparationError: Error? + let preparedRequest: OpenGroupAPI.PreparedSendData? = mockStorage.read { db in + do { + return try OpenGroupAPI.preparedSend( + db, + plaintext: "test".data(using: .utf8)!, + to: "testRoom", + on: "testserver", + whisperTo: nil, + whisperMods: false, + fileIds: nil, + using: dependencies + ) + } + catch { + preparationError = error + throw error + } + } + + expect(preparationError).to(matchError(OpenGroupAPIError.signingFailed)) + expect(preparedRequest).to(beNil()) + } + + // MARK: ---- fails to sign if no signature is generated + it("fails to sign if no signature is generated") { + mockCrypto + .when { + try $0.perform( + .sogsSignature( + message: anyArray(), + secretKey: anyArray(), + blindedSecretKey: anyArray(), + blindedPublicKey: anyArray() + ) + ) + } + .thenReturn(nil) + + var preparationError: Error? + let preparedRequest: OpenGroupAPI.PreparedSendData? = mockStorage.read { db in + do { + return try OpenGroupAPI.preparedSend( + db, + plaintext: "test".data(using: .utf8)!, + to: "testRoom", + on: "testserver", + whisperTo: nil, + whisperMods: false, + fileIds: nil, + using: dependencies + ) + } + catch { + preparationError = error + throw error + } + } + + expect(preparationError).to(matchError(OpenGroupAPIError.signingFailed)) + expect(preparedRequest).to(beNil()) + } + } + } + + // MARK: - when preparing an individual message request + context("when preparing an individual message request") { + // MARK: -- generates the request correctly + it("generates the request correctly") { + let preparedRequest: OpenGroupAPI.PreparedSendData? = mockStorage.read { db in + try OpenGroupAPI.preparedMessage( + db, + id: 123, + in: "testRoom", + on: "testserver", + using: dependencies + ) + } + + expect(preparedRequest?.request.url?.absoluteString).to(equal("testserver/room/testRoom/message/123")) + expect(preparedRequest?.request.httpMethod).to(equal("GET")) + } + } + + // MARK: - when preparing an update message request + context("when preparing an update message request") { + beforeEach { + mockStorage.write { db in + _ = try Identity + .filter(id: .ed25519PublicKey) + .updateAll(db, Identity.Columns.data.set(to: Data())) + _ = try Identity + .filter(id: .ed25519SecretKey) + .updateAll(db, Identity.Columns.data.set(to: Data())) + } + } + + // MARK: -- generates the request correctly + it("generates the request correctly") { + let preparedRequest: OpenGroupAPI.PreparedSendData? = mockStorage.read { db in + try OpenGroupAPI.preparedMessageUpdate( + db, + id: 123, + plaintext: "test".data(using: .utf8)!, + fileIds: nil, + in: "testRoom", + on: "testserver", + using: dependencies + ) + } + + expect(preparedRequest?.request.url?.absoluteString).to(equal("testserver/room/testRoom/message/123")) + expect(preparedRequest?.request.httpMethod).to(equal("PUT")) + } + + // MARK: -- when unblinded + context("when unblinded") { + beforeEach { + mockStorage.write { db in + _ = try Capability.deleteAll(db) + try Capability(openGroupServer: "testserver", variant: .sogs, isMissing: false).insert(db) + } + } + + // MARK: ---- signs the message correctly + it("signs the message correctly") { + let preparedRequest: OpenGroupAPI.PreparedSendData? = mockStorage.read { db in + try OpenGroupAPI.preparedMessageUpdate( + db, + id: 123, + plaintext: "test".data(using: .utf8)!, + fileIds: nil, + in: "testRoom", + on: "testserver", + using: dependencies + ) + } + + let requestBody: OpenGroupAPI.UpdateMessageRequest? = try? preparedRequest?.request.httpBody? + .decoded(as: OpenGroupAPI.UpdateMessageRequest.self, using: dependencies) + expect(requestBody?.data).to(equal("test".data(using: .utf8))) + expect(requestBody?.signature).to(equal("TestStandardSignature".data(using: .utf8))) + } + + // MARK: ---- fails to sign if there is no open group + it("fails to sign if there is no open group") { + mockStorage.write { db in + _ = try OpenGroup.deleteAll(db) + } + + var preparationError: Error? + let preparedRequest: OpenGroupAPI.PreparedSendData? = mockStorage.read { db in + do { + return try OpenGroupAPI.preparedMessageUpdate( + db, + id: 123, + plaintext: "test".data(using: .utf8)!, + fileIds: nil, + in: "testRoom", + on: "testserver", + using: dependencies + ) + } + catch { + preparationError = error + throw error + } + } + + expect(preparationError).to(matchError(OpenGroupAPIError.signingFailed)) + expect(preparedRequest).to(beNil()) + } + + // MARK: ---- fails to sign if there is no user key pair + it("fails to sign if there is no user key pair") { + mockStorage.write { db in + _ = try Identity.filter(id: .x25519PublicKey).deleteAll(db) + _ = try Identity.filter(id: .x25519PrivateKey).deleteAll(db) + } + + var preparationError: Error? + let preparedRequest: OpenGroupAPI.PreparedSendData? = mockStorage.read { db in + do { + return try OpenGroupAPI.preparedMessageUpdate( + db, + id: 123, + plaintext: "test".data(using: .utf8)!, + fileIds: nil, + in: "testRoom", + on: "testserver", + using: dependencies + ) + } + catch { + preparationError = error + throw error + } + } + + expect(preparationError).to(matchError(OpenGroupAPIError.signingFailed)) + expect(preparedRequest).to(beNil()) + } + + // MARK: ---- fails to sign if no signature is generated + it("fails to sign if no signature is generated") { + mockCrypto.reset() // The 'keyPair' value doesn't equate so have to explicitly reset + mockCrypto + .when { try $0.perform(.signEd25519(data: anyArray(), keyPair: any())) } + .thenReturn(nil) + + var preparationError: Error? + let preparedRequest: OpenGroupAPI.PreparedSendData? = mockStorage.read { db in + do { + return try OpenGroupAPI.preparedMessageUpdate( + db, + id: 123, + plaintext: "test".data(using: .utf8)!, + fileIds: nil, + in: "testRoom", + on: "testServer", + using: dependencies + ) + } + catch { + preparationError = error + throw error + } + } + + expect(preparationError).to(matchError(OpenGroupAPIError.signingFailed)) + expect(preparedRequest).to(beNil()) + } + } + + // MARK: -- when blinded + context("when blinded") { + beforeEach { + mockStorage.write { db in + _ = try Capability.deleteAll(db) + try Capability(openGroupServer: "testserver", variant: .sogs, isMissing: false).insert(db) + try Capability(openGroupServer: "testserver", variant: .blind, isMissing: false).insert(db) + } + } + + // MARK: ---- signs the message correctly + it("signs the message correctly") { + let preparedRequest: OpenGroupAPI.PreparedSendData? = mockStorage.read { db in + try OpenGroupAPI.preparedMessageUpdate( + db, + id: 123, + plaintext: "test".data(using: .utf8)!, + fileIds: nil, + in: "testRoom", + on: "testserver", + using: dependencies + ) + } + + let requestBody: OpenGroupAPI.UpdateMessageRequest? = try? preparedRequest?.request.httpBody? + .decoded(as: OpenGroupAPI.UpdateMessageRequest.self, using: dependencies) + expect(requestBody?.data).to(equal("test".data(using: .utf8))) + expect(requestBody?.signature).to(equal("TestSogsSignature".data(using: .utf8))) + } + + // MARK: ---- fails to sign if there is no open group + it("fails to sign if there is no open group") { + mockStorage.write { db in + _ = try OpenGroup.deleteAll(db) + } + + var preparationError: Error? + let preparedRequest: OpenGroupAPI.PreparedSendData? = mockStorage.read { db in + do { + return try OpenGroupAPI.preparedMessageUpdate( + db, + id: 123, + plaintext: "test".data(using: .utf8)!, + fileIds: nil, + in: "testRoom", + on: "testserver", + using: dependencies + ) + } + catch { + preparationError = error + throw error + } + } + + expect(preparationError).to(matchError(OpenGroupAPIError.signingFailed)) + expect(preparedRequest).to(beNil()) + } + + // MARK: ---- fails to sign if there is no ed key pair key + it("fails to sign if there is no ed key pair key") { + mockStorage.write { db in + _ = try Identity.filter(id: .ed25519PublicKey).deleteAll(db) + _ = try Identity.filter(id: .ed25519SecretKey).deleteAll(db) + } + + var preparationError: Error? + let preparedRequest: OpenGroupAPI.PreparedSendData? = mockStorage.read { db in + do { + return try OpenGroupAPI.preparedMessageUpdate( + db, + id: 123, + plaintext: "test".data(using: .utf8)!, + fileIds: nil, + in: "testRoom", + on: "testserver", + using: dependencies + ) + } + catch { + preparationError = error + throw error + } + } + + expect(preparationError).to(matchError(OpenGroupAPIError.signingFailed)) + expect(preparedRequest).to(beNil()) + } + + // MARK: ---- fails to sign if no signature is generated + it("fails to sign if no signature is generated") { + mockCrypto + .when { + try $0.perform( + .sogsSignature( + message: anyArray(), + secretKey: anyArray(), + blindedSecretKey: anyArray(), + blindedPublicKey: anyArray() + ) + ) + } + .thenReturn(nil) + + var preparationError: Error? + let preparedRequest: OpenGroupAPI.PreparedSendData? = mockStorage.read { db in + do { + return try OpenGroupAPI.preparedMessageUpdate( + db, + id: 123, + plaintext: "test".data(using: .utf8)!, + fileIds: nil, + in: "testRoom", + on: "testserver", + using: dependencies + ) + } + catch { + preparationError = error + throw error + } + } + + expect(preparationError).to(matchError(OpenGroupAPIError.signingFailed)) + expect(preparedRequest).to(beNil()) + } + } + } + + // MARK: - when preparing a delete message request + context("when preparing a delete message request") { + // MARK: -- generates the request correctly + it("generates the request correctly") { + let preparedRequest: OpenGroupAPI.PreparedSendData? = mockStorage.read { db in + try OpenGroupAPI.preparedMessageDelete( + db, + id: 123, + in: "testRoom", + on: "testserver", + using: dependencies + ) + } + + expect(preparedRequest?.request.url?.absoluteString).to(equal("testserver/room/testRoom/message/123")) + expect(preparedRequest?.request.httpMethod).to(equal("DELETE")) + } + } + + // MARK: - when preparing a delete all messages request + context("when preparing a delete all messages request") { + // MARK: -- generates the request correctly + it("generates the request correctly") { + let preparedRequest: OpenGroupAPI.PreparedSendData? = mockStorage.read { db in + try OpenGroupAPI.preparedMessagesDeleteAll( + db, + sessionId: "testUserId", + in: "testRoom", + on: "testserver", + using: dependencies + ) + } + + expect(preparedRequest?.request.url?.absoluteString).to(equal("testserver/room/testRoom/all/testUserId")) + expect(preparedRequest?.request.httpMethod).to(equal("DELETE")) + } + } + + // MARK: - when preparing a pin message request + context("when preparing a pin message request") { + // MARK: -- generates the request correctly + it("generates the request correctly") { + let preparedRequest: OpenGroupAPI.PreparedSendData? = mockStorage.read { db in + try OpenGroupAPI.preparedPinMessage( + db, + id: 123, + in: "testRoom", + on: "testserver", + using: dependencies + ) + } + + expect(preparedRequest?.request.url?.absoluteString).to(equal("testserver/room/testRoom/pin/123")) + expect(preparedRequest?.request.httpMethod).to(equal("POST")) + } + } + + // MARK: - when preparing an unpin message request + context("when preparing an unpin message request") { + // MARK: -- generates the request correctly + it("generates the request correctly") { + let preparedRequest: OpenGroupAPI.PreparedSendData? = mockStorage.read { db in + try OpenGroupAPI.preparedUnpinMessage( + db, + id: 123, + in: "testRoom", + on: "testserver", + using: dependencies + ) + } + + expect(preparedRequest?.request.url?.absoluteString).to(equal("testserver/room/testRoom/unpin/123")) + expect(preparedRequest?.request.httpMethod).to(equal("POST")) + } + } + + // MARK: - when preparing an unpin all request + context("when preparing an unpin all request") { + // MARK: -- generates the request correctly + it("generates the request correctly") { + let preparedRequest: OpenGroupAPI.PreparedSendData? = mockStorage.read { db in + try OpenGroupAPI.preparedUnpinAll( + db, + in: "testRoom", + on: "testserver", + using: dependencies + ) + } + + expect(preparedRequest?.request.url?.absoluteString).to(equal("testserver/room/testRoom/unpin/all")) + expect(preparedRequest?.request.httpMethod).to(equal("POST")) + } + } + + // MARK: - when preparing an upload file request + context("when preparing an upload file request") { + // MARK: -- generates the request correctly + it("generates the request correctly") { + let preparedRequest: OpenGroupAPI.PreparedSendData? = mockStorage.read { db in + try OpenGroupAPI.preparedUploadFile( + db, + bytes: [], + to: "testRoom", + on: "testserver", + using: dependencies + ) + } + + expect(preparedRequest?.request.url?.absoluteString).to(equal("testserver/room/testRoom/file")) + expect(preparedRequest?.request.httpMethod).to(equal("POST")) + } + + // MARK: -- doesn't add a fileName to the content-disposition header when not provided + it("doesn't add a fileName to the content-disposition header when not provided") { + let preparedRequest: OpenGroupAPI.PreparedSendData? = mockStorage.read { db in + try OpenGroupAPI.preparedUploadFile( + db, + bytes: [], + to: "testRoom", + on: "testserver", + using: dependencies + ) + } + + expect(preparedRequest?.request.allHTTPHeaderFields?[HTTPHeader.contentDisposition]) + .toNot(contain("filename")) + } + + // MARK: -- adds the fileName to the content-disposition header when provided + it("adds the fileName to the content-disposition header when provided") { + let preparedRequest: OpenGroupAPI.PreparedSendData? = mockStorage.read { db in + try OpenGroupAPI.preparedUploadFile( + db, + bytes: [], + fileName: "TestFileName", + to: "testRoom", + on: "testserver", + using: dependencies + ) + } + + expect(preparedRequest?.request.allHTTPHeaderFields?[HTTPHeader.contentDisposition]) + .to(contain("TestFileName")) + } + } + + // MARK: - when preparing a download file request + context("when preparing a download file request") { + // MARK: -- generates the request correctly + it("generates the request correctly") { + let preparedRequest: OpenGroupAPI.PreparedSendData? = mockStorage.read { db in + try OpenGroupAPI.preparedDownloadFile( + db, + fileId: "1", + from: "testRoom", + on: "testserver", + using: dependencies + ) + } + + expect(preparedRequest?.request.url?.absoluteString).to(equal("testserver/room/testRoom/file/1")) + expect(preparedRequest?.request.httpMethod).to(equal("GET")) + } + } + + // MARK: - when preparing a send direct message request + context("when preparing a send direct message request") { + // MARK: -- generates the request correctly + it("generates the request correctly") { + let preparedRequest: OpenGroupAPI.PreparedSendData? = mockStorage.read { db in + try OpenGroupAPI.preparedSend( + db, + ciphertext: "test".data(using: .utf8)!, + toInboxFor: "testUserId", + on: "testserver", + using: dependencies + ) + } + + expect(preparedRequest?.request.url?.absoluteString).to(equal("testserver/inbox/testUserId")) + expect(preparedRequest?.request.httpMethod).to(equal("POST")) + } + } + + // MARK: - when preparing a ban user request + context("when preparing a ban user request") { + // MARK: -- generates the request correctly + it("generates the request correctly") { + let preparedRequest: OpenGroupAPI.PreparedSendData? = mockStorage.read { db in + try OpenGroupAPI.preparedUserBan( + db, + sessionId: "testUserId", + for: nil, + from: nil, + on: "testserver", + using: dependencies + ) + } + + expect(preparedRequest?.request.url?.absoluteString).to(equal("testserver/user/testUserId/ban")) + expect(preparedRequest?.request.httpMethod).to(equal("POST")) + } + + // MARK: -- does a global ban if no room tokens are provided + it("does a global ban if no room tokens are provided") { + let preparedRequest: OpenGroupAPI.PreparedSendData? = mockStorage.read { db in + try OpenGroupAPI.preparedUserBan( + db, + sessionId: "testUserId", + for: nil, + from: nil, + on: "testserver", + using: dependencies + ) + } + + let requestBody: OpenGroupAPI.UserBanRequest? = try? preparedRequest?.request.httpBody? + .decoded(as: OpenGroupAPI.UserBanRequest.self, using: dependencies) + expect(requestBody?.global).to(beTrue()) + expect(requestBody?.rooms).to(beNil()) + } + + // MARK: -- does room specific bans if room tokens are provided + it("does room specific bans if room tokens are provided") { + let preparedRequest: OpenGroupAPI.PreparedSendData? = mockStorage.read { db in + try OpenGroupAPI.preparedUserBan( + db, + sessionId: "testUserId", + for: nil, + from: ["testRoom"], + on: "testserver", + using: dependencies + ) + } + + let requestBody: OpenGroupAPI.UserBanRequest? = try? preparedRequest?.request.httpBody? + .decoded(as: OpenGroupAPI.UserBanRequest.self, using: dependencies) + expect(requestBody?.global).to(beNil()) + expect(requestBody?.rooms).to(equal(["testRoom"])) + } + } + + // MARK: - when preparing an unban user request + context("when preparing an unban user request") { + // MARK: -- generates the request correctly + it("generates the request correctly") { + let preparedRequest: OpenGroupAPI.PreparedSendData? = mockStorage.read { db in + try OpenGroupAPI.preparedUserUnban( + db, + sessionId: "testUserId", + from: nil, + on: "testserver", + using: dependencies + ) + } + + expect(preparedRequest?.request.url?.absoluteString).to(equal("testserver/user/testUserId/unban")) + expect(preparedRequest?.request.httpMethod).to(equal("POST")) + } + + // MARK: -- does a global unban if no room tokens are provided + it("does a global unban if no room tokens are provided") { + let preparedRequest: OpenGroupAPI.PreparedSendData? = mockStorage.read { db in + try OpenGroupAPI.preparedUserUnban( + db, + sessionId: "testUserId", + from: nil, + on: "testserver", + using: dependencies + ) + } + + let requestBody: OpenGroupAPI.UserUnbanRequest? = try? preparedRequest?.request.httpBody? + .decoded(as: OpenGroupAPI.UserUnbanRequest.self, using: dependencies) + expect(requestBody?.global).to(beTrue()) + expect(requestBody?.rooms).to(beNil()) + } + + // MARK: -- does room specific unbans if room tokens are provided + it("does room specific unbans if room tokens are provided") { + let preparedRequest: OpenGroupAPI.PreparedSendData? = mockStorage.read { db in + try OpenGroupAPI.preparedUserUnban( + db, + sessionId: "testUserId", + from: ["testRoom"], + on: "testserver", + using: dependencies + ) + } + + let requestBody: OpenGroupAPI.UserUnbanRequest? = try? preparedRequest?.request.httpBody? + .decoded(as: OpenGroupAPI.UserUnbanRequest.self, using: dependencies) + expect(requestBody?.global).to(beNil()) + expect(requestBody?.rooms).to(equal(["testRoom"])) + } + } + + // MARK: - when preparing a user permissions request + context("when preparing a user permissions request") { + // MARK: -- generates the request correctly + it("generates the request correctly") { + let preparedRequest: OpenGroupAPI.PreparedSendData? = mockStorage.read { db in + try OpenGroupAPI.preparedUserModeratorUpdate( + db, + sessionId: "testUserId", + moderator: true, + admin: nil, + visible: true, + for: nil, + on: "testserver", + using: dependencies + ) + } + + expect(preparedRequest?.request.url?.absoluteString).to(equal("testserver/user/testUserId/moderator")) + expect(preparedRequest?.request.httpMethod).to(equal("POST")) + } + + // MARK: -- does a global update if no room tokens are provided + it("does a global update if no room tokens are provided") { + let preparedRequest: OpenGroupAPI.PreparedSendData? = mockStorage.read { db in + try OpenGroupAPI.preparedUserModeratorUpdate( + db, + sessionId: "testUserId", + moderator: true, + admin: nil, + visible: true, + for: nil, + on: "testserver", + using: dependencies + ) + } + + let requestBody: OpenGroupAPI.UserModeratorRequest? = try? preparedRequest?.request.httpBody? + .decoded(as: OpenGroupAPI.UserModeratorRequest.self, using: dependencies) + expect(requestBody?.global).to(beTrue()) + expect(requestBody?.rooms).to(beNil()) + } + + // MARK: -- does room specific updates if room tokens are provided + it("does room specific updates if room tokens are provided") { + let preparedRequest: OpenGroupAPI.PreparedSendData? = mockStorage.read { db in + try OpenGroupAPI.preparedUserModeratorUpdate( + db, + sessionId: "testUserId", + moderator: true, + admin: nil, + visible: true, + for: ["testRoom"], + on: "testserver", + using: dependencies + ) + } + + let requestBody: OpenGroupAPI.UserModeratorRequest? = try? preparedRequest?.request.httpBody? + .decoded(as: OpenGroupAPI.UserModeratorRequest.self, using: dependencies) + expect(requestBody?.global).to(beNil()) + expect(requestBody?.rooms).to(equal(["testRoom"])) + } + + // MARK: -- fails if neither moderator or admin are set + it("fails if neither moderator or admin are set") { + var preparationError: Error? + let preparedRequest: OpenGroupAPI.PreparedSendData? = mockStorage.read { db in + do { + return try OpenGroupAPI.preparedUserModeratorUpdate( + db, + sessionId: "testUserId", + moderator: nil, + admin: nil, + visible: true, + for: nil, + on: "testserver", + using: dependencies + ) + } + catch { + preparationError = error + throw error + } + } + + expect(preparationError).to(matchError(HTTPError.generic)) + expect(preparedRequest).to(beNil()) + } + } + + // MARK: - when preparing a ban and delete all request + context("when preparing a ban and delete all request") { + // MARK: -- generates the request correctly + it("generates the request correctly") { + let preparedRequest: OpenGroupAPI.PreparedSendData? = mockStorage.read { db in + try OpenGroupAPI.preparedUserBanAndDeleteAllMessages( + db, + sessionId: "testUserId", + in: "testRoom", + on: "testserver", + using: dependencies + ) + } + + expect(preparedRequest?.request.url?.absoluteString).to(equal("testserver/sequence")) + expect(preparedRequest?.request.httpMethod).to(equal("POST")) + expect(preparedRequest?.batchEndpoints.count).to(equal(2)) + expect(preparedRequest?.batchEndpoints[test: 0]).to(equal(.userBan("testUserId"))) + expect(preparedRequest?.batchEndpoints[test: 1]) + .to(equal(.roomDeleteMessages("testRoom", sessionId: "testUserId"))) + } + +// // MARK: -- bans the user from the specified room rather than globally +// it("bans the user from the specified room rather than globally") { +// let preparedRequest: OpenGroupAPI.PreparedSendData? = mockStorage.read { db in +// try OpenGroupAPI.preparedUserBanAndDeleteAllMessages( +// db, +// sessionId: "testUserId", +// in: "testRoom", +// on: "testserver", +// using: dependencies +// ) +// } +// +// let requestBody: OpenGroupAPI.UserBanRequest? = preparedRequest?.batchRequestBodies[test: 0]? +// .decoded(as: OpenGroupAPI.UserBanRequest.self) +// expect(requestBody?.global).to(beNil()) +// expect(requestBody?.rooms).to(equal(["testRoom"])) +// } + } + + // MARK: - when signing + context("when signing") { + // MARK: -- fails when there is no serverPublicKey + it("fails when there is no serverPublicKey") { + mockStorage.write { db in + _ = try OpenGroup.deleteAll(db) + } + + var preparationError: Error? + let preparedRequest: OpenGroupAPI.PreparedSendData<[OpenGroupAPI.Room]>? = mockStorage.read { db in + do { + return try OpenGroupAPI.preparedRooms( + db, + server: "testserver", + using: dependencies + ) + } + catch { + preparationError = error + throw error + } + } + + expect(preparationError).to(matchError(OpenGroupAPIError.noPublicKey)) + expect(preparedRequest).to(beNil()) + } + + // MARK: -- fails when there is no userEdKeyPair + it("fails when there is no userEdKeyPair") { + mockStorage.write { db in + _ = try Identity.filter(id: .ed25519PublicKey).deleteAll(db) + _ = try Identity.filter(id: .ed25519SecretKey).deleteAll(db) + } + + var preparationError: Error? + let preparedRequest: OpenGroupAPI.PreparedSendData<[OpenGroupAPI.Room]>? = mockStorage.read { db in + do { + return try OpenGroupAPI.preparedRooms( + db, + server: "testserver", + using: dependencies + ) + } + catch { + preparationError = error + throw error + } + } + + expect(preparationError).to(matchError(OpenGroupAPIError.signingFailed)) + expect(preparedRequest).to(beNil()) + } + + // MARK: -- fails when the serverPublicKey is not a hex string + it("fails when the serverPublicKey is not a hex string") { + mockStorage.write { db in + _ = try OpenGroup.updateAll(db, OpenGroup.Columns.publicKey.set(to: "TestString!!!")) + } + + var preparationError: Error? + let preparedRequest: OpenGroupAPI.PreparedSendData<[OpenGroupAPI.Room]>? = mockStorage.read { db in + do { + return try OpenGroupAPI.preparedRooms( + db, + server: "testserver", + using: dependencies + ) + } + catch { + preparationError = error + throw error + } + } + + expect(preparationError).to(matchError(OpenGroupAPIError.signingFailed)) + expect(preparedRequest).to(beNil()) + } + + // MARK: -- when unblinded + context("when unblinded") { + beforeEach { + mockStorage.write { db in + _ = try Capability.deleteAll(db) + try Capability(openGroupServer: "testserver", variant: .sogs, isMissing: false).insert(db) + } + } + + // MARK: ---- signs correctly + it("signs correctly") { + let preparedRequest: OpenGroupAPI.PreparedSendData<[OpenGroupAPI.Room]>? = mockStorage.read { db in + try OpenGroupAPI.preparedRooms( + db, + server: "testserver", + using: dependencies + ) + } + + expect(preparedRequest?.request.url?.absoluteString).to(equal("testserver/rooms")) + expect(preparedRequest?.request.httpMethod).to(equal("GET")) + expect(preparedRequest?.publicKey).to(equal("88672ccb97f40bb57238989226cf429b575ba355443f47bc76c5ab144a96c65b")) + expect(preparedRequest?.request.allHTTPHeaderFields).to(haveCount(4)) + expect(preparedRequest?.request.allHTTPHeaderFields?[HTTPHeader.sogsPubKey]) + .to(equal("00bac6e71efd7dfa4a83c98ed24f254ab2c267f9ccdb172a5280a0444ad24e89cc")) + expect(preparedRequest?.request.allHTTPHeaderFields?[HTTPHeader.sogsTimestamp]) + .to(equal("1234567890")) + expect(preparedRequest?.request.allHTTPHeaderFields?[HTTPHeader.sogsNonce]) + .to(equal("pK6YRtQApl4NhECGizF0Cg==")) + expect(preparedRequest?.request.allHTTPHeaderFields?[HTTPHeader.sogsSignature]) + .to(equal("TestSignature".bytes.toBase64())) + } + + // MARK: ---- fails when the signature is not generated + it("fails when the signature is not generated") { + mockCrypto + .when { try $0.perform(.signature(message: anyArray(), secretKey: anyArray())) } + .thenReturn(nil) + + var preparationError: Error? + let preparedRequest: OpenGroupAPI.PreparedSendData<[OpenGroupAPI.Room]>? = mockStorage.read { db in + do { + return try OpenGroupAPI.preparedRooms( + db, + server: "testserver", + using: dependencies + ) + } + catch { + preparationError = error + throw error + } + } + + expect(preparationError).to(matchError(OpenGroupAPIError.signingFailed)) + expect(preparedRequest).to(beNil()) + } + } + + // MARK: -- when blinded + context("when blinded") { + beforeEach { + mockStorage.write { db in + _ = try Capability.deleteAll(db) + try Capability(openGroupServer: "testserver", variant: .sogs, isMissing: false).insert(db) + try Capability(openGroupServer: "testserver", variant: .blind, isMissing: false).insert(db) + } + } + + // MARK: ---- signs correctly + it("signs correctly") { + let preparedRequest: OpenGroupAPI.PreparedSendData<[OpenGroupAPI.Room]>? = mockStorage.read { db in + try OpenGroupAPI.preparedRooms( + db, + server: "testserver", + using: dependencies + ) + } + + expect(preparedRequest?.request.url?.absoluteString).to(equal("testserver/rooms")) + expect(preparedRequest?.request.httpMethod).to(equal("GET")) + expect(preparedRequest?.publicKey).to(equal("88672ccb97f40bb57238989226cf429b575ba355443f47bc76c5ab144a96c65b")) + expect(preparedRequest?.request.allHTTPHeaderFields).to(haveCount(4)) + expect(preparedRequest?.request.allHTTPHeaderFields?[HTTPHeader.sogsPubKey]) + .to(equal("1588672ccb97f40bb57238989226cf429b575ba355443f47bc76c5ab144a96c65b")) + expect(preparedRequest?.request.allHTTPHeaderFields?[HTTPHeader.sogsTimestamp]) + .to(equal("1234567890")) + expect(preparedRequest?.request.allHTTPHeaderFields?[HTTPHeader.sogsNonce]) + .to(equal("pK6YRtQApl4NhECGizF0Cg==")) + expect(preparedRequest?.request.allHTTPHeaderFields?[HTTPHeader.sogsSignature]) + .to(equal("TestSogsSignature".bytes.toBase64())) + } + + // MARK: ---- fails when the blindedKeyPair is not generated + it("fails when the blindedKeyPair is not generated") { + mockCrypto + .when { [dependencies = dependencies!] in + $0.generate( + .blindedKeyPair( + serverPublicKey: any(), + edKeyPair: any(), + using: dependencies + ) + ) + } + .thenReturn(nil) + + var preparationError: Error? + let preparedRequest: OpenGroupAPI.PreparedSendData<[OpenGroupAPI.Room]>? = mockStorage.read { db in + do { + return try OpenGroupAPI.preparedRooms( + db, + server: "testserver", + using: dependencies + ) + } + catch { + preparationError = error + throw error + } + } + + expect(preparationError).to(matchError(OpenGroupAPIError.signingFailed)) + expect(preparedRequest).to(beNil()) + } + + // MARK: ---- fails when the sogsSignature is not generated + it("fails when the sogsSignature is not generated") { + mockCrypto + .when { [dependencies = dependencies!] in + $0.generate( + .blindedKeyPair( + serverPublicKey: any(), + edKeyPair: any(), + using: dependencies + ) + ) + } + .thenReturn(nil) + + var preparationError: Error? + let preparedRequest: OpenGroupAPI.PreparedSendData<[OpenGroupAPI.Room]>? = mockStorage.read { db in + do { + return try OpenGroupAPI.preparedRooms( + db, + server: "testserver", + using: dependencies + ) + } + catch { + preparationError = error + throw error + } + } + + expect(preparationError).to(matchError(OpenGroupAPIError.signingFailed)) + expect(preparedRequest).to(beNil()) + } + } + } + + // MARK: -- when sending + context("when sending") { + beforeEach { + mockNetwork + .when { $0.send(.onionRequest(any(), to: any(), with: any())) } + .thenReturn(MockNetwork.response(type: [OpenGroupAPI.Room].self)) + } + + // MARK: -- triggers sending correctly + it("triggers sending correctly") { var response: (info: ResponseInfoType, data: [OpenGroupAPI.Room])? mockStorage @@ -924,2280 +1846,67 @@ class OpenGroupAPISpec: QuickSpec { .handleEvents(receiveOutput: { result in response = result }) .mapError { error.setting(to: $0) } .sinkAndStore(in: &disposables) - - expect(response) - .toEventuallyNot( - beNil(), - timeout: .milliseconds(100) - ) - expect(error?.localizedDescription).to(beNil()) - - // Validate the response data - expect(response?.data).to(equal(TestApi.data)) - - // Validate request data - let requestData: TestOnionRequestAPI.RequestData? = (response?.info as? TestOnionRequestAPI.ResponseInfo)?.requestData - expect(requestData?.httpMethod).to(equal("GET")) - expect(requestData?.urlString).to(equal("testserver/rooms")) - } - } - - // MARK: - CapabilitiesAndRoom - - context("when doing a capabilitiesAndRoom request") { - context("and given a correct response") { - it("generates the request and handles the response correctly") { - class TestApi: TestOnionRequestAPI { - static let capabilitiesData: OpenGroupAPI.Capabilities = OpenGroupAPI.Capabilities(capabilities: [.sogs], missing: nil) - static let roomData: OpenGroupAPI.Room = OpenGroupAPI.Room( - token: "test", - name: "test", - roomDescription: nil, - infoUpdates: 0, - messageSequence: 0, - created: 0, - activeUsers: 0, - activeUsersCutoff: 0, - imageId: nil, - pinnedMessages: nil, - admin: false, - globalAdmin: false, - admins: [], - hiddenAdmins: nil, - moderator: false, - globalModerator: false, - moderators: [], - hiddenModerators: nil, - read: false, - defaultRead: nil, - defaultAccessible: nil, - write: false, - defaultWrite: nil, - upload: false, - defaultUpload: nil - ) - - override class var mockResponse: Data? { - let responses: [Data] = [ - try! JSONEncoder().encode( - HTTP.BatchSubResponse( - code: 200, - headers: [:], - body: capabilitiesData, - failedToParseBody: false - ) - ), - try! JSONEncoder().encode( - HTTP.BatchSubResponse( - code: 200, - headers: [:], - body: roomData, - failedToParseBody: false - ) - ) - ] - - return "[\(responses.map { String(data: $0, encoding: .utf8)! }.joined(separator: ","))]".data(using: .utf8) - } - } - dependencies = dependencies.with(onionApi: TestApi.self) - - var response: (info: ResponseInfoType, data: OpenGroupAPI.CapabilitiesAndRoomResponse)? - - mockStorage - .readPublisher { db in - try OpenGroupAPI.preparedCapabilitiesAndRoom( - db, - for: "testRoom", - on: "testserver", - using: dependencies - ) - } - .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } - .handleEvents(receiveOutput: { result in response = result }) - .mapError { error.setting(to: $0) } - .sinkAndStore(in: &disposables) - - expect(response) - .toEventuallyNot( - beNil(), - timeout: .milliseconds(100) - ) - expect(error?.localizedDescription).to(beNil()) - - // Validate the response data - expect(response?.data.capabilities.data).to(equal(TestApi.capabilitiesData)) - expect(response?.data.room.data).to(equal(TestApi.roomData)) - - // Validate request data - let requestData: TestOnionRequestAPI.RequestData? = (response?.info as? TestOnionRequestAPI.ResponseInfo)?.requestData - expect(requestData?.httpMethod).to(equal("POST")) - expect(requestData?.urlString).to(equal("testserver/sequence")) - } + + expect(response).toNot(beNil()) + expect(error).to(beNil()) } - context("and given an invalid response") { - it("errors when only a capabilities response is returned") { - class TestApi: TestOnionRequestAPI { - static let capabilitiesData: OpenGroupAPI.Capabilities = OpenGroupAPI.Capabilities(capabilities: [.sogs], missing: nil) - - override class var mockResponse: Data? { - let responses: [Data] = [ - try! JSONEncoder().encode( - HTTP.BatchSubResponse( - code: 200, - headers: [:], - body: capabilitiesData, - failedToParseBody: false - ) - ) - ] - - return "[\(responses.map { String(data: $0, encoding: .utf8)! }.joined(separator: ","))]".data(using: .utf8) - } - } - dependencies = dependencies.with(onionApi: TestApi.self) - - var response: (info: ResponseInfoType, data: OpenGroupAPI.CapabilitiesAndRoomResponse)? - - mockStorage - .readPublisher { db in - try OpenGroupAPI.preparedCapabilitiesAndRoom( - db, - for: "testRoom", - on: "testserver", - using: dependencies - ) - } - .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } - .handleEvents(receiveOutput: { result in response = result }) - .mapError { error.setting(to: $0) } - .sinkAndStore(in: &disposables) - - expect(error?.localizedDescription) - .toEventually( - equal(HTTPError.parsingFailed.localizedDescription), - timeout: .milliseconds(100) - ) - - expect(response).to(beNil()) - } + // MARK: -- fails when not given prepared data + it("fails when not given prepared data") { + var response: (info: ResponseInfoType, data: [OpenGroupAPI.Room])? - it("errors when only a room response is returned") { - class TestApi: TestOnionRequestAPI { - static let roomData: OpenGroupAPI.Room = OpenGroupAPI.Room( - token: "test", - name: "test", - roomDescription: nil, - infoUpdates: 0, - messageSequence: 0, - created: 0, - activeUsers: 0, - activeUsersCutoff: 0, - imageId: nil, - pinnedMessages: nil, - admin: false, - globalAdmin: false, - admins: [], - hiddenAdmins: nil, - moderator: false, - globalModerator: false, - moderators: [], - hiddenModerators: nil, - read: false, - defaultRead: nil, - defaultAccessible: nil, - write: false, - defaultWrite: nil, - upload: false, - defaultUpload: nil - ) - - override class var mockResponse: Data? { - let responses: [Data] = [ - try! JSONEncoder().encode( - HTTP.BatchSubResponse( - code: 200, - headers: [:], - body: roomData, - failedToParseBody: false - ) - ) - ] - - return "[\(responses.map { String(data: $0, encoding: .utf8)! }.joined(separator: ","))]".data(using: .utf8) - } - } - dependencies = dependencies.with(onionApi: TestApi.self) - - var response: (info: ResponseInfoType, data: OpenGroupAPI.CapabilitiesAndRoomResponse)? - - mockStorage - .readPublisher { db in - try OpenGroupAPI.preparedCapabilitiesAndRoom( - db, - for: "testRoom", - on: "testserver", - using: dependencies - ) - } - .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } - .handleEvents(receiveOutput: { result in response = result }) - .mapError { error.setting(to: $0) } - .sinkAndStore(in: &disposables) - - expect(error?.localizedDescription) - .toEventually( - equal(HTTPError.parsingFailed.localizedDescription), - timeout: .milliseconds(100) - ) - - expect(response).to(beNil()) - } - - it("errors when an extra response is returned") { - class TestApi: TestOnionRequestAPI { - static let capabilitiesData: OpenGroupAPI.Capabilities = OpenGroupAPI.Capabilities(capabilities: [.sogs], missing: nil) - static let roomData: OpenGroupAPI.Room = OpenGroupAPI.Room( - token: "test", - name: "test", - roomDescription: nil, - infoUpdates: 0, - messageSequence: 0, - created: 0, - activeUsers: 0, - activeUsersCutoff: 0, - imageId: nil, - pinnedMessages: nil, - admin: false, - globalAdmin: false, - admins: [], - hiddenAdmins: nil, - moderator: false, - globalModerator: false, - moderators: [], - hiddenModerators: nil, - read: false, - defaultRead: nil, - defaultAccessible: nil, - write: false, - defaultWrite: nil, - upload: false, - defaultUpload: nil - ) - - override class var mockResponse: Data? { - let responses: [Data] = [ - try! JSONEncoder().encode( - HTTP.BatchSubResponse( - code: 200, - headers: [:], - body: capabilitiesData, - failedToParseBody: false - ) - ), - try! JSONEncoder().encode( - HTTP.BatchSubResponse( - code: 200, - headers: [:], - body: roomData, - failedToParseBody: false - ) - ), - try! JSONEncoder().encode( - HTTP.BatchSubResponse( - code: 200, - headers: [:], - body: OpenGroupAPI.PinnedMessage(id: 1, pinnedAt: 1, pinnedBy: ""), - failedToParseBody: false - ) - ) - ] - - return "[\(responses.map { String(data: $0, encoding: .utf8)! }.joined(separator: ","))]".data(using: .utf8) - } - } - dependencies = dependencies.with(onionApi: TestApi.self) - - var response: (info: ResponseInfoType, data: OpenGroupAPI.CapabilitiesAndRoomResponse)? - - mockStorage - .readPublisher { db in - try OpenGroupAPI.preparedCapabilitiesAndRoom( - db, - for: "testRoom", - on: "testserver", - using: dependencies - ) - } - .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } - .handleEvents(receiveOutput: { result in response = result }) - .mapError { error.setting(to: $0) } - .sinkAndStore(in: &disposables) - - expect(error?.localizedDescription) - .toEventually( - equal(HTTPError.parsingFailed.localizedDescription), - timeout: .milliseconds(100) - ) - - expect(response).to(beNil()) - } - } - } - - // MARK: - Messages - - context("when sending messages") { - var messageData: OpenGroupAPI.Message! - - beforeEach { - class TestApi: TestOnionRequestAPI { - static let data: OpenGroupAPI.Message = OpenGroupAPI.Message( - id: 126, - sender: "testSender", - posted: 321, - edited: nil, - deleted: nil, - seqNo: 10, - whisper: false, - whisperMods: false, - whisperTo: nil, - base64EncodedData: nil, - base64EncodedSignature: nil, - reactions: nil - ) - - override class var mockResponse: Data? { return try! JSONEncoder().encode(data) } - } - messageData = TestApi.data - dependencies = dependencies.with(onionApi: TestApi.self) - } - - afterEach { - messageData = nil - } - - it("correctly sends the message") { - var response: (info: ResponseInfoType, data: OpenGroupAPI.Message)? - - mockStorage - .readPublisher { db in - try OpenGroupAPI - .preparedSend( - db, - plaintext: "test".data(using: .utf8)!, - to: "testRoom", - on: "testServer", - whisperTo: nil, - whisperMods: false, - fileIds: nil, - using: dependencies - ) - } - .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } + OpenGroupAPI.send(data: nil, using: dependencies) .handleEvents(receiveOutput: { result in response = result }) .mapError { error.setting(to: $0) } .sinkAndStore(in: &disposables) - - expect(response) - .toEventuallyNot( - beNil(), - timeout: .milliseconds(100) - ) - expect(error?.localizedDescription).to(beNil()) - - // Validate the response data - expect(response?.data).to(equal(messageData)) - - // Validate signature headers - let requestData: TestOnionRequestAPI.RequestData? = (response?.0 as? TestOnionRequestAPI.ResponseInfo)?.requestData - expect(requestData?.httpMethod).to(equal("POST")) - expect(requestData?.urlString).to(equal("testServer/room/testRoom/message")) - } - - context("when unblinded") { - beforeEach { - mockStorage.write { db in - _ = try Capability.deleteAll(db) - try Capability(openGroupServer: "testserver", variant: .sogs, isMissing: false).insert(db) - } - } - - it("signs the message correctly") { - var response: (info: ResponseInfoType, data: OpenGroupAPI.Message)? - - mockStorage - .readPublisher { db in - try OpenGroupAPI - .preparedSend( - db, - plaintext: "test".data(using: .utf8)!, - to: "testRoom", - on: "testServer", - whisperTo: nil, - whisperMods: false, - fileIds: nil, - using: dependencies - ) - } - .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } - .handleEvents(receiveOutput: { result in response = result }) - .mapError { error.setting(to: $0) } - .sinkAndStore(in: &disposables) - - expect(response) - .toEventuallyNot( - beNil(), - timeout: .milliseconds(100) - ) - expect(error?.localizedDescription).to(beNil()) - - // Validate request body - let requestData: TestOnionRequestAPI.RequestData? = (response?.0 as? TestOnionRequestAPI.ResponseInfo)?.requestData - let requestBody: OpenGroupAPI.SendMessageRequest = try! JSONDecoder().decode(OpenGroupAPI.SendMessageRequest.self, from: requestData!.body!) - - expect(requestBody.data).to(equal("test".data(using: .utf8))) - expect(requestBody.signature).to(equal("TestStandardSignature".data(using: .utf8))) - } - - it("fails to sign if there is no open group") { - mockStorage.write { db in - _ = try OpenGroup.deleteAll(db) - } - - var response: (info: ResponseInfoType, data: OpenGroupAPI.Message)? - - mockStorage - .readPublisher { db in - try OpenGroupAPI - .preparedSend( - db, - plaintext: "test".data(using: .utf8)!, - to: "testRoom", - on: "testserver", - whisperTo: nil, - whisperMods: false, - fileIds: nil, - using: dependencies - ) - } - .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } - .handleEvents(receiveOutput: { result in response = result }) - .mapError { error.setting(to: $0) } - .sinkAndStore(in: &disposables) - - expect(error?.localizedDescription) - .toEventually( - equal(OpenGroupAPIError.signingFailed.localizedDescription), - timeout: .milliseconds(100) - ) - - expect(response).to(beNil()) - } - - it("fails to sign if there is no user key pair") { - mockStorage.write { db in - _ = try Identity.filter(id: .x25519PublicKey).deleteAll(db) - _ = try Identity.filter(id: .x25519PrivateKey).deleteAll(db) - } - - var response: (info: ResponseInfoType, data: OpenGroupAPI.Message)? - - mockStorage - .readPublisher { db in - try OpenGroupAPI - .preparedSend( - db, - plaintext: "test".data(using: .utf8)!, - to: "testRoom", - on: "testserver", - whisperTo: nil, - whisperMods: false, - fileIds: nil, - using: dependencies - ) - } - .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } - .handleEvents(receiveOutput: { result in response = result }) - .mapError { error.setting(to: $0) } - .sinkAndStore(in: &disposables) - - expect(error?.localizedDescription) - .toEventually( - equal(OpenGroupAPIError.signingFailed.localizedDescription), - timeout: .milliseconds(100) - ) - - expect(response).to(beNil()) - } - - it("fails to sign if no signature is generated") { - mockEd25519.reset() // The 'keyPair' value doesn't equate so have to explicitly reset - mockEd25519.when { try $0.sign(data: anyArray(), keyPair: any()) }.thenReturn(nil) - - var response: (info: ResponseInfoType, data: OpenGroupAPI.Message)? - - mockStorage - .readPublisher { db in - try OpenGroupAPI - .preparedSend( - db, - plaintext: "test".data(using: .utf8)!, - to: "testRoom", - on: "testserver", - whisperTo: nil, - whisperMods: false, - fileIds: nil, - using: dependencies - ) - } - .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } - .handleEvents(receiveOutput: { result in response = result }) - .mapError { error.setting(to: $0) } - .sinkAndStore(in: &disposables) - - expect(error?.localizedDescription) - .toEventually( - equal(OpenGroupAPIError.signingFailed.localizedDescription), - timeout: .milliseconds(100) - ) - - expect(response).to(beNil()) - } - } - - context("when blinded") { - beforeEach { - mockStorage.write { db in - _ = try Capability.deleteAll(db) - try Capability(openGroupServer: "testserver", variant: .sogs, isMissing: false).insert(db) - try Capability(openGroupServer: "testserver", variant: .blind, isMissing: false).insert(db) - } - } - - it("signs the message correctly") { - var response: (info: ResponseInfoType, data: OpenGroupAPI.Message)? - - mockStorage - .readPublisher { db in - try OpenGroupAPI - .preparedSend( - db, - plaintext: "test".data(using: .utf8)!, - to: "testRoom", - on: "testserver", - whisperTo: nil, - whisperMods: false, - fileIds: nil, - using: dependencies - ) - } - .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } - .handleEvents(receiveOutput: { result in response = result }) - .mapError { error.setting(to: $0) } - .sinkAndStore(in: &disposables) - - expect(response) - .toEventuallyNot( - beNil(), - timeout: .milliseconds(100) - ) - expect(error?.localizedDescription).to(beNil()) - - // Validate request body - let requestData: TestOnionRequestAPI.RequestData? = (response?.0 as? TestOnionRequestAPI.ResponseInfo)?.requestData - let requestBody: OpenGroupAPI.SendMessageRequest = try! JSONDecoder().decode(OpenGroupAPI.SendMessageRequest.self, from: requestData!.body!) - - expect(requestBody.data).to(equal("test".data(using: .utf8))) - expect(requestBody.signature).to(equal("TestSogsSignature".data(using: .utf8))) - } - - it("fails to sign if there is no open group") { - mockStorage.write { db in - _ = try OpenGroup.deleteAll(db) - } - - var response: (info: ResponseInfoType, data: OpenGroupAPI.Message)? - - mockStorage - .readPublisher { db in - try OpenGroupAPI - .preparedSend( - db, - plaintext: "test".data(using: .utf8)!, - to: "testRoom", - on: "testServer", - whisperTo: nil, - whisperMods: false, - fileIds: nil, - using: dependencies - ) - } - .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } - .handleEvents(receiveOutput: { result in response = result }) - .mapError { error.setting(to: $0) } - .sinkAndStore(in: &disposables) - - expect(error?.localizedDescription) - .toEventually( - equal(OpenGroupAPIError.signingFailed.localizedDescription), - timeout: .milliseconds(100) - ) - - expect(response).to(beNil()) - } - - it("fails to sign if there is no ed key pair key") { - mockStorage.write { db in - _ = try Identity.filter(id: .ed25519PublicKey).deleteAll(db) - _ = try Identity.filter(id: .ed25519SecretKey).deleteAll(db) - } - - var response: (info: ResponseInfoType, data: OpenGroupAPI.Message)? - - mockStorage - .readPublisher { db in - try OpenGroupAPI - .preparedSend( - db, - plaintext: "test".data(using: .utf8)!, - to: "testRoom", - on: "testserver", - whisperTo: nil, - whisperMods: false, - fileIds: nil, - using: dependencies - ) - } - .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } - .handleEvents(receiveOutput: { result in response = result }) - .mapError { error.setting(to: $0) } - .sinkAndStore(in: &disposables) - - expect(error?.localizedDescription) - .toEventually( - equal(OpenGroupAPIError.signingFailed.localizedDescription), - timeout: .milliseconds(100) - ) - - expect(response).to(beNil()) - } - - it("fails to sign if no signature is generated") { - mockSodium - .when { - $0.sogsSignature( - message: anyArray(), - secretKey: anyArray(), - blindedSecretKey: anyArray(), - blindedPublicKey: anyArray() - ) - } - .thenReturn(nil) - - var response: (info: ResponseInfoType, data: OpenGroupAPI.Message)? - - mockStorage - .readPublisher { db in - try OpenGroupAPI - .preparedSend( - db, - plaintext: "test".data(using: .utf8)!, - to: "testRoom", - on: "testserver", - whisperTo: nil, - whisperMods: false, - fileIds: nil, - using: dependencies - ) - } - .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } - .handleEvents(receiveOutput: { result in response = result }) - .mapError { error.setting(to: $0) } - .sinkAndStore(in: &disposables) - - expect(error?.localizedDescription) - .toEventually( - equal(OpenGroupAPIError.signingFailed.localizedDescription), - timeout: .milliseconds(100) - ) - - expect(response).to(beNil()) - } - } - } - - context("when getting an individual message") { - it("generates the request and handles the response correctly") { - class TestApi: TestOnionRequestAPI { - static let data: OpenGroupAPI.Message = OpenGroupAPI.Message( - id: 126, - sender: "testSender", - posted: 321, - edited: nil, - deleted: nil, - seqNo: 10, - whisper: false, - whisperMods: false, - whisperTo: nil, - base64EncodedData: nil, - base64EncodedSignature: nil, - reactions: nil - ) - - override class var mockResponse: Data? { return try! JSONEncoder().encode(data) } - } - dependencies = dependencies.with(onionApi: TestApi.self) - - var response: (info: ResponseInfoType, data: OpenGroupAPI.Message)? - - mockStorage - .readPublisher { db in - try OpenGroupAPI - .preparedMessage( - db, - id: 123, - in: "testRoom", - on: "testserver", - using: dependencies - ) - } - .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } - .handleEvents(receiveOutput: { result in response = result }) - .mapError { error.setting(to: $0) } - .sinkAndStore(in: &disposables) - - expect(response) - .toEventuallyNot( - beNil(), - timeout: .milliseconds(100) - ) - expect(error?.localizedDescription).to(beNil()) - - // Validate the response data - expect(response?.data).to(equal(TestApi.data)) - - // Validate request data - let requestData: TestOnionRequestAPI.RequestData? = (response?.info as? TestOnionRequestAPI.ResponseInfo)?.requestData - expect(requestData?.httpMethod).to(equal("GET")) - expect(requestData?.urlString).to(equal("testserver/room/testRoom/message/123")) - } - } - - context("when updating a message") { - beforeEach { - class TestApi: TestOnionRequestAPI { - override class var mockResponse: Data? { return Data() } - } - dependencies = dependencies.with(onionApi: TestApi.self) - - mockStorage.write { db in - _ = try Identity - .filter(id: .ed25519PublicKey) - .updateAll(db, Identity.Columns.data.set(to: Data())) - _ = try Identity - .filter(id: .ed25519SecretKey) - .updateAll(db, Identity.Columns.data.set(to: Data())) - } - } - - it("correctly sends the update") { - var response: (info: ResponseInfoType, data: NoResponse)? - - mockStorage - .readPublisher { db in - try OpenGroupAPI - .preparedMessageUpdate( - db, - id: 123, - plaintext: "test".data(using: .utf8)!, - fileIds: nil, - in: "testRoom", - on: "testserver", - using: dependencies - ) - } - .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } - .handleEvents(receiveOutput: { result in response = result }) - .mapError { error.setting(to: $0) } - .sinkAndStore(in: &disposables) - - expect(response) - .toEventuallyNot( - beNil(), - timeout: .milliseconds(100) - ) - expect(error?.localizedDescription).to(beNil()) - - // Validate signature headers - let requestData: TestOnionRequestAPI.RequestData? = (response?.0 as? TestOnionRequestAPI.ResponseInfo)?.requestData - expect(requestData?.httpMethod).to(equal("PUT")) - expect(requestData?.urlString).to(equal("testserver/room/testRoom/message/123")) - } - - context("when unblinded") { - beforeEach { - mockStorage.write { db in - _ = try Capability.deleteAll(db) - try Capability(openGroupServer: "testserver", variant: .sogs, isMissing: false).insert(db) - } - } - - it("signs the message correctly") { - var response: (info: ResponseInfoType, data: NoResponse)? - - mockStorage - .readPublisher { db in - try OpenGroupAPI - .preparedMessageUpdate( - db, - id: 123, - plaintext: "test".data(using: .utf8)!, - fileIds: nil, - in: "testRoom", - on: "testserver", - using: dependencies - ) - } - .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } - .handleEvents(receiveOutput: { result in response = result }) - .mapError { error.setting(to: $0) } - .sinkAndStore(in: &disposables) - - expect(response) - .toEventuallyNot( - beNil(), - timeout: .milliseconds(100) - ) - expect(error?.localizedDescription).to(beNil()) - - // Validate request body - let requestData: TestOnionRequestAPI.RequestData? = (response?.0 as? TestOnionRequestAPI.ResponseInfo)?.requestData - let requestBody: OpenGroupAPI.UpdateMessageRequest = try! JSONDecoder().decode(OpenGroupAPI.UpdateMessageRequest.self, from: requestData!.body!) - - expect(requestBody.data).to(equal("test".data(using: .utf8))) - expect(requestBody.signature).to(equal("TestStandardSignature".data(using: .utf8))) - } - - it("fails to sign if there is no open group") { - mockStorage.write { db in - _ = try OpenGroup.deleteAll(db) - } - - var response: (info: ResponseInfoType, data: NoResponse)? - - mockStorage - .readPublisher { db in - try OpenGroupAPI - .preparedMessageUpdate( - db, - id: 123, - plaintext: "test".data(using: .utf8)!, - fileIds: nil, - in: "testRoom", - on: "testserver", - using: dependencies - ) - } - .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } - .handleEvents(receiveOutput: { result in response = result }) - .mapError { error.setting(to: $0) } - .sinkAndStore(in: &disposables) - - expect(error?.localizedDescription) - .toEventually( - equal(OpenGroupAPIError.signingFailed.localizedDescription), - timeout: .milliseconds(100) - ) - - expect(response).to(beNil()) - } - - it("fails to sign if there is no user key pair") { - mockStorage.write { db in - _ = try Identity.filter(id: .x25519PublicKey).deleteAll(db) - _ = try Identity.filter(id: .x25519PrivateKey).deleteAll(db) - } - - var response: (info: ResponseInfoType, data: NoResponse)? - - mockStorage - .readPublisher { db in - try OpenGroupAPI - .preparedMessageUpdate( - db, - id: 123, - plaintext: "test".data(using: .utf8)!, - fileIds: nil, - in: "testRoom", - on: "testserver", - using: dependencies - ) - } - .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } - .handleEvents(receiveOutput: { result in response = result }) - .mapError { error.setting(to: $0) } - .sinkAndStore(in: &disposables) - - expect(error?.localizedDescription) - .toEventually( - equal(OpenGroupAPIError.signingFailed.localizedDescription), - timeout: .milliseconds(100) - ) - - expect(response).to(beNil()) - } - - it("fails to sign if no signature is generated") { - mockEd25519.reset() // The 'keyPair' value doesn't equate so have to explicitly reset - mockEd25519.when { try $0.sign(data: anyArray(), keyPair: any()) }.thenReturn(nil) - - var response: (info: ResponseInfoType, data: NoResponse)? - - mockStorage - .readPublisher { db in - try OpenGroupAPI - .preparedMessageUpdate( - db, - id: 123, - plaintext: "test".data(using: .utf8)!, - fileIds: nil, - in: "testRoom", - on: "testServer", - using: dependencies - ) - } - .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } - .handleEvents(receiveOutput: { result in response = result }) - .mapError { error.setting(to: $0) } - .sinkAndStore(in: &disposables) - - expect(error?.localizedDescription) - .toEventually( - equal(OpenGroupAPIError.signingFailed.localizedDescription), - timeout: .milliseconds(100) - ) - - expect(response).to(beNil()) - } - } - - context("when blinded") { - beforeEach { - mockStorage.write { db in - _ = try Capability.deleteAll(db) - try Capability(openGroupServer: "testserver", variant: .sogs, isMissing: false).insert(db) - try Capability(openGroupServer: "testserver", variant: .blind, isMissing: false).insert(db) - } - } - - it("signs the message correctly") { - var response: (info: ResponseInfoType, data: NoResponse)? - - mockStorage - .readPublisher { db in - try OpenGroupAPI - .preparedMessageUpdate( - db, - id: 123, - plaintext: "test".data(using: .utf8)!, - fileIds: nil, - in: "testRoom", - on: "testserver", - using: dependencies - ) - } - .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } - .handleEvents(receiveOutput: { result in response = result }) - .mapError { error.setting(to: $0) } - .sinkAndStore(in: &disposables) - - expect(response) - .toEventuallyNot( - beNil(), - timeout: .milliseconds(100) - ) - expect(error?.localizedDescription).to(beNil()) - - // Validate request body - let requestData: TestOnionRequestAPI.RequestData? = (response?.0 as? TestOnionRequestAPI.ResponseInfo)?.requestData - let requestBody: OpenGroupAPI.UpdateMessageRequest = try! JSONDecoder().decode(OpenGroupAPI.UpdateMessageRequest.self, from: requestData!.body!) - - expect(requestBody.data).to(equal("test".data(using: .utf8))) - expect(requestBody.signature).to(equal("TestSogsSignature".data(using: .utf8))) - } - - it("fails to sign if there is no open group") { - mockStorage.write { db in - _ = try OpenGroup.deleteAll(db) - } - - var response: (info: ResponseInfoType, data: NoResponse)? - - mockStorage - .readPublisher { db in - try OpenGroupAPI - .preparedMessageUpdate( - db, - id: 123, - plaintext: "test".data(using: .utf8)!, - fileIds: nil, - in: "testRoom", - on: "testserver", - using: dependencies - ) - } - .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } - .handleEvents(receiveOutput: { result in response = result }) - .mapError { error.setting(to: $0) } - .sinkAndStore(in: &disposables) - - expect(error?.localizedDescription) - .toEventually( - equal(OpenGroupAPIError.signingFailed.localizedDescription), - timeout: .milliseconds(100) - ) - - expect(response).to(beNil()) - } - - it("fails to sign if there is no ed key pair key") { - mockStorage.write { db in - _ = try Identity.filter(id: .ed25519PublicKey).deleteAll(db) - _ = try Identity.filter(id: .ed25519SecretKey).deleteAll(db) - } - - var response: (info: ResponseInfoType, data: NoResponse)? - - mockStorage - .readPublisher { db in - try OpenGroupAPI - .preparedMessageUpdate( - db, - id: 123, - plaintext: "test".data(using: .utf8)!, - fileIds: nil, - in: "testRoom", - on: "testserver", - using: dependencies - ) - } - .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } - .handleEvents(receiveOutput: { result in response = result }) - .mapError { error.setting(to: $0) } - .sinkAndStore(in: &disposables) - - expect(error?.localizedDescription) - .toEventually( - equal(OpenGroupAPIError.signingFailed.localizedDescription), - timeout: .milliseconds(100) - ) - - expect(response).to(beNil()) - } - - it("fails to sign if no signature is generated") { - mockSodium - .when { - $0.sogsSignature( - message: anyArray(), - secretKey: anyArray(), - blindedSecretKey: anyArray(), - blindedPublicKey: anyArray() - ) - } - .thenReturn(nil) - - var response: (info: ResponseInfoType, data: NoResponse)? - - mockStorage - .readPublisher { db in - try OpenGroupAPI - .preparedMessageUpdate( - db, - id: 123, - plaintext: "test".data(using: .utf8)!, - fileIds: nil, - in: "testRoom", - on: "testserver", - using: dependencies - ) - } - .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } - .handleEvents(receiveOutput: { result in response = result }) - .mapError { error.setting(to: $0) } - .sinkAndStore(in: &disposables) - - expect(error?.localizedDescription) - .toEventually( - equal(OpenGroupAPIError.signingFailed.localizedDescription), - timeout: .milliseconds(100) - ) - - expect(response).to(beNil()) - } - } - } - - context("when deleting a message") { - it("generates the request and handles the response correctly") { - class TestApi: TestOnionRequestAPI { - override class var mockResponse: Data? { return Data() } - } - dependencies = dependencies.with(onionApi: TestApi.self) - - var response: (info: ResponseInfoType, data: NoResponse)? - - mockStorage - .readPublisher { db in - try OpenGroupAPI - .preparedMessageDelete( - db, - id: 123, - in: "testRoom", - on: "testserver", - using: dependencies - ) - } - .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } - .handleEvents(receiveOutput: { result in response = result }) - .mapError { error.setting(to: $0) } - .sinkAndStore(in: &disposables) - - expect(response) - .toEventuallyNot( - beNil(), - timeout: .milliseconds(100) - ) - expect(error?.localizedDescription).to(beNil()) - - // Validate request data - let requestData: TestOnionRequestAPI.RequestData? = (response?.info as? TestOnionRequestAPI.ResponseInfo)?.requestData - expect(requestData?.httpMethod).to(equal("DELETE")) - expect(requestData?.urlString).to(equal("testserver/room/testRoom/message/123")) - } - } - - context("when deleting all messages for a user") { - var response: (info: ResponseInfoType, data: NoResponse)? - - beforeEach { - class TestApi: TestOnionRequestAPI { - override class var mockResponse: Data? { return Data() } - } - dependencies = dependencies.with(onionApi: TestApi.self) - } - - afterEach { - response = nil - } - - it("generates the request and handles the response correctly") { - mockStorage - .readPublisher { db in - try OpenGroupAPI - .preparedMessagesDeleteAll( - db, - sessionId: "testUserId", - in: "testRoom", - on: "testserver", - using: dependencies - ) - } - .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } - .handleEvents(receiveOutput: { result in response = result }) - .mapError { error.setting(to: $0) } - .sinkAndStore(in: &disposables) - - expect(response) - .toEventuallyNot( - beNil(), - timeout: .milliseconds(100) - ) - expect(error?.localizedDescription).to(beNil()) - - // Validate request data - let requestData: TestOnionRequestAPI.RequestData? = (response?.info as? TestOnionRequestAPI.ResponseInfo)?.requestData - expect(requestData?.httpMethod).to(equal("DELETE")) - expect(requestData?.urlString).to(equal("testserver/room/testRoom/all/testUserId")) - } - } - - // MARK: - Pinning - - context("when pinning a message") { - it("generates the request and handles the response correctly") { - class TestApi: TestOnionRequestAPI { - override class var mockResponse: Data? { return Data() } - } - dependencies = dependencies.with(onionApi: TestApi.self) - - var response: (info: ResponseInfoType, data: NoResponse)? - - mockStorage - .readPublisher { db in - try OpenGroupAPI - .preparedPinMessage( - db, - id: 123, - in: "testRoom", - on: "testserver", - using: dependencies - ) - } - .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } - .handleEvents(receiveOutput: { result in response = result }) - .mapError { error.setting(to: $0) } - .sinkAndStore(in: &disposables) - - expect(response) - .toEventuallyNot( - beNil(), - timeout: .milliseconds(100) - ) - expect(error?.localizedDescription).to(beNil()) - - // Validate request data - let requestData: TestOnionRequestAPI.RequestData? = (response?.info as? TestOnionRequestAPI.ResponseInfo)?.requestData - expect(requestData?.httpMethod).to(equal("POST")) - expect(requestData?.urlString).to(equal("testserver/room/testRoom/pin/123")) - } - } - - context("when unpinning a message") { - it("generates the request and handles the response correctly") { - class TestApi: TestOnionRequestAPI { - override class var mockResponse: Data? { return Data() } - } - dependencies = dependencies.with(onionApi: TestApi.self) - - var response: (info: ResponseInfoType, data: NoResponse)? - - mockStorage - .readPublisher { db in - try OpenGroupAPI - .preparedUnpinMessage( - db, - id: 123, - in: "testRoom", - on: "testserver", - using: dependencies - ) - } - .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } - .handleEvents(receiveOutput: { result in response = result }) - .mapError { error.setting(to: $0) } - .sinkAndStore(in: &disposables) - - expect(response) - .toEventuallyNot( - beNil(), - timeout: .milliseconds(100) - ) - expect(error?.localizedDescription).to(beNil()) - - // Validate request data - let requestData: TestOnionRequestAPI.RequestData? = (response?.info as? TestOnionRequestAPI.ResponseInfo)?.requestData - expect(requestData?.httpMethod).to(equal("POST")) - expect(requestData?.urlString).to(equal("testserver/room/testRoom/unpin/123")) - } - } - - context("when unpinning all messages") { - it("generates the request and handles the response correctly") { - class TestApi: TestOnionRequestAPI { - override class var mockResponse: Data? { return Data() } - } - dependencies = dependencies.with(onionApi: TestApi.self) - - var response: (info: ResponseInfoType, data: NoResponse)? - - mockStorage - .readPublisher { db in - try OpenGroupAPI - .preparedUnpinAll( - db, - in: "testRoom", - on: "testserver", - using: dependencies - ) - } - .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } - .handleEvents(receiveOutput: { result in response = result }) - .mapError { error.setting(to: $0) } - .sinkAndStore(in: &disposables) - - expect(response) - .toEventuallyNot( - beNil(), - timeout: .milliseconds(100) - ) - expect(error?.localizedDescription).to(beNil()) - - // Validate request data - let requestData: TestOnionRequestAPI.RequestData? = (response?.info as? TestOnionRequestAPI.ResponseInfo)?.requestData - expect(requestData?.httpMethod).to(equal("POST")) - expect(requestData?.urlString).to(equal("testserver/room/testRoom/unpin/all")) - } - } - - // MARK: - Files - - context("when uploading files") { - it("generates the request and handles the response correctly") { - class TestApi: TestOnionRequestAPI { - override class var mockResponse: Data? { - return try! JSONEncoder().encode(FileUploadResponse(id: "1")) - } - } - dependencies = dependencies.with(onionApi: TestApi.self) - - mockStorage - .readPublisher { db in - try OpenGroupAPI - .preparedUploadFile( - db, - bytes: [], - to: "testRoom", - on: "testserver", - using: dependencies - ) - } - .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } - .handleEvents(receiveOutput: { result in response = result }) - .mapError { error.setting(to: $0) } - .sinkAndStore(in: &disposables) - - expect(response) - .toEventuallyNot( - beNil(), - timeout: .milliseconds(100) - ) - expect(error?.localizedDescription).to(beNil()) - - // Validate signature headers - let requestData: TestOnionRequestAPI.RequestData? = (response?.0 as? TestOnionRequestAPI.ResponseInfo)?.requestData - expect(requestData?.httpMethod).to(equal("POST")) - expect(requestData?.urlString).to(equal("testserver/room/testRoom/file")) - } - - it("doesn't add a fileName to the content-disposition header when not provided") { - class TestApi: TestOnionRequestAPI { - override class var mockResponse: Data? { - return try! JSONEncoder().encode(FileUploadResponse(id: "1")) - } - } - dependencies = dependencies.with(onionApi: TestApi.self) - - mockStorage - .readPublisher { db in - try OpenGroupAPI - .preparedUploadFile( - db, - bytes: [], - to: "testRoom", - on: "testserver", - using: dependencies - ) - } - .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } - .handleEvents(receiveOutput: { result in response = result }) - .mapError { error.setting(to: $0) } - .sinkAndStore(in: &disposables) - - expect(response) - .toEventuallyNot( - beNil(), - timeout: .milliseconds(100) - ) - expect(error?.localizedDescription).to(beNil()) - - // Validate signature headers - let requestData: TestOnionRequestAPI.RequestData? = (response?.0 as? TestOnionRequestAPI.ResponseInfo)?.requestData - expect(requestData?.headers[HTTPHeader.contentDisposition]) - .toNot(contain("filename")) - } - - it("adds the fileName to the content-disposition header when provided") { - class TestApi: TestOnionRequestAPI { - override class var mockResponse: Data? { - return try! JSONEncoder().encode(FileUploadResponse(id: "1")) - } - } - dependencies = dependencies.with(onionApi: TestApi.self) - - mockStorage - .readPublisher { db in - try OpenGroupAPI - .preparedUploadFile( - db, - bytes: [], - fileName: "TestFileName", - to: "testRoom", - on: "testserver", - using: dependencies - ) - } - .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } - .handleEvents(receiveOutput: { result in response = result }) - .mapError { error.setting(to: $0) } - .sinkAndStore(in: &disposables) - - expect(response) - .toEventuallyNot( - beNil(), - timeout: .milliseconds(100) - ) - expect(error?.localizedDescription).to(beNil()) - - // Validate signature headers - let requestData: TestOnionRequestAPI.RequestData? = (response?.0 as? TestOnionRequestAPI.ResponseInfo)?.requestData - expect(requestData?.headers[HTTPHeader.contentDisposition]).to(contain("TestFileName")) - } - } - - context("when downloading files") { - it("generates the request and handles the response correctly") { - class TestApi: TestOnionRequestAPI { - override class var mockResponse: Data? { - return Data() - } - } - dependencies = dependencies.with(onionApi: TestApi.self) - - mockStorage - .readPublisher { db in - try OpenGroupAPI - .preparedDownloadFile( - db, - fileId: "1", - from: "testRoom", - on: "testserver", - using: dependencies - ) - } - .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } - .handleEvents(receiveOutput: { result in response = result }) - .mapError { error.setting(to: $0) } - .sinkAndStore(in: &disposables) - - expect(response) - .toEventuallyNot( - beNil(), - timeout: .milliseconds(100) - ) - expect(error?.localizedDescription).to(beNil()) - - // Validate signature headers - let requestData: TestOnionRequestAPI.RequestData? = (response?.0 as? TestOnionRequestAPI.ResponseInfo)?.requestData - expect(requestData?.httpMethod).to(equal("GET")) - expect(requestData?.urlString).to(equal("testserver/room/testRoom/file/1")) - } - } - - // MARK: - Inbox/Outbox (Message Requests) - - context("when sending message requests") { - var messageData: OpenGroupAPI.SendDirectMessageResponse! - - beforeEach { - class TestApi: TestOnionRequestAPI { - static let data: OpenGroupAPI.SendDirectMessageResponse = OpenGroupAPI.SendDirectMessageResponse( - id: 126, - sender: "testSender", - recipient: "testRecipient", - posted: 321, - expires: 456 - ) - - override class var mockResponse: Data? { return try! JSONEncoder().encode(data) } - } - messageData = TestApi.data - dependencies = dependencies.with(onionApi: TestApi.self) - } - - afterEach { - messageData = nil - } - - it("correctly sends the message request") { - var response: (info: ResponseInfoType, data: OpenGroupAPI.SendDirectMessageResponse)? - - mockStorage - .readPublisher { db in - try OpenGroupAPI - .preparedSend( - db, - ciphertext: "test".data(using: .utf8)!, - toInboxFor: "testUserId", - on: "testserver", - using: dependencies - ) - } - .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } - .handleEvents(receiveOutput: { result in response = result }) - .mapError { error.setting(to: $0) } - .sinkAndStore(in: &disposables) - - expect(response) - .toEventuallyNot( - beNil(), - timeout: .milliseconds(100) - ) - expect(error?.localizedDescription).to(beNil()) - - // Validate the response data - expect(response?.data).to(equal(messageData)) - - // Validate signature headers - let requestData: TestOnionRequestAPI.RequestData? = (response?.0 as? TestOnionRequestAPI.ResponseInfo)?.requestData - expect(requestData?.httpMethod).to(equal("POST")) - expect(requestData?.urlString).to(equal("testserver/inbox/testUserId")) - } - } - - // MARK: - Users - - context("when banning a user") { - var response: (info: ResponseInfoType, data: NoResponse)? - - beforeEach { - class TestApi: TestOnionRequestAPI { - override class var mockResponse: Data? { return Data() } - } - dependencies = dependencies.with(onionApi: TestApi.self) - } - - afterEach { - response = nil - } - - it("generates the request and handles the response correctly") { - mockStorage - .readPublisher { db in - try OpenGroupAPI - .preparedUserBan( - db, - sessionId: "testUserId", - for: nil, - from: nil, - on: "testserver", - using: dependencies - ) - } - .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } - .handleEvents(receiveOutput: { result in response = result }) - .mapError { error.setting(to: $0) } - .sinkAndStore(in: &disposables) - - expect(response) - .toEventuallyNot( - beNil(), - timeout: .milliseconds(100) - ) - expect(error?.localizedDescription).to(beNil()) - - // Validate request data - let requestData: TestOnionRequestAPI.RequestData? = (response?.info as? TestOnionRequestAPI.ResponseInfo)?.requestData - expect(requestData?.httpMethod).to(equal("POST")) - expect(requestData?.urlString).to(equal("testserver/user/testUserId/ban")) - } - - it("does a global ban if no room tokens are provided") { - mockStorage - .readPublisher { db in - try OpenGroupAPI - .preparedUserBan( - db, - sessionId: "testUserId", - for: nil, - from: nil, - on: "testserver", - using: dependencies - ) - } - .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } - .handleEvents(receiveOutput: { result in response = result }) - .mapError { error.setting(to: $0) } - .sinkAndStore(in: &disposables) - - expect(response) - .toEventuallyNot( - beNil(), - timeout: .milliseconds(100) - ) - expect(error?.localizedDescription).to(beNil()) - - // Validate request data - let requestData: TestOnionRequestAPI.RequestData? = (response?.info as? TestOnionRequestAPI.ResponseInfo)?.requestData - let requestBody: OpenGroupAPI.UserBanRequest = try! JSONDecoder().decode(OpenGroupAPI.UserBanRequest.self, from: requestData!.body!) - - expect(requestBody.global).to(beTrue()) - expect(requestBody.rooms).to(beNil()) - } - - it("does room specific bans if room tokens are provided") { - mockStorage - .readPublisher { db in - try OpenGroupAPI - .preparedUserBan( - db, - sessionId: "testUserId", - for: nil, - from: ["testRoom"], - on: "testserver", - using: dependencies - ) - } - .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } - .handleEvents(receiveOutput: { result in response = result }) - .mapError { error.setting(to: $0) } - .sinkAndStore(in: &disposables) - - expect(response) - .toEventuallyNot( - beNil(), - timeout: .milliseconds(100) - ) - expect(error?.localizedDescription).to(beNil()) - - // Validate request data - let requestData: TestOnionRequestAPI.RequestData? = (response?.info as? TestOnionRequestAPI.ResponseInfo)?.requestData - let requestBody: OpenGroupAPI.UserBanRequest = try! JSONDecoder().decode(OpenGroupAPI.UserBanRequest.self, from: requestData!.body!) - - expect(requestBody.global).to(beNil()) - expect(requestBody.rooms).to(equal(["testRoom"])) - } - } - - context("when unbanning a user") { - var response: (info: ResponseInfoType, data: NoResponse)? - - beforeEach { - class TestApi: TestOnionRequestAPI { - override class var mockResponse: Data? { return Data() } - } - dependencies = dependencies.with(onionApi: TestApi.self) - } - - afterEach { - response = nil - } - - it("generates the request and handles the response correctly") { - mockStorage - .readPublisher { db in - try OpenGroupAPI - .preparedUserUnban( - db, - sessionId: "testUserId", - from: nil, - on: "testserver", - using: dependencies - ) - } - .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } - .handleEvents(receiveOutput: { result in response = result }) - .mapError { error.setting(to: $0) } - .sinkAndStore(in: &disposables) - - expect(response) - .toEventuallyNot( - beNil(), - timeout: .milliseconds(100) - ) - expect(error?.localizedDescription).to(beNil()) - - // Validate request data - let requestData: TestOnionRequestAPI.RequestData? = (response?.info as? TestOnionRequestAPI.ResponseInfo)?.requestData - expect(requestData?.httpMethod).to(equal("POST")) - expect(requestData?.urlString).to(equal("testserver/user/testUserId/unban")) - } - - it("does a global ban if no room tokens are provided") { - mockStorage - .readPublisher { db in - try OpenGroupAPI - .preparedUserUnban( - db, - sessionId: "testUserId", - from: nil, - on: "testserver", - using: dependencies - ) - } - .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } - .handleEvents(receiveOutput: { result in response = result }) - .mapError { error.setting(to: $0) } - .sinkAndStore(in: &disposables) - - expect(response) - .toEventuallyNot( - beNil(), - timeout: .milliseconds(100) - ) - expect(error?.localizedDescription).to(beNil()) - - // Validate request data - let requestData: TestOnionRequestAPI.RequestData? = (response?.info as? TestOnionRequestAPI.ResponseInfo)?.requestData - let requestBody: OpenGroupAPI.UserUnbanRequest = try! JSONDecoder().decode(OpenGroupAPI.UserUnbanRequest.self, from: requestData!.body!) - - expect(requestBody.global).to(beTrue()) - expect(requestBody.rooms).to(beNil()) - } - - it("does room specific bans if room tokens are provided") { - mockStorage - .readPublisher { db in - try OpenGroupAPI - .preparedUserUnban( - db, - sessionId: "testUserId", - from: ["testRoom"], - on: "testserver", - using: dependencies - ) - } - .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } - .handleEvents(receiveOutput: { result in response = result }) - .mapError { error.setting(to: $0) } - .sinkAndStore(in: &disposables) - - expect(response) - .toEventuallyNot( - beNil(), - timeout: .milliseconds(100) - ) - expect(error?.localizedDescription).to(beNil()) - - // Validate request data - let requestData: TestOnionRequestAPI.RequestData? = (response?.info as? TestOnionRequestAPI.ResponseInfo)?.requestData - let requestBody: OpenGroupAPI.UserUnbanRequest = try! JSONDecoder().decode(OpenGroupAPI.UserUnbanRequest.self, from: requestData!.body!) - - expect(requestBody.global).to(beNil()) - expect(requestBody.rooms).to(equal(["testRoom"])) - } - } - - context("when updating a users permissions") { - var response: (info: ResponseInfoType, data: NoResponse)? - - beforeEach { - class TestApi: TestOnionRequestAPI { - override class var mockResponse: Data? { return Data() } - } - dependencies = dependencies.with(onionApi: TestApi.self) - } - - afterEach { - response = nil - } - - it("generates the request and handles the response correctly") { - mockStorage - .readPublisher { db in - try OpenGroupAPI - .preparedUserModeratorUpdate( - db, - sessionId: "testUserId", - moderator: true, - admin: nil, - visible: true, - for: nil, - on: "testserver", - using: dependencies - ) - } - .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } - .handleEvents(receiveOutput: { result in response = result }) - .mapError { error.setting(to: $0) } - .sinkAndStore(in: &disposables) - - expect(response) - .toEventuallyNot( - beNil(), - timeout: .milliseconds(100) - ) - expect(error?.localizedDescription).to(beNil()) - - // Validate request data - let requestData: TestOnionRequestAPI.RequestData? = (response?.info as? TestOnionRequestAPI.ResponseInfo)?.requestData - expect(requestData?.httpMethod).to(equal("POST")) - expect(requestData?.urlString).to(equal("testserver/user/testUserId/moderator")) - } - - it("does a global update if no room tokens are provided") { - mockStorage - .readPublisher { db in - try OpenGroupAPI - .preparedUserModeratorUpdate( - db, - sessionId: "testUserId", - moderator: true, - admin: nil, - visible: true, - for: nil, - on: "testserver", - using: dependencies - ) - } - .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } - .handleEvents(receiveOutput: { result in response = result }) - .mapError { error.setting(to: $0) } - .sinkAndStore(in: &disposables) - - expect(response) - .toEventuallyNot( - beNil(), - timeout: .milliseconds(100) - ) - expect(error?.localizedDescription).to(beNil()) - - // Validate request data - let requestData: TestOnionRequestAPI.RequestData? = (response?.info as? TestOnionRequestAPI.ResponseInfo)?.requestData - let requestBody: OpenGroupAPI.UserModeratorRequest = try! JSONDecoder().decode(OpenGroupAPI.UserModeratorRequest.self, from: requestData!.body!) - - expect(requestBody.global).to(beTrue()) - expect(requestBody.rooms).to(beNil()) - } - - it("does room specific updates if room tokens are provided") { - mockStorage - .readPublisher { db in - try OpenGroupAPI - .preparedUserModeratorUpdate( - db, - sessionId: "testUserId", - moderator: true, - admin: nil, - visible: true, - for: ["testRoom"], - on: "testserver", - using: dependencies - ) - } - .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } - .handleEvents(receiveOutput: { result in response = result }) - .mapError { error.setting(to: $0) } - .sinkAndStore(in: &disposables) - - expect(response) - .toEventuallyNot( - beNil(), - timeout: .milliseconds(100) - ) - expect(error?.localizedDescription).to(beNil()) - - // Validate request data - let requestData: TestOnionRequestAPI.RequestData? = (response?.info as? TestOnionRequestAPI.ResponseInfo)?.requestData - let requestBody: OpenGroupAPI.UserModeratorRequest = try! JSONDecoder().decode(OpenGroupAPI.UserModeratorRequest.self, from: requestData!.body!) - - expect(requestBody.global).to(beNil()) - expect(requestBody.rooms).to(equal(["testRoom"])) - } - - it("fails if neither moderator or admin are set") { - mockStorage - .readPublisher { db in - try OpenGroupAPI - .preparedUserModeratorUpdate( - db, - sessionId: "testUserId", - moderator: nil, - admin: nil, - visible: true, - for: nil, - on: "testserver", - using: dependencies - ) - } - .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } - .handleEvents(receiveOutput: { result in response = result }) - .mapError { error.setting(to: $0) } - .sinkAndStore(in: &disposables) - - expect(error?.localizedDescription) - .toEventually( - equal(HTTPError.generic.localizedDescription), - timeout: .milliseconds(100) - ) - + + expect(error).to(matchError(OpenGroupAPIError.invalidPreparedData)) expect(response).to(beNil()) } } - - context("when banning and deleting all messages for a user") { - var response: (info: ResponseInfoType, data: OpenGroupAPI.BatchResponse)? - - beforeEach { - class TestApi: TestOnionRequestAPI { - override class var mockResponse: Data? { - let responses: [Data] = [ - try! JSONEncoder().encode( - HTTP.BatchSubResponse( - code: 200, - headers: [:], - body: nil, - failedToParseBody: false - ) - ), - try! JSONEncoder().encode( - HTTP.BatchSubResponse( - code: 200, - headers: [:], - body: nil, - failedToParseBody: false - ) - ) - ] - - return "[\(responses.map { String(data: $0, encoding: .utf8)! }.joined(separator: ","))]".data(using: .utf8) - } - } - dependencies = dependencies.with(onionApi: TestApi.self) - } - - afterEach { - response = nil - } - - it("generates the request and handles the response correctly") { - mockStorage - .readPublisher { db in - try OpenGroupAPI.preparedUserBanAndDeleteAllMessages( - db, - sessionId: "testUserId", - in: "testRoom", - on: "testserver", - using: dependencies - ) - } - .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } - .handleEvents(receiveOutput: { result in response = result }) - .mapError { error.setting(to: $0) } - .sinkAndStore(in: &disposables) - - expect(response) - .toEventuallyNot( - beNil(), - timeout: .milliseconds(100) - ) - expect(error?.localizedDescription).to(beNil()) - - // Validate request data - let requestData: TestOnionRequestAPI.RequestData? = (response?.info as? TestOnionRequestAPI.ResponseInfo)?.requestData - expect(requestData?.httpMethod).to(equal("POST")) - expect(requestData?.urlString).to(equal("testserver/sequence")) - } - - it("bans the user from the specified room rather than globally") { - mockStorage - .readPublisher { db in - try OpenGroupAPI.preparedUserBanAndDeleteAllMessages( - db, - sessionId: "testUserId", - in: "testRoom", - on: "testserver", - using: dependencies - ) - } - .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } - .handleEvents(receiveOutput: { result in response = result }) - .mapError { error.setting(to: $0) } - .sinkAndStore(in: &disposables) - - expect(response) - .toEventuallyNot( - beNil(), - timeout: .milliseconds(100) - ) - expect(error?.localizedDescription).to(beNil()) - - // Validate request data - let requestData: TestOnionRequestAPI.RequestData? = (response?.info as? TestOnionRequestAPI.ResponseInfo)?.requestData - let jsonObject: Any = try! JSONSerialization.jsonObject( - with: requestData!.body!, - options: [.fragmentsAllowed] - ) - let firstJsonObject: Any = ((jsonObject as! [Any]).first as! [String: Any])["json"]! - let firstJsonData: Data = try! JSONSerialization.data(withJSONObject: firstJsonObject) - let firstRequestBody: OpenGroupAPI.UserBanRequest = try! JSONDecoder() - .decode(OpenGroupAPI.UserBanRequest.self, from: firstJsonData) - - expect(firstRequestBody.global).to(beNil()) - expect(firstRequestBody.rooms).to(equal(["testRoom"])) - } - } - - // MARK: - Authentication - - context("when signing") { - beforeEach { - class TestApi: TestOnionRequestAPI { - override class var mockResponse: Data? { - return try! JSONEncoder().encode([OpenGroupAPI.Room]()) - } - } - - dependencies = dependencies.with(onionApi: TestApi.self) - } - - it("fails when there is no userEdKeyPair") { - mockStorage.write { db in - _ = try Identity.filter(id: .ed25519PublicKey).deleteAll(db) - _ = try Identity.filter(id: .ed25519SecretKey).deleteAll(db) - } - - mockStorage - .readPublisher { db in - try OpenGroupAPI - .preparedRooms( - db, - server: "testserver", - using: dependencies - ) - } - .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } - .handleEvents(receiveOutput: { result in response = result }) - .mapError { error.setting(to: $0) } - .sinkAndStore(in: &disposables) - - expect(error?.localizedDescription) - .toEventually( - equal(OpenGroupAPIError.signingFailed.localizedDescription), - timeout: .milliseconds(100) - ) - - expect(response).to(beNil()) - } - - it("fails when there is no serverPublicKey") { - mockStorage.write { db in - _ = try OpenGroup.deleteAll(db) - } - - mockStorage - .readPublisher { db in - try OpenGroupAPI - .preparedRooms( - db, - server: "testserver", - using: dependencies - ) - } - .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } - .handleEvents(receiveOutput: { result in response = result }) - .mapError { error.setting(to: $0) } - .sinkAndStore(in: &disposables) - - expect(error?.localizedDescription) - .toEventually( - equal(OpenGroupAPIError.noPublicKey.localizedDescription), - timeout: .milliseconds(100) - ) - - expect(response).to(beNil()) - } - - it("fails when the serverPublicKey is not a hex string") { - mockStorage.write { db in - _ = try OpenGroup.updateAll(db, OpenGroup.Columns.publicKey.set(to: "TestString!!!")) - } - - mockStorage - .readPublisher { db in - try OpenGroupAPI - .preparedRooms( - db, - server: "testserver", - using: dependencies - ) - } - .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } - .handleEvents(receiveOutput: { result in response = result }) - .mapError { error.setting(to: $0) } - .sinkAndStore(in: &disposables) - - expect(error?.localizedDescription) - .toEventually( - equal(OpenGroupAPIError.signingFailed.localizedDescription), - timeout: .milliseconds(100) - ) - - expect(response).to(beNil()) - } - - context("when unblinded") { - beforeEach { - mockStorage.write { db in - _ = try Capability.deleteAll(db) - try Capability(openGroupServer: "testserver", variant: .sogs, isMissing: false).insert(db) - } - } - - it("signs correctly") { - mockStorage - .readPublisher { db in - try OpenGroupAPI - .preparedRooms( - db, - server: "testserver", - using: dependencies - ) - } - .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } - .handleEvents(receiveOutput: { result in response = result }) - .mapError { error.setting(to: $0) } - .sinkAndStore(in: &disposables) - - expect(response) - .toEventuallyNot( - beNil(), - timeout: .milliseconds(100) - ) - expect(error?.localizedDescription).to(beNil()) - - // Validate signature headers - let requestData: TestOnionRequestAPI.RequestData? = (response?.0 as? TestOnionRequestAPI.ResponseInfo)?.requestData - expect(requestData?.urlString).to(equal("testserver/rooms")) - expect(requestData?.httpMethod).to(equal("GET")) - expect(requestData?.publicKey).to(equal("88672ccb97f40bb57238989226cf429b575ba355443f47bc76c5ab144a96c65b")) - expect(requestData?.headers).to(haveCount(4)) - expect(requestData?.headers[HTTPHeader.sogsPubKey]) - .to(equal("00bac6e71efd7dfa4a83c98ed24f254ab2c267f9ccdb172a5280a0444ad24e89cc")) - expect(requestData?.headers[HTTPHeader.sogsTimestamp]).to(equal("1234567890")) - expect(requestData?.headers[HTTPHeader.sogsNonce]).to(equal("pK6YRtQApl4NhECGizF0Cg==")) - expect(requestData?.headers[HTTPHeader.sogsSignature]).to(equal("TestSignature".bytes.toBase64())) - } - - it("fails when the signature is not generated") { - mockSign.when { $0.signature(message: anyArray(), secretKey: anyArray()) }.thenReturn(nil) - - mockStorage - .readPublisher { db in - try OpenGroupAPI - .preparedRooms( - db, - server: "testserver", - using: dependencies - ) - } - .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } - .handleEvents(receiveOutput: { result in response = result }) - .mapError { error.setting(to: $0) } - .sinkAndStore(in: &disposables) - - expect(error?.localizedDescription) - .toEventually( - equal(OpenGroupAPIError.signingFailed.localizedDescription), - timeout: .milliseconds(100) - ) - - expect(response).to(beNil()) - } - } - - context("when blinded") { - beforeEach { - mockStorage.write { db in - _ = try Capability.deleteAll(db) - try Capability(openGroupServer: "testserver", variant: .sogs, isMissing: false).insert(db) - try Capability(openGroupServer: "testserver", variant: .blind, isMissing: false).insert(db) - } - } - - it("signs correctly") { - mockStorage - .readPublisher { db in - try OpenGroupAPI - .preparedRooms( - db, - server: "testserver", - using: dependencies - ) - } - .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } - .handleEvents(receiveOutput: { result in response = result }) - .mapError { error.setting(to: $0) } - .sinkAndStore(in: &disposables) - - expect(response) - .toEventuallyNot( - beNil(), - timeout: .milliseconds(100) - ) - expect(error?.localizedDescription).to(beNil()) - - // Validate signature headers - let requestData: TestOnionRequestAPI.RequestData? = (response?.0 as? TestOnionRequestAPI.ResponseInfo)?.requestData - expect(requestData?.urlString).to(equal("testserver/rooms")) - expect(requestData?.httpMethod).to(equal("GET")) - expect(requestData?.publicKey).to(equal("88672ccb97f40bb57238989226cf429b575ba355443f47bc76c5ab144a96c65b")) - expect(requestData?.headers).to(haveCount(4)) - expect(requestData?.headers[HTTPHeader.sogsPubKey]).to(equal("1588672ccb97f40bb57238989226cf429b575ba355443f47bc76c5ab144a96c65b")) - expect(requestData?.headers[HTTPHeader.sogsTimestamp]).to(equal("1234567890")) - expect(requestData?.headers[HTTPHeader.sogsNonce]).to(equal("pK6YRtQApl4NhECGizF0Cg==")) - expect(requestData?.headers[HTTPHeader.sogsSignature]).to(equal("TestSogsSignature".bytes.toBase64())) - } - - it("fails when the blindedKeyPair is not generated") { - mockSodium - .when { $0.blindedKeyPair(serverPublicKey: any(), edKeyPair: any(), genericHash: mockGenericHash) } - .thenReturn(nil) - - mockStorage - .readPublisher { db in - try OpenGroupAPI - .preparedRooms( - db, - server: "testserver", - using: dependencies - ) - } - .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } - .handleEvents(receiveOutput: { result in response = result }) - .mapError { error.setting(to: $0) } - .sinkAndStore(in: &disposables) - - expect(error?.localizedDescription) - .toEventually( - equal(OpenGroupAPIError.signingFailed.localizedDescription), - timeout: .milliseconds(100) - ) - - expect(response).to(beNil()) - } - - it("fails when the sogsSignature is not generated") { - mockSodium - .when { $0.blindedKeyPair(serverPublicKey: any(), edKeyPair: any(), genericHash: mockGenericHash) } - .thenReturn(nil) - - mockStorage - .readPublisher { db in - try OpenGroupAPI - .preparedRooms( - db, - server: "testserver", - using: dependencies - ) - } - .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } - .handleEvents(receiveOutput: { result in response = result }) - .mapError { error.setting(to: $0) } - .sinkAndStore(in: &disposables) - - expect(error?.localizedDescription) - .toEventually( - equal(OpenGroupAPIError.signingFailed.localizedDescription), - timeout: .milliseconds(100) - ) - - expect(response).to(beNil()) - } - } - } } } } + +// MARK: - Mock Batch Responses + +extension OpenGroupAPI.BatchResponse { + // MARK: - Valid Responses + + static let mockCapabilitiesAndRoomResponse: AnyPublisher<(ResponseInfoType, Data?), Error> = MockNetwork.batchResponseData( + with: [ + (OpenGroupAPI.Endpoint.capabilities, OpenGroupAPI.Capabilities.mockBatchSubResponse()), + (OpenGroupAPI.Endpoint.room("testRoom"), OpenGroupAPI.Room.mockBatchSubResponse()) + ] + ) + + static let mockCapabilitiesAndRoomsResponse: AnyPublisher<(ResponseInfoType, Data?), Error> = MockNetwork.batchResponseData( + with: [ + (OpenGroupAPI.Endpoint.capabilities, OpenGroupAPI.Capabilities.mockBatchSubResponse()), + (OpenGroupAPI.Endpoint.rooms, [OpenGroupAPI.Room].mockBatchSubResponse()) + ] + ) + + // MARK: - Invalid Responses + + static let mockCapabilitiesAndBanResponse: AnyPublisher<(ResponseInfoType, Data?), Error> = MockNetwork.batchResponseData( + with: [ + (OpenGroupAPI.Endpoint.capabilities, OpenGroupAPI.Capabilities.mockBatchSubResponse()), + (OpenGroupAPI.Endpoint.userBan(""), NoResponse.mockBatchSubResponse()) + ] + ) + + static let mockBanAndRoomResponse: AnyPublisher<(ResponseInfoType, Data?), Error> = MockNetwork.batchResponseData( + with: [ + (OpenGroupAPI.Endpoint.userBan(""), NoResponse.mockBatchSubResponse()), + (OpenGroupAPI.Endpoint.room("testRoom"), OpenGroupAPI.Room.mockBatchSubResponse()) + ] + ) + + static let mockBanAndRoomsResponse: AnyPublisher<(ResponseInfoType, Data?), Error> = MockNetwork.batchResponseData( + with: [ + (OpenGroupAPI.Endpoint.userBan(""), NoResponse.mockBatchSubResponse()), + (OpenGroupAPI.Endpoint.rooms, [OpenGroupAPI.Room].mockBatchSubResponse()) + ] + ) +} diff --git a/SessionMessagingKitTests/Open Groups/OpenGroupManagerSpec.swift b/SessionMessagingKitTests/Open Groups/OpenGroupManagerSpec.swift index 1a0ab6ace..46ada277e 100644 --- a/SessionMessagingKitTests/Open Groups/OpenGroupManagerSpec.swift +++ b/SessionMessagingKitTests/Open Groups/OpenGroupManagerSpec.swift @@ -12,77 +12,18 @@ import Nimble @testable import SessionMessagingKit -// MARK: - OpenGroupManagerSpec - class OpenGroupManagerSpec: QuickSpec { - class TestCapabilitiesAndRoomApi: TestOnionRequestAPI { - static let capabilitiesData: OpenGroupAPI.Capabilities = OpenGroupAPI.Capabilities(capabilities: [.sogs], missing: nil) - static let roomData: OpenGroupAPI.Room = OpenGroupAPI.Room( - token: "test", - name: "test", - roomDescription: nil, - infoUpdates: 10, - messageSequence: 0, - created: 0, - activeUsers: 0, - activeUsersCutoff: 0, - imageId: nil, - pinnedMessages: nil, - admin: false, - globalAdmin: false, - admins: [], - hiddenAdmins: nil, - moderator: false, - globalModerator: false, - moderators: [], - hiddenModerators: nil, - read: false, - defaultRead: nil, - defaultAccessible: nil, - write: false, - defaultWrite: nil, - upload: false, - defaultUpload: nil - ) - - override class var mockResponse: Data? { - let responses: [Data] = [ - try! JSONEncoder().encode( - HTTP.BatchSubResponse( - code: 200, - headers: [:], - body: capabilitiesData, - failedToParseBody: false - ) - ), - try! JSONEncoder().encode( - HTTP.BatchSubResponse( - code: 200, - headers: [:], - body: roomData, - failedToParseBody: false - ) - ) - ] - - return "[\(responses.map { String(data: $0, encoding: .utf8)! }.joined(separator: ","))]".data(using: .utf8) - } - } - // MARK: - Spec override func spec() { - var mockOGMCache: MockOGMCache! - var mockGeneralCache: MockGeneralCache! var mockStorage: Storage! - var mockSodium: MockSodium! - var mockAeadXChaCha20Poly1305Ietf: MockAeadXChaCha20Poly1305Ietf! - var mockGenericHash: MockGenericHash! - var mockSign: MockSign! - var mockNonce16Generator: MockNonce16Generator! - var mockNonce24Generator: MockNonce24Generator! + var mockNetwork: MockNetwork! + var mockCrypto: MockCrypto! var mockUserDefaults: MockUserDefaults! - var dependencies: OpenGroupManager.OGMDependencies! + var mockCaches: MockCaches! + var mockGeneralCache: MockGeneralCache! + var mockOGMCache: MockOGMCache! + var dependencies: Dependencies! var disposables: [AnyCancellable] = [] var testInteraction1: Interaction! @@ -99,39 +40,30 @@ class OpenGroupManagerSpec: QuickSpec { // MARK: - Configuration beforeEach { - mockOGMCache = MockOGMCache() - mockGeneralCache = MockGeneralCache() - mockStorage = Storage( + mockStorage = SynchronousStorage( customWriter: try! DatabaseQueue(), customMigrationTargets: [ SNUtilitiesKit.self, SNMessagingKit.self ] ) - mockSodium = MockSodium() - mockAeadXChaCha20Poly1305Ietf = MockAeadXChaCha20Poly1305Ietf() - mockGenericHash = MockGenericHash() - mockSign = MockSign() - mockNonce16Generator = MockNonce16Generator() - mockNonce24Generator = MockNonce24Generator() + mockNetwork = MockNetwork() + mockCrypto = MockCrypto() mockUserDefaults = MockUserDefaults() - dependencies = OpenGroupManager.OGMDependencies( - subscribeQueue: DispatchQueue.main, - receiveQueue: DispatchQueue.main, - cache: mockOGMCache, - onionApi: TestCapabilitiesAndRoomApi.self, - generalCache: mockGeneralCache, + mockCaches = MockCaches() + mockGeneralCache = MockGeneralCache() + mockOGMCache = MockOGMCache() + dependencies = Dependencies( storage: mockStorage, - sodium: mockSodium, - genericHash: mockGenericHash, - sign: mockSign, - aeadXChaCha20Poly1305Ietf: mockAeadXChaCha20Poly1305Ietf, - ed25519: MockEd25519(), - nonceGenerator16: mockNonce16Generator, - nonceGenerator24: mockNonce24Generator, + network: mockNetwork, + crypto: mockCrypto, standardUserDefaults: mockUserDefaults, - date: Date(timeIntervalSince1970: 1234567890) + caches: mockCaches, + dateNow: Date(timeIntervalSince1970: 1234567890), + forceSynchronous: true ) + mockCaches[.general] = mockGeneralCache + mockCaches[.openGroupManager] = mockOGMCache testInteraction1 = Interaction( id: 234, serverHash: "TestServerHash", @@ -169,21 +101,10 @@ class OpenGroupManagerSpec: QuickSpec { infoUpdates: 10, sequenceNumber: 5 ) - testPollInfo = OpenGroupAPI.RoomPollInfo( + testPollInfo = OpenGroupAPI.RoomPollInfo.mockValue.with( token: "testRoom", activeUsers: 10, - admin: false, - globalAdmin: false, - moderator: false, - globalModerator: false, - read: false, - defaultRead: nil, - defaultAccessible: nil, - write: false, - defaultWrite: nil, - upload: false, - defaultUpload: nil, - details: TestCapabilitiesAndRoomApi.roomData + details: .mockValue ) testMessage = OpenGroupAPI.Message( id: 127, @@ -234,15 +155,17 @@ class OpenGroupManagerSpec: QuickSpec { try testOpenGroup.insert(db) try Capability(openGroupServer: testOpenGroup.server, variant: .sogs, isMissing: false).insert(db) } - mockOGMCache.when { $0.pendingChanges }.thenReturn([]) - mockGeneralCache.when { $0.encodedPublicKey }.thenReturn("05\(TestConstants.publicKey)") - mockGenericHash.when { $0.hash(message: anyArray(), outputLength: any()) }.thenReturn([]) - mockSodium - .when { [mockGenericHash = mockGenericHash!] sodium in - sodium.blindedKeyPair( - serverPublicKey: any(), - edKeyPair: any(), - genericHash: mockGenericHash + mockCrypto + .when { try $0.perform(.hash(message: anyArray(), outputLength: any())) } + .thenReturn([]) + mockCrypto + .when { [dependencies = dependencies!] crypto in + crypto.generate( + .blindedKeyPair( + serverPublicKey: any(), + edKeyPair: any(), + using: dependencies + ) ) } .thenReturn( @@ -251,24 +174,50 @@ class OpenGroupManagerSpec: QuickSpec { secretKey: Data.data(fromHex: TestConstants.edSecretKey)!.bytes ) ) - mockSodium + mockCrypto .when { - $0.sogsSignature( - message: anyArray(), - secretKey: anyArray(), - blindedSecretKey: anyArray(), - blindedPublicKey: anyArray() + try $0.perform( + .sogsSignature( + message: anyArray(), + secretKey: anyArray(), + blindedSecretKey: anyArray(), + blindedPublicKey: anyArray() + ) ) } .thenReturn("TestSogsSignature".bytes) - mockSign.when { $0.signature(message: anyArray(), secretKey: anyArray()) }.thenReturn("TestSignature".bytes) - - mockNonce16Generator - .when { $0.nonce() } + mockCrypto + .when { + try $0.perform( + .signature( + message: anyArray(), + secretKey: anyArray() + ) + ) + } + .thenReturn("TestSignature".bytes) + mockCrypto.when { $0.size(.nonce16) }.thenReturn(16) + mockCrypto + .when { try $0.perform(.generateNonce16()) } .thenReturn(Data(base64Encoded: "pK6YRtQApl4NhECGizF0Cg==")!.bytes) - mockNonce24Generator - .when { $0.nonce() } + mockCrypto.when { $0.size(.nonce24) }.thenReturn(24) + mockCrypto + .when { try $0.perform(.generateNonce24()) } .thenReturn(Data(base64Encoded: "pbTUizreT0sqJ2R2LloseQDyVL2RYztD")!.bytes) + mockCrypto.when { $0.size(.publicKey) }.thenReturn(32) + mockOGMCache.when { $0.pendingChanges }.thenReturn([]) + mockOGMCache.when { $0.pollers = any() }.thenReturn(()) + mockOGMCache.when { $0.isPolling = any() }.thenReturn(()) + mockOGMCache + .when { $0.defaultRoomsPublisher = any(type: [OpenGroupManager.DefaultRoomInfo].self) } + .thenReturn(()) + mockOGMCache + .when { $0.groupImagePublishers = any(typeA: String.self, typeB: AnyPublisher.self) } + .thenReturn(()) + mockOGMCache + .when { $0.pendingChanges = any(type: OpenGroupAPI.PendingChange.self) } + .thenReturn(()) + mockGeneralCache.when { $0.encodedPublicKey }.thenReturn("05\(TestConstants.publicKey)") cache = OpenGroupManager.Cache() openGroupManager = OpenGroupManager() @@ -280,13 +229,12 @@ class OpenGroupManagerSpec: QuickSpec { OpenGroupManager.shared.stopPolling() // Need to stop any pollers which get created during tests openGroupManager.stopPolling() // Assuming it's different from the above - mockOGMCache = nil mockStorage = nil - mockSodium = nil - mockAeadXChaCha20Poly1305Ietf = nil - mockGenericHash = nil - mockSign = nil + mockCrypto = nil mockUserDefaults = nil + mockCaches = nil + mockGeneralCache = nil + mockOGMCache = nil dependencies = nil disposables = [] @@ -294,12 +242,13 @@ class OpenGroupManagerSpec: QuickSpec { testGroupThread = nil testOpenGroup = nil + cache = nil openGroupManager = nil } - // MARK: - Cache - + // MARK: - cache data context("cache data") { + // MARK: -- defaults the time since last open to greatestFiniteMagnitude it("defaults the time since last open to greatestFiniteMagnitude") { mockUserDefaults .when { (defaults: inout any UserDefaultsType) -> Any? in @@ -311,25 +260,27 @@ class OpenGroupManagerSpec: QuickSpec { .to(beCloseTo(.greatestFiniteMagnitude)) } + // MARK: -- returns the time since the last open it("returns the time since the last open") { mockUserDefaults .when { (defaults: inout any UserDefaultsType) -> Any? in defaults.object(forKey: SNUserDefaults.Date.lastOpen.rawValue) } .thenReturn(Date(timeIntervalSince1970: 1234567880)) - dependencies = dependencies.with(date: Date(timeIntervalSince1970: 1234567890)) + dependencies.dateNow = Date(timeIntervalSince1970: 1234567890) expect(cache.getTimeSinceLastOpen(using: dependencies)) .to(beCloseTo(10)) } + // MARK: -- caches the time since the last open it("caches the time since the last open") { mockUserDefaults .when { (defaults: inout any UserDefaultsType) -> Any? in defaults.object(forKey: SNUserDefaults.Date.lastOpen.rawValue) } .thenReturn(Date(timeIntervalSince1970: 1234567770)) - dependencies = dependencies.with(date: Date(timeIntervalSince1970: 1234567780)) + dependencies.dateNow = Date(timeIntervalSince1970: 1234567780) expect(cache.getTimeSinceLastOpen(using: dependencies)) .to(beCloseTo(10)) @@ -346,8 +297,7 @@ class OpenGroupManagerSpec: QuickSpec { } } - // MARK: - Polling - + // MARK: - when starting polling context("when starting polling") { beforeEach { mockStorage.write { db in @@ -382,43 +332,38 @@ class OpenGroupManagerSpec: QuickSpec { .thenReturn(Date(timeIntervalSince1970: 1234567890)) } + // MARK: -- creates pollers for all of the open groups it("creates pollers for all of the open groups") { openGroupManager.startPolling(using: dependencies) expect(mockOGMCache) - .toEventually( - call(matchingParameters: true) { - $0.pollers = [ - "testserver": OpenGroupAPI.Poller(for: "testserver"), - "testserver1": OpenGroupAPI.Poller(for: "testserver1") - ] - }, - timeout: .milliseconds(50) - ) + .to(call(matchingParameters: true) { + $0.pollers = [ + "testserver": OpenGroupAPI.Poller(for: "testserver"), + "testserver1": OpenGroupAPI.Poller(for: "testserver1") + ] + }) } + // MARK: -- updates the isPolling flag it("updates the isPolling flag") { openGroupManager.startPolling(using: dependencies) expect(mockOGMCache) - .toEventually( - call(matchingParameters: true) { $0.isPolling = true }, - timeout: .milliseconds(50) - ) + .to(call(matchingParameters: true) { $0.isPolling = true }) } + // MARK: -- does nothing if already polling it("does nothing if already polling") { mockOGMCache.when { $0.isPolling }.thenReturn(true) openGroupManager.startPolling(using: dependencies) - expect(mockOGMCache).toEventuallyNot( - call { $0.pollers }, - timeout: .milliseconds(50) - ) + expect(mockOGMCache).toNot(call { $0.pollers }) } } + // MARK: - when stopping polling context("when stopping polling") { beforeEach { mockStorage.write { db in @@ -440,12 +385,14 @@ class OpenGroupManagerSpec: QuickSpec { mockOGMCache.when { $0.pollers }.thenReturn(["testserver": OpenGroupAPI.Poller(for: "testserver")]) } + // MARK: - removes all pollers it("removes all pollers") { openGroupManager.stopPolling(using: dependencies) expect(mockOGMCache).to(call(matchingParameters: true) { $0.pollers = [:] }) } + // MARK: - updates the isPolling flag it("updates the isPolling flag") { openGroupManager.stopPolling(using: dependencies) @@ -453,76 +400,87 @@ class OpenGroupManagerSpec: QuickSpec { } } - // MARK: - Adding & Removing - - // MARK: - --isSessionRunOpenGroup - + // MARK: - when checking if an open group is run by session context("when checking if an open group is run by session") { + // MARK: -- returns false when it does not match one of Sessions servers with no scheme it("returns false when it does not match one of Sessions servers with no scheme") { expect(OpenGroupManager.isSessionRunOpenGroup(server: "test.test")) .to(beFalse()) } + // MARK: -- returns false when it does not match one of Sessions servers in http it("returns false when it does not match one of Sessions servers in http") { expect(OpenGroupManager.isSessionRunOpenGroup(server: "http://test.test")) .to(beFalse()) } + // MARK: -- returns false when it does not match one of Sessions servers in https it("returns false when it does not match one of Sessions servers in https") { expect(OpenGroupManager.isSessionRunOpenGroup(server: "https://test.test")) .to(beFalse()) } + // MARK: -- returns true when it matches Sessions SOGS IP it("returns true when it matches Sessions SOGS IP") { expect(OpenGroupManager.isSessionRunOpenGroup(server: "116.203.70.33")) .to(beTrue()) } + // MARK: -- returns true when it matches Sessions SOGS IP with http it("returns true when it matches Sessions SOGS IP with http") { expect(OpenGroupManager.isSessionRunOpenGroup(server: "http://116.203.70.33")) .to(beTrue()) } + // MARK: -- returns true when it matches Sessions SOGS IP with https it("returns true when it matches Sessions SOGS IP with https") { expect(OpenGroupManager.isSessionRunOpenGroup(server: "https://116.203.70.33")) .to(beTrue()) } + // MARK: -- returns true when it matches Sessions SOGS IP with a port it("returns true when it matches Sessions SOGS IP with a port") { expect(OpenGroupManager.isSessionRunOpenGroup(server: "116.203.70.33:80")) .to(beTrue()) } + // MARK: -- returns true when it matches Sessions SOGS domain it("returns true when it matches Sessions SOGS domain") { expect(OpenGroupManager.isSessionRunOpenGroup(server: "open.getsession.org")) .to(beTrue()) } + // MARK: -- returns true when it matches Sessions SOGS domain with http it("returns true when it matches Sessions SOGS domain with http") { expect(OpenGroupManager.isSessionRunOpenGroup(server: "http://open.getsession.org")) .to(beTrue()) } + // MARK: -- returns true when it matches Sessions SOGS domain with https it("returns true when it matches Sessions SOGS domain with https") { expect(OpenGroupManager.isSessionRunOpenGroup(server: "https://open.getsession.org")) .to(beTrue()) } + // MARK: -- returns true when it matches Sessions SOGS domain with a port it("returns true when it matches Sessions SOGS domain with a port") { expect(OpenGroupManager.isSessionRunOpenGroup(server: "open.getsession.org:80")) .to(beTrue()) } } - // MARK: - --hasExistingOpenGroup - + // MARK: - when checking it has an existing open group context("when checking it has an existing open group") { + // MARK: -- when there is a thread for the room and the cache has a poller context("when there is a thread for the room and the cache has a poller") { + // MARK: ---- for the no-scheme variant context("for the no-scheme variant") { beforeEach { - mockOGMCache.when { $0.pollers }.thenReturn(["testserver": OpenGroupAPI.Poller(for: "testserver")]) + mockOGMCache.when { $0.pollers } + .thenReturn(["testserver": OpenGroupAPI.Poller(for: "testserver")]) } + // MARK: ------ returns true when no scheme is provided it("returns true when no scheme is provided") { expect( mockStorage.read { db -> Bool in @@ -532,12 +490,13 @@ class OpenGroupManagerSpec: QuickSpec { roomToken: "testRoom", server: "testServer", publicKey: TestConstants.serverPublicKey, - dependencies: dependencies + using: dependencies ) } ).to(beTrue()) } + // MARK: ------ returns true when a http scheme is provided it("returns true when a http scheme is provided") { expect( mockStorage.read { db -> Bool in @@ -547,12 +506,13 @@ class OpenGroupManagerSpec: QuickSpec { roomToken: "testRoom", server: "http://testServer", publicKey: TestConstants.serverPublicKey, - dependencies: dependencies + using: dependencies ) } ).to(beTrue()) } + // MARK: ------ returns true when a https scheme is provided it("returns true when a https scheme is provided") { expect( mockStorage.read { db -> Bool in @@ -562,18 +522,21 @@ class OpenGroupManagerSpec: QuickSpec { roomToken: "testRoom", server: "https://testServer", publicKey: TestConstants.serverPublicKey, - dependencies: dependencies + using: dependencies ) } ).to(beTrue()) } } + // MARK: ---- for the http variant context("for the http variant") { beforeEach { - mockOGMCache.when { $0.pollers }.thenReturn(["http://testserver": OpenGroupAPI.Poller(for: "http://testserver")]) + mockOGMCache.when { $0.pollers } + .thenReturn(["http://testserver": OpenGroupAPI.Poller(for: "http://testserver")]) } + // MARK: ------ returns true when no scheme is provided it("returns true when no scheme is provided") { expect( mockStorage.read { db -> Bool in @@ -583,12 +546,13 @@ class OpenGroupManagerSpec: QuickSpec { roomToken: "testRoom", server: "testServer", publicKey: TestConstants.serverPublicKey, - dependencies: dependencies + using: dependencies ) } ).to(beTrue()) } + // MARK: ------ returns true when a http scheme is provided it("returns true when a http scheme is provided") { expect( mockStorage.read { db -> Bool in @@ -598,12 +562,13 @@ class OpenGroupManagerSpec: QuickSpec { roomToken: "testRoom", server: "http://testServer", publicKey: TestConstants.serverPublicKey, - dependencies: dependencies + using: dependencies ) } ).to(beTrue()) } + // MARK: ------ returns true when a https scheme is provided it("returns true when a https scheme is provided") { expect( mockStorage.read { db -> Bool in @@ -613,18 +578,21 @@ class OpenGroupManagerSpec: QuickSpec { roomToken: "testRoom", server: "https://testServer", publicKey: TestConstants.serverPublicKey, - dependencies: dependencies + using: dependencies ) } ).to(beTrue()) } } + // MARK: ---- for the https variant context("for the https variant") { beforeEach { - mockOGMCache.when { $0.pollers }.thenReturn(["https://testserver": OpenGroupAPI.Poller(for: "https://testserver")]) + mockOGMCache.when { $0.pollers } + .thenReturn(["https://testserver": OpenGroupAPI.Poller(for: "https://testserver")]) } + // MARK: ------ returns true when no scheme is provided it("returns true when no scheme is provided") { expect( mockStorage.read { db -> Bool in @@ -634,12 +602,13 @@ class OpenGroupManagerSpec: QuickSpec { roomToken: "testRoom", server: "testServer", publicKey: TestConstants.serverPublicKey, - dependencies: dependencies + using: dependencies ) } ).to(beTrue()) } + // MARK: ------ returns true when a http scheme is provided it("returns true when a http scheme is provided") { expect( mockStorage.read { db -> Bool in @@ -649,12 +618,13 @@ class OpenGroupManagerSpec: QuickSpec { roomToken: "testRoom", server: "http://testServer", publicKey: TestConstants.serverPublicKey, - dependencies: dependencies + using: dependencies ) } ).to(beTrue()) } + // MARK: ------ returns true when a https scheme is provided it("returns true when a https scheme is provided") { expect( mockStorage.read { db -> Bool in @@ -664,7 +634,7 @@ class OpenGroupManagerSpec: QuickSpec { roomToken: "testRoom", server: "https://testServer", publicKey: TestConstants.serverPublicKey, - dependencies: dependencies + using: dependencies ) } ).to(beTrue()) @@ -672,9 +642,12 @@ class OpenGroupManagerSpec: QuickSpec { } } + // MARK: -- when given the legacy DNS host and there is a cached poller for the default server context("when given the legacy DNS host and there is a cached poller for the default server") { + // MARK: ---- returns true it("returns true") { - mockOGMCache.when { $0.pollers }.thenReturn(["http://116.203.70.33": OpenGroupAPI.Poller(for: "http://116.203.70.33")]) + mockOGMCache.when { $0.pollers } + .thenReturn(["http://116.203.70.33": OpenGroupAPI.Poller(for: "http://116.203.70.33")]) mockStorage.write { db in try SessionThread( id: OpenGroup.idFor(roomToken: "testRoom", server: "http://116.203.70.33"), @@ -697,14 +670,16 @@ class OpenGroupManagerSpec: QuickSpec { roomToken: "testRoom", server: "http://open.getsession.org", publicKey: TestConstants.serverPublicKey, - dependencies: dependencies + using: dependencies ) } ).to(beTrue()) } } + // MARK: -- when given the default server and there is a cached poller for the legacy DNS host context("when given the default server and there is a cached poller for the legacy DNS host") { + // MARK: ---- returns true it("returns true") { mockOGMCache.when { $0.pollers }.thenReturn(["http://open.getsession.org": OpenGroupAPI.Poller(for: "http://open.getsession.org")]) mockStorage.write { db in @@ -729,13 +704,14 @@ class OpenGroupManagerSpec: QuickSpec { roomToken: "testRoom", server: "http://116.203.70.33", publicKey: TestConstants.serverPublicKey, - dependencies: dependencies + using: dependencies ) } ).to(beTrue()) } } + // MARK: -- returns false when given an invalid server it("returns false when given an invalid server") { mockOGMCache.when { $0.pollers }.thenReturn(["testserver": OpenGroupAPI.Poller(for: "testserver")]) @@ -747,12 +723,13 @@ class OpenGroupManagerSpec: QuickSpec { roomToken: "testRoom", server: "%%%", publicKey: TestConstants.serverPublicKey, - dependencies: dependencies + using: dependencies ) } ).to(beFalse()) } + // MARK: -- returns false if there is not a poller for the server in the cache it("returns false if there is not a poller for the server in the cache") { mockOGMCache.when { $0.pollers }.thenReturn([:]) @@ -764,12 +741,13 @@ class OpenGroupManagerSpec: QuickSpec { roomToken: "testRoom", server: "testServer", publicKey: TestConstants.serverPublicKey, - dependencies: dependencies + using: dependencies ) } ).to(beFalse()) } + // MARK: -- returns false if there is a poller for the server in the cache but no thread for the room it("returns false if there is a poller for the server in the cache but no thread for the room") { mockOGMCache.when { $0.pollers }.thenReturn(["testserver": OpenGroupAPI.Poller(for: "testserver")]) mockStorage.write { db in @@ -784,21 +762,23 @@ class OpenGroupManagerSpec: QuickSpec { roomToken: "testRoom", server: "testServer", publicKey: TestConstants.serverPublicKey, - dependencies: dependencies + using: dependencies ) } ).to(beFalse()) } } - // MARK: - --add - + // MARK: - when adding context("when adding") { beforeEach { mockStorage.write { db in try OpenGroup.deleteAll(db) } + mockNetwork + .when { $0.send(.onionRequest(any(), to: any(), with: any())) } + .thenReturn(OpenGroupAPI.BatchResponse.mockCapabilitiesAndRoomResponse) mockOGMCache.when { $0.pollers }.thenReturn([:]) mockUserDefaults @@ -808,9 +788,8 @@ class OpenGroupManagerSpec: QuickSpec { .thenReturn(Date(timeIntervalSince1970: 1234567890)) } + // MARK: -- stores the open group server it("stores the open group server") { - var didComplete: Bool = false // Prevent multi-threading test bugs - mockStorage .writePublisher { (db: Database) -> Bool in openGroupManager @@ -820,7 +799,7 @@ class OpenGroupManagerSpec: QuickSpec { server: "testServer", publicKey: TestConstants.serverPublicKey, calledFromConfigHandling: true, // Don't trigger SessionUtil logic - dependencies: dependencies + using: dependencies ) } .flatMap { successfullyAddedGroup in @@ -830,13 +809,11 @@ class OpenGroupManagerSpec: QuickSpec { server: "testServer", publicKey: TestConstants.serverPublicKey, calledFromConfigHandling: true, // Don't trigger SessionUtil logic - dependencies: dependencies + using: dependencies ) } - .handleEvents(receiveCompletion: { _ in didComplete = true }) .sinkAndStore(in: &disposables) - expect(didComplete).toEventually(beTrue(), timeout: .milliseconds(50)) expect( mockStorage .read { (db: Database) in @@ -846,12 +823,11 @@ class OpenGroupManagerSpec: QuickSpec { .fetchOne(db) } ) - .to(equal(OpenGroup.idFor(roomToken: "testRoom", server: "testServer"))) + .to(equal(OpenGroup.idFor(roomToken: "testRoom", server: "testServer"))) } + // MARK: -- adds a poller it("adds a poller") { - var didComplete: Bool = false // Prevent multi-threading test bugs - mockStorage .writePublisher { (db: Database) -> Bool in openGroupManager @@ -861,7 +837,7 @@ class OpenGroupManagerSpec: QuickSpec { server: "testServer", publicKey: TestConstants.serverPublicKey, calledFromConfigHandling: true, // Don't trigger SessionUtil logic - dependencies: dependencies + using: dependencies ) } .flatMap { successfullyAddedGroup in @@ -871,22 +847,18 @@ class OpenGroupManagerSpec: QuickSpec { server: "testServer", publicKey: TestConstants.serverPublicKey, calledFromConfigHandling: true, // Don't trigger SessionUtil logic - dependencies: dependencies + using: dependencies ) } - .handleEvents(receiveCompletion: { _ in didComplete = true }) .sinkAndStore(in: &disposables) - expect(didComplete).toEventually(beTrue(), timeout: .milliseconds(50)) expect(mockOGMCache) - .toEventually( - call(matchingParameters: true) { - $0.pollers = ["testserver": OpenGroupAPI.Poller(for: "testserver")] - }, - timeout: .milliseconds(50) - ) + .to(call(matchingParameters: true) { + $0.pollers = ["testserver": OpenGroupAPI.Poller(for: "testserver")] + }) } + // MARK: -- an existing room context("an existing room") { beforeEach { mockOGMCache.when { $0.pollers } @@ -896,9 +868,8 @@ class OpenGroupManagerSpec: QuickSpec { } } + // MARK: ---- does not reset the sequence number or update the public key it("does not reset the sequence number or update the public key") { - var didComplete: Bool = false // Prevent multi-threading test bugs - mockStorage .writePublisher { (db: Database) -> Bool in openGroupManager @@ -910,7 +881,7 @@ class OpenGroupManagerSpec: QuickSpec { .replacingOccurrences(of: "c3", with: "00") .replacingOccurrences(of: "b3", with: "00"), calledFromConfigHandling: true, // Don't trigger SessionUtil logic - dependencies: dependencies + using: dependencies ) } .flatMap { successfullyAddedGroup in @@ -922,13 +893,11 @@ class OpenGroupManagerSpec: QuickSpec { .replacingOccurrences(of: "c3", with: "00") .replacingOccurrences(of: "b3", with: "00"), calledFromConfigHandling: true, // Don't trigger SessionUtil logic - dependencies: dependencies + using: dependencies ) } - .handleEvents(receiveCompletion: { _ in didComplete = true }) .sinkAndStore(in: &disposables) - expect(didComplete).toEventually(beTrue(), timeout: .milliseconds(50)) expect( mockStorage .read { db in @@ -950,12 +919,12 @@ class OpenGroupManagerSpec: QuickSpec { } } + // MARK: -- with an invalid response context("with an invalid response") { beforeEach { - class TestApi: TestOnionRequestAPI { - override class var mockResponse: Data? { return Data() } - } - dependencies = dependencies.with(onionApi: TestApi.self) + mockNetwork + .when { $0.send(.onionRequest(any(), to: any(), with: any())) } + .thenReturn(MockNetwork.response(data: Data())) mockUserDefaults .when { (defaults: inout any UserDefaultsType) -> Any? in @@ -964,6 +933,7 @@ class OpenGroupManagerSpec: QuickSpec { .thenReturn(Date(timeIntervalSince1970: 1234567890)) } + // MARK: ---- fails with the error it("fails with the error") { var error: Error? @@ -976,7 +946,7 @@ class OpenGroupManagerSpec: QuickSpec { server: "testServer", publicKey: TestConstants.serverPublicKey, calledFromConfigHandling: true, // Don't trigger SessionUtil logic - dependencies: dependencies + using: dependencies ) } .flatMap { successfullyAddedGroup in @@ -986,23 +956,18 @@ class OpenGroupManagerSpec: QuickSpec { server: "testServer", publicKey: TestConstants.serverPublicKey, calledFromConfigHandling: true, // Don't trigger SessionUtil logic - dependencies: dependencies + using: dependencies ) } .mapError { result -> Error in error.setting(to: result) } .sinkAndStore(in: &disposables) - expect(error?.localizedDescription) - .toEventually( - equal(HTTPError.parsingFailed.localizedDescription), - timeout: .milliseconds(50) - ) + expect(error).to(matchError(HTTPError.parsingFailed)) } } } - // MARK: - --delete - + // MARK: - when deleting context("when deleting") { beforeEach { mockStorage.write { db in @@ -1023,6 +988,7 @@ class OpenGroupManagerSpec: QuickSpec { mockOGMCache.when { $0.pollers }.thenReturn([:]) } + // MARK: -- removes all interactions for the thread it("removes all interactions for the thread") { mockStorage.write { db in openGroupManager @@ -1038,6 +1004,7 @@ class OpenGroupManagerSpec: QuickSpec { .to(equal(0)) } + // MARK: -- removes the given thread it("removes the given thread") { mockStorage.write { db in openGroupManager @@ -1053,7 +1020,9 @@ class OpenGroupManagerSpec: QuickSpec { .to(equal(0)) } + // MARK: -- and there is only one open group for this server context("and there is only one open group for this server") { + // MARK: ---- stops the poller it("stops the poller") { mockOGMCache.when { $0.pollers }.thenReturn(["testserver": OpenGroupAPI.Poller(for: "testserver")]) @@ -1070,6 +1039,7 @@ class OpenGroupManagerSpec: QuickSpec { expect(mockOGMCache).to(call(matchingParameters: true) { $0.pollers = [:] }) } + // MARK: ---- removes the open group it("removes the open group") { mockStorage.write { db in openGroupManager @@ -1086,6 +1056,7 @@ class OpenGroupManagerSpec: QuickSpec { } } + // MARK: -- and the are multiple open groups for this server context("and the are multiple open groups for this server") { beforeEach { mockStorage.write { db in @@ -1109,6 +1080,7 @@ class OpenGroupManagerSpec: QuickSpec { } } + // MARK: ---- removes the open group it("removes the open group") { mockStorage.write { db in openGroupManager @@ -1125,6 +1097,7 @@ class OpenGroupManagerSpec: QuickSpec { } } + // MARK: -- and it is the default server context("and it is the default server") { beforeEach { mockStorage.write { db in @@ -1162,6 +1135,7 @@ class OpenGroupManagerSpec: QuickSpec { } } + // MARK: ---- does not remove the open group it("does not remove the open group") { mockStorage.write { db in openGroupManager @@ -1177,6 +1151,7 @@ class OpenGroupManagerSpec: QuickSpec { .to(equal(2)) } + // MARK: ---- deactivates the open group it("deactivates the open group") { mockStorage.write { db in openGroupManager @@ -1201,10 +1176,7 @@ class OpenGroupManagerSpec: QuickSpec { } } - // MARK: - Response Processing - - // MARK: - --handleCapabilities - + // MARK: - when handling capabilities context("when handling capabilities") { beforeEach { mockStorage.write { db in @@ -1217,14 +1189,14 @@ class OpenGroupManagerSpec: QuickSpec { } } + // MARK: -- stores the capabilities it("stores the capabilities") { expect(mockStorage.read { db in try Capability.fetchCount(db) }) .to(equal(1)) } } - // MARK: - --handlePollInfo - + // MARK: - when handling room poll info context("when handling room poll info") { beforeEach { mockStorage.write { db in @@ -1242,9 +1214,8 @@ class OpenGroupManagerSpec: QuickSpec { .thenReturn(nil) } + // MARK: -- saves the updated open group it("saves the updated open group") { - var didComplete: Bool = false // Prevent multi-threading test bugs - mockStorage.write { db in try OpenGroupManager.handlePollInfo( db, @@ -1252,11 +1223,10 @@ class OpenGroupManagerSpec: QuickSpec { publicKey: TestConstants.publicKey, for: "testRoom", on: "testServer", - dependencies: dependencies - ) { didComplete = true } + using: dependencies + )// { didComplete = true } } - expect(didComplete).toEventually(beTrue(), timeout: .milliseconds(50)) expect( mockStorage.read { db in try OpenGroup @@ -1267,6 +1237,7 @@ class OpenGroupManagerSpec: QuickSpec { ).to(equal(10)) } + // MARK: -- calls the completion block it("calls the completion block") { var didCallComplete: Bool = false @@ -1277,17 +1248,14 @@ class OpenGroupManagerSpec: QuickSpec { publicKey: TestConstants.publicKey, for: "testRoom", on: "testServer", - dependencies: dependencies + using: dependencies ) { didCallComplete = true } } - expect(didCallComplete) - .toEventually( - beTrue(), - timeout: .milliseconds(50) - ) + expect(didCallComplete).to(beTrue()) } + // MARK: -- calls the room image completion block when waiting but there is no image it("calls the room image completion block when waiting but there is no image") { var didCallComplete: Bool = false @@ -1299,17 +1267,14 @@ class OpenGroupManagerSpec: QuickSpec { for: "testRoom", on: "testServer", waitForImageToComplete: true, - dependencies: dependencies + using: dependencies ) { didCallComplete = true } } - expect(didCallComplete) - .toEventually( - beTrue(), - timeout: .milliseconds(50) - ) + expect(didCallComplete).to(beTrue()) } + // MARK: -- calls the room image completion block when waiting and there is an image it("calls the room image completion block when waiting and there is an image") { var didCallComplete: Bool = false @@ -1341,36 +1306,21 @@ class OpenGroupManagerSpec: QuickSpec { for: "testRoom", on: "testServer", waitForImageToComplete: true, - dependencies: dependencies + using: dependencies ) { didCallComplete = true } } - expect(didCallComplete) - .toEventually( - beTrue(), - timeout: .milliseconds(50) - ) + expect(didCallComplete).to(beTrue()) } + // MARK: -- and updating the moderator list context("and updating the moderator list") { + // MARK: ---- successfully updates it("successfully updates") { - var didComplete: Bool = false // Prevent multi-threading test bugs - - testPollInfo = OpenGroupAPI.RoomPollInfo( + testPollInfo = OpenGroupAPI.RoomPollInfo.mockValue.with( token: "testRoom", activeUsers: 10, - admin: false, - globalAdmin: false, - moderator: false, - globalModerator: false, - read: false, - defaultRead: nil, - defaultAccessible: nil, - write: false, - defaultWrite: nil, - upload: false, - defaultUpload: nil, - details: TestCapabilitiesAndRoomApi.roomData.with( + details: OpenGroupAPI.Room.mockValue.with( moderators: ["TestMod"], hiddenModerators: [], admins: [], @@ -1385,11 +1335,10 @@ class OpenGroupManagerSpec: QuickSpec { publicKey: TestConstants.publicKey, for: "testRoom", on: "testServer", - dependencies: dependencies - ) { didComplete = true } + using: dependencies + ) } - expect(didComplete).toEventually(beTrue(), timeout: .milliseconds(50)) expect( mockStorage.read { db -> GroupMember? in try GroupMember @@ -1412,24 +1361,12 @@ class OpenGroupManagerSpec: QuickSpec { )) } + // MARK: ---- updates for hidden moderators it("updates for hidden moderators") { - var didComplete: Bool = false // Prevent multi-threading test bugs - - testPollInfo = OpenGroupAPI.RoomPollInfo( + testPollInfo = OpenGroupAPI.RoomPollInfo.mockValue.with( token: "testRoom", activeUsers: 10, - admin: false, - globalAdmin: false, - moderator: false, - globalModerator: false, - read: false, - defaultRead: nil, - defaultAccessible: nil, - write: false, - defaultWrite: nil, - upload: false, - defaultUpload: nil, - details: TestCapabilitiesAndRoomApi.roomData.with( + details: OpenGroupAPI.Room.mockValue.with( moderators: [], hiddenModerators: ["TestMod2"], admins: [], @@ -1444,11 +1381,10 @@ class OpenGroupManagerSpec: QuickSpec { publicKey: TestConstants.publicKey, for: "testRoom", on: "testServer", - dependencies: dependencies - ) { didComplete = true } + using: dependencies + ) } - expect(didComplete).toEventually(beTrue(), timeout: .milliseconds(50)) expect( mockStorage.read { db -> GroupMember? in try GroupMember @@ -1471,24 +1407,11 @@ class OpenGroupManagerSpec: QuickSpec { )) } + // MARK: ---- does not insert mods if no moderators are provided it("does not insert mods if no moderators are provided") { - var didComplete: Bool = false // Prevent multi-threading test bugs - - testPollInfo = OpenGroupAPI.RoomPollInfo( + testPollInfo = OpenGroupAPI.RoomPollInfo.mockValue.with( token: "testRoom", - activeUsers: 10, - admin: false, - globalAdmin: false, - moderator: false, - globalModerator: false, - read: false, - defaultRead: nil, - defaultAccessible: nil, - write: false, - defaultWrite: nil, - upload: false, - defaultUpload: nil, - details: nil + activeUsers: 10 ) mockStorage.write { db in @@ -1498,35 +1421,23 @@ class OpenGroupManagerSpec: QuickSpec { publicKey: TestConstants.publicKey, for: "testRoom", on: "testServer", - dependencies: dependencies - ) { didComplete = true } + using: dependencies + ) } - expect(didComplete).toEventually(beTrue(), timeout: .milliseconds(50)) expect(mockStorage.read { db -> Int in try GroupMember.fetchCount(db) }) .to(equal(0)) } } + // MARK: -- and updating the admin list context("and updating the admin list") { + // MARK: ---- successfully updates it("successfully updates") { - var didComplete: Bool = false // Prevent multi-threading test bugs - - testPollInfo = OpenGroupAPI.RoomPollInfo( + testPollInfo = OpenGroupAPI.RoomPollInfo.mockValue.with( token: "testRoom", activeUsers: 10, - admin: false, - globalAdmin: false, - moderator: false, - globalModerator: false, - read: false, - defaultRead: nil, - defaultAccessible: nil, - write: false, - defaultWrite: nil, - upload: false, - defaultUpload: nil, - details: TestCapabilitiesAndRoomApi.roomData.with( + details: OpenGroupAPI.Room.mockValue.with( moderators: [], hiddenModerators: [], admins: ["TestAdmin"], @@ -1541,11 +1452,10 @@ class OpenGroupManagerSpec: QuickSpec { publicKey: TestConstants.publicKey, for: "testRoom", on: "testServer", - dependencies: dependencies - ) { didComplete = true } + using: dependencies + ) } - expect(didComplete).toEventually(beTrue(), timeout: .milliseconds(50)) expect( mockStorage.read { db -> GroupMember? in try GroupMember @@ -1568,24 +1478,12 @@ class OpenGroupManagerSpec: QuickSpec { )) } + // MARK: ---- updates for hidden admins it("updates for hidden admins") { - var didComplete: Bool = false // Prevent multi-threading test bugs - - testPollInfo = OpenGroupAPI.RoomPollInfo( + testPollInfo = OpenGroupAPI.RoomPollInfo.mockValue.with( token: "testRoom", activeUsers: 10, - admin: false, - globalAdmin: false, - moderator: false, - globalModerator: false, - read: false, - defaultRead: nil, - defaultAccessible: nil, - write: false, - defaultWrite: nil, - upload: false, - defaultUpload: nil, - details: TestCapabilitiesAndRoomApi.roomData.with( + details: OpenGroupAPI.Room.mockValue.with( moderators: [], hiddenModerators: [], admins: [], @@ -1600,11 +1498,10 @@ class OpenGroupManagerSpec: QuickSpec { publicKey: TestConstants.publicKey, for: "testRoom", on: "testServer", - dependencies: dependencies - ) { didComplete = true } + using: dependencies + ) } - expect(didComplete).toEventually(beTrue(), timeout: .milliseconds(50)) expect( mockStorage.read { db -> GroupMember? in try GroupMember @@ -1627,23 +1524,11 @@ class OpenGroupManagerSpec: QuickSpec { )) } + // MARK: ---- does not insert an admin if no admins are provided it("does not insert an admin if no admins are provided") { - var didComplete: Bool = false // Prevent multi-threading test bugs - - testPollInfo = OpenGroupAPI.RoomPollInfo( + testPollInfo = OpenGroupAPI.RoomPollInfo.mockValue.with( token: "testRoom", activeUsers: 10, - admin: false, - globalAdmin: false, - moderator: false, - globalModerator: false, - read: false, - defaultRead: nil, - defaultAccessible: nil, - write: false, - defaultWrite: nil, - upload: false, - defaultUpload: nil, details: nil ) @@ -1654,17 +1539,18 @@ class OpenGroupManagerSpec: QuickSpec { publicKey: TestConstants.publicKey, for: "testRoom", on: "testServer", - dependencies: dependencies - ) { didComplete = true } + using: dependencies + ) } - expect(didComplete).toEventually(beTrue(), timeout: .milliseconds(50)) expect(mockStorage.read { db -> Int in try GroupMember.fetchCount(db) }) .to(equal(0)) } } + // MARK: -- when it cannot get the open group context("when it cannot get the open group") { + // MARK: ---- does not save the thread it("does not save the thread") { mockStorage.write { db in try OpenGroup.deleteAll(db) @@ -1677,7 +1563,7 @@ class OpenGroupManagerSpec: QuickSpec { publicKey: TestConstants.publicKey, for: "testRoom", on: "testServer", - dependencies: dependencies + using: dependencies ) } @@ -1685,10 +1571,10 @@ class OpenGroupManagerSpec: QuickSpec { } } + // MARK: -- when not given a public key context("when not given a public key") { + // MARK: ---- saves the open group with the existing public key it("saves the open group with the existing public key") { - var didComplete: Bool = false // Prevent multi-threading test bugs - mockStorage.write { db in try OpenGroupManager.handlePollInfo( db, @@ -1696,11 +1582,10 @@ class OpenGroupManagerSpec: QuickSpec { publicKey: nil, for: "testRoom", on: "testServer", - dependencies: dependencies - ) { didComplete = true } + using: dependencies + ) } - expect(didComplete).toEventually(beTrue(), timeout: .milliseconds(50)) expect( mockStorage.read { db -> String? in try OpenGroup @@ -1712,10 +1597,10 @@ class OpenGroupManagerSpec: QuickSpec { } } + // MARK: -- when checking to start polling context("when checking to start polling") { + // MARK: ---- starts a new poller when not already polling it("starts a new poller when not already polling") { - var didComplete: Bool = false // Prevent multi-threading test bugs - mockOGMCache.when { $0.pollers }.thenReturn([:]) mockStorage.write { db in @@ -1725,20 +1610,18 @@ class OpenGroupManagerSpec: QuickSpec { publicKey: TestConstants.publicKey, for: "testRoom", on: "testServer", - dependencies: dependencies - ) { didComplete = true } + using: dependencies + ) } - expect(didComplete).toEventually(beTrue(), timeout: .milliseconds(50)) expect(mockOGMCache) .to(call(matchingParameters: true) { $0.pollers = ["testserver": OpenGroupAPI.Poller(for: "testserver")] }) } + // MARK: ---- does not start a new poller when already polling it("does not start a new poller when already polling") { - var didComplete: Bool = false // Prevent multi-threading test bugs - mockOGMCache.when { $0.pollers }.thenReturn(["testserver": OpenGroupAPI.Poller(for: "testserver")]) mockStorage.write { db in @@ -1748,15 +1631,15 @@ class OpenGroupManagerSpec: QuickSpec { publicKey: TestConstants.publicKey, for: "testRoom", on: "testServer", - dependencies: dependencies - ) { didComplete = true } + using: dependencies + ) } - expect(didComplete).toEventually(beTrue(), timeout: .milliseconds(50)) expect(mockOGMCache).to(call(.exactly(times: 1)) { $0.pollers }) } } + // MARK: -- when trying to get the room image context("when trying to get the room image") { beforeEach { let image: UIImage = UIImage(color: .red, size: CGSize(width: 1, height: 1)) @@ -1773,49 +1656,15 @@ class OpenGroupManagerSpec: QuickSpec { ]) } + // MARK: ---- uses the provided room image id if available it("uses the provided room image id if available") { - var didComplete: Bool = false // Prevent multi-threading test bugs - - testPollInfo = OpenGroupAPI.RoomPollInfo( + testPollInfo = OpenGroupAPI.RoomPollInfo.mockValue.with( token: "testRoom", activeUsers: 10, - admin: false, - globalAdmin: false, - moderator: false, - globalModerator: false, - read: false, - defaultRead: nil, - defaultAccessible: nil, - write: false, - defaultWrite: nil, - upload: false, - defaultUpload: nil, - details: OpenGroupAPI.Room( + details: OpenGroupAPI.Room.mockValue.with( token: "test", name: "test", - roomDescription: nil, - infoUpdates: 0, - messageSequence: 0, - created: 0, - activeUsers: 0, - activeUsersCutoff: 0, - imageId: "10", - pinnedMessages: nil, - admin: false, - globalAdmin: false, - admins: [], - hiddenAdmins: nil, - moderator: false, - globalModerator: false, - moderators: [], - hiddenModerators: nil, - read: false, - defaultRead: nil, - defaultAccessible: nil, - write: false, - defaultWrite: nil, - upload: false, - defaultUpload: nil + imageId: "10" ) ) @@ -1827,11 +1676,10 @@ class OpenGroupManagerSpec: QuickSpec { for: "testRoom", on: "testServer", waitForImageToComplete: true, - dependencies: dependencies - ) { didComplete = true } + using: dependencies + ) } - expect(didComplete).toEventually(beTrue(), timeout: .milliseconds(50)) expect( mockStorage.read { db -> String? in try OpenGroup @@ -1850,9 +1698,8 @@ class OpenGroupManagerSpec: QuickSpec { ).toNot(beNil()) } + // MARK: ---- uses the existing room image id if none is provided it("uses the existing room image id if none is provided") { - var didComplete: Bool = false // Prevent multi-threading test bugs - mockStorage.write { db in try OpenGroup.deleteAll(db) try OpenGroup( @@ -1868,20 +1715,9 @@ class OpenGroupManagerSpec: QuickSpec { ).insert(db) } - testPollInfo = OpenGroupAPI.RoomPollInfo( + testPollInfo = OpenGroupAPI.RoomPollInfo.mockValue.with( token: "testRoom", activeUsers: 10, - admin: false, - globalAdmin: false, - moderator: false, - globalModerator: false, - read: false, - defaultRead: nil, - defaultAccessible: nil, - write: false, - defaultWrite: nil, - upload: false, - defaultUpload: nil, details: nil ) @@ -1893,11 +1729,10 @@ class OpenGroupManagerSpec: QuickSpec { for: "testRoom", on: "testServer", waitForImageToComplete: true, - dependencies: dependencies - ) { didComplete = true } + using: dependencies + ) } - expect(didComplete).toEventually(beTrue(), timeout: .milliseconds(50)) expect( mockStorage.read { db -> String? in try OpenGroup @@ -1916,9 +1751,8 @@ class OpenGroupManagerSpec: QuickSpec { ).toNot(beNil()) } + // MARK: ---- uses the new room image id if there is an existing one it("uses the new room image id if there is an existing one") { - var didComplete: Bool = false // Prevent multi-threading test bugs - mockStorage.write { db in try OpenGroup.deleteAll(db) try OpenGroup( @@ -1934,46 +1768,14 @@ class OpenGroupManagerSpec: QuickSpec { ).insert(db) } - testPollInfo = OpenGroupAPI.RoomPollInfo( + testPollInfo = OpenGroupAPI.RoomPollInfo.mockValue.with( token: "testRoom", activeUsers: 10, - admin: false, - globalAdmin: false, - moderator: false, - globalModerator: false, - read: false, - defaultRead: nil, - defaultAccessible: nil, - write: false, - defaultWrite: nil, - upload: false, - defaultUpload: nil, - details: OpenGroupAPI.Room( + details: OpenGroupAPI.Room.mockValue.with( token: "test", name: "test", - roomDescription: nil, infoUpdates: 10, - messageSequence: 0, - created: 0, - activeUsers: 0, - activeUsersCutoff: 0, - imageId: "10", - pinnedMessages: nil, - admin: false, - globalAdmin: false, - admins: [], - hiddenAdmins: nil, - moderator: false, - globalModerator: false, - moderators: [], - hiddenModerators: nil, - read: false, - defaultRead: nil, - defaultAccessible: nil, - write: false, - defaultWrite: nil, - upload: false, - defaultUpload: nil + imageId: "10" ) ) @@ -1985,11 +1787,10 @@ class OpenGroupManagerSpec: QuickSpec { for: "testRoom", on: "testServer", waitForImageToComplete: true, - dependencies: dependencies - ) { didComplete = true } + using: dependencies + ) } - expect(didComplete).toEventually(beTrue(), timeout: .milliseconds(50)) expect( mockStorage.read { db -> String? in try OpenGroup @@ -2006,16 +1807,11 @@ class OpenGroupManagerSpec: QuickSpec { .fetchOne(db) } ).toNot(beNil()) - expect(mockOGMCache) - .toEventually( - call(.exactly(times: 1)) { $0.groupImagePublishers }, - timeout: .milliseconds(50) - ) + expect(mockOGMCache).to(call(.exactly(times: 1)) { $0.groupImagePublishers }) } + // MARK: ---- does nothing if there is no room image it("does nothing if there is no room image") { - var didComplete: Bool = false // Prevent multi-threading test bugs - mockStorage.write { db in try OpenGroupManager.handlePollInfo( db, @@ -2024,11 +1820,10 @@ class OpenGroupManagerSpec: QuickSpec { for: "testRoom", on: "testServer", waitForImageToComplete: true, - dependencies: dependencies - ) { didComplete = true } + using: dependencies + ) } - expect(didComplete).toEventually(beTrue(), timeout: .milliseconds(50)) expect( mockStorage.read { db -> Data? in try OpenGroup @@ -2039,54 +1834,20 @@ class OpenGroupManagerSpec: QuickSpec { ).to(beNil()) } + // MARK: ---- does nothing if it fails to retrieve the room image it("does nothing if it fails to retrieve the room image") { - var didComplete: Bool = false // Prevent multi-threading test bugs - mockOGMCache.when { $0.groupImagePublishers } .thenReturn([ OpenGroup.idFor(roomToken: "testRoom", server: "testServer"): Fail(error: HTTPError.generic).eraseToAnyPublisher() ]) - testPollInfo = OpenGroupAPI.RoomPollInfo( + testPollInfo = OpenGroupAPI.RoomPollInfo.mockValue.with( token: "testRoom", activeUsers: 10, - admin: false, - globalAdmin: false, - moderator: false, - globalModerator: false, - read: false, - defaultRead: nil, - defaultAccessible: nil, - write: false, - defaultWrite: nil, - upload: false, - defaultUpload: nil, - details: OpenGroupAPI.Room( + details: OpenGroupAPI.Room.mockValue.with( token: "test", name: "test", - roomDescription: nil, - infoUpdates: 0, - messageSequence: 0, - created: 0, - activeUsers: 0, - activeUsersCutoff: 0, - imageId: "10", - pinnedMessages: nil, - admin: false, - globalAdmin: false, - admins: [], - hiddenAdmins: nil, - moderator: false, - globalModerator: false, - moderators: [], - hiddenModerators: nil, - read: false, - defaultRead: nil, - defaultAccessible: nil, - write: false, - defaultWrite: nil, - upload: false, - defaultUpload: nil + imageId: "10" ) ) @@ -2098,11 +1859,10 @@ class OpenGroupManagerSpec: QuickSpec { for: "testRoom", on: "testServer", waitForImageToComplete: true, - dependencies: dependencies - ) { didComplete = true } + using: dependencies + ) } - expect(didComplete).toEventually(beTrue(), timeout: .milliseconds(50)) expect( mockStorage.read { db -> Data? in try OpenGroup @@ -2113,49 +1873,16 @@ class OpenGroupManagerSpec: QuickSpec { ).to(beNil()) } + // MARK: ---- saves the retrieved room image it("saves the retrieved room image") { - var didComplete: Bool = false // Prevent multi-threading test bugs - - testPollInfo = OpenGroupAPI.RoomPollInfo( + testPollInfo = OpenGroupAPI.RoomPollInfo.mockValue.with( token: "testRoom", activeUsers: 10, - admin: false, - globalAdmin: false, - moderator: false, - globalModerator: false, - read: false, - defaultRead: nil, - defaultAccessible: nil, - write: false, - defaultWrite: nil, - upload: false, - defaultUpload: nil, - details: OpenGroupAPI.Room( + details: OpenGroupAPI.Room.mockValue.with( token: "test", name: "test", - roomDescription: nil, infoUpdates: 10, - messageSequence: 0, - created: 0, - activeUsers: 0, - activeUsersCutoff: 0, - imageId: "10", - pinnedMessages: nil, - admin: false, - globalAdmin: false, - admins: [], - hiddenAdmins: nil, - moderator: false, - globalModerator: false, - moderators: [], - hiddenModerators: nil, - read: false, - defaultRead: nil, - defaultAccessible: nil, - write: false, - defaultWrite: nil, - upload: false, - defaultUpload: nil + imageId: "10" ) ) mockStorage.write { db in @@ -2166,11 +1893,10 @@ class OpenGroupManagerSpec: QuickSpec { for: "testRoom", on: "testServer", waitForImageToComplete: true, - dependencies: dependencies - ) { didComplete = true } + using: dependencies + ) } - expect(didComplete).toEventually(beTrue(), timeout: .milliseconds(50)) expect( mockStorage.read { db -> Data? in try OpenGroup @@ -2183,8 +1909,7 @@ class OpenGroupManagerSpec: QuickSpec { } } - // MARK: - --handleMessages - + // MARK: - when handling messages context("when handling messages") { beforeEach { mockStorage.write { db in @@ -2194,6 +1919,7 @@ class OpenGroupManagerSpec: QuickSpec { } } + // MARK: -- updates the sequence number when there are messages it("updates the sequence number when there are messages") { mockStorage.write { db in OpenGroupManager.handleMessages( @@ -2216,7 +1942,7 @@ class OpenGroupManagerSpec: QuickSpec { ], for: "testRoom", on: "testServer", - dependencies: dependencies + using: dependencies ) } @@ -2230,6 +1956,7 @@ class OpenGroupManagerSpec: QuickSpec { ).to(equal(124)) } + // MARK: -- does not update the sequence number if there are no messages it("does not update the sequence number if there are no messages") { mockStorage.write { db in OpenGroupManager.handleMessages( @@ -2237,7 +1964,7 @@ class OpenGroupManagerSpec: QuickSpec { messages: [], for: "testRoom", on: "testServer", - dependencies: dependencies + using: dependencies ) } @@ -2251,6 +1978,7 @@ class OpenGroupManagerSpec: QuickSpec { ).to(equal(5)) } + // MARK: -- ignores a message with no sender it("ignores a message with no sender") { mockStorage.write { db in try Interaction.deleteAll(db) @@ -2277,13 +2005,14 @@ class OpenGroupManagerSpec: QuickSpec { ], for: "testRoom", on: "testServer", - dependencies: dependencies + using: dependencies ) } expect(mockStorage.read { db -> Int in try Interaction.fetchCount(db) }).to(equal(0)) } + // MARK: -- ignores a message with invalid data it("ignores a message with invalid data") { mockStorage.write { db in try Interaction.deleteAll(db) @@ -2310,13 +2039,14 @@ class OpenGroupManagerSpec: QuickSpec { ], for: "testRoom", on: "testServer", - dependencies: dependencies + using: dependencies ) } expect(mockStorage.read { db -> Int in try Interaction.fetchCount(db) }).to(equal(0)) } + // MARK: -- processes a message with valid data it("processes a message with valid data") { mockStorage.write { db in OpenGroupManager.handleMessages( @@ -2324,13 +2054,14 @@ class OpenGroupManagerSpec: QuickSpec { messages: [testMessage], for: "testRoom", on: "testServer", - dependencies: dependencies + using: dependencies ) } expect(mockStorage.read { db -> Int in try Interaction.fetchCount(db) }).to(equal(1)) } + // MARK: -- processes valid messages when combined with invalid ones it("processes valid messages when combined with invalid ones") { mockStorage.write { db in OpenGroupManager.handleMessages( @@ -2354,14 +2085,16 @@ class OpenGroupManagerSpec: QuickSpec { ], for: "testRoom", on: "testServer", - dependencies: dependencies + using: dependencies ) } expect(mockStorage.read { db -> Int in try Interaction.fetchCount(db) }).to(equal(1)) } + // MARK: -- with no data context("with no data") { + // MARK: ---- deletes the message if we have the message it("deletes the message if we have the message") { mockStorage.write { db in try Interaction @@ -2392,13 +2125,14 @@ class OpenGroupManagerSpec: QuickSpec { ], for: "testRoom", on: "testServer", - dependencies: dependencies + using: dependencies ) } expect(mockStorage.read { db -> Int in try Interaction.fetchCount(db) }).to(equal(0)) } + // MARK: ---- does nothing if we do not have the message it("does nothing if we do not have the message") { mockStorage.write { db in OpenGroupManager.handleMessages( @@ -2421,7 +2155,7 @@ class OpenGroupManagerSpec: QuickSpec { ], for: "testRoom", on: "testServer", - dependencies: dependencies + using: dependencies ) } @@ -2430,41 +2164,49 @@ class OpenGroupManagerSpec: QuickSpec { } } - // MARK: - --handleDirectMessages - + // MARK: - when handling direct messages context("when handling direct messages") { beforeEach { - mockSodium - .when { - $0.sharedBlindedEncryptionKey( - secretKey: anyArray(), - otherBlindedPublicKey: anyArray(), - fromBlindedPublicKey: anyArray(), - toBlindedPublicKey: anyArray(), - genericHash: mockGenericHash + mockCrypto + .when { [dependencies = dependencies!] crypto in + try crypto.perform( + .sharedBlindedEncryptionKey( + secretKey: anyArray(), + otherBlindedPublicKey: anyArray(), + fromBlindedPublicKey: anyArray(), + toBlindedPublicKey: anyArray(), + using: dependencies + ) ) } .thenReturn([]) - mockSodium - .when { $0.generateBlindingFactor(serverPublicKey: any(), genericHash: mockGenericHash) } + mockCrypto + .when { [dependencies = dependencies!] crypto in + try crypto.perform( + .generateBlindingFactor(serverPublicKey: any(), using: dependencies) + ) + } .thenReturn([]) - mockAeadXChaCha20Poly1305Ietf + mockCrypto .when { - $0.decrypt( - authenticatedCipherText: anyArray(), - secretKey: anyArray(), - nonce: anyArray() + try $0.perform( + .decryptAeadXChaCha20( + authenticatedCipherText: anyArray(), + secretKey: anyArray(), + nonce: anyArray() + ) ) } .thenReturn( Data(base64Encoded:"ChQKC1Rlc3RNZXNzYWdlONCI7I/3Iw==")!.bytes + [UInt8](repeating: 0, count: 32) ) - mockSign - .when { $0.toX25519(ed25519PublicKey: anyArray()) } + mockCrypto + .when { try $0.perform(.toX25519(ed25519PublicKey: anyArray())) } .thenReturn(Data(hex: TestConstants.publicKey).bytes) } + // MARK: -- does nothing if there are no messages it("does nothing if there are no messages") { mockStorage.write { db in OpenGroupManager.handleDirectMessages( @@ -2472,7 +2214,7 @@ class OpenGroupManagerSpec: QuickSpec { messages: [], fromOutbox: false, on: "testServer", - dependencies: dependencies + using: dependencies ) } @@ -2494,6 +2236,7 @@ class OpenGroupManagerSpec: QuickSpec { ).to(equal(0)) } + // MARK: -- does nothing if it cannot get the open group it("does nothing if it cannot get the open group") { mockStorage.write { db in try OpenGroup.deleteAll(db) @@ -2505,7 +2248,7 @@ class OpenGroupManagerSpec: QuickSpec { messages: [testDirectMessage], fromOutbox: false, on: "testServer", - dependencies: dependencies + using: dependencies ) } @@ -2527,6 +2270,7 @@ class OpenGroupManagerSpec: QuickSpec { ).to(beNil()) } + // MARK: -- ignores messages with non base64 encoded data it("ignores messages with non base64 encoded data") { testDirectMessage = OpenGroupAPI.DirectMessage( id: testDirectMessage.id, @@ -2543,24 +2287,34 @@ class OpenGroupManagerSpec: QuickSpec { messages: [testDirectMessage], fromOutbox: false, on: "testServer", - dependencies: dependencies + using: dependencies ) } expect(mockStorage.read { db -> Int in try Interaction.fetchCount(db) }).to(equal(0)) } + // MARK: -- for the inbox context("for the inbox") { beforeEach { - mockSodium - .when { $0.combineKeys(lhsKeyBytes: anyArray(), rhsKeyBytes: anyArray()) } + mockCrypto + .when { try $0.perform(.combineKeys(lhsKeyBytes: anyArray(), rhsKeyBytes: anyArray())) } .thenReturn(Data(hex: testDirectMessage.sender.removingIdPrefixIfNeeded()).bytes) - - mockSodium - .when { $0.sessionId(any(), matchesBlindedId: any(), serverPublicKey: any(), genericHash: mockGenericHash) } + mockCrypto + .when { [dependencies = dependencies!] crypto in + crypto.verify( + .sessionId( + any(), + matchesBlindedId: any(), + serverPublicKey: any(), + using: dependencies + ) + ) + } .thenReturn(false) } + // MARK: ---- updates the inbox latest message id it("updates the inbox latest message id") { mockStorage.write { db in OpenGroupManager.handleDirectMessages( @@ -2568,7 +2322,7 @@ class OpenGroupManagerSpec: QuickSpec { messages: [testDirectMessage], fromOutbox: false, on: "testServer", - dependencies: dependencies + using: dependencies ) } @@ -2582,6 +2336,7 @@ class OpenGroupManagerSpec: QuickSpec { ).to(equal(128)) } + // MARK: ---- ignores a message with invalid data it("ignores a message with invalid data") { testDirectMessage = OpenGroupAPI.DirectMessage( id: testDirectMessage.id, @@ -2598,13 +2353,14 @@ class OpenGroupManagerSpec: QuickSpec { messages: [testDirectMessage], fromOutbox: false, on: "testServer", - dependencies: dependencies + using: dependencies ) } expect(mockStorage.read { db -> Int in try Interaction.fetchCount(db) }).to(equal(0)) } + // MARK: ---- processes a message with valid data it("processes a message with valid data") { mockStorage.write { db in OpenGroupManager.handleDirectMessages( @@ -2612,13 +2368,14 @@ class OpenGroupManagerSpec: QuickSpec { messages: [testDirectMessage], fromOutbox: false, on: "testServer", - dependencies: dependencies + using: dependencies ) } expect(mockStorage.read { db -> Int in try Interaction.fetchCount(db) }).to(equal(1)) } + // MARK: ---- processes valid messages when combined with invalid ones it("processes valid messages when combined with invalid ones") { mockStorage.write { db in OpenGroupManager.handleDirectMessages( @@ -2636,7 +2393,7 @@ class OpenGroupManagerSpec: QuickSpec { ], fromOutbox: false, on: "testServer", - dependencies: dependencies + using: dependencies ) } @@ -2644,17 +2401,27 @@ class OpenGroupManagerSpec: QuickSpec { } } + // MARK: -- for the outbox context("for the outbox") { beforeEach { - mockSodium - .when { $0.combineKeys(lhsKeyBytes: anyArray(), rhsKeyBytes: anyArray()) } + mockCrypto + .when { try $0.perform(.combineKeys(lhsKeyBytes: anyArray(), rhsKeyBytes: anyArray())) } .thenReturn(Data(hex: testDirectMessage.recipient.removingIdPrefixIfNeeded()).bytes) - - mockSodium - .when { $0.sessionId(any(), matchesBlindedId: any(), serverPublicKey: any(), genericHash: mockGenericHash) } + mockCrypto + .when { [dependencies = dependencies!] crypto in + crypto.verify( + .sessionId( + any(), + matchesBlindedId: any(), + serverPublicKey: any(), + using: dependencies + ) + ) + } .thenReturn(false) } + // MARK: ---- updates the outbox latest message id it("updates the outbox latest message id") { mockStorage.write { db in OpenGroupManager.handleDirectMessages( @@ -2662,7 +2429,7 @@ class OpenGroupManagerSpec: QuickSpec { messages: [testDirectMessage], fromOutbox: true, on: "testServer", - dependencies: dependencies + using: dependencies ) } @@ -2676,6 +2443,7 @@ class OpenGroupManagerSpec: QuickSpec { ).to(equal(128)) } + // MARK: ---- retrieves an existing blinded id lookup it("retrieves an existing blinded id lookup") { mockStorage.write { db in try BlindedIdLookup( @@ -2692,7 +2460,7 @@ class OpenGroupManagerSpec: QuickSpec { messages: [testDirectMessage], fromOutbox: true, on: "testServer", - dependencies: dependencies + using: dependencies ) } @@ -2700,6 +2468,7 @@ class OpenGroupManagerSpec: QuickSpec { expect(mockStorage.read { db -> Int in try SessionThread.fetchCount(db) }).to(equal(2)) } + // MARK: ---- falls back to using the blinded id if no lookup is found it("falls back to using the blinded id if no lookup is found") { mockStorage.write { db in OpenGroupManager.handleDirectMessages( @@ -2707,7 +2476,7 @@ class OpenGroupManagerSpec: QuickSpec { messages: [testDirectMessage], fromOutbox: true, on: "testServer", - dependencies: dependencies + using: dependencies ) } @@ -2728,6 +2497,7 @@ class OpenGroupManagerSpec: QuickSpec { ).toNot(beNil()) } + // MARK: ---- ignores a message with invalid data it("ignores a message with invalid data") { testDirectMessage = OpenGroupAPI.DirectMessage( id: testDirectMessage.id, @@ -2744,13 +2514,14 @@ class OpenGroupManagerSpec: QuickSpec { messages: [testDirectMessage], fromOutbox: true, on: "testServer", - dependencies: dependencies + using: dependencies ) } expect(mockStorage.read { db -> Int in try SessionThread.fetchCount(db) }).to(equal(1)) } + // MARK: ---- processes a message with valid data it("processes a message with valid data") { mockStorage.write { db in OpenGroupManager.handleDirectMessages( @@ -2758,13 +2529,14 @@ class OpenGroupManagerSpec: QuickSpec { messages: [testDirectMessage], fromOutbox: true, on: "testServer", - dependencies: dependencies + using: dependencies ) } expect(mockStorage.read { db -> Int in try SessionThread.fetchCount(db) }).to(equal(2)) } + // MARK: ---- processes valid messages when combined with invalid ones it("processes valid messages when combined with invalid ones") { mockStorage.write { db in OpenGroupManager.handleDirectMessages( @@ -2782,7 +2554,7 @@ class OpenGroupManagerSpec: QuickSpec { ], fromOutbox: true, on: "testServer", - dependencies: dependencies + using: dependencies ) } @@ -2791,10 +2563,7 @@ class OpenGroupManagerSpec: QuickSpec { } } - // MARK: - Convenience - - // MARK: - --isUserModeratorOrAdmin - + // MARK: - when determining if a user is a moderator or an admin context("when determining if a user is a moderator or an admin") { beforeEach { mockStorage.write { db in @@ -2802,6 +2571,7 @@ class OpenGroupManagerSpec: QuickSpec { } } + // MARK: -- uses an empty set for moderators by default it("uses an empty set for moderators by default") { expect( OpenGroupManager.isUserModeratorOrAdmin( @@ -2813,6 +2583,7 @@ class OpenGroupManagerSpec: QuickSpec { ).to(beFalse()) } + // MARK: -- uses an empty set for admins by default it("uses an empty set for admins by default") { expect( OpenGroupManager.isUserModeratorOrAdmin( @@ -2824,6 +2595,7 @@ class OpenGroupManagerSpec: QuickSpec { ).to(beFalse()) } + // MARK: -- returns true if the key is in the moderator set it("returns true if the key is in the moderator set") { mockStorage.write { db in try GroupMember( @@ -2844,6 +2616,7 @@ class OpenGroupManagerSpec: QuickSpec { ).to(beTrue()) } + // MARK: -- returns true if the key is in the admin set it("returns true if the key is in the admin set") { mockStorage.write { db in try GroupMember( @@ -2864,6 +2637,7 @@ class OpenGroupManagerSpec: QuickSpec { ).to(beTrue()) } + // MARK: -- returns true if the moderator is hidden it("returns true if the moderator is hidden") { mockStorage.write { db in try GroupMember( @@ -2884,6 +2658,7 @@ class OpenGroupManagerSpec: QuickSpec { ).to(beTrue()) } + // MARK: -- returns true if the admin is hidden it("returns true if the admin is hidden") { mockStorage.write { db in try GroupMember( @@ -2904,6 +2679,7 @@ class OpenGroupManagerSpec: QuickSpec { ).to(beTrue()) } + // MARK: -- returns false if the key is not a valid session id it("returns false if the key is not a valid session id") { expect( OpenGroupManager.isUserModeratorOrAdmin( @@ -2915,7 +2691,9 @@ class OpenGroupManagerSpec: QuickSpec { ).to(beFalse()) } + // MARK: -- and the key is a standard session id context("and the key is a standard session id") { + // MARK: ---- returns false if the key is not the users session id it("returns false if the key is not the users session id") { mockStorage.write { db in let otherKey: String = TestConstants.publicKey.replacingOccurrences(of: "7", with: "6") @@ -2934,6 +2712,7 @@ class OpenGroupManagerSpec: QuickSpec { ).to(beFalse()) } + // MARK: ---- returns true if the key is the current users and the users unblinded id is a moderator or admin it("returns true if the key is the current users and the users unblinded id is a moderator or admin") { mockStorage.write { db in let otherKey: String = TestConstants.publicKey.replacingOccurrences(of: "7", with: "6") @@ -2959,14 +2738,17 @@ class OpenGroupManagerSpec: QuickSpec { ).to(beTrue()) } + // MARK: ---- returns true if the key is the current users and the users blinded id is a moderator or admin it("returns true if the key is the current users and the users blinded id is a moderator or admin") { let otherKey: String = TestConstants.publicKey.replacingOccurrences(of: "7", with: "6") - mockSodium - .when { - $0.blindedKeyPair( - serverPublicKey: any(), - edKeyPair: any(), - genericHash: mockGenericHash + mockCrypto + .when { [dependencies = dependencies!] crypto in + crypto.generate( + .blindedKeyPair( + serverPublicKey: any(), + edKeyPair: any(), + using: dependencies + ) ) } .thenReturn( @@ -2995,7 +2777,9 @@ class OpenGroupManagerSpec: QuickSpec { } } + // MARK: -- and the key is unblinded context("and the key is unblinded") { + // MARK: ---- returns false if unable to retrieve the user ed25519 key it("returns false if unable to retrieve the user ed25519 key") { mockStorage.write { db in try Identity.filter(id: .ed25519PublicKey).deleteAll(db) @@ -3012,6 +2796,7 @@ class OpenGroupManagerSpec: QuickSpec { ).to(beFalse()) } + // MARK: ---- returns false if the key is not the users unblinded id it("returns false if the key is not the users unblinded id") { mockStorage.write { db in let otherKey: String = TestConstants.publicKey.replacingOccurrences(of: "7", with: "6") @@ -3030,6 +2815,7 @@ class OpenGroupManagerSpec: QuickSpec { ).to(beFalse()) } + // MARK: ---- returns true if the key is the current users and the users session id is a moderator or admin it("returns true if the key is the current users and the users session id is a moderator or admin") { let otherKey: String = TestConstants.publicKey.replacingOccurrences(of: "7", with: "6") mockGeneralCache.when { $0.encodedPublicKey }.thenReturn("05\(otherKey)") @@ -3057,14 +2843,17 @@ class OpenGroupManagerSpec: QuickSpec { ).to(beTrue()) } + // MARK: ---- returns true if the key is the current users and the users blinded id is a moderator or admin it("returns true if the key is the current users and the users blinded id is a moderator or admin") { let otherKey: String = TestConstants.publicKey.replacingOccurrences(of: "7", with: "6") - mockSodium - .when { - $0.blindedKeyPair( - serverPublicKey: any(), - edKeyPair: any(), - genericHash: mockGenericHash + mockCrypto + .when { [dependencies = dependencies!] crypto in + crypto.generate( + .blindedKeyPair( + serverPublicKey: any(), + edKeyPair: any(), + using: dependencies + ) ) } .thenReturn( @@ -3098,7 +2887,9 @@ class OpenGroupManagerSpec: QuickSpec { } } + // MARK: -- and the key is blinded context("and the key is blinded") { + // MARK: ---- returns false if unable to retrieve the user ed25519 key it("returns false if unable to retrieve the user ed25519 key") { mockStorage.write { db in try Identity.filter(id: .ed25519PublicKey).deleteAll(db) @@ -3115,13 +2906,16 @@ class OpenGroupManagerSpec: QuickSpec { ).to(beFalse()) } + // MARK: ---- returns false if unable generate a blinded key it("returns false if unable generate a blinded key") { - mockSodium - .when { - $0.blindedKeyPair( - serverPublicKey: any(), - edKeyPair: any(), - genericHash: mockGenericHash + mockCrypto + .when { [dependencies = dependencies!] crypto in + crypto.generate( + .blindedKeyPair( + serverPublicKey: any(), + edKeyPair: any(), + using: dependencies + ) ) } .thenReturn(nil) @@ -3136,14 +2930,17 @@ class OpenGroupManagerSpec: QuickSpec { ).to(beFalse()) } + // MARK: ---- returns false if the key is not the users blinded id it("returns false if the key is not the users blinded id") { let otherKey: String = TestConstants.publicKey.replacingOccurrences(of: "7", with: "6") - mockSodium - .when { - $0.blindedKeyPair( - serverPublicKey: any(), - edKeyPair: any(), - genericHash: mockGenericHash + mockCrypto + .when { [dependencies = dependencies!] crypto in + crypto.generate( + .blindedKeyPair( + serverPublicKey: any(), + edKeyPair: any(), + using: dependencies + ) ) } .thenReturn( @@ -3163,15 +2960,18 @@ class OpenGroupManagerSpec: QuickSpec { ).to(beFalse()) } + // MARK: ---- returns true if the key is the current users and the users session id is a moderator or admin it("returns true if the key is the current users and the users session id is a moderator or admin") { let otherKey: String = TestConstants.publicKey.replacingOccurrences(of: "7", with: "6") mockGeneralCache.when { $0.encodedPublicKey }.thenReturn("05\(otherKey)") - mockSodium - .when { - $0.blindedKeyPair( - serverPublicKey: any(), - edKeyPair: any(), - genericHash: mockGenericHash + mockCrypto + .when { [dependencies = dependencies!] crypto in + crypto.generate( + .blindedKeyPair( + serverPublicKey: any(), + edKeyPair: any(), + using: dependencies + ) ) } .thenReturn( @@ -3204,13 +3004,16 @@ class OpenGroupManagerSpec: QuickSpec { ).to(beTrue()) } + // MARK: ---- returns true if the key is the current users and the users unblinded id is a moderator or admin it("returns true if the key is the current users and the users unblinded id is a moderator or admin") { - mockSodium - .when { - $0.blindedKeyPair( - serverPublicKey: any(), - edKeyPair: any(), - genericHash: mockGenericHash + mockCrypto + .when { [dependencies = dependencies!] crypto in + crypto.generate( + .blindedKeyPair( + serverPublicKey: any(), + edKeyPair: any(), + using: dependencies + ) ) } .thenReturn( @@ -3247,67 +3050,12 @@ class OpenGroupManagerSpec: QuickSpec { } } - // MARK: - --getDefaultRoomsIfNeeded - + // MARK: - when getting the default rooms if needed context("when getting the default rooms if needed") { beforeEach { - class TestRoomsApi: TestOnionRequestAPI { - static let capabilitiesData: OpenGroupAPI.Capabilities = OpenGroupAPI.Capabilities(capabilities: [.sogs], missing: nil) - static let roomsData: [OpenGroupAPI.Room] = [ - TestCapabilitiesAndRoomApi.roomData, - OpenGroupAPI.Room( - token: "test2", - name: "test2", - roomDescription: nil, - infoUpdates: 11, - messageSequence: 0, - created: 0, - activeUsers: 0, - activeUsersCutoff: 0, - imageId: "12", - pinnedMessages: nil, - admin: false, - globalAdmin: false, - admins: [], - hiddenAdmins: nil, - moderator: false, - globalModerator: false, - moderators: [], - hiddenModerators: nil, - read: false, - defaultRead: nil, - defaultAccessible: nil, - write: false, - defaultWrite: nil, - upload: false, - defaultUpload: nil - ) - ] - - override class var mockResponse: Data? { - let responses: [Data] = [ - try! JSONEncoder().encode( - HTTP.BatchSubResponse( - code: 200, - headers: [:], - body: capabilitiesData, - failedToParseBody: false - ) - ), - try! JSONEncoder().encode( - HTTP.BatchSubResponse( - code: 200, - headers: [:], - body: roomsData, - failedToParseBody: false - ) - ) - ] - - return "[\(responses.map { String(data: $0, encoding: .utf8)! }.joined(separator: ","))]".data(using: .utf8) - } - } - dependencies = dependencies.with(onionApi: TestRoomsApi.self) + mockNetwork + .when { $0.send(.onionRequest(any(), to: any(), with: any())) } + .thenReturn(OpenGroupAPI.BatchResponse.mockCapabilitiesAndRoomsResponse) mockStorage.write { db in try OpenGroup.deleteAll(db) @@ -3335,6 +3083,7 @@ class OpenGroupManagerSpec: QuickSpec { }.thenReturn(()) } + // MARK: -- caches the publisher if there is no cached publisher it("caches the publisher if there is no cached publisher") { let publisher = OpenGroupManager.getDefaultRoomsIfNeeded(using: dependencies) @@ -3344,33 +3093,11 @@ class OpenGroupManagerSpec: QuickSpec { }) } + // MARK: -- returns the cached publisher if there is one it("returns the cached publisher if there is one") { - let uniqueRoomInstance: OpenGroupAPI.Room = OpenGroupAPI.Room( + let uniqueRoomInstance: OpenGroupAPI.Room = OpenGroupAPI.Room.mockValue.with( token: "UniqueId", - name: "", - roomDescription: nil, - infoUpdates: 0, - messageSequence: 0, - created: 0, - activeUsers: 0, - activeUsersCutoff: 0, - imageId: nil, - pinnedMessages: nil, - admin: false, - globalAdmin: false, - admins: [], - hiddenAdmins: nil, - moderator: false, - globalModerator: false, - moderators: [], - hiddenModerators: nil, - read: false, - defaultRead: nil, - defaultAccessible: nil, - write: false, - defaultWrite: nil, - upload: false, - defaultUpload: nil + name: "" ) let publisher = Future<[OpenGroupManager.DefaultRoomInfo], Error> { resolver in resolver(Result.success([(uniqueRoomInstance, nil)])) @@ -3384,10 +3111,13 @@ class OpenGroupManagerSpec: QuickSpec { .to(equal(publisher.firstValue()?.map { $0.room })) } + // MARK: -- stores the open group information it("stores the open group information") { OpenGroupManager.getDefaultRoomsIfNeeded(using: dependencies) - expect(mockStorage.read { db -> Int in try OpenGroup.fetchCount(db) }).to(equal(1)) + // 1 for the value returned from the API and 1 for the default added + // by the 'RetrieveDefaultOpenGroupRoomsJob' logic + expect(mockStorage.read { db -> Int in try OpenGroup.fetchCount(db) }).to(equal(2)) expect( mockStorage.read { db -> String? in try OpenGroup @@ -3414,6 +3144,7 @@ class OpenGroupManagerSpec: QuickSpec { ).to(beFalse()) } + // MARK: -- fetches rooms for the server it("fetches rooms for the server") { var response: [OpenGroupManager.DefaultRoomInfo]? @@ -3422,53 +3153,14 @@ class OpenGroupManagerSpec: QuickSpec { .sinkAndStore(in: &disposables) expect(response?.map { $0.room }) - .toEventually( - equal( - [ - TestCapabilitiesAndRoomApi.roomData, - OpenGroupAPI.Room( - token: "test2", - name: "test2", - roomDescription: nil, - infoUpdates: 11, - messageSequence: 0, - created: 0, - activeUsers: 0, - activeUsersCutoff: 0, - imageId: "12", - pinnedMessages: nil, - admin: false, - globalAdmin: false, - admins: [], - hiddenAdmins: nil, - moderator: false, - globalModerator: false, - moderators: [], - hiddenModerators: nil, - read: false, - defaultRead: nil, - defaultAccessible: nil, - write: false, - defaultWrite: nil, - upload: false, - defaultUpload: nil - ) - ] - ), - timeout: .milliseconds(50) - ) + .to(equal([OpenGroupAPI.Room.mockValue])) } + // MARK: -- will retry fetching rooms 8 times before it fails it("will retry fetching rooms 8 times before it fails") { - class TestRoomsApi: TestOnionRequestAPI { - static var callCounter: Int = 0 - - override class var mockResponse: Data? { - callCounter += 1 - return nil - } - } - dependencies = dependencies.with(onionApi: TestRoomsApi.self) + mockNetwork + .when { $0.send(.onionRequest(any(), to: any(), with: any())) } + .thenReturn(MockNetwork.nullResponse()) var error: Error? @@ -3476,19 +3168,16 @@ class OpenGroupManagerSpec: QuickSpec { .mapError { result -> Error in error.setting(to: result) } .sinkAndStore(in: &disposables) - expect(error?.localizedDescription) - .toEventually( - equal(HTTPError.invalidResponse.localizedDescription), - timeout: .milliseconds(50) - ) - expect(TestRoomsApi.callCounter).to(equal(9)) // First attempt + 8 retries + expect(error).to(matchError(HTTPError.parsingFailed)) + expect(mockNetwork) // First attempt + 8 retries + .to(call(.exactly(times: 9)) { $0.send(.onionRequest(any(), to: any(), with: any())) }) } + // MARK: -- removes the cache publisher if all retries fail it("removes the cache publisher if all retries fail") { - class TestRoomsApi: TestOnionRequestAPI { - override class var mockResponse: Data? { return nil } - } - dependencies = dependencies.with(onionApi: TestRoomsApi.self) + mockNetwork + .when { $0.send(.onionRequest(any(), to: any(), with: any())) } + .thenReturn(MockNetwork.nullResponse()) var error: Error? @@ -3496,93 +3185,69 @@ class OpenGroupManagerSpec: QuickSpec { .mapError { result -> Error in error.setting(to: result) } .sinkAndStore(in: &disposables) - expect(error?.localizedDescription) - .toEventually( - equal(HTTPError.invalidResponse.localizedDescription), - timeout: .milliseconds(50) - ) + expect(error) + .to(matchError(HTTPError.parsingFailed)) expect(mockOGMCache) .to(call(matchingParameters: true) { $0.defaultRoomsPublisher = nil }) } + // MARK: -- fetches the image for any rooms with images it("fetches the image for any rooms with images") { - class TestRoomsApi: TestOnionRequestAPI { - static let capabilitiesData: OpenGroupAPI.Capabilities = OpenGroupAPI.Capabilities(capabilities: [.sogs], missing: nil) - static let roomsData: [OpenGroupAPI.Room] = [ - OpenGroupAPI.Room( - token: "test2", - name: "test2", - roomDescription: nil, - infoUpdates: 11, - messageSequence: 0, - created: 0, - activeUsers: 0, - activeUsersCutoff: 0, - imageId: "12", - pinnedMessages: nil, - admin: false, - globalAdmin: false, - admins: [], - hiddenAdmins: nil, - moderator: false, - globalModerator: false, - moderators: [], - hiddenModerators: nil, - read: false, - defaultRead: nil, - defaultAccessible: nil, - write: false, - defaultWrite: nil, - upload: false, - defaultUpload: nil - ) - ] - - override class var mockResponse: Data? { - let responses: [Data] = [ - try! JSONEncoder().encode( - HTTP.BatchSubResponse( - code: 200, - headers: [:], - body: capabilitiesData, - failedToParseBody: false - ) - ), - try! JSONEncoder().encode( - HTTP.BatchSubResponse( - code: 200, - headers: [:], - body: roomsData, - failedToParseBody: false - ) - ) - ] - - return "[\(responses.map { String(data: $0, encoding: .utf8)! }.joined(separator: ","))]".data(using: .utf8) + mockNetwork + .when { + $0.send(.onionRequest( + URLRequest(url: URL(string: "https://open.getsession.org/sequence")!), + to: OpenGroupAPI.defaultServer, + with: OpenGroupAPI.defaultServerPublicKey + )) } - } + .thenReturn( + MockNetwork.batchResponseData( + with: [ + (OpenGroupAPI.Endpoint.capabilities, OpenGroupAPI.Capabilities.mockBatchSubResponse()), + ( + OpenGroupAPI.Endpoint.rooms, + [ + OpenGroupAPI.Room.mockValue.with( + token: "test2", + name: "test2", + infoUpdates: 11, + imageId: "12" + ) + ].batchSubResponse() + ) + ] + ) + ) + mockNetwork + .when { + $0.send( + .onionRequest( + URLRequest(url: URL(string: "https://open.getsession.org/room/test2/file/12")!), + to: OpenGroupAPI.defaultServer, + with: OpenGroupAPI.defaultServerPublicKey, + timeout: FileServerAPI.fileDownloadTimeout + ) + ) + } + .thenReturn(MockNetwork.response(data: Data([1, 2, 3]))) + let testDate: Date = Date(timeIntervalSince1970: 1234567890) - dependencies = dependencies.with( - onionApi: TestRoomsApi.self, - date: testDate - ) + dependencies.dateNow = testDate OpenGroupManager .getDefaultRoomsIfNeeded(using: dependencies) .sinkAndStore(in: &disposables) expect(mockUserDefaults) - .toEventually( - call(matchingParameters: true) { - $0.set( - testDate, - forKey: SNUserDefaults.Date.lastOpenGroupImageUpdate.rawValue - ) - }, - timeout: .milliseconds(100) - ) + .to(call(matchingParameters: true) { + $0.set( + testDate, + forKey: SNUserDefaults.Date.lastOpenGroupImageUpdate.rawValue + ) + }) expect( mockStorage.read { db -> Data? in try OpenGroup @@ -3591,25 +3256,23 @@ class OpenGroupManagerSpec: QuickSpec { .asRequest(of: Data.self) .fetchOne(db) } - ).to(equal(TestRoomsApi.mockResponse!)) + ).to(equal(Data([1, 2, 3]))) } } - // MARK: - --roomImage - + // MARK: - when getting a room image context("when getting a room image") { beforeEach { - class TestImageApi: TestOnionRequestAPI { - override class var mockResponse: Data? { return Data([1, 2, 3]) } - } - dependencies = dependencies.with(onionApi: TestImageApi.self) + mockNetwork + .when { $0.send(.onionRequest(any(), to: any(), with: any())) } + .thenReturn(MockNetwork.response(data: Data([1, 2, 3]))) - mockUserDefaults.when { (defaults: inout any UserDefaultsType) -> Any? in - defaults.object(forKey: any()) - }.thenReturn(nil) - mockUserDefaults.when { (defaults: inout any UserDefaultsType) -> Any? in - defaults.set(anyAny(), forKey: any()) - }.thenReturn(()) + mockUserDefaults + .when { (defaults: inout any UserDefaultsType) -> Any? in defaults.object(forKey: any()) } + .thenReturn(nil) + mockUserDefaults + .when { (defaults: inout any UserDefaultsType) -> Any? in defaults.set(anyAny(), forKey: any()) } + .thenReturn(()) mockOGMCache.when { $0.groupImagePublishers }.thenReturn([:]) mockStorage.write { db in @@ -3626,6 +3289,7 @@ class OpenGroupManagerSpec: QuickSpec { } } + // MARK: -- retrieves the image retrieval publisher from the cache if it exists it("retrieves the image retrieval publisher from the cache if it exists") { let publisher = Future { resolver in resolver(Result.success(Data([5, 4, 3, 2, 1]))) @@ -3648,11 +3312,11 @@ class OpenGroupManagerSpec: QuickSpec { .handleEvents(receiveOutput: { result = $0 }) .sinkAndStore(in: &disposables) - expect(result).toEventually(equal(publisher.firstValue()), timeout: .milliseconds(50)) + expect(result).to(equal(publisher.firstValue())) } + // MARK: -- does not save the fetched image to storage it("does not save the fetched image to storage") { - var didComplete: Bool = false OpenGroupManager .roomImage( fileId: "1", @@ -3661,10 +3325,8 @@ class OpenGroupManagerSpec: QuickSpec { existingData: nil, using: dependencies ) - .handleEvents(receiveCompletion: { _ in didComplete = true }) .sinkAndStore(in: &disposables) - expect(didComplete).toEventually(beTrue(), timeout: .milliseconds(50)) expect( mockStorage.read { db -> Data? in try OpenGroup @@ -3673,14 +3335,11 @@ class OpenGroupManagerSpec: QuickSpec { .asRequest(of: Data.self) .fetchOne(db) } - ).toEventually( - beNil(), - timeout: .milliseconds(50) - ) + ).to(beNil()) } + // MARK: -- does not update the image update timestamp it("does not update the image update timestamp") { - var didComplete: Bool = false OpenGroupManager .roomImage( fileId: "1", @@ -3689,37 +3348,19 @@ class OpenGroupManagerSpec: QuickSpec { existingData: nil, using: dependencies ) - .handleEvents(receiveCompletion: { _ in didComplete = true }) .sinkAndStore(in: &disposables) - expect(didComplete).toEventually(beTrue(), timeout: .milliseconds(50)) expect(mockUserDefaults) - .toEventuallyNot( - call(matchingParameters: true) { - $0.set( - dependencies.date, - forKey: SNUserDefaults.Date.lastOpenGroupImageUpdate.rawValue - ) - }, - timeout: .milliseconds(50) - ) + .toNot(call(matchingParameters: true) { + $0.set( + dependencies.dateNow, + forKey: SNUserDefaults.Date.lastOpenGroupImageUpdate.rawValue + ) + }) } + // MARK: -- adds the image retrieval publisher to the cache it("adds the image retrieval publisher to the cache") { - class TestNeverReturningApi: OnionRequestAPIType { - static func sendOnionRequest(_ request: URLRequest, to server: String, with x25519PublicKey: String, timeout: TimeInterval) -> AnyPublisher<(ResponseInfoType, Data?), Error> { - return Future<(ResponseInfoType, Data?), Error> { _ in }.eraseToAnyPublisher() - } - - static func sendOnionRequest(_ payload: Data, to snode: Snode, timeout: TimeInterval) -> AnyPublisher<(ResponseInfoType, Data?), Error> { - return Just(Data()) - .setFailureType(to: Error.self) - .map { data in (HTTP.ResponseInfo(code: 0, headers: [:]), data) } - .eraseToAnyPublisher() - } - } - dependencies = dependencies.with(onionApi: TestNeverReturningApi.self) - let publisher = OpenGroupManager .roomImage( fileId: "1", @@ -3731,15 +3372,14 @@ class OpenGroupManagerSpec: QuickSpec { publisher.sinkAndStore(in: &disposables) expect(mockOGMCache) - .toEventually( - call(matchingParameters: true) { - $0.groupImagePublishers = [OpenGroup.idFor(roomToken: "testRoom", server: "testServer"): publisher] - }, - timeout: .milliseconds(50) - ) + .to(call(matchingParameters: true) { + $0.groupImagePublishers = [OpenGroup.idFor(roomToken: "testRoom", server: "testServer"): publisher] + }) } + // MARK: -- for the default server context("for the default server") { + // MARK: ---- fetches a new image if there is no cached one it("fetches a new image if there is no cached one") { var result: Data? @@ -3754,12 +3394,11 @@ class OpenGroupManagerSpec: QuickSpec { .handleEvents(receiveOutput: { (data: Data) in result = data }) .sinkAndStore(in: &disposables) - expect(result).toEventually(equal(Data([1, 2, 3])), timeout: .milliseconds(50)) + expect(result).to(equal(Data([1, 2, 3]))) } + // MARK: ---- saves the fetched image to storage it("saves the fetched image to storage") { - var didComplete: Bool = false - OpenGroupManager .roomImage( fileId: "1", @@ -3768,10 +3407,8 @@ class OpenGroupManagerSpec: QuickSpec { existingData: nil, using: dependencies ) - .handleEvents(receiveCompletion: { _ in didComplete = true }) .sinkAndStore(in: &disposables) - expect(didComplete).toEventually(beTrue(), timeout: .milliseconds(50)) expect( mockStorage.read { db -> Data? in try OpenGroup @@ -3780,15 +3417,11 @@ class OpenGroupManagerSpec: QuickSpec { .asRequest(of: Data.self) .fetchOne(db) } - ).toEventuallyNot( - beNil(), - timeout: .milliseconds(50) - ) + ).toNot(beNil()) } + // MARK: ---- updates the image update timestamp it("updates the image update timestamp") { - var didComplete: Bool = false - OpenGroupManager .roomImage( fileId: "1", @@ -3797,30 +3430,26 @@ class OpenGroupManagerSpec: QuickSpec { existingData: nil, using: dependencies ) - .handleEvents(receiveCompletion: { _ in didComplete = true }) .sinkAndStore(in: &disposables) - expect(didComplete).toEventually(beTrue(), timeout: .milliseconds(50)) expect(mockUserDefaults) - .toEventually( - call(matchingParameters: true) { - $0.set( - dependencies.date, - forKey: SNUserDefaults.Date.lastOpenGroupImageUpdate.rawValue - ) - }, - timeout: .milliseconds(50) - ) + .to(call(matchingParameters: true) { + $0.set( + dependencies.dateNow, + forKey: SNUserDefaults.Date.lastOpenGroupImageUpdate.rawValue + ) + }) } + // MARK: ---- and there is a cached image context("and there is a cached image") { beforeEach { - dependencies = dependencies.with(date: Date(timeIntervalSince1970: 1234567890)) + dependencies.dateNow = Date(timeIntervalSince1970: 1234567890) mockUserDefaults .when { (defaults: inout any UserDefaultsType) -> Any? in defaults.object(forKey: any()) } - .thenReturn(dependencies.date) + .thenReturn(dependencies.dateNow) mockStorage.write(updates: { db in try OpenGroup .filter(id: OpenGroup.idFor(roomToken: "testRoom", server: OpenGroupAPI.defaultServer)) @@ -3831,6 +3460,7 @@ class OpenGroupManagerSpec: QuickSpec { }) } + // MARK: ------ retrieves the cached image it("retrieves the cached image") { var result: Data? @@ -3845,13 +3475,14 @@ class OpenGroupManagerSpec: QuickSpec { .handleEvents(receiveOutput: { (data: Data) in result = data }) .sinkAndStore(in: &disposables) - expect(result).toEventually(equal(Data([2, 3, 4])), timeout: .milliseconds(50)) + expect(result).to(equal(Data([2, 3, 4]))) } + // MARK: ------ fetches a new image if the cached on is older than a week it("fetches a new image if the cached on is older than a week") { let weekInSeconds: TimeInterval = (7 * 24 * 60 * 60) let targetTimestamp: TimeInterval = ( - dependencies.date.timeIntervalSince1970 - weekInSeconds - 1 + dependencies.dateNow.timeIntervalSince1970 - weekInSeconds - 1 ) mockUserDefaults .when { (defaults: inout any UserDefaultsType) -> Any? in @@ -3872,7 +3503,7 @@ class OpenGroupManagerSpec: QuickSpec { .handleEvents(receiveOutput: { (data: Data) in result = data }) .sinkAndStore(in: &disposables) - expect(result).toEventually(equal(Data([1, 2, 3])), timeout: .milliseconds(50)) + expect(result).to(equal(Data([1, 2, 3]))) } } } @@ -3881,34 +3512,38 @@ class OpenGroupManagerSpec: QuickSpec { } } -// MARK: - Room Convenience Extensions +// MARK: - Convenience Extensions extension OpenGroupAPI.Room { func with( - moderators: [String], - hiddenModerators: [String], - admins: [String], - hiddenAdmins: [String] + token: String? = nil, + name: String? = nil, + infoUpdates: Int64? = nil, + imageId: String? = nil, + moderators: [String]? = nil, + hiddenModerators: [String]? = nil, + admins: [String]? = nil, + hiddenAdmins: [String]? = nil ) -> OpenGroupAPI.Room { return OpenGroupAPI.Room( - token: self.token, - name: self.name, + token: (token ?? self.token), + name: (name ?? self.name), roomDescription: self.roomDescription, - infoUpdates: self.infoUpdates, + infoUpdates: (infoUpdates ?? self.infoUpdates), messageSequence: self.messageSequence, created: self.created, activeUsers: self.activeUsers, activeUsersCutoff: self.activeUsersCutoff, - imageId: self.imageId, + imageId: (imageId ?? self.imageId), pinnedMessages: self.pinnedMessages, admin: self.admin, globalAdmin: self.globalAdmin, - admins: admins, - hiddenAdmins: hiddenAdmins, + admins: (admins ?? self.admins), + hiddenAdmins: (hiddenAdmins ?? self.hiddenAdmins), moderator: self.moderator, globalModerator: self.globalModerator, - moderators: moderators, - hiddenModerators: hiddenModerators, + moderators: (moderators ?? self.moderators), + hiddenModerators: (hiddenModerators ?? self.hiddenModerators), read: self.read, defaultRead: self.defaultRead, defaultAccessible: self.defaultAccessible, @@ -3919,3 +3554,160 @@ extension OpenGroupAPI.Room { ) } } + +extension OpenGroupAPI.RoomPollInfo { + func with( + token: String? = nil, + activeUsers: Int64? = nil, + details: OpenGroupAPI.Room? = .mockValue + ) -> OpenGroupAPI.RoomPollInfo { + return OpenGroupAPI.RoomPollInfo( + token: (token ?? self.token), + activeUsers: (activeUsers ?? self.activeUsers), + admin: self.admin, + globalAdmin: self.globalAdmin, + moderator: self.moderator, + globalModerator: self.globalModerator, + read: self.read, + defaultRead: self.defaultRead, + defaultAccessible: self.defaultAccessible, + write: self.write, + defaultWrite: self.defaultWrite, + upload: self.upload, + defaultUpload: self.defaultUpload, + details: details + ) + } +} + +// MARK: - Mock Types + +extension OpenGroupAPI.Capabilities: Mocked { + static var mockValue: OpenGroupAPI.Capabilities = OpenGroupAPI.Capabilities(capabilities: [], missing: nil) +} + +extension OpenGroupAPI.Room: Mocked { + static var mockValue: OpenGroupAPI.Room = OpenGroupAPI.Room( + token: "test", + name: "testRoom", + roomDescription: nil, + infoUpdates: 1, + messageSequence: 1, + created: 1, + activeUsers: 1, + activeUsersCutoff: 1, + imageId: nil, + pinnedMessages: nil, + admin: false, + globalAdmin: false, + admins: [], + hiddenAdmins: nil, + moderator: false, + globalModerator: false, + moderators: [], + hiddenModerators: nil, + read: true, + defaultRead: nil, + defaultAccessible: nil, + write: true, + defaultWrite: nil, + upload: true, + defaultUpload: nil + ) +} + +extension OpenGroupAPI.RoomPollInfo: Mocked { + static var mockValue: OpenGroupAPI.RoomPollInfo = OpenGroupAPI.RoomPollInfo( + token: "test", + activeUsers: 1, + admin: false, + globalAdmin: false, + moderator: false, + globalModerator: false, + read: true, + defaultRead: nil, + defaultAccessible: nil, + write: true, + defaultWrite: nil, + upload: true, + defaultUpload: false, + details: .mockValue + ) +} + +extension OpenGroupAPI.Message: Mocked { + static var mockValue: OpenGroupAPI.Message = OpenGroupAPI.Message( + id: 100, + sender: TestConstants.blindedPublicKey, + posted: 1, + edited: nil, + deleted: nil, + seqNo: 1, + whisper: false, + whisperMods: false, + whisperTo: nil, + base64EncodedData: nil, + base64EncodedSignature: nil, + reactions: nil + ) +} + +extension OpenGroupAPI.SendDirectMessageResponse: Mocked { + static var mockValue: OpenGroupAPI.SendDirectMessageResponse = OpenGroupAPI.SendDirectMessageResponse( + id: 1, + sender: TestConstants.blindedPublicKey, + recipient: "testRecipient", + posted: 1122, + expires: 2233 + ) +} + +extension OpenGroupAPI.DirectMessage: Mocked { + static var mockValue: OpenGroupAPI.DirectMessage = OpenGroupAPI.DirectMessage( + id: 101, + sender: TestConstants.blindedPublicKey, + recipient: "testRecipient", + posted: 1212, + expires: 2323, + base64EncodedMessage: "TestMessage".data(using: .utf8)!.base64EncodedString() + ) +} + +extension OpenGroupAPI.BatchResponse { + static let mockUnblindedPollResponse: AnyPublisher<(ResponseInfoType, Data?), Error> = MockNetwork.batchResponseData( + with: [ + (OpenGroupAPI.Endpoint.capabilities, OpenGroupAPI.Capabilities.mockBatchSubResponse()), + (OpenGroupAPI.Endpoint.roomPollInfo("testRoom", 0), OpenGroupAPI.RoomPollInfo.mockBatchSubResponse()), + (OpenGroupAPI.Endpoint.roomMessagesRecent("testRoom"), [OpenGroupAPI.Message].mockBatchSubResponse()) + ] + ) + + static let mockBlindedPollResponse: AnyPublisher<(ResponseInfoType, Data?), Error> = MockNetwork.batchResponseData( + with: [ + (OpenGroupAPI.Endpoint.capabilities, OpenGroupAPI.Capabilities.mockBatchSubResponse()), + (OpenGroupAPI.Endpoint.roomPollInfo("testRoom", 0), OpenGroupAPI.RoomPollInfo.mockBatchSubResponse()), + (OpenGroupAPI.Endpoint.roomMessagesRecent("testRoom"), OpenGroupAPI.Message.mockBatchSubResponse()), + (OpenGroupAPI.Endpoint.inboxSince(id: 0), OpenGroupAPI.DirectMessage.mockBatchSubResponse()), + (OpenGroupAPI.Endpoint.outboxSince(id: 0), OpenGroupAPI.DirectMessage.self.mockBatchSubResponse()) + ] + ) + + static let mockCapabilitiesResponse: AnyPublisher<(ResponseInfoType, Data?), Error> = MockNetwork.batchResponseData( + with: [ + (OpenGroupAPI.Endpoint.capabilities, OpenGroupAPI.Capabilities.mockBatchSubResponse()) + ] + ) + + static let mockRoomResponse: AnyPublisher<(ResponseInfoType, Data?), Error> = MockNetwork.batchResponseData( + with: [ + (OpenGroupAPI.Endpoint.capabilities, OpenGroupAPI.Room.mockBatchSubResponse()) + ] + ) + + static let mockBanAndDeleteAllResponse: AnyPublisher<(ResponseInfoType, Data?), Error> = MockNetwork.batchResponseData( + with: [ + (OpenGroupAPI.Endpoint.userBan(""), NoResponse.mockBatchSubResponse()), + (OpenGroupAPI.Endpoint.roomDeleteMessages("testRoon", sessionId: ""), NoResponse.mockBatchSubResponse()) + ] + ) +} diff --git a/SessionMessagingKitTests/Open Groups/Types/SodiumProtocolsSpec.swift b/SessionMessagingKitTests/Open Groups/Types/SodiumProtocolsSpec.swift deleted file mode 100644 index f27eee889..000000000 --- a/SessionMessagingKitTests/Open Groups/Types/SodiumProtocolsSpec.swift +++ /dev/null @@ -1,98 +0,0 @@ -// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. - -import Foundation - -import Quick -import Nimble - -@testable import SessionMessagingKit - -class SodiumProtocolsSpec: QuickSpec { - // MARK: - Spec - - override func spec() { - describe("an AeadXChaCha20Poly1305IetfType") { - let testValue: [UInt8] = [1, 2, 3] - - it("provides the default values in it's extensions") { - let mockAead: MockAeadXChaCha20Poly1305Ietf = MockAeadXChaCha20Poly1305Ietf() - mockAead - .when { - $0.encrypt( - message: anyArray(), - secretKey: anyArray(), - nonce: anyArray(), - additionalData: anyArray() - ) - } - .thenReturn(testValue) - mockAead - .when { - $0.decrypt( - authenticatedCipherText: anyArray(), - secretKey: anyArray(), - nonce: anyArray(), - additionalData: anyArray() - ) - } - .thenReturn(testValue) - - _ = mockAead.encrypt(message: [], secretKey: [], nonce: []) - _ = mockAead.decrypt(authenticatedCipherText: [], secretKey: [], nonce: []) - - expect(mockAead) - .to(call { - $0.encrypt(message: anyArray(), secretKey: anyArray(), nonce: anyArray(), additionalData: anyArray()) - }) - - expect(mockAead) - .to(call { - $0.decrypt( - authenticatedCipherText: anyArray(), - secretKey: anyArray(), - nonce: anyArray(), - additionalData: anyArray() - ) - }) - } - } - - describe("a GenericHashType") { - let testValue: [UInt8] = [1, 2, 3] - - it("provides the default values in it's extensions") { - let mockGenericHash: MockGenericHash = MockGenericHash() - mockGenericHash - .when { $0.hash(message: anyArray(), key: anyArray()) } - .thenReturn(testValue) - mockGenericHash - .when { - $0.hashSaltPersonal( - message: anyArray(), - outputLength: any(), - key: anyArray(), - salt: anyArray(), - personal: anyArray() - ) - } - .thenReturn(testValue) - - _ = mockGenericHash.hash(message: []) - _ = mockGenericHash.hashSaltPersonal(message: [], outputLength: 0, salt: [], personal: []) - - expect(mockGenericHash) - .to(call { $0.hash(message: anyArray(), key: anyArray()) }) - expect(mockGenericHash) - .to(call { - $0.hashSaltPersonal( - message: anyArray(), - outputLength: any(), - key: anyArray(), - salt: anyArray(), - personal: anyArray() - ) - }) - } - } - } -} diff --git a/SessionMessagingKitTests/Sending & Receiving/MessageReceiverDecryptionSpec.swift b/SessionMessagingKitTests/Sending & Receiving/MessageReceiverDecryptionSpec.swift index 7f9a45a13..4429b0ecd 100644 --- a/SessionMessagingKitTests/Sending & Receiving/MessageReceiverDecryptionSpec.swift +++ b/SessionMessagingKitTests/Sending & Receiving/MessageReceiverDecryptionSpec.swift @@ -15,93 +15,110 @@ class MessageReceiverDecryptionSpec: QuickSpec { override func spec() { var mockStorage: Storage! - var mockSodium: MockSodium! - var mockBox: MockBox! - var mockGenericHash: MockGenericHash! - var mockSign: MockSign! - var mockAeadXChaCha: MockAeadXChaCha20Poly1305Ietf! - var mockNonce24Generator: MockNonce24Generator! - var dependencies: SMKDependencies! + var mockCrypto: MockCrypto! + var dependencies: Dependencies! describe("a MessageReceiver") { beforeEach { - mockStorage = Storage( + mockStorage = SynchronousStorage( customWriter: try! DatabaseQueue(), customMigrationTargets: [ SNUtilitiesKit.self, SNMessagingKit.self ] ) - mockSodium = MockSodium() - mockBox = MockBox() - mockGenericHash = MockGenericHash() - mockSign = MockSign() - mockAeadXChaCha = MockAeadXChaCha20Poly1305Ietf() - mockNonce24Generator = MockNonce24Generator() - - mockAeadXChaCha - .when { $0.encrypt(message: anyArray(), secretKey: anyArray(), nonce: anyArray()) } - .thenReturn(nil) - - dependencies = SMKDependencies( + mockCrypto = MockCrypto() + dependencies = Dependencies( storage: mockStorage, - sodium: mockSodium, - box: mockBox, - genericHash: mockGenericHash, - sign: mockSign, - aeadXChaCha20Poly1305Ietf: mockAeadXChaCha, - nonceGenerator24: mockNonce24Generator + crypto: mockCrypto ) mockStorage.write { db in try Identity(variant: .ed25519PublicKey, data: Data(hex: TestConstants.edPublicKey)).insert(db) try Identity(variant: .ed25519SecretKey, data: Data(hex: TestConstants.edSecretKey)).insert(db) } - mockBox + mockCrypto + .when { [dependencies = dependencies!] crypto in + try crypto.perform( + .encryptAeadXChaCha20( + message: anyArray(), + secretKey: anyArray(), + nonce: anyArray(), + using: dependencies + ) + ) + } + .thenReturn(nil) + mockCrypto .when { - $0.open( - anonymousCipherText: anyArray(), - recipientPublicKey: anyArray(), - recipientSecretKey: anyArray() + try $0.perform( + .open( + anonymousCipherText: anyArray(), + recipientPublicKey: anyArray(), + recipientSecretKey: anyArray() + ) ) } .thenReturn([UInt8](repeating: 0, count: 100)) - mockSodium - .when { $0.blindedKeyPair(serverPublicKey: any(), edKeyPair: any(), genericHash: mockGenericHash) } + mockCrypto + .when { [dependencies = dependencies!] crypto in + crypto.generate( + .blindedKeyPair( + serverPublicKey: any(), + edKeyPair: any(), + using: dependencies + ) + ) + } .thenReturn( KeyPair( publicKey: Data(hex: TestConstants.blindedPublicKey).bytes, secretKey: Data(hex: TestConstants.edSecretKey).bytes ) ) - mockSodium - .when { - $0.sharedBlindedEncryptionKey( - secretKey: anyArray(), - otherBlindedPublicKey: anyArray(), - fromBlindedPublicKey: anyArray(), - toBlindedPublicKey: anyArray(), - genericHash: mockGenericHash + mockCrypto + .when { [dependencies = dependencies!] crypto in + try crypto.perform( + .sharedBlindedEncryptionKey( + secretKey: anyArray(), + otherBlindedPublicKey: anyArray(), + fromBlindedPublicKey: anyArray(), + toBlindedPublicKey: anyArray(), + using: dependencies + ) ) } .thenReturn([]) - mockSodium - .when { $0.generateBlindingFactor(serverPublicKey: any(), genericHash: mockGenericHash) } + mockCrypto + .when { [dependencies = dependencies!] crypto in + try crypto.perform(.generateBlindingFactor(serverPublicKey: any(), using: dependencies)) + } .thenReturn([]) - mockSodium - .when { $0.combineKeys(lhsKeyBytes: anyArray(), rhsKeyBytes: anyArray()) } + mockCrypto + .when { try $0.perform(.combineKeys(lhsKeyBytes: anyArray(), rhsKeyBytes: anyArray())) } .thenReturn(Data(hex: TestConstants.blindedPublicKey).bytes) - mockSign - .when { $0.toX25519(ed25519PublicKey: anyArray()) } + mockCrypto + .when { try $0.perform(.toX25519(ed25519PublicKey: anyArray())) } .thenReturn(Data(hex: TestConstants.publicKey).bytes) - mockSign - .when { $0.verify(message: anyArray(), publicKey: anyArray(), signature: anyArray()) } + mockCrypto + .when { $0.verify(.signature(message: anyArray(), publicKey: anyArray(), signature: anyArray())) } .thenReturn(true) - mockAeadXChaCha - .when { $0.decrypt(authenticatedCipherText: anyArray(), secretKey: anyArray(), nonce: anyArray()) } + mockCrypto + .when { + try $0.perform( + .decryptAeadXChaCha20( + authenticatedCipherText: anyArray(), + secretKey: anyArray(), + nonce: anyArray() + ) + ) + } .thenReturn("TestMessage".data(using: .utf8)!.bytes + [UInt8](repeating: 0, count: 32)) - mockNonce24Generator - .when { $0.nonce() } + mockCrypto.when { $0.size(.nonce24) }.thenReturn(24) + mockCrypto.when { $0.size(.publicKey) }.thenReturn(32) + mockCrypto.when { $0.size(.signature) }.thenReturn(64) + mockCrypto + .when { try $0.perform(.generateNonce24()) } .thenReturn(Data(base64Encoded: "pbTUizreT0sqJ2R2LloseQDyVL2RYztD")!.bytes) } @@ -117,7 +134,7 @@ class MessageReceiverDecryptionSpec: QuickSpec { publicKey: Data.data(fromHex: TestConstants.publicKey)!.bytes, secretKey: Data.data(fromHex: TestConstants.privateKey)!.bytes ), - dependencies: SMKDependencies() + using: Dependencies() ) expect(String(data: (result?.plaintext ?? Data()), encoding: .utf8)).to(equal("TestMessage")) @@ -126,12 +143,14 @@ class MessageReceiverDecryptionSpec: QuickSpec { } it("throws an error if it cannot open the message") { - mockBox + mockCrypto .when { - $0.open( - anonymousCipherText: anyArray(), - recipientPublicKey: anyArray(), - recipientSecretKey: anyArray() + try $0.perform( + .open( + anonymousCipherText: anyArray(), + recipientPublicKey: anyArray(), + recipientSecretKey: anyArray() + ) ) } .thenReturn(nil) @@ -143,19 +162,21 @@ class MessageReceiverDecryptionSpec: QuickSpec { publicKey: Data.data(fromHex: TestConstants.publicKey)!.bytes, secretKey: Data.data(fromHex: TestConstants.privateKey)!.bytes ), - dependencies: dependencies + using: dependencies ) } .to(throwError(MessageReceiverError.decryptionFailed)) } it("throws an error if the open message is too short") { - mockBox + mockCrypto .when { - $0.open( - anonymousCipherText: anyArray(), - recipientPublicKey: anyArray(), - recipientSecretKey: anyArray() + try $0.perform( + .open( + anonymousCipherText: anyArray(), + recipientPublicKey: anyArray(), + recipientSecretKey: anyArray() + ) ) } .thenReturn([1, 2, 3]) @@ -167,15 +188,15 @@ class MessageReceiverDecryptionSpec: QuickSpec { publicKey: Data.data(fromHex: TestConstants.publicKey)!.bytes, secretKey: Data.data(fromHex: TestConstants.privateKey)!.bytes ), - dependencies: dependencies + using: dependencies ) } .to(throwError(MessageReceiverError.decryptionFailed)) } it("throws an error if it cannot verify the message") { - mockSign - .when { $0.verify(message: anyArray(), publicKey: anyArray(), signature: anyArray()) } + mockCrypto + .when { $0.verify(.signature(message: anyArray(), publicKey: anyArray(), signature: anyArray())) } .thenReturn(false) expect { @@ -185,14 +206,14 @@ class MessageReceiverDecryptionSpec: QuickSpec { publicKey: Data.data(fromHex: TestConstants.publicKey)!.bytes, secretKey: Data.data(fromHex: TestConstants.privateKey)!.bytes ), - dependencies: dependencies + using: dependencies ) } .to(throwError(MessageReceiverError.invalidSignature)) } it("throws an error if it cannot get the senders x25519 public key") { - mockSign.when { $0.toX25519(ed25519PublicKey: anyArray()) }.thenReturn(nil) + mockCrypto.when { try $0.perform(.toX25519(ed25519PublicKey: anyArray())) }.thenReturn(nil) expect { try MessageReceiver.decryptWithSessionProtocol( @@ -201,7 +222,7 @@ class MessageReceiverDecryptionSpec: QuickSpec { publicKey: Data.data(fromHex: TestConstants.publicKey)!.bytes, secretKey: Data.data(fromHex: TestConstants.privateKey)!.bytes ), - dependencies: dependencies + using: dependencies ) } .to(throwError(MessageReceiverError.decryptionFailed)) @@ -223,7 +244,7 @@ class MessageReceiverDecryptionSpec: QuickSpec { publicKey: Data.data(fromHex: TestConstants.edPublicKey)!.bytes, secretKey: Data.data(fromHex: TestConstants.edSecretKey)!.bytes ), - using: SMKDependencies() + using: Dependencies() ) expect(String(data: (result?.plaintext ?? Data()), encoding: .utf8)).to(equal("TestMessage")) @@ -271,8 +292,16 @@ class MessageReceiverDecryptionSpec: QuickSpec { } it("throws an error if it cannot get the blinded keyPair") { - mockSodium - .when { $0.blindedKeyPair(serverPublicKey: any(), edKeyPair: any(), genericHash: mockGenericHash) } + mockCrypto + .when { [dependencies = dependencies!] crypto in + crypto.generate( + .blindedKeyPair( + serverPublicKey: any(), + edKeyPair: any(), + using: dependencies + ) + ) + } .thenReturn(nil) expect { @@ -296,14 +325,16 @@ class MessageReceiverDecryptionSpec: QuickSpec { } it("throws an error if it cannot get the decryption key") { - mockSodium - .when { - $0.sharedBlindedEncryptionKey( - secretKey: anyArray(), - otherBlindedPublicKey: anyArray(), - fromBlindedPublicKey: anyArray(), - toBlindedPublicKey: anyArray(), - genericHash: mockGenericHash + mockCrypto + .when { [dependencies = dependencies!] crypto in + try crypto.perform( + .sharedBlindedEncryptionKey( + secretKey: anyArray(), + otherBlindedPublicKey: anyArray(), + fromBlindedPublicKey: anyArray(), + toBlindedPublicKey: anyArray(), + using: dependencies + ) ) } .thenReturn(nil) @@ -350,8 +381,16 @@ class MessageReceiverDecryptionSpec: QuickSpec { } it("throws an error if it cannot decrypt the data") { - mockAeadXChaCha - .when { $0.decrypt(authenticatedCipherText: anyArray(), secretKey: anyArray(), nonce: anyArray()) } + mockCrypto + .when { + try $0.perform( + .decryptAeadXChaCha20( + authenticatedCipherText: anyArray(), + secretKey: anyArray(), + nonce: anyArray() + ) + ) + } .thenReturn(nil) expect { @@ -375,8 +414,16 @@ class MessageReceiverDecryptionSpec: QuickSpec { } it("throws an error if the inner bytes are too short") { - mockAeadXChaCha - .when { $0.decrypt(authenticatedCipherText: anyArray(), secretKey: anyArray(), nonce: anyArray()) } + mockCrypto + .when { + try $0.perform( + .decryptAeadXChaCha20( + authenticatedCipherText: anyArray(), + secretKey: anyArray(), + nonce: anyArray() + ) + ) + } .thenReturn([1, 2, 3]) expect { @@ -400,8 +447,10 @@ class MessageReceiverDecryptionSpec: QuickSpec { } it("throws an error if it cannot generate the blinding factor") { - mockSodium - .when { $0.generateBlindingFactor(serverPublicKey: any(), genericHash: mockGenericHash) } + mockCrypto + .when { [dependencies = dependencies!] crypto in + try crypto.perform(.generateBlindingFactor(serverPublicKey: any(), using: dependencies)) + } .thenReturn(nil) expect { @@ -425,8 +474,8 @@ class MessageReceiverDecryptionSpec: QuickSpec { } it("throws an error if it cannot generate the combined key") { - mockSodium - .when { $0.combineKeys(lhsKeyBytes: anyArray(), rhsKeyBytes: anyArray()) } + mockCrypto + .when { try $0.perform(.combineKeys(lhsKeyBytes: anyArray(), rhsKeyBytes: anyArray())) } .thenReturn(nil) expect { @@ -450,8 +499,8 @@ class MessageReceiverDecryptionSpec: QuickSpec { } it("throws an error if the combined key does not match kA") { - mockSodium - .when { $0.combineKeys(lhsKeyBytes: anyArray(), rhsKeyBytes: anyArray()) } + mockCrypto + .when { try $0.perform(.combineKeys(lhsKeyBytes: anyArray(), rhsKeyBytes: anyArray())) } .thenReturn(Data(hex: TestConstants.publicKey).bytes) expect { @@ -475,8 +524,8 @@ class MessageReceiverDecryptionSpec: QuickSpec { } it("throws an error if it cannot get the senders x25519 public key") { - mockSign - .when { $0.toX25519(ed25519PublicKey: anyArray()) } + mockCrypto + .when { try $0.perform(.toX25519(ed25519PublicKey: anyArray())) } .thenReturn(nil) expect { diff --git a/SessionMessagingKitTests/Sending & Receiving/MessageSenderEncryptionSpec.swift b/SessionMessagingKitTests/Sending & Receiving/MessageSenderEncryptionSpec.swift index f937b3744..27510a4f5 100644 --- a/SessionMessagingKitTests/Sending & Receiving/MessageSenderEncryptionSpec.swift +++ b/SessionMessagingKitTests/Sending & Receiving/MessageSenderEncryptionSpec.swift @@ -15,53 +15,55 @@ class MessageSenderEncryptionSpec: QuickSpec { override func spec() { var mockStorage: Storage! - var mockBox: MockBox! - var mockSign: MockSign! - var mockNonce24Generator: MockNonce24Generator! - var dependencies: SMKDependencies! + var mockCrypto: MockCrypto! + var dependencies: Dependencies! describe("a MessageSender") { + // MARK: - Configuration + beforeEach { - mockStorage = Storage( + mockStorage = SynchronousStorage( customWriter: try! DatabaseQueue(), customMigrationTargets: [ SNUtilitiesKit.self, SNMessagingKit.self ] ) - mockBox = MockBox() - mockSign = MockSign() - mockNonce24Generator = MockNonce24Generator() + mockCrypto = MockCrypto() - dependencies = SMKDependencies( + dependencies = Dependencies( storage: mockStorage, - box: mockBox, - sign: mockSign, - nonceGenerator24: mockNonce24Generator + crypto: mockCrypto ) mockStorage.write { db in try Identity(variant: .ed25519PublicKey, data: Data(hex: TestConstants.edPublicKey)).insert(db) try Identity(variant: .ed25519SecretKey, data: Data(hex: TestConstants.edSecretKey)).insert(db) } - mockNonce24Generator - .when { $0.nonce() } + mockCrypto + .when { try $0.perform(.generateNonce24()) } .thenReturn(Data(base64Encoded: "pbTUizreT0sqJ2R2LloseQDyVL2RYztD")!.bytes) } + // MARK: - when encrypting with the session protocol context("when encrypting with the session protocol") { beforeEach { - mockBox.when { $0.seal(message: anyArray(), recipientPublicKey: anyArray()) }.thenReturn([1, 2, 3]) - mockSign.when { $0.signature(message: anyArray(), secretKey: anyArray()) }.thenReturn([]) + mockCrypto + .when { try $0.perform(.seal(message: anyArray(), recipientPublicKey: anyArray())) } + .thenReturn([1, 2, 3]) + mockCrypto + .when { try $0.perform(.signature(message: anyArray(), secretKey: anyArray())) } + .thenReturn([]) } + // MARK: -- can encrypt correctly it("can encrypt correctly") { - let result = mockStorage.write { db in + let result: Data? = mockStorage.read { db in try? MessageSender.encryptWithSessionProtocol( db, plaintext: "TestMessage".data(using: .utf8)!, for: "05\(TestConstants.publicKey)", - using: SMKDependencies(storage: mockStorage) + using: Dependencies() // Don't mock ) } @@ -70,8 +72,9 @@ class MessageSenderEncryptionSpec: QuickSpec { expect(result?.count).to(equal(155)) } + // MARK: -- returns the correct value when mocked it("returns the correct value when mocked") { - let result = mockStorage.write { db in + let result: Data? = mockStorage.read { db in try? MessageSender.encryptWithSessionProtocol( db, plaintext: "TestMessage".data(using: .utf8)!, @@ -83,13 +86,14 @@ class MessageSenderEncryptionSpec: QuickSpec { expect(result?.bytes).to(equal([1, 2, 3])) } + // MARK: -- throws an error if there is no ed25519 keyPair it("throws an error if there is no ed25519 keyPair") { mockStorage.write { db in _ = try Identity.filter(id: .ed25519PublicKey).deleteAll(db) _ = try Identity.filter(id: .ed25519SecretKey).deleteAll(db) } - mockStorage.write { db in + mockStorage.read { db in expect { try MessageSender.encryptWithSessionProtocol( db, @@ -102,10 +106,13 @@ class MessageSenderEncryptionSpec: QuickSpec { } } + // MARK: -- throws an error if the signature generation fails it("throws an error if the signature generation fails") { - mockSign.when { $0.signature(message: anyArray(), secretKey: anyArray()) }.thenReturn(nil) + mockCrypto + .when { try $0.perform(.signature(message: anyArray(), secretKey: anyArray())) } + .thenReturn(nil) - mockStorage.write { db in + mockStorage.read { db in expect { try MessageSender.encryptWithSessionProtocol( db, @@ -118,10 +125,13 @@ class MessageSenderEncryptionSpec: QuickSpec { } } + // MARK: -- throws an error if the encryption fails it("throws an error if the encryption fails") { - mockBox.when { $0.seal(message: anyArray(), recipientPublicKey: anyArray()) }.thenReturn(nil) + mockCrypto + .when { try $0.perform(.seal(message: anyArray(), recipientPublicKey: anyArray())) } + .thenReturn(nil) - mockStorage.write { db in + mockStorage.read { db in expect { try MessageSender.encryptWithSessionProtocol( db, @@ -135,9 +145,67 @@ class MessageSenderEncryptionSpec: QuickSpec { } } + // MARK: - when encrypting with the blinded session protocol context("when encrypting with the blinded session protocol") { - it("successfully encrypts") { - let result = mockStorage.write { db in + beforeEach { + mockCrypto + .when { [dependencies = dependencies!] crypto in + crypto.generate(.blindedKeyPair(serverPublicKey: any(), edKeyPair: any(), using: dependencies)) + } + .thenReturn( + KeyPair( + publicKey: Data.data(fromHex: TestConstants.publicKey)!.bytes, + secretKey: Data.data(fromHex: TestConstants.edSecretKey)!.bytes + ) + ) + mockCrypto + .when { [dependencies = dependencies!] crypto in + try crypto.perform( + .sharedBlindedEncryptionKey( + secretKey: anyArray(), + otherBlindedPublicKey: anyArray(), + fromBlindedPublicKey: anyArray(), + toBlindedPublicKey: anyArray(), + using: dependencies + ) + ) + } + .thenReturn([1, 2, 3]) + mockCrypto + .when { [dependencies = dependencies!] crypto in + try crypto.perform( + .encryptAeadXChaCha20( + message: anyArray(), + secretKey: anyArray(), + nonce: anyArray(), + additionalData: anyArray(), + using: dependencies + ) + ) + } + .thenReturn([2, 3, 4]) + } + + // MARK: -- can encrypt correctly + it("can encrypt correctly") { + let result: Data? = mockStorage.read { db in + try? MessageSender.encryptWithSessionBlindingProtocol( + db, + plaintext: "TestMessage".data(using: .utf8)!, + for: "15\(TestConstants.blindedPublicKey)", + openGroupPublicKey: TestConstants.serverPublicKey, + using: Dependencies() // Don't mock + ) + } + + // Note: A Nonce is used for this so we can't compare the exact value when not mocked + expect(result).toNot(beNil()) + expect(result?.count).to(equal(84)) + } + + // MARK: -- returns the correct value when mocked + it("returns the correct value when mocked") { + let result: Data? = mockStorage.read { db in try? MessageSender.encryptWithSessionBlindingProtocol( db, plaintext: "TestMessage".data(using: .utf8)!, @@ -148,15 +216,12 @@ class MessageSenderEncryptionSpec: QuickSpec { } expect(result?.toHexString()) - .to(equal( - "00db16b6687382811d69875a5376f66acad9c49fe5e26bcf770c7e6e9c230299" + - "f61b315299dd1fa700dd7f34305c0465af9e64dc791d7f4123f1eeafa5b4d48b" + - "3ade4f4b2a2764762e5a2c7900f254bd91633b43" - )) + .to(equal("00020304a5b4d48b3ade4f4b2a2764762e5a2c7900f254bd91633b43")) } + // MARK: -- includes a version at the start of the encrypted value it("includes a version at the start of the encrypted value") { - let result = mockStorage.write { db in + let result: Data? = mockStorage.read { db in try? MessageSender.encryptWithSessionBlindingProtocol( db, plaintext: "TestMessage".data(using: .utf8)!, @@ -169,8 +234,9 @@ class MessageSenderEncryptionSpec: QuickSpec { expect(result?.toHexString().prefix(2)).to(equal("00")) } + // MARK: -- includes the nonce at the end of the encrypted value it("includes the nonce at the end of the encrypted value") { - let maybeResult = mockStorage.write { db in + let maybeResult: Data? = mockStorage.read { db in try? MessageSender.encryptWithSessionBlindingProtocol( db, plaintext: "TestMessage".data(using: .utf8)!, @@ -186,8 +252,9 @@ class MessageSenderEncryptionSpec: QuickSpec { .to(equal("pbTUizreT0sqJ2R2LloseQDyVL2RYztD")) } + // MARK: -- throws an error if the recipient isn't a blinded id it("throws an error if the recipient isn't a blinded id") { - mockStorage.write { db in + mockStorage.read { db in expect { try MessageSender.encryptWithSessionBlindingProtocol( db, @@ -201,13 +268,14 @@ class MessageSenderEncryptionSpec: QuickSpec { } } + // MARK: -- throws an error if there is no ed25519 keyPair it("throws an error if there is no ed25519 keyPair") { mockStorage.write { db in _ = try Identity.filter(id: .ed25519PublicKey).deleteAll(db) _ = try Identity.filter(id: .ed25519SecretKey).deleteAll(db) } - mockStorage.write { db in + mockStorage.read { db in expect { try MessageSender.encryptWithSessionBlindingProtocol( db, @@ -221,22 +289,21 @@ class MessageSenderEncryptionSpec: QuickSpec { } } + // MARK: -- throws an error if it fails to generate a blinded keyPair it("throws an error if it fails to generate a blinded keyPair") { - let mockSodium: MockSodium = MockSodium() - let mockGenericHash: MockGenericHash = MockGenericHash() - dependencies = dependencies.with(sodium: mockSodium, genericHash: mockGenericHash) - - mockSodium - .when { - $0.blindedKeyPair( - serverPublicKey: any(), - edKeyPair: any(), - genericHash: mockGenericHash + mockCrypto + .when { [dependencies = dependencies!] crypto in + crypto.generate( + .blindedKeyPair( + serverPublicKey: any(), + edKeyPair: any(), + using: dependencies + ) ) } .thenReturn(nil) - mockStorage.write { db in + mockStorage.read { db in expect { try MessageSender.encryptWithSessionBlindingProtocol( db, @@ -250,38 +317,23 @@ class MessageSenderEncryptionSpec: QuickSpec { } } + // MARK: -- throws an error if it fails to generate an encryption key it("throws an error if it fails to generate an encryption key") { - let mockSodium: MockSodium = MockSodium() - let mockGenericHash: MockGenericHash = MockGenericHash() - dependencies = dependencies.with(sodium: mockSodium, genericHash: mockGenericHash) - - mockSodium - .when { - $0.blindedKeyPair( - serverPublicKey: any(), - edKeyPair: any(), - genericHash: mockGenericHash - ) - } - .thenReturn( - KeyPair( - publicKey: Data(hex: TestConstants.edPublicKey).bytes, - secretKey: Data(hex: TestConstants.edSecretKey).bytes - ) - ) - mockSodium - .when { - $0.sharedBlindedEncryptionKey( - secretKey: anyArray(), - otherBlindedPublicKey: anyArray(), - fromBlindedPublicKey: anyArray(), - toBlindedPublicKey: anyArray(), - genericHash: mockGenericHash + mockCrypto + .when { [dependencies = dependencies!] crypto in + try crypto.perform( + .sharedBlindedEncryptionKey( + secretKey: anyArray(), + otherBlindedPublicKey: anyArray(), + fromBlindedPublicKey: anyArray(), + toBlindedPublicKey: anyArray(), + using: dependencies + ) ) } .thenReturn(nil) - mockStorage.write { db in + mockStorage.read { db in expect { try MessageSender.encryptWithSessionBlindingProtocol( db, @@ -295,15 +347,23 @@ class MessageSenderEncryptionSpec: QuickSpec { } } + // MARK: -- throws an error if it fails to encrypt it("throws an error if it fails to encrypt") { - let mockAeadXChaCha: MockAeadXChaCha20Poly1305Ietf = MockAeadXChaCha20Poly1305Ietf() - dependencies = dependencies.with(aeadXChaCha20Poly1305Ietf: mockAeadXChaCha) - - mockAeadXChaCha - .when { $0.encrypt(message: anyArray(), secretKey: anyArray(), nonce: anyArray()) } + mockCrypto + .when { + try $0.perform( + .encryptAeadXChaCha20( + message: anyArray(), + secretKey: anyArray(), + nonce: anyArray(), + additionalData: anyArray(), + using: dependencies + ) + ) + } .thenReturn(nil) - mockStorage.write { db in + mockStorage.read { db in expect { try MessageSender.encryptWithSessionBlindingProtocol( db, diff --git a/SessionMessagingKitTests/Utilities/CryptoSMKSpec.swift b/SessionMessagingKitTests/Utilities/CryptoSMKSpec.swift new file mode 100644 index 000000000..5db9f95b9 --- /dev/null +++ b/SessionMessagingKitTests/Utilities/CryptoSMKSpec.swift @@ -0,0 +1,402 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import Sodium +import SessionUtilitiesKit + +import Quick +import Nimble + +@testable import SessionMessagingKit + +class CryptoSMKSpec: QuickSpec { + // MARK: - Spec + + override func spec() { + var crypto: Crypto! + var mockCrypto: MockCrypto! + var dependencies: Dependencies! + + beforeEach { + crypto = Crypto() + mockCrypto = MockCrypto() + dependencies = Dependencies(crypto: crypto) + } + + describe("Crypto for SessionMessagingKit") { + + // MARK: - when extending Sign + context("when extending Sign") { + // MARK: -- can convert an ed25519 public key into an x25519 public key + it("can convert an ed25519 public key into an x25519 public key") { + let result = try? crypto.perform(.toX25519(ed25519PublicKey: TestConstants.edPublicKey.bytes)) + + expect(result?.toHexString()) + .to(equal("95ffb559d4e804e9b414a5178454c426f616b4a61089b217b41165dbb7c9fe2d")) + } + + // MARK: -- can convert an ed25519 private key into an x25519 private key + it("can convert an ed25519 private key into an x25519 private key") { + let result = try? crypto.perform(.toX25519(ed25519SecretKey: TestConstants.edSecretKey.bytes)) + + expect(result?.toHexString()) + .to(equal("c83f9a1479b103c275d2db2d6c199fdc6f589b29b742f6405e01cc5a9a1d135d")) + } + } + + // MARK: - when extending Sodium + context("when extending Sodium") { + // MARK: -- and generating a blinding factor + context("and generating a blinding factor") { + // MARK: --- successfully generates a blinding factor + it("successfully generates a blinding factor") { + let result = try? crypto.perform( + .generateBlindingFactor( + serverPublicKey: TestConstants.serverPublicKey, + using: dependencies + ) + ) + + expect(result?.toHexString()) + .to(equal("84e3eb75028a9b73fec031b7448e322a68ca6485fad81ab1bead56f759ebeb0f")) + } + + // MARK: --- fails if the serverPublicKey is not a hex string + it("fails if the serverPublicKey is not a hex string") { + let result = try? crypto.perform( + .generateBlindingFactor( + serverPublicKey: "Test", + using: dependencies + ) + ) + + expect(result).to(beNil()) + } + + // MARK: --- fails if it cannot hash the serverPublicKey bytes + it("fails if it cannot hash the serverPublicKey bytes") { + dependencies = Dependencies(crypto: mockCrypto) + mockCrypto + .when { try $0.perform(.hash(message: anyArray(), outputLength: any())) } + .thenReturn(nil) + + let result = try? crypto.perform( + .generateBlindingFactor( + serverPublicKey: TestConstants.serverPublicKey, + using: dependencies + ) + ) + + expect(result).to(beNil()) + } + } + + // MARK: -- and generating a blinded key pair + context("and generating a blinded key pair") { + // MARK: --- successfully generates a blinded key pair + it("successfully generates a blinded key pair") { + let result = crypto.generate( + .blindedKeyPair( + serverPublicKey: TestConstants.serverPublicKey, + edKeyPair: KeyPair( + publicKey: Data(hex: TestConstants.edPublicKey).bytes, + secretKey: Data(hex: TestConstants.edSecretKey).bytes + ), + using: dependencies + ) + ) + + // Note: The first 64 characters of the secretKey are consistent but the chars after that always differ + expect(result?.publicKey.toHexString()).to(equal(TestConstants.blindedPublicKey)) + expect(String(result?.secretKey.toHexString().prefix(64) ?? "")) + .to(equal("16663322d6b684e1c9dcc02b9e8642c3affd3bc431a9ea9e63dbbac88ce7a305")) + } + + // MARK: --- fails if the edKeyPair public key length wrong + it("fails if the edKeyPair public key length wrong") { + let result = crypto.generate( + .blindedKeyPair( + serverPublicKey: TestConstants.serverPublicKey, + edKeyPair: KeyPair( + publicKey: Data(hex: String(TestConstants.edPublicKey.prefix(4))).bytes, + secretKey: Data(hex: TestConstants.edSecretKey).bytes + ), + using: dependencies + ) + ) + + expect(result).to(beNil()) + } + + // MARK: --- fails if the edKeyPair secret key length wrong + it("fails if the edKeyPair secret key length wrong") { + let result = crypto.generate( + .blindedKeyPair( + serverPublicKey: TestConstants.serverPublicKey, + edKeyPair: KeyPair( + publicKey: Data(hex: TestConstants.edPublicKey).bytes, + secretKey: Data(hex: String(TestConstants.edSecretKey.prefix(4))).bytes + ), + using: dependencies + ) + ) + + expect(result).to(beNil()) + } + + // MARK: --- fails if it cannot generate a blinding factor + it("fails if it cannot generate a blinding factor") { + let result = crypto.generate( + .blindedKeyPair( + serverPublicKey: "Test", + edKeyPair: KeyPair( + publicKey: Data(hex: TestConstants.edPublicKey).bytes, + secretKey: Data(hex: TestConstants.edSecretKey).bytes + ), + using: dependencies + ) + ) + + expect(result).to(beNil()) + } + } + + // MARK: -- and generating a sogsSignature + context("and generating a sogsSignature") { + // MARK: --- generates a correct signature + it("generates a correct signature") { + let result = try? crypto.perform( + .sogsSignature( + message: "TestMessage".bytes, + secretKey: Data(hex: TestConstants.edSecretKey).bytes, + blindedSecretKey: Data(hex: "44d82cc15c0a5056825cae7520b6b52d000a23eb0c5ed94c4be2d9dc41d2d409").bytes, + blindedPublicKey: Data(hex: "0bb7815abb6ba5142865895f3e5286c0527ba4d31dbb75c53ce95e91ffe025a2").bytes + ) + ) + + expect(result?.toHexString()) + .to(equal( + "dcc086abdd2a740d9260b008fb37e12aa0ff47bd2bd9e177bbbec37fd46705a9" + + "072ce747bda66c788c3775cdd7ad60ad15a478e0886779aad5d795fd7bf8350d" + )) + } + } + + // MARK: -- and combining keys + context("and combining keys") { + // MARK: --- generates a correct combined key + it("generates a correct combined key") { + let result = try? crypto.perform( + .combineKeys( + lhsKeyBytes: Data(hex: TestConstants.edSecretKey).bytes, + rhsKeyBytes: Data(hex: TestConstants.edPublicKey).bytes + ) + ) + + expect(result?.toHexString()) + .to(equal("1159b5d0fcfba21228eb2121a0f59712fa8276fc6e5547ff519685a40b9819e6")) + } + } + + // MARK: -- and creating a shared blinded encryption key + context("and creating a shared blinded encryption key") { + // MARK: --- generates a correct combined key + it("generates a correct combined key") { + let result = try? crypto.perform( + .sharedBlindedEncryptionKey( + secretKey: Data(hex: TestConstants.edSecretKey).bytes, + otherBlindedPublicKey: Data(hex: TestConstants.blindedPublicKey).bytes, + fromBlindedPublicKey: Data(hex: TestConstants.publicKey).bytes, + toBlindedPublicKey: Data(hex: TestConstants.blindedPublicKey).bytes, + using: dependencies + ) + ) + + expect(result?.toHexString()) + .to(equal("388ee09e4c356b91f1cce5cc0aa0cf59e8e8cade69af61685d09c2d2731bc99e")) + } + + // MARK: --- fails if the scalar multiplication fails + it("fails if the scalar multiplication fails") { + let result = try? crypto.perform( + .sharedBlindedEncryptionKey( + secretKey: Data(hex: TestConstants.edSecretKey).bytes, + otherBlindedPublicKey: Data(hex: TestConstants.publicKey).bytes, + fromBlindedPublicKey: Data(hex: TestConstants.edPublicKey).bytes, + toBlindedPublicKey: Data(hex: TestConstants.publicKey).bytes, + using: dependencies + ) + ) + + expect(result?.toHexString()).to(beNil()) + } + } + + // MARK: -- and checking if a session id matches a blinded id + context("and checking if a session id matches a blinded id") { + // MARK: --- returns true when they match + it("returns true when they match") { + let result = crypto.verify( + .sessionId( + "05\(TestConstants.publicKey)", + matchesBlindedId: "15\(TestConstants.blindedPublicKey)", + serverPublicKey: TestConstants.serverPublicKey, + using: dependencies + ) + ) + + expect(result).to(beTrue()) + } + + // MARK: --- returns false if given an invalid session id + it("returns false if given an invalid session id") { + let result = crypto.verify( + .sessionId( + "AB\(TestConstants.publicKey)", + matchesBlindedId: "15\(TestConstants.blindedPublicKey)", + serverPublicKey: TestConstants.serverPublicKey, + using: dependencies + ) + ) + + expect(result).to(beFalse()) + } + + // MARK: --- returns false if given an invalid blinded id + it("returns false if given an invalid blinded id") { + let result = crypto.verify( + .sessionId( + "05\(TestConstants.publicKey)", + matchesBlindedId: "AB\(TestConstants.blindedPublicKey)", + serverPublicKey: TestConstants.serverPublicKey, + using: dependencies + ) + ) + + expect(result).to(beFalse()) + } + + // MARK: --- returns false if it fails to generate the blinding factor + it("returns false if it fails to generate the blinding factor") { + let result = crypto.verify( + .sessionId( + "05\(TestConstants.publicKey)", + matchesBlindedId: "15\(TestConstants.blindedPublicKey)", + serverPublicKey: "Test", + using: dependencies + ) + ) + + expect(result).to(beFalse()) + } + } + } + + // MARK: - when extending GenericHash + describe("when extending GenericHash") { + // MARK: -- and generating a hash with salt and personal values + context("and generating a hash with salt and personal values") { + // MARK: --- generates a hash correctly + it("generates a hash correctly") { + let result = try? crypto.perform( + .hashSaltPersonal( + message: "TestMessage".bytes, + outputLength: 32, + key: "Key".bytes, + salt: "Salt".bytes, + personal: "Personal".bytes + ) + ) + + expect(result).toNot(beNil()) + expect(result?.count).to(equal(32)) + } + + // MARK: --- generates a hash correctly with no key + it("generates a hash correctly with no key") { + let result = try? crypto.perform( + .hashSaltPersonal( + message: "TestMessage".bytes, + outputLength: 32, + key: nil, + salt: "Salt".bytes, + personal: "Personal".bytes + ) + ) + + expect(result).toNot(beNil()) + expect(result?.count).to(equal(32)) + } + + // MARK: --- fails if given invalid options + it("fails if given invalid options") { + let result = try? crypto.perform( + .hashSaltPersonal( + message: "TestMessage".bytes, + outputLength: 65, // Max of 64 + key: "Key".bytes, + salt: "Salt".bytes, + personal: "Personal".bytes + ) + ) + + expect(result).to(beNil()) + } + } + } + + // MARK: - when extending AeadXChaCha20Poly1305Ietf + context("when extending AeadXChaCha20Poly1305Ietf") { + // MARK: -- when encrypting + context("when encrypting") { + // MARK: --- encrypts correctly + it("encrypts correctly") { + let result = try? crypto.perform( + .encryptAeadXChaCha20( + message: "TestMessage".bytes, + secretKey: Data(hex: TestConstants.publicKey).bytes, + nonce: "TestNonce".bytes, + additionalData: nil, + using: Dependencies() + ) + ) + + expect(result).toNot(beNil()) + expect(result?.count).to(equal(27)) + } + + // MARK: --- encrypts correctly with additional data + it("encrypts correctly with additional data") { + let result = try? crypto.perform( + .encryptAeadXChaCha20( + message: "TestMessage".bytes, + secretKey: Data(hex: TestConstants.publicKey).bytes, + nonce: "TestNonce".bytes, + additionalData: "TestData".bytes, + using: Dependencies() + ) + ) + + expect(result).toNot(beNil()) + expect(result?.count).to(equal(27)) + } + + // MARK: --- fails if given an invalid key + it("fails if given an invalid key") { + let result = try? crypto.perform( + .encryptAeadXChaCha20( + message: "TestMessage".bytes, + secretKey: "TestKey".bytes, + nonce: "TestNonce".bytes, + additionalData: "TestData".bytes, + using: Dependencies() + ) + ) + + expect(result).to(beNil()) + } + } + } + } + } +} diff --git a/SessionMessagingKitTests/Utilities/SodiumUtilitiesSpec.swift b/SessionMessagingKitTests/Utilities/SodiumUtilitiesSpec.swift deleted file mode 100644 index 99668e499..000000000 --- a/SessionMessagingKitTests/Utilities/SodiumUtilitiesSpec.swift +++ /dev/null @@ -1,352 +0,0 @@ -// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. - -import Foundation -import Sodium -import SessionUtilitiesKit - -import Quick -import Nimble - -@testable import SessionMessagingKit - -class SodiumUtilitiesSpec: QuickSpec { - // MARK: - Spec - - override func spec() { - // MARK: - Sign - - describe("an extended Sign") { - var sign: Sign! - - beforeEach { - sign = Sodium().sign - } - - it("can convert an ed25519 public key into an x25519 public key") { - let result = sign.toX25519(ed25519PublicKey: TestConstants.edPublicKey.bytes) - - expect(result?.toHexString()) - .to(equal("95ffb559d4e804e9b414a5178454c426f616b4a61089b217b41165dbb7c9fe2d")) - } - - it("can convert an ed25519 private key into an x25519 private key") { - let result = sign.toX25519(ed25519SecretKey: TestConstants.edSecretKey.bytes) - - expect(result?.toHexString()) - .to(equal("c83f9a1479b103c275d2db2d6c199fdc6f589b29b742f6405e01cc5a9a1d135d")) - } - } - - // MARK: - Sodium - - describe("an extended Sodium") { - var sodium: Sodium! - var genericHash: GenericHashType! - - beforeEach { - sodium = Sodium() - genericHash = sodium.genericHash - } - - context("when generating a blinding factor") { - it("successfully generates a blinding factor") { - let result = sodium.generateBlindingFactor( - serverPublicKey: TestConstants.serverPublicKey, - genericHash: genericHash - ) - - expect(result?.toHexString()) - .to(equal("84e3eb75028a9b73fec031b7448e322a68ca6485fad81ab1bead56f759ebeb0f")) - } - - it("fails if the serverPublicKey is not a hex string") { - let result = sodium.generateBlindingFactor( - serverPublicKey: "Test", - genericHash: genericHash - ) - - expect(result).to(beNil()) - } - - it("fails if it cannot hash the serverPublicKey bytes") { - genericHash = MockGenericHash() - (genericHash as? MockGenericHash)? - .when { $0.hash(message: anyArray(), outputLength: any()) } - .thenReturn(nil) - - let result = sodium.generateBlindingFactor( - serverPublicKey: TestConstants.serverPublicKey, - genericHash: genericHash - ) - - expect(result).to(beNil()) - } - } - - context("when generating a blinded key pair") { - it("successfully generates a blinded key pair") { - let result = sodium.blindedKeyPair( - serverPublicKey: TestConstants.serverPublicKey, - edKeyPair: KeyPair( - publicKey: Data(hex: TestConstants.edPublicKey).bytes, - secretKey: Data(hex: TestConstants.edSecretKey).bytes - ), - genericHash: genericHash - ) - - // Note: The first 64 characters of the secretKey are consistent but the chars after that always differ - expect(result?.publicKey.toHexString()).to(equal(TestConstants.blindedPublicKey)) - expect(String(result?.secretKey.toHexString().prefix(64) ?? "")) - .to(equal("16663322d6b684e1c9dcc02b9e8642c3affd3bc431a9ea9e63dbbac88ce7a305")) - } - - it("fails if the edKeyPair public key length wrong") { - let result = sodium.blindedKeyPair( - serverPublicKey: TestConstants.serverPublicKey, - edKeyPair: KeyPair( - publicKey: Data(hex: String(TestConstants.edPublicKey.prefix(4))).bytes, - secretKey: Data(hex: TestConstants.edSecretKey).bytes - ), - genericHash: genericHash - ) - - expect(result).to(beNil()) - } - - it("fails if the edKeyPair secret key length wrong") { - let result = sodium.blindedKeyPair( - serverPublicKey: TestConstants.serverPublicKey, - edKeyPair: KeyPair( - publicKey: Data(hex: TestConstants.edPublicKey).bytes, - secretKey: Data(hex: String(TestConstants.edSecretKey.prefix(4))).bytes - ), - genericHash: genericHash - ) - - expect(result).to(beNil()) - } - - it("fails if it cannot generate a blinding factor") { - let result = sodium.blindedKeyPair( - serverPublicKey: "Test", - edKeyPair: KeyPair( - publicKey: Data(hex: TestConstants.edPublicKey).bytes, - secretKey: Data(hex: TestConstants.edSecretKey).bytes - ), - genericHash: genericHash - ) - - expect(result).to(beNil()) - } - } - - context("when generating a sogsSignature") { - it("generates a correct signature") { - let result = sodium.sogsSignature( - message: "TestMessage".bytes, - secretKey: Data(hex: TestConstants.edSecretKey).bytes, - blindedSecretKey: Data(hex: "44d82cc15c0a5056825cae7520b6b52d000a23eb0c5ed94c4be2d9dc41d2d409").bytes, - blindedPublicKey: Data(hex: "0bb7815abb6ba5142865895f3e5286c0527ba4d31dbb75c53ce95e91ffe025a2").bytes - ) - - expect(result?.toHexString()) - .to(equal( - "dcc086abdd2a740d9260b008fb37e12aa0ff47bd2bd9e177bbbec37fd46705a9" + - "072ce747bda66c788c3775cdd7ad60ad15a478e0886779aad5d795fd7bf8350d" - )) - } - } - - context("when combining keys") { - it("generates a correct combined key") { - let result = sodium.combineKeys( - lhsKeyBytes: Data(hex: TestConstants.edSecretKey).bytes, - rhsKeyBytes: Data(hex: TestConstants.edPublicKey).bytes - ) - - expect(result?.toHexString()) - .to(equal("1159b5d0fcfba21228eb2121a0f59712fa8276fc6e5547ff519685a40b9819e6")) - } - - it("fails if the scalar multiplication fails") { - let result = sodium.combineKeys( - lhsKeyBytes: sodium.generatePrivateKeyScalar(secretKey: Data(hex: TestConstants.edSecretKey).bytes), - rhsKeyBytes: Data(hex: TestConstants.publicKey).bytes - ) - - expect(result).to(beNil()) - } - } - - context("when creating a shared blinded encryption key") { - it("generates a correct combined key") { - let result = sodium.sharedBlindedEncryptionKey( - secretKey: Data(hex: TestConstants.edSecretKey).bytes, - otherBlindedPublicKey: Data(hex: TestConstants.blindedPublicKey).bytes, - fromBlindedPublicKey: Data(hex: TestConstants.publicKey).bytes, - toBlindedPublicKey: Data(hex: TestConstants.blindedPublicKey).bytes, - genericHash: genericHash - ) - - expect(result?.toHexString()) - .to(equal("388ee09e4c356b91f1cce5cc0aa0cf59e8e8cade69af61685d09c2d2731bc99e")) - } - - it("fails if the scalar multiplication fails") { - let result = sodium.sharedBlindedEncryptionKey( - secretKey: Data(hex: TestConstants.edSecretKey).bytes, - otherBlindedPublicKey: Data(hex: TestConstants.publicKey).bytes, - fromBlindedPublicKey: Data(hex: TestConstants.edPublicKey).bytes, - toBlindedPublicKey: Data(hex: TestConstants.publicKey).bytes, - genericHash: genericHash - ) - - expect(result?.toHexString()).to(beNil()) - } - } - - context("when checking if a session id matches a blinded id") { - it("returns true when they match") { - let result = sodium.sessionId( - "05\(TestConstants.publicKey)", - matchesBlindedId: "15\(TestConstants.blindedPublicKey)", - serverPublicKey: TestConstants.serverPublicKey, - genericHash: genericHash - ) - - expect(result).to(beTrue()) - } - - it("returns false if given an invalid session id") { - let result = sodium.sessionId( - "AB\(TestConstants.publicKey)", - matchesBlindedId: "15\(TestConstants.blindedPublicKey)", - serverPublicKey: TestConstants.serverPublicKey, - genericHash: genericHash - ) - - expect(result).to(beFalse()) - } - - it("returns false if given an invalid blinded id") { - let result = sodium.sessionId( - "05\(TestConstants.publicKey)", - matchesBlindedId: "AB\(TestConstants.blindedPublicKey)", - serverPublicKey: TestConstants.serverPublicKey, - genericHash: genericHash - ) - - expect(result).to(beFalse()) - } - - it("returns false if it fails to generate the blinding factor") { - let result = sodium.sessionId( - "05\(TestConstants.publicKey)", - matchesBlindedId: "15\(TestConstants.blindedPublicKey)", - serverPublicKey: "Test", - genericHash: genericHash - ) - - expect(result).to(beFalse()) - } - } - } - - // MARK: - GenericHash - - describe("an extended GenericHash") { - var genericHash: GenericHashType! - - beforeEach { - genericHash = Sodium().genericHash - } - - context("when generating a hash with salt and personal values") { - it("generates a hash correctly") { - let result = genericHash.hashSaltPersonal( - message: "TestMessage".bytes, - outputLength: 32, - key: "Key".bytes, - salt: "Salt".bytes, - personal: "Personal".bytes - ) - - expect(result).toNot(beNil()) - expect(result?.count).to(equal(32)) - } - - it("generates a hash correctly with no key") { - let result = genericHash.hashSaltPersonal( - message: "TestMessage".bytes, - outputLength: 32, - key: nil, - salt: "Salt".bytes, - personal: "Personal".bytes - ) - - expect(result).toNot(beNil()) - expect(result?.count).to(equal(32)) - } - - it("fails if given invalid options") { - let result = genericHash.hashSaltPersonal( - message: "TestMessage".bytes, - outputLength: 65, // Max of 64 - key: "Key".bytes, - salt: "Salt".bytes, - personal: "Personal".bytes - ) - - expect(result).to(beNil()) - } - } - } - - // MARK: - AeadXChaCha20Poly1305IetfType - - describe("an extended AeadXChaCha20Poly1305IetfType") { - var aeadXchacha20poly1305ietf: AeadXChaCha20Poly1305IetfType! - - beforeEach { - aeadXchacha20poly1305ietf = Sodium().aead.xchacha20poly1305ietf - } - - context("when encrypting") { - it("encrypts correctly") { - let result = aeadXchacha20poly1305ietf.encrypt( - message: "TestMessage".bytes, - secretKey: Data(hex: TestConstants.publicKey).bytes, - nonce: "TestNonce".bytes, - additionalData: nil - ) - - expect(result).toNot(beNil()) - expect(result?.count).to(equal(27)) - } - - it("encrypts correctly with additional data") { - let result = aeadXchacha20poly1305ietf.encrypt( - message: "TestMessage".bytes, - secretKey: Data(hex: TestConstants.publicKey).bytes, - nonce: "TestNonce".bytes, - additionalData: "TestData".bytes - ) - - expect(result).toNot(beNil()) - expect(result?.count).to(equal(27)) - } - - it("fails if given an invalid key") { - let result = aeadXchacha20poly1305ietf.encrypt( - message: "TestMessage".bytes, - secretKey: "TestKey".bytes, - nonce: "TestNonce".bytes, - additionalData: "TestData".bytes - ) - - expect(result).to(beNil()) - } - } - } - } -} diff --git a/SessionMessagingKitTests/_TestUtilities/DependencyExtensions.swift b/SessionMessagingKitTests/_TestUtilities/DependencyExtensions.swift deleted file mode 100644 index 83e6af787..000000000 --- a/SessionMessagingKitTests/_TestUtilities/DependencyExtensions.swift +++ /dev/null @@ -1,44 +0,0 @@ -// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. - -import Foundation -import GRDB -import SessionSnodeKit -import SessionUtilitiesKit - -@testable import SessionMessagingKit - -extension SMKDependencies { - public func with( - onionApi: OnionRequestAPIType.Type? = nil, - generalCache: MutableGeneralCacheType? = nil, - storage: Storage? = nil, - scheduler: ValueObservationScheduler? = nil, - sodium: SodiumType? = nil, - box: BoxType? = nil, - genericHash: GenericHashType? = nil, - sign: SignType? = nil, - aeadXChaCha20Poly1305Ietf: AeadXChaCha20Poly1305IetfType? = nil, - ed25519: Ed25519Type? = nil, - nonceGenerator16: NonceGenerator16ByteType? = nil, - nonceGenerator24: NonceGenerator24ByteType? = nil, - standardUserDefaults: UserDefaultsType? = nil, - date: Date? = nil - ) -> SMKDependencies { - return SMKDependencies( - onionApi: (onionApi ?? self._onionApi.wrappedValue), - generalCache: (generalCache ?? self._mutableGeneralCache.wrappedValue), - storage: (storage ?? self._storage.wrappedValue), - scheduler: (scheduler ?? self._scheduler.wrappedValue), - sodium: (sodium ?? self._sodium.wrappedValue), - box: (box ?? self._box.wrappedValue), - genericHash: (genericHash ?? self._genericHash.wrappedValue), - sign: (sign ?? self._sign.wrappedValue), - aeadXChaCha20Poly1305Ietf: (aeadXChaCha20Poly1305Ietf ?? self._aeadXChaCha20Poly1305Ietf.wrappedValue), - ed25519: (ed25519 ?? self._ed25519.wrappedValue), - nonceGenerator16: (nonceGenerator16 ?? self._nonceGenerator16.wrappedValue), - nonceGenerator24: (nonceGenerator24 ?? self._nonceGenerator24.wrappedValue), - standardUserDefaults: (standardUserDefaults ?? self._standardUserDefaults.wrappedValue), - date: (date ?? self._date.wrappedValue) - ) - } -} diff --git a/SessionMessagingKitTests/_TestUtilities/MockAeadXChaCha20Poly1305Ietf.swift b/SessionMessagingKitTests/_TestUtilities/MockAeadXChaCha20Poly1305Ietf.swift deleted file mode 100644 index cb3888b59..000000000 --- a/SessionMessagingKitTests/_TestUtilities/MockAeadXChaCha20Poly1305Ietf.swift +++ /dev/null @@ -1,19 +0,0 @@ -// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. - -import Foundation -import Sodium - -@testable import SessionMessagingKit - -class MockAeadXChaCha20Poly1305Ietf: Mock, AeadXChaCha20Poly1305IetfType { - var KeyBytes: Int = 32 - var ABytes: Int = 16 - - func encrypt(message: Bytes, secretKey: Bytes, nonce: Bytes, additionalData: Bytes?) -> Bytes? { - return accept(args: [message, secretKey, nonce, additionalData]) as? Bytes - } - - func decrypt(authenticatedCipherText: Bytes, secretKey: Bytes, nonce: Bytes, additionalData: Bytes?) -> Bytes? { - return accept(args: [authenticatedCipherText, secretKey, nonce, additionalData]) as? Bytes - } -} diff --git a/SessionMessagingKitTests/_TestUtilities/MockBox.swift b/SessionMessagingKitTests/_TestUtilities/MockBox.swift deleted file mode 100644 index 3a991eec9..000000000 --- a/SessionMessagingKitTests/_TestUtilities/MockBox.swift +++ /dev/null @@ -1,16 +0,0 @@ -// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. - -import Foundation -import Sodium - -@testable import SessionMessagingKit - -class MockBox: Mock, BoxType { - func seal(message: Bytes, recipientPublicKey: Bytes) -> Bytes? { - return accept(args: [message, recipientPublicKey]) as? Bytes - } - - func open(anonymousCipherText: Bytes, recipientPublicKey: Bytes, recipientSecretKey: Bytes) -> Bytes? { - return accept(args: [anonymousCipherText, recipientPublicKey, recipientSecretKey]) as? Bytes - } -} diff --git a/SessionMessagingKitTests/_TestUtilities/MockEd25519.swift b/SessionMessagingKitTests/_TestUtilities/MockEd25519.swift deleted file mode 100644 index 259a18bfd..000000000 --- a/SessionMessagingKitTests/_TestUtilities/MockEd25519.swift +++ /dev/null @@ -1,17 +0,0 @@ -// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. - -import Foundation -import Sodium -import SessionUtilitiesKit - -@testable import SessionMessagingKit - -class MockEd25519: Mock, Ed25519Type { - func sign(data: Bytes, keyPair: KeyPair) throws -> Bytes? { - return accept(args: [data, keyPair]) as? Bytes - } - - func verifySignature(_ signature: Data, publicKey: Data, data: Data) throws -> Bool { - return accept(args: [signature, publicKey, data]) as! Bool - } -} diff --git a/SessionMessagingKitTests/_TestUtilities/MockGenericHash.swift b/SessionMessagingKitTests/_TestUtilities/MockGenericHash.swift deleted file mode 100644 index f3eccdbc1..000000000 --- a/SessionMessagingKitTests/_TestUtilities/MockGenericHash.swift +++ /dev/null @@ -1,20 +0,0 @@ -// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. - -import Foundation -import Sodium - -@testable import SessionMessagingKit - -class MockGenericHash: Mock, GenericHashType { - func hash(message: Bytes, key: Bytes?) -> Bytes? { - return accept(args: [message, key]) as? Bytes - } - - func hash(message: Bytes, outputLength: Int) -> Bytes? { - return accept(args: [message, outputLength]) as? Bytes - } - - func hashSaltPersonal(message: Bytes, outputLength: Int, key: Bytes?, salt: Bytes, personal: Bytes) -> Bytes? { - return accept(args: [message, outputLength, key, salt, personal]) as? Bytes - } -} diff --git a/SessionMessagingKitTests/_TestUtilities/MockNonce16Generator.swift b/SessionMessagingKitTests/_TestUtilities/MockNonce16Generator.swift deleted file mode 100644 index 3fcaab255..000000000 --- a/SessionMessagingKitTests/_TestUtilities/MockNonce16Generator.swift +++ /dev/null @@ -1,11 +0,0 @@ -// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. - -import Foundation - -@testable import SessionMessagingKit - -class MockNonce16Generator: Mock, NonceGenerator16ByteType { - var NonceBytes: Int = 16 - - func nonce() -> Array { return accept() as! [UInt8] } -} diff --git a/SessionMessagingKitTests/_TestUtilities/MockNonce24Generator.swift b/SessionMessagingKitTests/_TestUtilities/MockNonce24Generator.swift deleted file mode 100644 index 8b733af64..000000000 --- a/SessionMessagingKitTests/_TestUtilities/MockNonce24Generator.swift +++ /dev/null @@ -1,11 +0,0 @@ -// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. - -import Foundation - -@testable import SessionMessagingKit - -class MockNonce24Generator: Mock, NonceGenerator24ByteType { - var NonceBytes: Int = 24 - - func nonce() -> Array { return accept() as! [UInt8] } -} diff --git a/SessionMessagingKitTests/_TestUtilities/MockOGMCache.swift b/SessionMessagingKitTests/_TestUtilities/MockOGMCache.swift index ec2b8ac10..d326d0a15 100644 --- a/SessionMessagingKitTests/_TestUtilities/MockOGMCache.swift +++ b/SessionMessagingKitTests/_TestUtilities/MockOGMCache.swift @@ -6,7 +6,7 @@ import SessionUtilitiesKit @testable import SessionMessagingKit -class MockOGMCache: Mock, OGMMutableCacheType { +class MockOGMCache: Mock, OGMCacheType { var defaultRoomsPublisher: AnyPublisher<[OpenGroupManager.DefaultRoomInfo], Error>? { get { return accept() as? AnyPublisher<[OpenGroupManager.DefaultRoomInfo], Error> } set { accept(args: [newValue]) } diff --git a/SessionMessagingKitTests/_TestUtilities/MockSign.swift b/SessionMessagingKitTests/_TestUtilities/MockSign.swift deleted file mode 100644 index 67a4ebe7f..000000000 --- a/SessionMessagingKitTests/_TestUtilities/MockSign.swift +++ /dev/null @@ -1,23 +0,0 @@ -// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. - -import Foundation -import Sodium - -@testable import SessionMessagingKit - -class MockSign: Mock, SignType { - var Bytes: Int = 64 - var PublicKeyBytes: Int = 32 - - func signature(message: Bytes, secretKey: Bytes) -> Bytes? { - return accept(args: [message, secretKey]) as? Bytes - } - - func verify(message: Bytes, publicKey: Bytes, signature: Bytes) -> Bool { - return accept(args: [message, publicKey, signature]) as! Bool - } - - func toX25519(ed25519PublicKey: Bytes) -> Bytes? { - return accept(args: [ed25519PublicKey]) as? Bytes - } -} diff --git a/SessionMessagingKitTests/_TestUtilities/MockSodium.swift b/SessionMessagingKitTests/_TestUtilities/MockSodium.swift deleted file mode 100644 index a679462e0..000000000 --- a/SessionMessagingKitTests/_TestUtilities/MockSodium.swift +++ /dev/null @@ -1,38 +0,0 @@ -// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. - -import Foundation -import Sodium -import SessionUtilitiesKit - -@testable import SessionMessagingKit - -class MockSodium: Mock, SodiumType { - func getBox() -> BoxType { return accept() as! BoxType } - func getGenericHash() -> GenericHashType { return accept() as! GenericHashType } - func getSign() -> SignType { return accept() as! SignType } - func getAeadXChaCha20Poly1305Ietf() -> AeadXChaCha20Poly1305IetfType { return accept() as! AeadXChaCha20Poly1305IetfType } - - func generateBlindingFactor(serverPublicKey: String, genericHash: GenericHashType) -> Bytes? { - return accept(args: [serverPublicKey, genericHash]) as? Bytes - } - - func blindedKeyPair(serverPublicKey: String, edKeyPair: KeyPair, genericHash: GenericHashType) -> KeyPair? { - return accept(args: [serverPublicKey, edKeyPair, genericHash]) as? KeyPair - } - - func sogsSignature(message: Bytes, secretKey: Bytes, blindedSecretKey ka: Bytes, blindedPublicKey kA: Bytes) -> Bytes? { - return accept(args: [message, secretKey, ka, kA]) as? Bytes - } - - func combineKeys(lhsKeyBytes: Bytes, rhsKeyBytes: Bytes) -> Bytes? { - return accept(args: [lhsKeyBytes, rhsKeyBytes]) as? Bytes - } - - func sharedBlindedEncryptionKey(secretKey a: Bytes, otherBlindedPublicKey: Bytes, fromBlindedPublicKey kA: Bytes, toBlindedPublicKey kB: Bytes, genericHash: GenericHashType) -> Bytes? { - return accept(args: [a, otherBlindedPublicKey, kA, kB, genericHash]) as? Bytes - } - - func sessionId(_ sessionId: String, matchesBlindedId blindedSessionId: String, serverPublicKey: String, genericHash: GenericHashType) -> Bool { - return accept(args: [sessionId, blindedSessionId, serverPublicKey, genericHash]) as! Bool - } -} diff --git a/SessionMessagingKitTests/_TestUtilities/OGMDependencyExtensions.swift b/SessionMessagingKitTests/_TestUtilities/OGMDependencyExtensions.swift deleted file mode 100644 index a2be81109..000000000 --- a/SessionMessagingKitTests/_TestUtilities/OGMDependencyExtensions.swift +++ /dev/null @@ -1,46 +0,0 @@ -// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. - -import Foundation -import GRDB -import SessionSnodeKit -import SessionUtilitiesKit - -@testable import SessionMessagingKit - -extension OpenGroupManager.OGMDependencies { - public func with( - cache: OGMMutableCacheType? = nil, - onionApi: OnionRequestAPIType.Type? = nil, - generalCache: MutableGeneralCacheType? = nil, - storage: Storage? = nil, - scheduler: ValueObservationScheduler? = nil, - sodium: SodiumType? = nil, - box: BoxType? = nil, - genericHash: GenericHashType? = nil, - sign: SignType? = nil, - aeadXChaCha20Poly1305Ietf: AeadXChaCha20Poly1305IetfType? = nil, - ed25519: Ed25519Type? = nil, - nonceGenerator16: NonceGenerator16ByteType? = nil, - nonceGenerator24: NonceGenerator24ByteType? = nil, - standardUserDefaults: UserDefaultsType? = nil, - date: Date? = nil - ) -> OpenGroupManager.OGMDependencies { - return OpenGroupManager.OGMDependencies( - cache: (cache ?? self._mutableCache.wrappedValue), - onionApi: (onionApi ?? self._onionApi.wrappedValue), - generalCache: (generalCache ?? self._mutableGeneralCache.wrappedValue), - storage: (storage ?? self._storage.wrappedValue), - scheduler: (scheduler ?? self._scheduler.wrappedValue), - sodium: (sodium ?? self._sodium.wrappedValue), - box: (box ?? self._box.wrappedValue), - genericHash: (genericHash ?? self._genericHash.wrappedValue), - sign: (sign ?? self._sign.wrappedValue), - aeadXChaCha20Poly1305Ietf: (aeadXChaCha20Poly1305Ietf ?? self._aeadXChaCha20Poly1305Ietf.wrappedValue), - ed25519: (ed25519 ?? self._ed25519.wrappedValue), - nonceGenerator16: (nonceGenerator16 ?? self._nonceGenerator16.wrappedValue), - nonceGenerator24: (nonceGenerator24 ?? self._nonceGenerator24.wrappedValue), - standardUserDefaults: (standardUserDefaults ?? self._standardUserDefaults.wrappedValue), - date: (date ?? self._date.wrappedValue) - ) - } -} diff --git a/SessionMessagingKitTests/_TestUtilities/TestOnionRequestAPI.swift b/SessionMessagingKitTests/_TestUtilities/TestOnionRequestAPI.swift deleted file mode 100644 index 89aab1217..000000000 --- a/SessionMessagingKitTests/_TestUtilities/TestOnionRequestAPI.swift +++ /dev/null @@ -1,82 +0,0 @@ -// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. - -import Foundation -import Combine -import SessionSnodeKit -import SessionUtilitiesKit - -@testable import SessionMessagingKit - -// FIXME: Change 'OnionRequestAPIType' to have instance methods instead of static methods once everything is updated to use 'Dependencies' -class TestOnionRequestAPI: OnionRequestAPIType { - struct RequestData: Codable { - let urlString: String? - let httpMethod: String - let headers: [String: String] - let body: Data? - let destination: OnionRequestAPIDestination - - var publicKey: String? { - switch destination { - case .snode: return nil - case .server(_, _, let x25519PublicKey, _, _): return x25519PublicKey - } - } - } - - class ResponseInfo: ResponseInfoType { - let requestData: RequestData - let code: Int - let headers: [String: String] - - init(requestData: RequestData, code: Int, headers: [String: String]) { - self.requestData = requestData - self.code = code - self.headers = headers - } - } - - class var mockResponse: Data? { return nil } - - static func sendOnionRequest(_ request: URLRequest, to server: String, with x25519PublicKey: String, timeout: TimeInterval) -> AnyPublisher<(ResponseInfoType, Data?), Error> { - let responseInfo: ResponseInfo = ResponseInfo( - requestData: RequestData( - urlString: request.url?.absoluteString, - httpMethod: (request.httpMethod ?? "GET"), - headers: (request.allHTTPHeaderFields ?? [:]), - body: request.httpBody, - destination: OnionRequestAPIDestination.server( - host: (request.url?.host ?? ""), - target: OnionRequestAPIVersion.v4.rawValue, - x25519PublicKey: x25519PublicKey, - scheme: request.url!.scheme, - port: request.url!.port.map { UInt16($0) } - ) - ), - code: 200, - headers: [:] - ) - - return Just((responseInfo, mockResponse)) - .setFailureType(to: Error.self) - .eraseToAnyPublisher() - } - - static func sendOnionRequest(_ payload: Data, to snode: Snode, timeout: TimeInterval) -> AnyPublisher<(ResponseInfoType, Data?), Error> { - let responseInfo: ResponseInfo = ResponseInfo( - requestData: RequestData( - urlString: "\(snode.address):\(snode.port)/onion_req/v2", - httpMethod: "POST", - headers: [:], - body: payload, - destination: OnionRequestAPIDestination.snode(snode) - ), - code: 200, - headers: [:] - ) - - return Just((responseInfo, mockResponse)) - .setFailureType(to: Error.self) - .eraseToAnyPublisher() - } -} diff --git a/SessionShareExtension/ThreadPickerVC.swift b/SessionShareExtension/ThreadPickerVC.swift index 4728da4d3..c7b4cd2b9 100644 --- a/SessionShareExtension/ThreadPickerVC.swift +++ b/SessionShareExtension/ThreadPickerVC.swift @@ -175,7 +175,13 @@ final class ThreadPickerVC: UIViewController, UITableViewDataSource, UITableView ) } - func attachmentApproval(_ attachmentApproval: AttachmentApprovalViewController, didApproveAttachments attachments: [SignalAttachment], forThreadId threadId: String, messageText: String?) { + func attachmentApproval( + _ attachmentApproval: AttachmentApprovalViewController, + didApproveAttachments attachments: [SignalAttachment], + forThreadId threadId: String, + messageText: String?, + using dependencies: Dependencies = Dependencies() + ) { // Sharing a URL or plain text will populate the 'messageText' field so in those // cases we should ignore the attachments let isSharingUrl: Bool = (attachments.count == 1 && attachments[0].isUrl) @@ -198,7 +204,7 @@ final class ThreadPickerVC: UIViewController, UITableViewDataSource, UITableView // Resume database NotificationCenter.default.post(name: Database.resumeNotification, object: self) - Storage.shared + dependencies.storage .writePublisher { db -> MessageSender.PreparedSendData in guard let threadVariant: SessionThread.Variant = try SessionThread @@ -262,12 +268,13 @@ final class ThreadPickerVC: UIViewController, UITableViewDataSource, UITableView db, interaction: interaction, threadId: threadId, - threadVariant: threadVariant + threadVariant: threadVariant, + using: dependencies ) } .subscribe(on: DispatchQueue.global(qos: .userInitiated)) - .flatMap { MessageSender.performUploadsIfNeeded(preparedSendData: $0) } - .flatMap { MessageSender.sendImmediate(preparedSendData: $0) } + .flatMap { MessageSender.performUploadsIfNeeded(preparedSendData: $0, using: dependencies) } + .flatMap { MessageSender.sendImmediate(data: $0, using: dependencies) } .subscribe(on: DispatchQueue.global(qos: .userInitiated)) .receive(on: DispatchQueue.main) .sinkUntilComplete( diff --git a/SessionSnodeKit/Database/Models/SnodeReceivedMessageInfo.swift b/SessionSnodeKit/Database/Models/SnodeReceivedMessageInfo.swift index c0b7eff3c..f867542eb 100644 --- a/SessionSnodeKit/Database/Models/SnodeReceivedMessageInfo.swift +++ b/SessionSnodeKit/Database/Models/SnodeReceivedMessageInfo.swift @@ -76,11 +76,16 @@ public extension SnodeReceivedMessageInfo { // MARK: - GRDB Interactions public extension SnodeReceivedMessageInfo { - static func pruneExpiredMessageHashInfo(for snode: Snode, namespace: SnodeAPI.Namespace, associatedWith publicKey: String) { + static func pruneExpiredMessageHashInfo( + for snode: Snode, + namespace: SnodeAPI.Namespace, + associatedWith publicKey: String, + using dependencies: Dependencies + ) { // Delete any expired SnodeReceivedMessageInfo values associated to a specific node (even // though this runs very quickly we fetch the rowIds we want to delete from a 'read' call // to avoid blocking the write queue since this method is called very frequently) - let rowIds: [Int64] = Storage.shared + let rowIds: [Int64] = dependencies.storage .read { db in // Only prune the hashes if new hashes exist for this Snode (if they don't then // we don't want to clear out the legacy hashes) @@ -102,7 +107,7 @@ public extension SnodeReceivedMessageInfo { // If there are no rowIds to delete then do nothing guard !rowIds.isEmpty else { return } - Storage.shared.write { db in + dependencies.storage.write { db in try SnodeReceivedMessageInfo .filter(rowIds.contains(Column.rowID)) .deleteAll(db) @@ -114,8 +119,13 @@ public extension SnodeReceivedMessageInfo { /// **Note:** This method uses a `write` instead of a read because there is a single write queue for the database and it's /// very common for this method to be called after the hash value has been updated but before the various `read` threads /// have been updated, resulting in a pointless fetch for data the app has already received - static func fetchLastNotExpired(for snode: Snode, namespace: SnodeAPI.Namespace, associatedWith publicKey: String) -> SnodeReceivedMessageInfo? { - return Storage.shared.read { db in + static func fetchLastNotExpired( + for snode: Snode, + namespace: SnodeAPI.Namespace, + associatedWith publicKey: String, + using dependencies: Dependencies + ) -> SnodeReceivedMessageInfo? { + return dependencies.storage.read { db in let nonLegacyHash: SnodeReceivedMessageInfo? = try SnodeReceivedMessageInfo .filter( SnodeReceivedMessageInfo.Columns.wasDeletedOrInvalid == nil || diff --git a/SessionSnodeKit/Jobs/GetSnodePoolJob.swift b/SessionSnodeKit/Jobs/GetSnodePoolJob.swift index c7b226189..22cdad93d 100644 --- a/SessionSnodeKit/Jobs/GetSnodePoolJob.swift +++ b/SessionSnodeKit/Jobs/GetSnodePoolJob.swift @@ -17,7 +17,7 @@ public enum GetSnodePoolJob: JobExecutor { success: @escaping (Job, Bool, Dependencies) -> (), failure: @escaping (Job, Error?, Bool, Dependencies) -> (), deferred: @escaping (Job, Dependencies) -> (), - dependencies: Dependencies = Dependencies() + using dependencies: Dependencies ) { // If we already have cached Snodes then we still want to trigger the 'SnodeAPI.getSnodePool' // but we want to succeed this job immediately (since it's marked as blocking), this allows us @@ -35,7 +35,7 @@ public enum GetSnodePoolJob: JobExecutor { // If we don't have the snode pool cached then we should also try to build the path (this will // speed up the onboarding process for new users because it can run before the user is created) SnodeAPI.getSnodePool() - .flatMap { _ in OnionRequestAPI.getPath(excluding: nil) } + .flatMap { _ in OnionRequestAPI.getPath(excluding: nil, using: dependencies) } .subscribe(on: queue) .receive(on: queue) .sinkUntilComplete( @@ -53,13 +53,14 @@ public enum GetSnodePoolJob: JobExecutor { ) } - public static func run() { + public static func run(using dependencies: Dependencies = Dependencies()) { GetSnodePoolJob.run( Job(variant: .getSnodePool), queue: .global(qos: .background), success: { _, _, _ in }, failure: { _, _, _, _ in }, - deferred: { _, _ in } + deferred: { _, _ in }, + using: dependencies ) } } diff --git a/SessionSnodeKit/Networking/NetworkType+SnodeKit.swift b/SessionSnodeKit/Networking/NetworkType+SnodeKit.swift deleted file mode 100644 index 04969735d..000000000 --- a/SessionSnodeKit/Networking/NetworkType+SnodeKit.swift +++ /dev/null @@ -1,3 +0,0 @@ -// Copyright © 2023 Rangeproof Pty Ltd. All rights reserved. - -import Foundation diff --git a/SessionSnodeKit/Networking/OnionRequestAPI.swift b/SessionSnodeKit/Networking/OnionRequestAPI.swift index 926c49008..ef06b4a20 100644 --- a/SessionSnodeKit/Networking/OnionRequestAPI.swift +++ b/SessionSnodeKit/Networking/OnionRequestAPI.swift @@ -6,23 +6,31 @@ import CryptoKit import GRDB import SessionUtilitiesKit -public protocol OnionRequestAPIType { - static func sendOnionRequest(_ payload: Data, to snode: Snode, timeout: TimeInterval) -> AnyPublisher<(ResponseInfoType, Data?), Error> - static func sendOnionRequest(_ request: URLRequest, to server: String, with x25519PublicKey: String, timeout: TimeInterval) -> AnyPublisher<(ResponseInfoType, Data?), Error> -} - -public extension OnionRequestAPIType { - static func sendOnionRequest(_ payload: Data, to snode: Snode) -> AnyPublisher<(ResponseInfoType, Data?), Error> { - return sendOnionRequest(payload, to: snode, timeout: HTTP.defaultTimeout) +public extension Network.RequestType { + static func onionRequest(_ payload: Data, to snode: Snode, timeout: TimeInterval = HTTP.defaultTimeout) -> Network.RequestType { + return Network.RequestType( + id: "onionRequest", + url: snode.address, + method: "POST", + body: payload, + args: [payload, snode, timeout] + ) { OnionRequestAPI.sendOnionRequest(payload, to: snode, timeout: timeout) } } - static func sendOnionRequest(_ request: URLRequest, to server: String, with x25519PublicKey: String) -> AnyPublisher<(ResponseInfoType, Data?), Error> { - return sendOnionRequest(request, to: server, with: x25519PublicKey, timeout: HTTP.defaultTimeout) + static func onionRequest(_ request: URLRequest, to server: String, with x25519PublicKey: String, timeout: TimeInterval = HTTP.defaultTimeout) -> Network.RequestType { + return Network.RequestType( + id: "onionRequest", + url: request.url?.absoluteString, + method: request.httpMethod, + headers: request.allHTTPHeaderFields, + body: request.httpBody, + args: [request, server, x25519PublicKey, timeout] + ) { OnionRequestAPI.sendOnionRequest(request, to: server, with: x25519PublicKey, timeout: timeout) } } } /// See the "Onion Requests" section of [The Session Whitepaper](https://arxiv.org/pdf/2002.04609.pdf) for more information. -public enum OnionRequestAPI: OnionRequestAPIType { +public enum OnionRequestAPI { private static var buildPathsPublisher: Atomic?> = Atomic(nil) private static var pathFailureCount: Atomic<[[Snode]: UInt]> = Atomic([:]) private static var snodeFailureCount: Atomic<[Snode: UInt]> = Atomic([:]) @@ -66,12 +74,12 @@ public enum OnionRequestAPI: OnionRequestAPIType { // MARK: - Private API /// Tests the given snode. The returned promise errors out if the snode is faulty; the promise is fulfilled otherwise. - private static func testSnode(_ snode: Snode) -> AnyPublisher { + private static func testSnode(_ snode: Snode, using dependencies: Dependencies) -> AnyPublisher { let url = "\(snode.address):\(snode.port)/get_stats/v1" let timeout: TimeInterval = 3 // Use a shorter timeout for testing return HTTP.execute(.get, url, timeout: timeout) - .decoded(as: SnodeAPI.GetStatsResponse.self) + .decoded(as: SnodeAPI.GetStatsResponse.self, using: dependencies) .tryMap { response -> Void in guard let version: Version = response.version else { throw OnionRequestAPIError.missingSnodeVersion } guard version >= Version(major: 2, minor: 0, patch: 7) else { @@ -86,7 +94,10 @@ public enum OnionRequestAPI: OnionRequestAPIType { /// Finds `targetGuardSnodeCount` guard snodes to use for path building. The returned promise errors out with /// `Error.insufficientSnodes` if not enough (reliable) snodes are available. - private static func getGuardSnodes(reusing reusableGuardSnodes: [Snode]) -> AnyPublisher, Error> { + private static func getGuardSnodes( + reusing reusableGuardSnodes: [Snode], + using dependencies: Dependencies + ) -> AnyPublisher, Error> { guard guardSnodes.wrappedValue.count < targetGuardSnodeCount else { return Just(guardSnodes.wrappedValue) .setFailureType(to: Error.self) @@ -115,7 +126,7 @@ public enum OnionRequestAPI: OnionRequestAPIType { SNLog("Testing guard snode: \(candidate).") // Loop until a reliable guard snode is found - return testSnode(candidate) + return testSnode(candidate, using: dependencies) .map { _ in candidate } .catch { _ in return Just(()) @@ -143,7 +154,10 @@ public enum OnionRequestAPI: OnionRequestAPIType { /// Builds and returns `targetPathCount` paths. The returned promise errors out with `Error.insufficientSnodes` /// if not enough (reliable) snodes are available. @discardableResult - private static func buildPaths(reusing reusablePaths: [[Snode]]) -> AnyPublisher<[[Snode]], Error> { + private static func buildPaths( + reusing reusablePaths: [[Snode]], + using dependencies: Dependencies + ) -> AnyPublisher<[[Snode]], Error> { if let existingBuildPathsPublisher = buildPathsPublisher.wrappedValue { return existingBuildPathsPublisher } @@ -164,7 +178,7 @@ public enum OnionRequestAPI: OnionRequestAPIType { /// Need to include the post-request code and a `shareReplay` within the publisher otherwise it can still be executed /// multiple times as a result of multiple subscribers let reusableGuardSnodes = reusablePaths.map { $0[0] } - let publisher: AnyPublisher<[[Snode]], Error> = getGuardSnodes(reusing: reusableGuardSnodes) + let publisher: AnyPublisher<[[Snode]], Error> = getGuardSnodes(reusing: reusableGuardSnodes, using: dependencies) .flatMap { (guardSnodes: Set) -> AnyPublisher<[[Snode]], Error> in var unusedSnodes: Set = SnodeAPI.snodePool.wrappedValue .subtracting(guardSnodes) @@ -227,7 +241,10 @@ public enum OnionRequestAPI: OnionRequestAPIType { } /// Returns a `Path` to be used for building an onion request. Builds new paths as needed. - internal static func getPath(excluding snode: Snode?) -> AnyPublisher<[Snode], Error> { + internal static func getPath( + excluding snode: Snode?, + using dependencies: Dependencies + ) -> AnyPublisher<[Snode], Error> { guard pathSize >= 1 else { preconditionFailure("Can't build path of size zero.") } let paths: [[Snode]] = OnionRequestAPI.paths @@ -257,7 +274,7 @@ public enum OnionRequestAPI: OnionRequestAPIType { else if !paths.isEmpty { if let snode = snode { if let path = paths.first(where: { !$0.contains(snode) }) { - buildPaths(reusing: paths) // Re-build paths in the background + buildPaths(reusing: paths, using: dependencies) // Re-build paths in the background .subscribe(on: DispatchQueue.global(qos: .background)) .sink(receiveCompletion: { _ in cancellable = [] }, receiveValue: { _ in }) .store(in: &cancellable) @@ -267,7 +284,7 @@ public enum OnionRequestAPI: OnionRequestAPIType { .eraseToAnyPublisher() } else { - return buildPaths(reusing: paths) + return buildPaths(reusing: paths, using: dependencies) .flatMap { paths in guard let path: [Snode] = paths.filter({ !$0.contains(snode) }).randomElement() else { return Fail<[Snode], Error>(error: OnionRequestAPIError.insufficientSnodes) @@ -282,7 +299,7 @@ public enum OnionRequestAPI: OnionRequestAPIType { } } else { - buildPaths(reusing: paths) // Re-build paths in the background + buildPaths(reusing: paths, using: dependencies) // Re-build paths in the background .subscribe(on: DispatchQueue.global(qos: .background)) .sink(receiveCompletion: { _ in cancellable = [] }, receiveValue: { _ in }) .store(in: &cancellable) @@ -298,7 +315,7 @@ public enum OnionRequestAPI: OnionRequestAPIType { } } else { - return buildPaths(reusing: []) + return buildPaths(reusing: [], using: dependencies) .flatMap { paths in if let snode = snode { if let path = paths.filter({ !$0.contains(snode) }).randomElement() { @@ -330,7 +347,7 @@ public enum OnionRequestAPI: OnionRequestAPIType { private static func drop(_ snode: Snode) throws { // We repair the path here because we can do it sync. In the case where we drop a whole - // path we leave the re-building up to getPath(excluding:) because re-building the path + // path we leave the re-building up to getPath(excluding:using:) because re-building the path // in that case is async. OnionRequestAPI.snodeFailureCount.mutate { $0[snode] = 0 } var oldPaths = paths @@ -375,7 +392,8 @@ public enum OnionRequestAPI: OnionRequestAPIType { /// Builds an onion around `payload` and returns the result. private static func buildOnion( around payload: Data, - targetedAt destination: OnionRequestAPIDestination + targetedAt destination: OnionRequestAPIDestination, + using dependencies: Dependencies ) -> AnyPublisher { var guardSnode: Snode! var targetSnodeSymmetricKey: Data! // Needed by invoke(_:on:with:) to decrypt the response sent back by the destination @@ -384,7 +402,7 @@ public enum OnionRequestAPI: OnionRequestAPIType { if case .snode(let snode) = destination { snodeToExclude = snode } - return getPath(excluding: snodeToExclude) + return getPath(excluding: snodeToExclude, using: dependencies) .flatMap { path -> AnyPublisher in guardSnode = path.first! @@ -490,11 +508,12 @@ public enum OnionRequestAPI: OnionRequestAPIType { with payload: Data, to destination: OnionRequestAPIDestination, version: OnionRequestAPIVersion, - timeout: TimeInterval = HTTP.defaultTimeout + timeout: TimeInterval = HTTP.defaultTimeout, + using dependencies: Dependencies = Dependencies() ) -> AnyPublisher<(ResponseInfoType, Data?), Error> { var guardSnode: Snode? - return buildOnion(around: payload, targetedAt: destination) + return buildOnion(around: payload, targetedAt: destination, using: dependencies) .flatMap { intermediate -> AnyPublisher<(ResponseInfoType, Data?), Error> in guardSnode = intermediate.guardSnode let url = "\(guardSnode!.address):\(guardSnode!.port)/onion_req/v2" diff --git a/SessionSnodeKit/Networking/SnodeAPI.swift b/SessionSnodeKit/Networking/SnodeAPI.swift index 04e1836c2..159d13fad 100644 --- a/SessionSnodeKit/Networking/SnodeAPI.swift +++ b/SessionSnodeKit/Networking/SnodeAPI.swift @@ -6,6 +6,18 @@ import Sodium import GRDB import SessionUtilitiesKit +public extension Network.RequestType { + static func message( + _ message: SnodeMessage, + in namespace: SnodeAPI.Namespace, + using dependencies: Dependencies = Dependencies() + ) -> Network.RequestType { + return Network.RequestType(id: "snodeAPI.sendMessage", args: [message, namespace]) { + SnodeAPI.sendMessage(message, in: namespace, using: dependencies) + } + } +} + public final class SnodeAPI { internal static let sodium: Atomic = Atomic(Sodium()) @@ -135,11 +147,13 @@ public final class SnodeAPI { return !hasInsufficientSnodes } - public static func getSnodePool() -> AnyPublisher, Error> { + public static func getSnodePool( + using dependencies: Dependencies = Dependencies() + ) -> AnyPublisher, Error> { loadSnodePoolIfNeeded() let now: Date = Date() - let hasSnodePoolExpired: Bool = Storage.shared[.lastSnodePoolRefreshDate] + let hasSnodePoolExpired: Bool = dependencies.storage[.lastSnodePoolRefreshDate] .map { now.timeIntervalSince($0) > 2 * 60 * 60 } .defaulting(to: true) let snodePool: Set = SnodeAPI.snodePool.wrappedValue @@ -163,10 +177,10 @@ public final class SnodeAPI { } let targetPublisher: AnyPublisher, Error> = { - guard snodePool.count >= minSnodePoolCount else { return getSnodePoolFromSeedNode() } + guard snodePool.count >= minSnodePoolCount else { return getSnodePoolFromSeedNode(using: dependencies) } - return getSnodePoolFromSnode() - .catch { _ in getSnodePoolFromSeedNode() } + return getSnodePoolFromSnode(using: dependencies) + .catch { _ in getSnodePoolFromSeedNode(using: dependencies) } .eraseToAnyPublisher() }() @@ -199,7 +213,10 @@ public final class SnodeAPI { } } - public static func getSessionID(for onsName: String) -> AnyPublisher { + public static func getSessionID( + for onsName: String, + using dependencies: Dependencies = Dependencies() + ) -> AnyPublisher { let validationCount = 3 // The name must be lowercased @@ -236,7 +253,8 @@ public final class SnodeAPI { ) ), to: snode, - associatedWith: nil + associatedWith: nil, + using: dependencies ) .decoded(as: ONSResolveResponse.self) .tryMap { _, response -> String in @@ -264,7 +282,7 @@ public final class SnodeAPI { public static func getSwarm( for publicKey: String, - using dependencies: SSKDependencies = SSKDependencies() + using dependencies: Dependencies = Dependencies() ) -> AnyPublisher, Error> { loadSwarmIfNeeded(for: publicKey) @@ -304,14 +322,14 @@ public final class SnodeAPI { refreshingConfigHashes: [String] = [], from snode: Snode, associatedWith publicKey: String, - using dependencies: SSKDependencies = SSKDependencies() + using dependencies: Dependencies = Dependencies() ) -> AnyPublisher<[SnodeAPI.Namespace: (info: ResponseInfoType, data: (messages: [SnodeReceivedMessage], lastHash: String?)?)], Error> { guard let userED25519KeyPair = Identity.fetchUserEd25519KeyPair() else { return Fail(error: SnodeAPIError.noKeyPair) .eraseToAnyPublisher() } - let userX25519PublicKey: String = getUserHexEncodedPublicKey() + let userX25519PublicKey: String = getUserHexEncodedPublicKey(using: dependencies) return Just(()) .setFailureType(to: Error.self) @@ -324,14 +342,16 @@ public final class SnodeAPI { SnodeReceivedMessageInfo.pruneExpiredMessageHashInfo( for: snode, namespace: namespace, - associatedWith: publicKey + associatedWith: publicKey, + using: dependencies ) result[namespace] = SnodeReceivedMessageInfo .fetchLastNotExpired( for: snode, namespace: namespace, - associatedWith: publicKey + associatedWith: publicKey, + using: dependencies )? .hash } @@ -445,7 +465,7 @@ public final class SnodeAPI { .grouped(by: \.expiry) .mapValues({ groupedResults in groupedResults.map { $0.hash } }) { - Storage.shared.writeAsync { db in + dependencies.storage.writeAsync { db in try groupedExpiryResult.forEach { updatedExpiry, hashes in try SnodeReceivedMessageInfo .filter(hashes.contains(SnodeReceivedMessageInfo.Columns.hash)) @@ -493,7 +513,7 @@ public final class SnodeAPI { in namespace: SnodeAPI.Namespace, from snode: Snode, associatedWith publicKey: String, - using dependencies: SSKDependencies = SSKDependencies() + using dependencies: Dependencies = Dependencies() ) -> AnyPublisher<(info: ResponseInfoType, data: (messages: [SnodeReceivedMessage], lastHash: String?)?), Error> { return Deferred { Future { resolver in @@ -501,14 +521,16 @@ public final class SnodeAPI { SnodeReceivedMessageInfo.pruneExpiredMessageHashInfo( for: snode, namespace: namespace, - associatedWith: publicKey + associatedWith: publicKey, + using: dependencies ) let maybeLastHash: String? = SnodeReceivedMessageInfo .fetchLastNotExpired( for: snode, namespace: namespace, - associatedWith: publicKey + associatedWith: publicKey, + using: dependencies )? .hash @@ -592,7 +614,7 @@ public final class SnodeAPI { public static func sendMessage( _ message: SnodeMessage, in namespace: Namespace, - using dependencies: SSKDependencies = SSKDependencies() + using dependencies: Dependencies ) -> AnyPublisher<(ResponseInfoType, SendMessagesResponse), Error> { let publicKey: String = message.recipient let userX25519PublicKey: String = getUserHexEncodedPublicKey() @@ -661,7 +683,7 @@ public final class SnodeAPI { public static func sendConfigMessages( _ messages: [(message: SnodeMessage, namespace: Namespace)], allObsoleteHashes: [String], - using dependencies: SSKDependencies = SSKDependencies() + using dependencies: Dependencies = Dependencies() ) -> AnyPublisher { guard !messages.isEmpty, @@ -755,7 +777,7 @@ public final class SnodeAPI { publicKey: String, serverHashes: [String], updatedExpiryMs: UInt64, - using dependencies: SSKDependencies = SSKDependencies() + using dependencies: Dependencies = Dependencies() ) -> AnyPublisher<[String: [(hash: String, expiry: UInt64)]], Error> { guard let userED25519KeyPair = Identity.fetchUserEd25519KeyPair() else { return Fail(error: SnodeAPIError.noKeyPair) @@ -796,7 +818,7 @@ public final class SnodeAPI { public static func revokeSubkey( publicKey: String, subkeyToRevoke: String, - using dependencies: SSKDependencies = SSKDependencies() + using dependencies: Dependencies = Dependencies() ) -> AnyPublisher { guard let userED25519KeyPair = Identity.fetchUserEd25519KeyPair() else { return Fail(error: SnodeAPIError.noKeyPair) @@ -839,14 +861,14 @@ public final class SnodeAPI { public static func deleteMessages( publicKey: String, serverHashes: [String], - using dependencies: SSKDependencies = SSKDependencies() + using dependencies: Dependencies = Dependencies() ) -> AnyPublisher<[String: Bool], Error> { guard let userED25519KeyPair = Identity.fetchUserEd25519KeyPair() else { return Fail(error: SnodeAPIError.noKeyPair) .eraseToAnyPublisher() } - let userX25519PublicKey: String = getUserHexEncodedPublicKey() + let userX25519PublicKey: String = getUserHexEncodedPublicKey(using: dependencies) return getSwarm(for: publicKey) .tryFlatMapWithRandomSnode(retry: maxRetryCount) { snode -> AnyPublisher<[String: Bool], Error> in @@ -894,7 +916,7 @@ public final class SnodeAPI { /// Clears all the user's data from their swarm. Returns a dictionary of snode public key to deletion confirmation. public static func deleteAllMessages( namespace: SnodeAPI.Namespace, - using dependencies: SSKDependencies = SSKDependencies() + using dependencies: Dependencies = Dependencies() ) -> AnyPublisher<[String: Bool], Error> { guard let userED25519KeyPair = Identity.fetchUserEd25519KeyPair() else { return Fail(error: SnodeAPIError.noKeyPair) @@ -941,7 +963,7 @@ public final class SnodeAPI { public static func deleteAllMessages( beforeMs: UInt64, namespace: SnodeAPI.Namespace, - using dependencies: SSKDependencies = SSKDependencies() + using dependencies: Dependencies = Dependencies() ) -> AnyPublisher<[String: Bool], Error> { guard let userED25519KeyPair = Identity.fetchUserEd25519KeyPair() else { return Fail(error: SnodeAPIError.noKeyPair) @@ -989,7 +1011,7 @@ public final class SnodeAPI { private static func getNetworkTime( from snode: Snode, - using dependencies: SSKDependencies = SSKDependencies() + using dependencies: Dependencies = Dependencies() ) -> AnyPublisher { return SnodeAPI .send( @@ -998,7 +1020,8 @@ public final class SnodeAPI { body: [:] ), to: snode, - associatedWith: nil + associatedWith: nil, + using: dependencies ) .decoded(as: GetNetworkTimestampResponse.self, using: dependencies) .map { _, response in response.timestamp } @@ -1013,7 +1036,7 @@ public final class SnodeAPI { } private static func getSnodePoolFromSeedNode( - dependencies: SSKDependencies = SSKDependencies() + using dependencies: Dependencies ) -> AnyPublisher, Error> { let request: SnodeRequest = SnodeRequest( endpoint: .jsonGetNServiceNodes, @@ -1073,7 +1096,7 @@ public final class SnodeAPI { } private static func getSnodePoolFromSnode( - dependencies: SSKDependencies = SSKDependencies() + using dependencies: Dependencies ) -> AnyPublisher, Error> { var snodePool = SnodeAPI.snodePool.wrappedValue var snodes: Set = [] @@ -1110,7 +1133,8 @@ public final class SnodeAPI { ) ), to: snode, - associatedWith: nil + associatedWith: nil, + using: dependencies ) .decoded(as: SnodePoolResponse.self, using: dependencies) .mapError { error -> Error in @@ -1149,7 +1173,7 @@ public final class SnodeAPI { request: SnodeRequest, to snode: Snode, associatedWith publicKey: String?, - using dependencies: SSKDependencies = SSKDependencies() + using dependencies: Dependencies ) -> AnyPublisher<(ResponseInfoType, Data?), Error> { guard let payload: Data = try? JSONEncoder().encode(request) else { return Fail(error: HTTPError.invalidJSON) @@ -1175,11 +1199,8 @@ public final class SnodeAPI { .eraseToAnyPublisher() } - return dependencies.onionApi - .sendOnionRequest( - payload, - to: snode - ) + return dependencies.network + .send(.onionRequest(payload, to: snode)) .mapError { error in switch error { case HTTPError.httpRequestFailed(let statusCode, let data): diff --git a/SessionSnodeKit/SSKDependencies.swift b/SessionSnodeKit/SSKDependencies.swift deleted file mode 100644 index 875762f75..000000000 --- a/SessionSnodeKit/SSKDependencies.swift +++ /dev/null @@ -1,38 +0,0 @@ -// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. - -import Foundation -import GRDB -import SessionUtilitiesKit - -open class SSKDependencies: Dependencies { - public var _onionApi: Atomic - public var onionApi: OnionRequestAPIType.Type { - get { Dependencies.getValueSettingIfNull(&_onionApi) { OnionRequestAPI.self } } - set { _onionApi.mutate { $0 = newValue } } - } - - // MARK: - Initialization - - public init( - subscribeQueue: DispatchQueue? = nil, - receiveQueue: DispatchQueue? = nil, - onionApi: OnionRequestAPIType.Type? = nil, - generalCache: MutableGeneralCacheType? = nil, - storage: Storage? = nil, - scheduler: ValueObservationScheduler? = nil, - standardUserDefaults: UserDefaultsType? = nil, - date: Date? = nil - ) { - _onionApi = Atomic(onionApi) - - super.init( - subscribeQueue: subscribeQueue, - receiveQueue: receiveQueue, - generalCache: generalCache, - storage: storage, - scheduler: scheduler, - standardUserDefaults: standardUserDefaults, - date: date - ) - } -} diff --git a/SessionTests/Conversations/Settings/ThreadDisappearingMessagesViewModelSpec.swift b/SessionTests/Conversations/Settings/ThreadDisappearingMessagesViewModelSpec.swift index 0a9dfcf21..25f110c1e 100644 --- a/SessionTests/Conversations/Settings/ThreadDisappearingMessagesViewModelSpec.swift +++ b/SessionTests/Conversations/Settings/ThreadDisappearingMessagesViewModelSpec.swift @@ -24,7 +24,7 @@ class ThreadDisappearingMessagesSettingsViewModelSpec: QuickSpec { // MARK: - Configuration beforeEach { - mockStorage = Storage( + mockStorage = SynchronousStorage( customWriter: try! DatabaseQueue(), customMigrationTargets: [ SNUtilitiesKit.self, @@ -44,10 +44,10 @@ class ThreadDisappearingMessagesSettingsViewModelSpec: QuickSpec { ).insert(db) } viewModel = ThreadDisappearingMessagesSettingsViewModel( - dependencies: dependencies, threadId: "TestId", threadVariant: .contact, - config: DisappearingMessagesConfiguration.defaultWith("TestId") + config: DisappearingMessagesConfiguration.defaultWith("TestId"), + using: dependencies ) cancellables.append( viewModel.observableTableData @@ -127,10 +127,10 @@ class ThreadDisappearingMessagesSettingsViewModelSpec: QuickSpec { _ = try config.saved(db) } viewModel = ThreadDisappearingMessagesSettingsViewModel( - dependencies: dependencies, threadId: "TestId", threadVariant: .contact, - config: config + config: config, + using: dependencies ) cancellables.append( viewModel.observableTableData @@ -232,11 +232,7 @@ class ThreadDisappearingMessagesSettingsViewModelSpec: QuickSpec { items?.first?.action?() - expect(didDismissScreen) - .toEventually( - beTrue(), - timeout: .milliseconds(100) - ) + expect(didDismissScreen).to(beTrue()) } it("saves the updated config") { diff --git a/SessionTests/Conversations/Settings/ThreadSettingsViewModelSpec.swift b/SessionTests/Conversations/Settings/ThreadSettingsViewModelSpec.swift index 60ed929db..22fe69cda 100644 --- a/SessionTests/Conversations/Settings/ThreadSettingsViewModelSpec.swift +++ b/SessionTests/Conversations/Settings/ThreadSettingsViewModelSpec.swift @@ -16,6 +16,7 @@ class ThreadSettingsViewModelSpec: QuickSpec { override func spec() { var mockStorage: Storage! + var mockCaches: MockCaches! var mockGeneralCache: MockGeneralCache! var disposables: [AnyCancellable] = [] var dependencies: Dependencies! @@ -35,12 +36,14 @@ class ThreadSettingsViewModelSpec: QuickSpec { SNUIKit.self ] ) + mockCaches = MockCaches() mockGeneralCache = MockGeneralCache() dependencies = Dependencies( - generalCache: mockGeneralCache, storage: mockStorage, + caches: mockCaches, scheduler: .immediate ) + mockCaches[.general] = mockGeneralCache mockGeneralCache.when { $0.encodedPublicKey }.thenReturn("05\(TestConstants.publicKey)") mockStorage.write { db in try SessionThread( @@ -68,12 +71,12 @@ class ThreadSettingsViewModelSpec: QuickSpec { ).insert(db) } viewModel = ThreadSettingsViewModel( - dependencies: dependencies, threadId: "TestId", threadVariant: .contact, didTriggerSearch: { didTriggerSearchCallbackTriggered = true - } + }, + using: dependencies ) disposables.append( viewModel.observableTableData @@ -166,12 +169,12 @@ class ThreadSettingsViewModelSpec: QuickSpec { } viewModel = ThreadSettingsViewModel( - dependencies: dependencies, threadId: "05\(TestConstants.publicKey)", threadVariant: .contact, didTriggerSearch: { didTriggerSearchCallbackTriggered = true - } + }, + using: dependencies ) disposables.append( viewModel.observableTableData @@ -440,12 +443,12 @@ class ThreadSettingsViewModelSpec: QuickSpec { } viewModel = ThreadSettingsViewModel( - dependencies: dependencies, threadId: "TestId", threadVariant: .legacyGroup, didTriggerSearch: { didTriggerSearchCallbackTriggered = true - } + }, + using: dependencies ) disposables.append( viewModel.observableTableData @@ -482,12 +485,12 @@ class ThreadSettingsViewModelSpec: QuickSpec { } viewModel = ThreadSettingsViewModel( - dependencies: dependencies, threadId: "TestId", threadVariant: .community, didTriggerSearch: { didTriggerSearchCallbackTriggered = true - } + }, + using: dependencies ) disposables.append( viewModel.observableTableData diff --git a/SessionTests/Settings/NotificationContentViewModelSpec.swift b/SessionTests/Settings/NotificationContentViewModelSpec.swift index e6d1e5999..c0ca9f1c4 100644 --- a/SessionTests/Settings/NotificationContentViewModelSpec.swift +++ b/SessionTests/Settings/NotificationContentViewModelSpec.swift @@ -23,7 +23,7 @@ class NotificationContentViewModelSpec: QuickSpec { // MARK: - Configuration beforeEach { - mockStorage = Storage( + mockStorage = SynchronousStorage( customWriter: try! DatabaseQueue(), customMigrationTargets: [ SNUtilitiesKit.self, diff --git a/SessionUtilitiesKit/Combine/Publisher+Utilities.swift b/SessionUtilitiesKit/Combine/Publisher+Utilities.swift index 796e9b29d..a0985631e 100644 --- a/SessionUtilitiesKit/Combine/Publisher+Utilities.swift +++ b/SessionUtilitiesKit/Combine/Publisher+Utilities.swift @@ -43,6 +43,28 @@ public extension Publisher { } .eraseToAnyPublisher() } + + func subscribe( + on scheduler: S, + options: S.SchedulerOptions? = nil, + using dependencies: Dependencies = Dependencies() + ) -> AnyPublisher where S: Scheduler { + guard !dependencies.forceSynchronous else { return self.eraseToAnyPublisher() } + + return self.subscribe(on: scheduler, options: options) + .eraseToAnyPublisher() + } + + func receive( + on scheduler: S, + options: S.SchedulerOptions? = nil, + using dependencies: Dependencies = Dependencies() + ) -> AnyPublisher where S: Scheduler { + guard !dependencies.forceSynchronous else { return self.eraseToAnyPublisher() } + + return self.receive(on: scheduler, options: options) + .eraseToAnyPublisher() + } } // MARK: - Convenience diff --git a/SessionUtilitiesKit/Combine/RetryWithDependencies.swift b/SessionUtilitiesKit/Combine/RetryWithDependencies.swift new file mode 100644 index 000000000..407296a8d --- /dev/null +++ b/SessionUtilitiesKit/Combine/RetryWithDependencies.swift @@ -0,0 +1,39 @@ +// Copyright © 2023 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import Combine + +extension Publishers { + struct RetryWithDependencies: Publisher { + typealias Output = Upstream.Output + typealias Failure = Upstream.Failure + + let upstream: Upstream + let retries: Int + let dependencies: Dependencies + + func receive(subscriber: S) where S : Subscriber, Failure == S.Failure, Output == S.Input { + upstream + .catch { [upstream, retries, dependencies] error -> AnyPublisher in + guard retries > 0 else { + return Fail(error: error).eraseToAnyPublisher() + } + + return RetryWithDependencies(upstream: upstream, retries: retries - 1, dependencies: dependencies) + .eraseToAnyPublisher() + } + .receive(subscriber: subscriber) + } + } +} + +public extension Publisher { + func retry(_ retries: Int, using dependencies: Dependencies) -> AnyPublisher { + guard !dependencies.forceSynchronous else { + return Publishers.RetryWithDependencies(upstream: self, retries: retries, dependencies: dependencies) + .eraseToAnyPublisher() + } + + return self.retry(retries).eraseToAnyPublisher() + } +} diff --git a/SessionUtilitiesKit/Database/Models/Job.swift b/SessionUtilitiesKit/Database/Models/Job.swift index 800d581cf..ee1190ade 100644 --- a/SessionUtilitiesKit/Database/Models/Job.swift +++ b/SessionUtilitiesKit/Database/Models/Job.swift @@ -289,7 +289,9 @@ public struct Job: Codable, Equatable, Hashable, Identifiable, FetchableRecord, guard let details: T = details, - let detailsData: Data = try? JSONEncoder().encode(details) + let detailsData: Data = try? JSONEncoder() + .with(outputFormatting: .sortedKeys) // Needed for deterministic comparison + .encode(details) else { return nil } self.priority = priority @@ -395,7 +397,11 @@ public extension Job { } func with(details: T) -> Job? { - guard let detailsData: Data = try? JSONEncoder().encode(details) else { return nil } + guard + let detailsData: Data = try? JSONEncoder() + .with(outputFormatting: .sortedKeys) // Needed for deterministic comparison + .encode(details) + else { return nil } return Job( id: self.id, diff --git a/SessionUtilitiesKit/Database/Storage.swift b/SessionUtilitiesKit/Database/Storage.swift index 7f5c3dd13..5238ace7d 100644 --- a/SessionUtilitiesKit/Database/Storage.swift +++ b/SessionUtilitiesKit/Database/Storage.swift @@ -40,6 +40,7 @@ open class Storage { public static let defaultPublisherScheduler: ValueObservationScheduler = .async(onQueue: .main) fileprivate var dbWriter: DatabaseWriter? + internal var testDbWriter: DatabaseWriter? { dbWriter } private var migrator: DatabaseMigrator? private var migrationProgressUpdater: Atomic<((String, CGFloat) -> ())>? @@ -435,10 +436,11 @@ open class Storage { // MARK: - Functions - @discardableResult public final func write( + @discardableResult public func write( fileName: String = #file, functionName: String = #function, lineNumber: Int = #line, + using dependencies: Dependencies = Dependencies(), updates: @escaping (Database) throws -> T? ) -> T? { guard isValid, let dbWriter: DatabaseWriter = dbWriter else { return nil } @@ -453,12 +455,14 @@ open class Storage { fileName: String = #file, functionName: String = #function, lineNumber: Int = #line, + using dependencies: Dependencies = Dependencies(), updates: @escaping (Database) throws -> T ) { writeAsync( fileName: fileName, functionName: functionName, lineNumber: lineNumber, + using: dependencies, updates: updates, completion: { _, _ in } ) @@ -468,6 +472,7 @@ open class Storage { fileName: String = #file, functionName: String = #function, lineNumber: Int = #line, + using dependencies: Dependencies = Dependencies(), updates: @escaping (Database) throws -> T, completion: @escaping (Database, Swift.Result) throws -> Void ) { @@ -492,6 +497,7 @@ open class Storage { fileName: String = #file, functionName: String = #function, lineNumber: Int = #line, + using dependencies: Dependencies = Dependencies(), updates: @escaping (Database) throws -> T ) -> AnyPublisher { guard isValid, let dbWriter: DatabaseWriter = dbWriter else { @@ -520,6 +526,7 @@ open class Storage { } open func readPublisher( + using dependencies: Dependencies = Dependencies(), value: @escaping (Database) throws -> T ) -> AnyPublisher { guard isValid, let dbWriter: DatabaseWriter = dbWriter else { @@ -545,7 +552,10 @@ open class Storage { }.eraseToAnyPublisher() } - @discardableResult public final func read(_ value: (Database) throws -> T?) -> T? { + @discardableResult public func read( + using dependencies: Dependencies = Dependencies(), + _ value: (Database) throws -> T? + ) -> T? { guard isValid, let dbWriter: DatabaseWriter = dbWriter else { return nil } do { return try dbWriter.read(value) } diff --git a/SessionUtilitiesKit/Database/Utilities/Database+Utilities.swift b/SessionUtilitiesKit/Database/Utilities/Database+Utilities.swift index 53c30f9c6..5cdbdd363 100644 --- a/SessionUtilitiesKit/Database/Utilities/Database+Utilities.swift +++ b/SessionUtilitiesKit/Database/Utilities/Database+Utilities.swift @@ -87,9 +87,7 @@ public extension Database { // Only allow a single observer per `dedupeId` per transaction, this allows us to // schedule an action to run at most once per transaction (eg. auto-scheduling a ConfigSyncJob // when receiving messages) - guard !TransactionHandler.registeredHandlers.wrappedValue.contains(dedupeId) else { - return - } + guard !TransactionHandler.registeredHandlers.wrappedValue.contains(dedupeId) else { return } add( transactionObserver: TransactionHandler( diff --git a/SessionUtilitiesKit/General/Caches.swift b/SessionUtilitiesKit/General/Caches.swift new file mode 100644 index 000000000..e1086b9bc --- /dev/null +++ b/SessionUtilitiesKit/General/Caches.swift @@ -0,0 +1,126 @@ +// Copyright © 2023 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +// MARK: - CacheType + +public protocol MutableCacheType {} +public protocol ImmutableCacheType {} + +// MARK: - Cache + +public class Cache {} + +// MARK: - CacheInfo + +public enum CacheInfo { + public class Config: Cache { + public let key: Int + public let createInstance: () -> M + public let mutableInstance: (M) -> MutableCacheType + public let immutableInstance: (M) -> I + + fileprivate init( + createInstance: @escaping () -> M, + mutableInstance: @escaping (M) -> MutableCacheType, + immutableInstance: @escaping (M) -> I + ) { + self.key = ObjectIdentifier(M.self).hashValue + self.createInstance = createInstance + self.mutableInstance = mutableInstance + self.immutableInstance = immutableInstance + } + } +} + +public extension CacheInfo { + static func create( + createInstance: @escaping () -> M, + mutableInstance: @escaping (M) -> MutableCacheType, + immutableInstance: @escaping (M) -> I + ) -> CacheInfo.Config { + return CacheInfo.Config( + createInstance: createInstance, + mutableInstance: mutableInstance, + immutableInstance: immutableInstance + ) + } +} + + +public protocol CacheType: MutableCacheType { + associatedtype ImmutableCache = ImmutableCacheType + associatedtype MutableCache: MutableCacheType + + init() + func mutableInstance() -> MutableCache + func immutableInstance() -> ImmutableCache +} + +public extension CacheType where MutableCache == Self { + func mutableInstance() -> Self { return self } +} + +public protocol CachesType { + subscript(cache: CacheInfo.Config) -> I { get } + + @discardableResult func mutate( + cache: CacheInfo.Config, + _ mutation: (inout M) -> R + ) -> R + @discardableResult func mutate( + cache: CacheInfo.Config, + _ mutation: (inout M) throws -> R + ) throws -> R +} + +// MARK: - Caches Logic + +public extension Dependencies { + class Caches: CachesType { + /// The caches need to be accessed as singleton instances so we store them in a static variable in the `Caches` type + private static var cacheInstances: Atomic<[Int: MutableCacheType]> = Atomic([:]) + + // MARK: - Initialization + + public init() {} + + // MARK: - Immutable Access + + public subscript(cache: CacheInfo.Config) -> I { + get { Caches.getValueSettingIfNull(cache: cache, &Caches.cacheInstances) } + } + + // MARK: - Mutable Access + + @discardableResult public func mutate(cache: CacheInfo.Config, _ mutation: (inout M) -> R) -> R { + return Caches.cacheInstances.mutate { caches in + var value: M = ((caches[cache.key] as? M) ?? cache.createInstance()) + return mutation(&value) + } + } + + @discardableResult public func mutate(cache: CacheInfo.Config, _ mutation: (inout M) throws -> R) throws -> R { + return try Caches.cacheInstances.mutate { caches in + var value: M = ((caches[cache.key] as? M) ?? cache.createInstance()) + return try mutation(&value) + } + } + + // MARK: - Convenience + + @discardableResult private static func getValueSettingIfNull( + cache: CacheInfo.Config, + _ store: inout Atomic<[Int: MutableCacheType]> + ) -> I { + guard let value: M = (store.wrappedValue[cache.key] as? M) else { + let value: M = cache.createInstance() + let mutableInstance: MutableCacheType = cache.mutableInstance(value) + store.mutate { $0[cache.key] = mutableInstance } + return cache.immutableInstance(value) + } + + return cache.immutableInstance(value) + } + } +} diff --git a/SessionUtilitiesKit/General/Data+Utilities.swift b/SessionUtilitiesKit/General/Data+Utilities.swift index bac5db7d9..1114ad973 100644 --- a/SessionUtilitiesKit/General/Data+Utilities.swift +++ b/SessionUtilitiesKit/General/Data+Utilities.swift @@ -14,9 +14,7 @@ public extension Data { return try decoder.decode(type, from: self) } - catch { - throw HTTPError.parsingFailed - } + catch { throw HTTPError.parsingFailed } } func removingIdPrefixIfNeeded() -> Data { diff --git a/SessionUtilitiesKit/General/Dependencies.swift b/SessionUtilitiesKit/General/Dependencies.swift index 0621482ef..a6b99474e 100644 --- a/SessionUtilitiesKit/General/Dependencies.swift +++ b/SessionUtilitiesKit/General/Dependencies.swift @@ -3,107 +3,98 @@ import Foundation import GRDB -open class Dependencies { - /// These should not be accessed directly but rather via an instance of this type - private static let _generalCacheInstance: MutableGeneralCacheType = General.Cache() - private static let _generalCacheInstanceAccessQueue = DispatchQueue(label: "GeneralCacheInstanceAccess") - - public var _subscribeQueue: Atomic - public var subscribeQueue: DispatchQueue { - get { Dependencies.getValueSettingIfNull(&_subscribeQueue) { DispatchQueue.global(qos: .default) } } - set { _subscribeQueue.mutate { $0 = newValue } } - } - - public var _receiveQueue: Atomic - public var receiveQueue: DispatchQueue { - get { Dependencies.getValueSettingIfNull(&_receiveQueue) { DispatchQueue.global(qos: .default) } } - set { _receiveQueue.mutate { $0 = newValue } } - } - - public var _mutableGeneralCache: Atomic - public var mutableGeneralCache: Atomic { - get { - Dependencies.getMutableValueSettingIfNull(&_mutableGeneralCache) { - Dependencies._generalCacheInstanceAccessQueue.sync { Dependencies._generalCacheInstance } - } - } - } - public var generalCache: GeneralCacheType { - get { - Dependencies.getValueSettingIfNull(&_mutableGeneralCache) { - Dependencies._generalCacheInstanceAccessQueue.sync { Dependencies._generalCacheInstance } - } - } - set { - guard let mutableValue: MutableGeneralCacheType = newValue as? MutableGeneralCacheType else { return } - - _mutableGeneralCache.mutate { $0 = mutableValue } - } - } - - public var _storage: Atomic +public class Dependencies { + private var _storage: Atomic public var storage: Storage { get { Dependencies.getValueSettingIfNull(&_storage) { Storage.shared } } set { _storage.mutate { $0 = newValue } } } - public var _jobRunner: Atomic - public var jobRunner: JobRunnerType { - get { Dependencies.getValueSettingIfNull(&_jobRunner) { JobRunner.instance } } - set { _jobRunner.mutate { $0 = newValue } } + private var _network: Atomic + public var network: NetworkType { + get { Dependencies.getValueSettingIfNull(&_network) { Network() } } + set { _network.mutate { $0 = newValue } } } - public var _scheduler: Atomic - public var scheduler: ValueObservationScheduler { - get { Dependencies.getValueSettingIfNull(&_scheduler) { Storage.defaultPublisherScheduler } } - set { _scheduler.mutate { $0 = newValue } } + private var _crypto: Atomic + public var crypto: CryptoType { + get { Dependencies.getValueSettingIfNull(&_crypto) { Crypto() } } + set { _crypto.mutate { $0 = newValue } } } - public var _standardUserDefaults: Atomic + private var _standardUserDefaults: Atomic public var standardUserDefaults: UserDefaultsType { get { Dependencies.getValueSettingIfNull(&_standardUserDefaults) { UserDefaults.standard } } set { _standardUserDefaults.mutate { $0 = newValue } } } - public var _date: Atomic - public var date: Date { - get { Dependencies.getValueSettingIfNull(&_date) { Date() } } - set { _date.mutate { $0 = newValue } } + private var _caches: CachesType + public var caches: CachesType { + get { _caches } + set { _caches = newValue } } - public var _fixedTime: Atomic + private var _jobRunner: Atomic + public var jobRunner: JobRunnerType { + get { Dependencies.getValueSettingIfNull(&_jobRunner) { JobRunner.instance } } + set { _jobRunner.mutate { $0 = newValue } } + } + + private var _scheduler: Atomic + public var scheduler: ValueObservationScheduler { + get { Dependencies.getValueSettingIfNull(&_scheduler) { Storage.defaultPublisherScheduler } } + set { _scheduler.mutate { $0 = newValue } } + } + + private var _dateNow: Atomic + public var dateNow: Date { + get { (_dateNow.wrappedValue ?? Date()) } + set { _dateNow.mutate { $0 = newValue } } + } + + private var _fixedTime: Atomic public var fixedTime: Int { get { Dependencies.getValueSettingIfNull(&_fixedTime) { 0 } } set { _fixedTime.mutate { $0 = newValue } } } + private var _forceSynchronous: Bool + public var forceSynchronous: Bool { + get { _forceSynchronous } + set { _forceSynchronous = newValue } + } + + public var asyncExecutions: [Int: [() -> Void]] = [:] + // MARK: - Initialization public init( - subscribeQueue: DispatchQueue? = nil, - receiveQueue: DispatchQueue? = nil, - generalCache: MutableGeneralCacheType? = nil, storage: Storage? = nil, + network: NetworkType? = nil, + crypto: CryptoType? = nil, + standardUserDefaults: UserDefaultsType? = nil, + caches: CachesType = Caches(), jobRunner: JobRunnerType? = nil, scheduler: ValueObservationScheduler? = nil, - standardUserDefaults: UserDefaultsType? = nil, - date: Date? = nil, - fixedTime: Int? = nil + dateNow: Date? = nil, + fixedTime: Int? = nil, + forceSynchronous: Bool = false ) { - _subscribeQueue = Atomic(subscribeQueue) - _receiveQueue = Atomic(receiveQueue) - _mutableGeneralCache = Atomic(generalCache) _storage = Atomic(storage) + _network = Atomic(network) + _crypto = Atomic(crypto) + _standardUserDefaults = Atomic(standardUserDefaults) + _caches = caches _jobRunner = Atomic(jobRunner) _scheduler = Atomic(scheduler) - _standardUserDefaults = Atomic(standardUserDefaults) - _date = Atomic(date) + _dateNow = Atomic(dateNow) _fixedTime = Atomic(fixedTime) + _forceSynchronous = forceSynchronous } // MARK: - Convenience - public static func getValueSettingIfNull(_ maybeValue: inout Atomic, _ valueGenerator: () -> T) -> T { + private static func getValueSettingIfNull(_ maybeValue: inout Atomic, _ valueGenerator: () -> T) -> T { guard let value: T = maybeValue.wrappedValue else { let value: T = valueGenerator() maybeValue.mutate { $0 = value } @@ -113,7 +104,7 @@ open class Dependencies { return value } - public static func getMutableValueSettingIfNull(_ maybeValue: inout Atomic, _ valueGenerator: () -> T) -> Atomic { + private static func getMutableValueSettingIfNull(_ maybeValue: inout Atomic, _ valueGenerator: () -> T) -> Atomic { guard let value: T = maybeValue.wrappedValue else { let value: T = valueGenerator() maybeValue.mutate { $0 = value } @@ -122,4 +113,23 @@ open class Dependencies { return Atomic(value) } + +#if DEBUG + public func stepForwardInTime() { + let targetTime: Int = ((_fixedTime.wrappedValue ?? 0) + 1) + _fixedTime.mutate { $0 = targetTime } + + if let currentDate: Date = _dateNow.wrappedValue { + _dateNow.mutate { $0 = Date(timeIntervalSince1970: currentDate.timeIntervalSince1970 + 1) } + } + + // Run and clear any executions which should run at the target time + let targetKeys: [Int] = asyncExecutions.keys + .filter { $0 <= targetTime } + targetKeys.forEach { key in + asyncExecutions[key]?.forEach { $0() } + asyncExecutions[key] = nil + } + } +#endif } diff --git a/SessionUtilitiesKit/General/Dictionary+Utilities.swift b/SessionUtilitiesKit/General/Dictionary+Utilities.swift index 7adfe64aa..ee1fafd85 100644 --- a/SessionUtilitiesKit/General/Dictionary+Utilities.swift +++ b/SessionUtilitiesKit/General/Dictionary+Utilities.swift @@ -67,4 +67,18 @@ public extension Dictionary { return updatedDictionary } + + mutating func append(_ value: T?, toArrayOn key: Key?) where Value == [T] { + guard let key: Key = key, let value: T = value else { return } + + self[key] = (self[key] ?? []).appending(value) + } +} + +extension Dictionary where Value == Array<() -> Void> { + mutating func appendTo(_ key: Key?, _ value: @escaping () -> Void) { + guard let key: Key = key else { return } + + self[key] = (self[key] ?? []).appending(value) + } } diff --git a/SessionUtilitiesKit/General/General.swift b/SessionUtilitiesKit/General/General.swift index 5f59f2b13..34ea83b3a 100644 --- a/SessionUtilitiesKit/General/General.swift +++ b/SessionUtilitiesKit/General/General.swift @@ -6,12 +6,20 @@ import GRDB // MARK: - General.Cache public enum General { - public class Cache: MutableGeneralCacheType { + public class Cache: GeneralCacheType { public var encodedPublicKey: String? = nil public var recentReactionTimestamps: [Int64] = [] } } +public extension Cache { + static let general: CacheInfo.Config = CacheInfo.create( + createInstance: { General.Cache() }, + mutableInstance: { $0 }, + immutableInstance: { $0 } + ) +} + // MARK: - GeneralError public enum GeneralError: Error { @@ -22,13 +30,13 @@ public enum GeneralError: Error { // MARK: - Convenience -public func getUserHexEncodedPublicKey(_ db: Database? = nil, dependencies: Dependencies = Dependencies()) -> String { - if let cachedKey: String = dependencies.generalCache.encodedPublicKey { return cachedKey } +public func getUserHexEncodedPublicKey(_ db: Database? = nil, using dependencies: Dependencies = Dependencies()) -> String { + if let cachedKey: String = dependencies.caches[.general].encodedPublicKey { return cachedKey } if let publicKey: Data = Identity.fetchUserPublicKey(db) { // Can be nil under some circumstances let sessionId: SessionId = SessionId(.standard, publicKey: publicKey.bytes) - dependencies.mutableGeneralCache.mutate { $0.encodedPublicKey = sessionId.hexString } + dependencies.caches.mutate(cache: .general) { $0.encodedPublicKey = sessionId.hexString } return sessionId.hexString } @@ -37,14 +45,14 @@ public func getUserHexEncodedPublicKey(_ db: Database? = nil, dependencies: Depe // MARK: - GeneralCacheType -public protocol MutableGeneralCacheType: GeneralCacheType { - var encodedPublicKey: String? { get set } - var recentReactionTimestamps: [Int64] { get set } -} - -/// This is a read-only version of the `OGMMutableCacheType` designed to avoid unintentionally mutating the instance in a +/// This is a read-only version of the `General.Cache` designed to avoid unintentionally mutating the instance in a /// non-thread-safe way -public protocol GeneralCacheType { +public protocol ImmutableGeneralCacheType: ImmutableCacheType { var encodedPublicKey: String? { get } var recentReactionTimestamps: [Int64] { get } } + +public protocol GeneralCacheType: ImmutableGeneralCacheType, MutableCacheType { + var encodedPublicKey: String? { get set } + var recentReactionTimestamps: [Int64] { get set } +} diff --git a/SessionUtilitiesKit/General/Timer+MainThread.swift b/SessionUtilitiesKit/General/Timer+MainThread.swift index 7cea385a2..7805762ef 100644 --- a/SessionUtilitiesKit/General/Timer+MainThread.swift +++ b/SessionUtilitiesKit/General/Timer+MainThread.swift @@ -3,14 +3,24 @@ import Foundation extension Timer { - - @discardableResult - public static func scheduledTimerOnMainThread( + @discardableResult public static func scheduledTimerOnMainThread( withTimeInterval timeInterval: TimeInterval, repeats: Bool = false, + using dependencies: Dependencies = Dependencies(), block: @escaping (Timer) -> Void ) -> Timer { let timer = Timer(timeInterval: timeInterval, repeats: repeats, block: block) + + // If we are forcing synchrnonous execution (ie. running unit tests) then ceil the + // timeInterval for execution and append it to the execution set so the test can + // trigger the logic in a synchronous way) + guard !dependencies.forceSynchronous else { + dependencies.asyncExecutions.appendTo(Int(ceil(dependencies.dateNow.timeIntervalSince1970 + timeInterval))) { + block(timer) + } + return timer + } + RunLoop.main.add(timer, forMode: .common) return timer } diff --git a/SessionUtilitiesKit/JobRunner/JobRunner.swift b/SessionUtilitiesKit/JobRunner/JobRunner.swift index 1f7d7e042..b118c0571 100644 --- a/SessionUtilitiesKit/JobRunner/JobRunner.swift +++ b/SessionUtilitiesKit/JobRunner/JobRunner.swift @@ -8,21 +8,22 @@ public protocol JobRunnerType { func setExecutor(_ executor: JobExecutor.Type, for variant: Job.Variant) func canStart(queue: JobQueue?) -> Bool + func afterBlockingQueue(callback: @escaping () -> ()) // MARK: - State Management func jobInfoFor(jobs: [Job]?, state: JobRunner.JobState, variant: Job.Variant?) -> [Int64: JobRunner.JobInfo] - func appDidFinishLaunching(dependencies: Dependencies) - func appDidBecomeActive(dependencies: Dependencies) - func startNonBlockingQueues(dependencies: Dependencies) + func appDidFinishLaunching(using dependencies: Dependencies) + func appDidBecomeActive(using dependencies: Dependencies) + func startNonBlockingQueues(using dependencies: Dependencies) func stopAndClearPendingJobs(exceptForVariant: Job.Variant?, onComplete: (() -> ())?) // MARK: - Job Scheduling - @discardableResult func add(_ db: Database, job: Job?, canStartJob: Bool, dependencies: Dependencies) -> Job? - func upsert(_ db: Database, job: Job?, canStartJob: Bool, dependencies: Dependencies) - @discardableResult func insert(_ db: Database, job: Job?, before otherJob: Job, dependencies: Dependencies) -> (Int64, Job)? + @discardableResult func add(_ db: Database, job: Job?, canStartJob: Bool, using dependencies: Dependencies) -> Job? + func upsert(_ db: Database, job: Job?, canStartJob: Bool, using dependencies: Dependencies) + @discardableResult func insert(_ db: Database, job: Job?, before otherJob: Job) -> (Int64, Job)? } // MARK: - JobRunnerType Convenience @@ -61,7 +62,11 @@ public extension JobRunnerType { inState state: JobRunner.JobState = .any, with jobDetails: T ) -> Bool { - guard let detailsData: Data = try? JSONEncoder().encode(jobDetails) else { return false } + guard + let detailsData: Data = try? JSONEncoder() + .with(outputFormatting: .sortedKeys) // Needed for deterministic comparison + .encode(jobDetails) + else { return false } return jobInfoFor(jobs: nil, state: state, variant: variant) .values @@ -103,7 +108,7 @@ public protocol JobExecutor { success: @escaping (Job, Bool, Dependencies) -> (), failure: @escaping (Job, Error?, Bool, Dependencies) -> (), deferred: @escaping (Job, Dependencies) -> (), - dependencies: Dependencies + using dependencies: Dependencies ) } @@ -130,11 +135,26 @@ public final class JobRunner: JobRunnerType { case notFound } - public struct JobInfo: Equatable { + public struct JobInfo: Equatable, CustomDebugStringConvertible { public let variant: Job.Variant public let threadId: String? public let interactionId: Int64? public let detailsData: Data? + + public var debugDescription: String { + let dataDescription: String = detailsData + .map { data in "Data(hex: \(data.toHexString()), \(data.bytes.count) bytes" } + .defaulting(to: "nil") + + return [ + "JobRunner.JobInfo(", + "variant: \(variant),", + " threadId: \(threadId ?? "nil"),", + " interactionId: \(interactionId.map { "\($0)" } ?? "nil"),", + " detailsData: \(dataDescription)", + ")" + ].joined() + } } // MARK: - Variables @@ -145,22 +165,23 @@ public final class JobRunner: JobRunnerType { private var blockingQueueDrainCallback: Atomic<[() -> ()]> = Atomic([]) internal var appReadyToStartQueues: Atomic = Atomic(false) + internal var appHasBecomeActive: Atomic = Atomic(false) internal var perSessionJobsCompleted: Atomic> = Atomic([]) internal var hasCompletedInitialBecomeActive: Atomic = Atomic(false) internal var shutdownBackgroundTask: Atomic = Atomic(nil) - // TODO: Check these??? - internal static var executorMap: Atomic<[Job.Variant: JobExecutor.Type]> = Atomic([:]) - fileprivate static var canStartQueues: Atomic = Atomic(false) - private static var blockingQueueDrainCallback: Atomic<[() -> ()]> = Atomic([]) - - + private var canStartNonBlockingQueue: Bool { + blockingQueue.wrappedValue?.hasStartedAtLeastOnce.wrappedValue == true && + blockingQueue.wrappedValue?.isRunning.wrappedValue != true && + appHasBecomeActive.wrappedValue + } + // MARK: - Initialization init( isTestingJobRunner: Bool = false, variantsToExclude: [Job.Variant] = [], - dependencies: Dependencies = Dependencies() + using dependencies: Dependencies = Dependencies() ) { var jobVariants: Set = Job.Variant.allCases .filter { !variantsToExclude.contains($0) } @@ -177,6 +198,7 @@ public final class JobRunner: JobRunnerType { JobQueue( type: .blocking, qos: .default, + isTestingJobRunner: isTestingJobRunner, jobVariants: [] ) ) @@ -187,6 +209,7 @@ public final class JobRunner: JobRunnerType { type: .messageSend, executionType: .concurrent, // Allow as many jobs to run at once as supported by the device qos: .default, + isTestingJobRunner: isTestingJobRunner, jobVariants: [ jobVariants.remove(.attachmentUpload), jobVariants.remove(.messageSend), @@ -208,6 +231,7 @@ public final class JobRunner: JobRunnerType { // update message has been processed (ie. guaranteed to fail) executionType: .serial, qos: .default, + isTestingJobRunner: isTestingJobRunner, jobVariants: [ jobVariants.remove(.messageReceive), jobVariants.remove(.configMessageReceive) @@ -219,6 +243,7 @@ public final class JobRunner: JobRunnerType { JobQueue( type: .attachmentDownload, qos: .utility, + isTestingJobRunner: isTestingJobRunner, jobVariants: [ jobVariants.remove(.attachmentDownload) ].compactMap { $0 } @@ -229,6 +254,7 @@ public final class JobRunner: JobRunnerType { JobQueue( type: .general(number: 0), qos: .utility, + isTestingJobRunner: isTestingJobRunner, jobVariants: Array(jobVariants) ) ].reduce(into: [:]) { prev, next in @@ -243,7 +269,7 @@ public final class JobRunner: JobRunnerType { $0?.onQueueDrained = { [weak self] in // Once all blocking jobs have been completed we want to start running // the remaining job queues - self?.startNonBlockingQueues(dependencies: dependencies) + self?.startNonBlockingQueues(using: dependencies) self?.blockingQueueDrainCallback.mutate { $0.forEach { $0() } @@ -259,19 +285,6 @@ public final class JobRunner: JobRunnerType { } } - // TODO: Check if any of these are needed - //internal static var executorMap: Atomic<[Job.Variant: JobExecutor.Type]> = Atomic([:]) - //fileprivate static var perSessionJobsCompleted: Atomic> = Atomic([]) - //private static var hasCompletedInitialBecomeActive: Atomic = Atomic(false) - //private static var shutdownBackgroundTask: Atomic = Atomic(nil) - //fileprivate static var canStartQueues: Atomic = Atomic(false) - //private static var blockingQueueDrainCallback: Atomic<[() -> ()]> = Atomic([]) - // - //fileprivate static var canStartNonBlockingQueue: Bool { - // blockingQueue.wrappedValue?.hasStartedAtLeastOnce.wrappedValue == true && - // blockingQueue.wrappedValue?.isRunning.wrappedValue != true - //} - // MARK: - Configuration public func setExecutor(_ executor: JobExecutor.Type, for variant: Job.Variant) { @@ -279,164 +292,92 @@ public final class JobRunner: JobRunnerType { queues.wrappedValue[variant]?.setExecutor(executor, for: variant) } -// TODO: Double chekc this - //public func canStart(queue: JobQueue) -> Bool { - // return ( - // allowToExecuteJobs && - // appReadyToStartQueues.wrappedValue - // ) + public func canStart(queue: JobQueue?) -> Bool { + return ( + allowToExecuteJobs && + appReadyToStartQueues.wrappedValue && ( + queue?.type == .blocking || + canStartNonBlockingQueue + ) + ) + } - // TODO: Double check this - //public static func afterBlockingQueue(callback: @escaping () -> ()) { - // guard - // (blockingQueue.wrappedValue?.hasStartedAtLeastOnce.wrappedValue != true) || - // (blockingQueue.wrappedValue?.isRunning.wrappedValue == true) - // else { return callback() } - // - // blockingQueueDrainCallback.mutate { $0.append(callback) } - //} - - // MARK: - Execution - - /// Add a job onto the queue, if the queue isn't currently running and 'canStartJob' is true then this will start - /// the JobRunner - /// - /// **Note:** If the job has a `behaviour` of `runOnceNextLaunch` or the `nextRunTimestamp` - /// is in the future then the job won't be started - //@discardableResult public static func add(_ db: Database, job: Job?, canStartJob: Bool = true) -> Job? { - // // Store the job into the database (getting an id for it) - // guard let updatedJob: Job = try? job?.inserted(db) else { - // SNLog("[JobRunner] Unable to add \(job.map { "\($0.variant)" } ?? "unknown") job") - // return nil - // } - // guard !canStartJob || updatedJob.id != nil else { - // SNLog("[JobRunner] Not starting \(job.map { "\($0.variant)" } ?? "unknown") job due to missing id") - // return nil - // } - // - // // Wait until the transaction has been completed before updating the queue (to ensure anything - // // created during the transaction has been saved to the database before any corresponding jobs - // // are run) - // db.afterNextTransactionNested { _ in - // queues.wrappedValue[updatedJob.variant]?.add(updatedJob, canStartJob: canStartJob) - // - // // Don't start the queue if the job can't be started - // guard canStartJob else { return } - // - // queues.wrappedValue[updatedJob.variant]?.start() - // } - // - // return updatedJob - //} + public func afterBlockingQueue(callback: @escaping () -> ()) { + guard + (blockingQueue.wrappedValue?.hasStartedAtLeastOnce.wrappedValue != true) || + (blockingQueue.wrappedValue?.isRunning.wrappedValue == true) + else { return callback() } + blockingQueueDrainCallback.mutate { $0.append(callback) } + } + // MARK: - State Management - - public func isCurrentlyRunning(_ job: Job?) -> Bool { - guard let job: Job = job else { return false } - - return !detailsFor(jobs: [job], state: .running).isEmpty - } - - public func hasJob(of variant: Job.Variant, inState state: JobRunner.JobState, with jobDetails: T) -> Bool { - guard let detailsData: Data = try? JSONEncoder().encode(jobDetails) else { return false } - - return detailsFor(state: state, variant: variant).values.contains(detailsData) - } - // // Wait until the transaction has been completed before updating the queue (to ensure anything - // // created during the transaction has been saved to the database before any corresponding jobs - // // are run) - // db.afterNextTransactionNested { _ in - // queues.wrappedValue[job.variant]?.upsert(job, canStartJob: canStartJob) - // - // // Don't start the queue if the job can't be started - // guard canStartJob else { return } - // - // queues.wrappedValue[job.variant]?.start() - // } - //} - - ///// Insert a job before another job in the queue - ///// - ///// **Note:** This function assumes the relevant job queue is already running and as such **will not** start the queue if it isn't running - //@discardableResult public static func insert(_ db: Database, job: Job?, before otherJob: Job) -> (Int64, Job)? { - // switch job?.behaviour { - // case .recurringOnActive, .recurringOnLaunch, .runOnceNextLaunch: - // SNLog("[JobRunner] Attempted to insert \(job.map { "\($0.variant)" } ?? "unknown") job before the current one even though it's behaviour is \(job.map { "\($0.behaviour)" } ?? "unknown")") - // return nil - // - // default: break - // } - // - // // Store the job into the database (getting an id for it) - // guard let updatedJob: Job = try? job?.inserted(db) else { - // SNLog("[JobRunner] Unable to add \(job.map { "\($0.variant)" } ?? "unknown") job") - // return nil - // } - // guard let jobId: Int64 = updatedJob.id else { - // SNLog("[JobRunner] Unable to add \(job.map { "\($0.variant)" } ?? "unknown") job due to missing id") - // return nil - // } - // - // // Wait until the transaction has been completed before updating the queue (to ensure anything - // // created during the transaction has been saved to the database before any corresponding jobs - // // are run) - // db.afterNextTransactionNested { _ in - // queues.wrappedValue[updatedJob.variant]?.insert(updatedJob, before: otherJob) - // } - // - // return (jobId, updatedJob) - //} - - public func detailsFor( + public func jobInfoFor( jobs: [Job]?, state: JobRunner.JobState, variant: Job.Variant? - ) -> [Int64: Data?] { - var result: [(Int64, Data?)] = [] + ) -> [Int64: JobRunner.JobInfo] { + var result: [(Int64, JobRunner.JobInfo)] = [] let targetKeys: [JobQueue.JobKey] = (jobs?.compactMap { JobQueue.JobKey($0) } ?? []) let targetVariants: [Job.Variant] = (variant.map { [$0] } ?? jobs?.map { $0.variant }) .defaulting(to: []) // Insert the state of any pending jobs if state.contains(.pending) { - func detailsFor(queue: JobQueue?, variants: [Job.Variant]) -> [(Int64, Data?)] { + func infoFor(queue: JobQueue?, variants: [Job.Variant]) -> [(Int64, JobRunner.JobInfo)] { return (queue?.pendingJobsQueue.wrappedValue .filter { variants.isEmpty || variants.contains($0.variant) } - .compactMap { job -> (Int64, Data?)? in + .compactMap { job -> (Int64, JobRunner.JobInfo)? in guard let jobKey: JobQueue.JobKey = JobQueue.JobKey(job) else { return nil } - guard !targetKeys.isEmpty else { return (jobKey.id, job.details) } + guard + targetKeys.isEmpty || + targetKeys.contains(jobKey) + else { return nil } - return (targetKeys.contains(jobKey) ? (jobKey.id, job.details) : nil) + return ( + jobKey.id, + JobRunner.JobInfo( + variant: job.variant, + threadId: job.threadId, + interactionId: job.interactionId, + detailsData: job.details + ) + ) }) .defaulting(to: []) } - result.append(contentsOf: detailsFor(queue: blockingQueue.wrappedValue, variants: targetVariants)) + result.append(contentsOf: infoFor(queue: blockingQueue.wrappedValue, variants: targetVariants)) queues.wrappedValue .filter { key, _ -> Bool in targetVariants.isEmpty || targetVariants.contains(key) } - .values - .forEach { queue in result.append(contentsOf: detailsFor(queue: queue, variants: targetVariants)) } + .map { _, queue in queue } + .asSet() + .forEach { queue in result.append(contentsOf: infoFor(queue: queue, variants: targetVariants)) } } // Insert the state of any running jobs if state.contains(.running) { - func detailsFor(queue: JobQueue?, variants: [Job.Variant]) -> [(Int64, Data?)] { - return (queue?.detailsForCurrentlyRunningJobs.wrappedValue - .filter { variants.isEmpty || variants.contains($0.key.variant) } - .compactMap { jobKey, details -> (Int64, Data?)? in - guard !targetKeys.isEmpty else { return (jobKey.id, details) } + func infoFor(queue: JobQueue?, variants: [Job.Variant]) -> [(Int64, JobRunner.JobInfo)] { + return (queue?.infoForAllCurrentlyRunningJobs() + .filter { variants.isEmpty || variants.contains($0.value.variant) } + .compactMap { jobId, info -> (Int64, JobRunner.JobInfo)? in + guard + targetKeys.isEmpty || + targetKeys.contains(JobQueue.JobKey(id: jobId, variant: info.variant)) + else { return nil } - return (targetKeys.contains(jobKey) ? (jobKey.id, details) : nil) + return (jobId, info) }) .defaulting(to: []) } - result.append(contentsOf: detailsFor(queue: blockingQueue.wrappedValue, variants: targetVariants)) + result.append(contentsOf: infoFor(queue: blockingQueue.wrappedValue, variants: targetVariants)) queues.wrappedValue .filter { key, _ -> Bool in targetVariants.isEmpty || targetVariants.contains(key) } - .values - .forEach { queue in result.append(contentsOf: detailsFor(queue: queue, variants: targetVariants)) } + .map { _, queue in queue } + .asSet() + .forEach { queue in result.append(contentsOf: infoFor(queue: queue, variants: targetVariants)) } } return result @@ -445,7 +386,7 @@ public final class JobRunner: JobRunnerType { } } - public func appDidFinishLaunching(dependencies: Dependencies) { + public func appDidFinishLaunching(using dependencies: Dependencies) { // Flag that the JobRunner can start it's queues appReadyToStartQueues.mutate { $0 = true } @@ -484,13 +425,11 @@ public final class JobRunner: JobRunnerType { } .defaulting(to: ([], [])) - guard !jobsToRun.blocking.isEmpty || !jobsToRun.nonBlocking.isEmpty else { return } - // Add and start any blocking jobs blockingQueue.wrappedValue?.appDidFinishLaunching( with: jobsToRun.blocking, canStart: true, - dependencies: dependencies + using: dependencies ) // Add any non-blocking jobs (we don't start these incase there are blocking "on active" @@ -499,13 +438,18 @@ public final class JobRunner: JobRunnerType { let jobQueues: [Job.Variant: JobQueue] = queues.wrappedValue jobsByVariant.forEach { variant, jobs in - jobQueues[variant]?.appDidFinishLaunching(with: jobs, canStart: false, dependencies: dependencies) + jobQueues[variant]?.appDidFinishLaunching( + with: jobs, + canStart: false, + using: dependencies + ) } } - public func appDidBecomeActive(dependencies: Dependencies) { - // Flag that the JobRunner can start it's queues + public func appDidBecomeActive(using dependencies: Dependencies) { + // Flag that the JobRunner can start it's queues and start queueing non-launch jobs appReadyToStartQueues.mutate { $0 = true } + appHasBecomeActive.mutate { $0 = true } // If we have a running "sutdownBackgroundTask" then we want to cancel it as otherwise it // can result in the database being suspended and us being unable to interact with it at all @@ -535,27 +479,37 @@ public final class JobRunner: JobRunnerType { guard !jobsToRun.isEmpty else { if !blockingQueueIsRunning { - jobQueues.forEach { _, queue in queue.start(dependencies: dependencies) } + jobQueues.map { _, queue in queue }.asSet().forEach { $0.start(using: dependencies) } } return } // Add and start any non-blocking jobs (if there are no blocking jobs) + // + // We only want to trigger the queue to start once so we need to consolidate the + // queues to list of jobs (as queues can handle multiple job variants), this means + // that 'onActive' jobs will be queued before any standard jobs let jobsByVariant: [Job.Variant: [Job]] = jobsToRun.grouped(by: \.variant) - jobQueues.forEach { variant, queue in - queue.appDidBecomeActive( - with: (jobsByVariant[variant] ?? []), - canStart: !blockingQueueIsRunning, - dependencies: dependencies - ) - } + jobQueues + .reduce(into: [:]) { result, variantAndQueue in + result[variantAndQueue.value] = (result[variantAndQueue.value] ?? []) + .appending(contentsOf: (jobsByVariant[variantAndQueue.key] ?? [])) + } + .forEach { queue, jobs in + queue.appDidBecomeActive( + with: jobs, + canStart: !blockingQueueIsRunning, + using: dependencies + ) + } + self.hasCompletedInitialBecomeActive.mutate { $0 = true } } - public func startNonBlockingQueues(dependencies: Dependencies) { - queues.wrappedValue.forEach { _, queue in - queue.start(dependencies: dependencies) + public func startNonBlockingQueues(using dependencies: Dependencies) { + queues.wrappedValue.map { _, queue in queue }.asSet().forEach { queue in + queue.start(using: dependencies) } } @@ -567,10 +521,12 @@ public final class JobRunner: JobRunnerType { // rescheduling themselves while in the background, when the app restarts or becomes active // the JobRunenr will update this flag) appReadyToStartQueues.mutate { $0 = false } + appHasBecomeActive.mutate { $0 = false } // Stop all queues except for the one containing the `exceptForVariant` queues.wrappedValue - .values + .map { _, queue in queue } + .asSet() .filter { queue -> Bool in guard let exceptForVariant: Job.Variant = exceptForVariant else { return true } @@ -619,66 +575,71 @@ public final class JobRunner: JobRunnerType { // MARK: - Execution - public func add( + @discardableResult public func add( _ db: Database, job: Job?, canStartJob: Bool, - dependencies: Dependencies - ) { + using dependencies: Dependencies + ) -> Job? { // Store the job into the database (getting an id for it) guard let updatedJob: Job = try? job?.inserted(db) else { SNLog("[JobRunner] Unable to add \(job.map { "\($0.variant)" } ?? "unknown") job") - return + return nil } guard !canStartJob || updatedJob.id != nil else { SNLog("[JobRunner] Not starting \(job.map { "\($0.variant)" } ?? "unknown") job due to missing id") - return + return nil } - queues.wrappedValue[updatedJob.variant]?.add(db, job: updatedJob, canStartJob: canStartJob, dependencies: dependencies) + // Don't add to the queue if the JobRunner isn't ready (it's been saved to the db so it'll be loaded + // once the queue actually get started later) + guard canAddToQueue(updatedJob) else { return updatedJob } + + queues.wrappedValue[updatedJob.variant]?.add(db, job: updatedJob, canStartJob: canStartJob, using: dependencies) // Don't start the queue if the job can't be started - guard canStartJob else { return } + guard canStartJob else { return updatedJob } // Start the job runner if needed db.afterNextTransactionNestedOnce(dedupeId: "JobRunner-Start: \(updatedJob.variant)") { [weak self] _ in - self?.queues.wrappedValue[updatedJob.variant]?.start(dependencies: dependencies) + self?.queues.wrappedValue[updatedJob.variant]?.start(using: dependencies) } + + return updatedJob } public func upsert( _ db: Database, job: Job?, canStartJob: Bool, - dependencies: Dependencies + using dependencies: Dependencies ) { guard let job: Job = job else { return } // Ignore null jobs guard job.id != nil else { - add(db, job: job, canStartJob: canStartJob, dependencies: dependencies) + add(db, job: job, canStartJob: canStartJob, using: dependencies) return } - queues.wrappedValue[job.variant]?.upsert(db, job: job, canStartJob: canStartJob, dependencies: dependencies) + // Don't add to the queue if the JobRunner isn't ready (it's been saved to the db so it'll be loaded + // once the queue actually get started later) + guard canAddToQueue(job) else { return } + + queues.wrappedValue[job.variant]?.upsert(db, job: job, canStartJob: canStartJob, using: dependencies) // Don't start the queue if the job can't be started guard canStartJob else { return } // Start the job runner if needed + db.afterNextTransactionNestedOnce(dedupeId: "JobRunner-Start: \(job.variant)") { [weak self] _ in - self?.queues.wrappedValue[job.variant]?.start(dependencies: dependencies) + self?.queues.wrappedValue[job.variant]?.start(using: dependencies) } } - - //public static func infoForCurrentlyRunningJobs(of variant: Job.Variant) -> [Int64: JobInfo] { - // return (queues.wrappedValue[variant]?.infoForAllCurrentlyRunningJobs()) - // .defaulting(to: [:]) - //} @discardableResult public func insert( _ db: Database, job: Job?, - before otherJob: Job, - dependencies: Dependencies + before otherJob: Job ) -> (Int64, Job)? { switch job?.behaviour { case .recurringOnActive, .recurringOnLaunch, .runOnceNextLaunch: @@ -698,8 +659,7 @@ public final class JobRunner: JobRunnerType { return nil } - queues.wrappedValue[updatedJob.variant]? - .insert(updatedJob, before: otherJob, dependencies: dependencies) + queues.wrappedValue[updatedJob.variant]?.insert(updatedJob, before: otherJob) return (jobId, updatedJob) } @@ -752,11 +712,20 @@ public final class JobRunner: JobRunnerType { let maxBackoff: Double = 10 * 60 // 10 minutes return 0.25 * min(maxBackoff, pow(2, Double(job.failureCount))) } + + fileprivate func canAddToQueue(_ job: Job) -> Bool { + // We can only start the job if it's an "on launch" job or the app has become active + return ( + job.behaviour == .runOnceNextLaunch || + job.behaviour == .recurringOnLaunch || + appHasBecomeActive.wrappedValue + ) + } } // MARK: - JobQueue -public final class JobQueue { +public final class JobQueue: Hashable { fileprivate enum QueueType: Hashable { case blocking case general(number: Int) @@ -792,33 +761,21 @@ public final class JobQueue { static func create( queue: JobQueue, timestamp: TimeInterval, - dependencies: Dependencies + using dependencies: Dependencies ) -> Trigger? { - guard !SNUtilitiesKit.isRunningTests else { - /// When running unit tests don't schedule a proper Timer, use a while loop instead and base it on the `fixedTime` - /// value instead of `dependencies.date` to simplify things - DispatchQueue.global(qos: .default).async { [weak queue] in - while dependencies.fixedTime < Int(timestamp) { - Thread.sleep(forTimeInterval: 0.01) // Wait for 10ms - } - - queue?.start(dependencies: dependencies) - } - return nil - } - /// Setup the trigger (wait at least 1 second before triggering) /// /// **Note:** We use the `Timer.scheduledTimerOnMainThread` method because running a timer /// on our random queue threads results in the timer never firing, the `start` method will redirect itself to /// the correct thread let trigger: Trigger = Trigger() - trigger.fireTimestamp = max(1, (timestamp - dependencies.date.timeIntervalSince1970)) + trigger.fireTimestamp = max(1, (timestamp - dependencies.dateNow.timeIntervalSince1970)) trigger.timer = Timer.scheduledTimerOnMainThread( withTimeInterval: trigger.fireTimestamp, repeats: false, + using: dependencies, block: { [weak queue] _ in - queue?.start(dependencies: dependencies) + queue?.start(using: dependencies) } ) return trigger @@ -835,6 +792,11 @@ public final class JobQueue { fileprivate let id: Int64 fileprivate let variant: Job.Variant + fileprivate init(id: Int64, variant: Job.Variant) { + self.id = id + self.variant = variant + } + fileprivate init?(_ job: Job?) { guard let id: Int64 = job?.id, let variant: Job.Variant = job?.variant else { return nil } @@ -845,17 +807,14 @@ public final class JobQueue { private static let deferralLoopThreshold: Int = 3 - private let type: QueueType + private let id: UUID = UUID() + fileprivate let type: QueueType private let executionType: ExecutionType private let qosClass: DispatchQoS private let queueKey: DispatchSpecificKey = DispatchSpecificKey() private let queueContext: String - - /// The specific types of jobs this queue manages, if this is left empty it will handle all jobs not handled by other queues fileprivate let jobVariants: [Job.Variant] - fileprivate var onQueueDrained: (() -> ())? - private lazy var internalQueue: DispatchQueue = { let result: DispatchQueue = DispatchQueue( label: self.queueContext, @@ -870,17 +829,18 @@ public final class JobQueue { }() private var executorMap: Atomic<[Job.Variant: JobExecutor.Type]> = Atomic([:]) - private var nextTrigger: Atomic = Atomic(nil) + fileprivate var canStart: ((JobQueue?) -> Bool)? + fileprivate var onQueueDrained: (() -> ())? fileprivate var hasStartedAtLeastOnce: Atomic = Atomic(false) fileprivate var isRunning: Atomic = Atomic(false) fileprivate var pendingJobsQueue: Atomic<[Job]> = Atomic([]) - fileprivate var jobsCurrentlyRunning: Atomic> = Atomic([]) - // TODO: Check these - fileprivate var detailsForCurrentlyRunningJobs: Atomic<[JobKey: Data?]> = Atomic([:]) + + private var nextTrigger: Atomic = Atomic(nil) + private var jobCallbacks: Atomic<[Int64: [(JobRunner.JobResult) -> ()]]> = Atomic([:]) private var currentlyRunningJobIds: Atomic> = Atomic([]) private var currentlyRunningJobInfo: Atomic<[Int64: JobRunner.JobInfo]> = Atomic([:]) - private var jobCallbacks: Atomic<[Int64: [(JobRunner.JobResult) -> ()]]> = Atomic([:]) private var deferLoopTracker: Atomic<[Int64: (count: Int, times: [TimeInterval])]> = Atomic([:]) + private let maxDeferralsPerSecond: Int fileprivate var hasPendingJobs: Bool { !pendingJobsQueue.wrappedValue.isEmpty } @@ -890,15 +850,25 @@ public final class JobQueue { type: QueueType, executionType: ExecutionType = .serial, qos: DispatchQoS, - jobVariants: [Job.Variant], - onQueueDrained: (() -> ())? = nil + isTestingJobRunner: Bool, + jobVariants: [Job.Variant] ) { self.type = type self.executionType = executionType self.queueContext = "JobQueue-\(type.name)" self.qosClass = qos + self.maxDeferralsPerSecond = (isTestingJobRunner ? 10 : 1) // Allow for tripping the defer loop in tests self.jobVariants = jobVariants - self.onQueueDrained = onQueueDrained + } + + // MARK: - Hashable + + public func hash(into hasher: inout Hasher) { + id.hash(into: &hasher) + } + + public static func == (lhs: JobQueue, rhs: JobQueue) -> Bool { + return (lhs.id == rhs.id) } // MARK: - Configuration @@ -909,12 +879,17 @@ public final class JobQueue { // MARK: - Execution - fileprivate func add(_ db: Database, job: Job, canStartJob: Bool = true, dependencies: Dependencies) { + fileprivate func add( + _ db: Database, + job: Job, + canStartJob: Bool, + using dependencies: Dependencies + ) { // Check if the job should be added to the queue guard canStartJob, job.behaviour != .runOnceNextLaunch, - job.nextRunTimestamp <= dependencies.date.timeIntervalSince1970 + job.nextRunTimestamp <= dependencies.dateNow.timeIntervalSince1970 else { return } guard job.id != nil else { SNLog("[JobRunner] Prevented attempt to add \(job.variant) job without id to queue") @@ -929,7 +904,7 @@ public final class JobQueue { // Ensure that the database commit has completed and then trigger the next job to run (need // to ensure any interactions have been correctly inserted first) db.afterNextTransactionNestedOnce(dedupeId: "JobRunner-Add: \(job.variant)") { [weak self] _ in - self?.runNextJob(dependencies: dependencies) + self?.runNextJob(using: dependencies) } } @@ -938,7 +913,12 @@ public final class JobQueue { /// /// **Note:** If the job has a `behaviour` of `runOnceNextLaunch` or the `nextRunTimestamp` /// is in the future then the job won't be started - fileprivate func upsert(_ db: Database, job: Job, canStartJob: Bool = true, dependencies: Dependencies) { + fileprivate func upsert( + _ db: Database, + job: Job, + canStartJob: Bool, + using dependencies: Dependencies + ) { guard let jobId: Int64 = job.id else { SNLog("[JobRunner] Prevented attempt to upsert \(job.variant) job without id to queue") return @@ -961,10 +941,10 @@ public final class JobQueue { // If we didn't update an existing job then we need to add it to the pendingJobsQueue guard !didUpdateExistingJob else { return } - add(db, job: job, canStartJob: canStartJob, dependencies: dependencies) + add(db, job: job, canStartJob: canStartJob, using: dependencies) } - fileprivate func insert(_ job: Job, before otherJob: Job, dependencies: Dependencies) { + fileprivate func insert(_ job: Job, before otherJob: Job) { guard job.id != nil else { SNLog("[JobRunner] Prevented attempt to insert \(job.variant) job without id to queue") return @@ -987,21 +967,22 @@ public final class JobQueue { fileprivate func appDidFinishLaunching( with jobs: [Job], canStart: Bool, - dependencies: Dependencies + using dependencies: Dependencies ) { pendingJobsQueue.mutate { $0.append(contentsOf: jobs) } // Start the job runner if needed if canStart && !isRunning.wrappedValue { - start(dependencies: dependencies) + start(using: dependencies) } } fileprivate func appDidBecomeActive( with jobs: [Job], canStart: Bool, - dependencies: Dependencies + using dependencies: Dependencies ) { + let currentlyRunningJobIds: Set = currentlyRunningJobIds.wrappedValue pendingJobsQueue.mutate { queue in // Avoid re-adding jobs to the queue that are already in it (this can @@ -1018,39 +999,28 @@ public final class JobQueue { // Start the job runner if needed if canStart && !isRunning.wrappedValue { - start(dependencies: dependencies) + start(using: dependencies) } } - //fileprivate func isCurrentlyRunning(_ jobId: Int64) -> Bool { - // return currentlyRunningJobIds.wrappedValue.contains(jobId) - //} - // - //fileprivate func infoForAllCurrentlyRunningJobs() -> [Int64: JobRunner.JobInfo] { - // return currentlyRunningJobInfo.wrappedValue - //} + fileprivate func infoForAllCurrentlyRunningJobs() -> [Int64: JobRunner.JobInfo] { + return currentlyRunningJobInfo.wrappedValue + } fileprivate func afterCurrentlyRunningJob(_ jobId: Int64, callback: @escaping (JobRunner.JobResult) -> ()) { - guard jobsCurrentlyRunning.wrappedValue.contains(jobId) else { - callback(.notFound) - return - } + guard currentlyRunningJobIds.wrappedValue.contains(jobId) else { return callback(.notFound) } jobCallbacks.mutate { jobCallbacks in jobCallbacks[jobId] = (jobCallbacks[jobId] ?? []).appending(callback) } } - //fileprivate func hasPendingOrRunningJob(with detailsData: Data?) -> Bool { - // guard let detailsData: Data = detailsData else { return false } - // - // let pendingJobs: [Job] = pendingJobsQueue.wrappedValue fileprivate func hasPendingOrRunningJobWith( threadId: String? = nil, interactionId: Int64? = nil, detailsData: Data? = nil ) -> Bool { - let pendingJobs: [Job] = queue.wrappedValue + let pendingJobs: [Job] = pendingJobsQueue.wrappedValue let currentlyRunningJobInfo: [Int64: JobRunner.JobInfo] = currentlyRunningJobInfo.wrappedValue var possibleJobIds: Set = Set(currentlyRunningJobInfo.keys) .inserting(contentsOf: pendingJobs.compactMap { $0.id }.asSet()) @@ -1116,29 +1086,17 @@ public final class JobQueue { fileprivate func start( forceWhenAlreadyRunning: Bool = false, - dependencies: Dependencies + using dependencies: Dependencies ) { // Only start if the JobRunner is allowed to start the queue - guard dependencies.jobRunner.canStart(queue: self) else { return } + guard canStart?(self) == true else { return } guard forceWhenAlreadyRunning || !isRunning.wrappedValue else { return } -// TODO: Check this - // We only want the JobRunner to run in the main app - //guard - // HasAppContext() && - // CurrentAppContext().isMainApp && - // !CurrentAppContext().isRunningTests && - // JobRunner.canStartQueues.wrappedValue && - // ( - // type == .blocking || - // JobRunner.canStartNonBlockingQueue - // ) - //else { return } // The JobRunner runs synchronously we need to ensure this doesn't start // on the main thread (if it is on the main thread then swap to a different thread) - guard DispatchQueue.getSpecific(key: queueKey) == queueContext else { - internalQueue.async { [weak self] in - self?.start(dependencies: dependencies) + guard DispatchQueue.with(key: queueKey, matches: queueContext, using: dependencies) else { + internalQueue.async(using: dependencies) { [weak self] in + self?.start(using: dependencies) } return } @@ -1153,11 +1111,9 @@ public final class JobQueue { hasStartedAtLeastOnce.mutate { $0 = true } // Get any pending jobs - let jobIdsAlreadyRunning: Set = jobsCurrentlyRunning.wrappedValue + let jobIdsAlreadyRunning: Set = currentlyRunningJobIds.wrappedValue let jobsAlreadyInQueue: Set = pendingJobsQueue.wrappedValue.compactMap { $0.id }.asSet() - let jobsToRun: [Job] = dependencies.storage.read { db in -// TODO: Check this - //let jobIdsAlreadyRunning: Set = currentlyRunningJobIds.wrappedValue + let jobsToRun: [Job] = dependencies.storage.read(using: dependencies) { db in try Job .filterPendingJobs( variants: jobVariants, @@ -1183,16 +1139,16 @@ public final class JobQueue { guard jobCount > 0 else { if jobIdsAlreadyRunning.isEmpty { isRunning.mutate { $0 = false } - scheduleNextSoonestJob(dependencies: dependencies) + scheduleNextSoonestJob(using: dependencies) } return } // Run the first job in the pendingJobsQueue if !wasAlreadyRunning { - SNLog("[JobRunner] Starting \(queueContext) with (\(jobCount) job\(jobCount != 1 ? "s" : ""))") + SNLogNotTests("[JobRunner] Starting \(queueContext) with (\(jobCount) job\(jobCount != 1 ? "s" : ""))") } - runNextJob(dependencies: dependencies) + runNextJob(using: dependencies) } fileprivate func stopAndClearPendingJobs() { @@ -1201,14 +1157,14 @@ public final class JobQueue { deferLoopTracker.mutate { $0 = [:] } } - private func runNextJob(dependencies: Dependencies) { + private func runNextJob(using dependencies: Dependencies) { // Ensure the queue is running (if we've stopped the queue then we shouldn't start the next job) guard isRunning.wrappedValue else { return } // Ensure this is running on the correct queue - guard DispatchQueue.getSpecific(key: queueKey) == queueContext else { - internalQueue.async { [weak self] in - self?.runNextJob(dependencies: dependencies) + guard DispatchQueue.with(key: queueKey, matches: queueContext, using: dependencies) else { + internalQueue.async(using: dependencies) { [weak self] in + self?.runNextJob(using: dependencies) } return } @@ -1220,7 +1176,7 @@ public final class JobQueue { // Always attempt to schedule the next soonest job (otherwise if enough jobs get started in rapid // succession then pending/failed jobs in the database may never get re-started in a concurrent queue) - scheduleNextSoonestJob(dependencies: dependencies) + scheduleNextSoonestJob(using: dependencies) return } guard let jobExecutor: JobExecutor.Type = executorMap.wrappedValue[nextJob.variant] else { @@ -1229,7 +1185,7 @@ public final class JobQueue { nextJob, error: JobRunnerError.executorMissing, permanentFailure: true, - dependencies: dependencies + using: dependencies ) return } @@ -1239,7 +1195,7 @@ public final class JobQueue { nextJob, error: JobRunnerError.requiredThreadIdMissing, permanentFailure: true, - dependencies: dependencies + using: dependencies ) return } @@ -1249,7 +1205,7 @@ public final class JobQueue { nextJob, error: JobRunnerError.requiredInteractionIdMissing, permanentFailure: true, - dependencies: dependencies + using: dependencies ) return } @@ -1259,19 +1215,19 @@ public final class JobQueue { nextJob, error: JobRunnerError.jobIdMissing, permanentFailure: false, - dependencies: dependencies + using: dependencies ) return } // If the 'nextRunTimestamp' for the job is in the future then don't run it yet - guard nextJob.nextRunTimestamp <= dependencies.date.timeIntervalSince1970 else { - handleJobDeferred(nextJob, dependencies: dependencies) + guard nextJob.nextRunTimestamp <= dependencies.dateNow.timeIntervalSince1970 else { + handleJobDeferred(nextJob, using: dependencies) return } // Check if the next job has any dependencies - let dependencyInfo: (expectedCount: Int, jobs: Set) = dependencies.storage.read { db in + let dependencyInfo: (expectedCount: Int, jobs: Set) = dependencies.storage.read(using: dependencies) { db in let expectedDependencies: Set = try JobDependencies .filter(JobDependencies.Columns.jobId == nextJob.id) .fetchSet(db) @@ -1289,7 +1245,7 @@ public final class JobQueue { nextJob, error: JobRunnerError.missingDependencies, permanentFailure: true, - dependencies: dependencies + using: dependencies ) return } @@ -1301,9 +1257,7 @@ public final class JobQueue { /// /// **Note:** We don't add the current job back the the queue because it should only be re-added if it's dependencies /// are successfully completed - let currentlyRunningJobIds: [Int64] = Array(detailsForCurrentlyRunningJobs.wrappedValue.keys.map { $0.id }) - // TODO: CHeck this - //let currentlyRunningJobIds: [Int64] = Array(currentlyRunningJobIds.wrappedValue) + let currentlyRunningJobIds: [Int64] = Array(currentlyRunningJobIds.wrappedValue) let dependencyJobsNotCurrentlyRunning: [Job] = dependencyInfo.jobs .filter { job in !currentlyRunningJobIds.contains(job.id ?? -1) } .sorted { lhs, rhs in (lhs.id ?? -1) < (rhs.id ?? -1) } @@ -1313,7 +1267,7 @@ public final class JobQueue { .filter { !dependencyJobsNotCurrentlyRunning.contains($0) } .inserting(contentsOf: dependencyJobsNotCurrentlyRunning, at: 0) } - handleJobDeferred(nextJob, dependencies: dependencies) + handleJobDeferred(nextJob, using: dependencies) return } @@ -1335,14 +1289,13 @@ public final class JobQueue { currentlyRunningJobInfo = currentlyRunningJobInfo.setting( nextJob.id, JobRunner.JobInfo( + variant: nextJob.variant, threadId: nextJob.threadId, interactionId: nextJob.interactionId, detailsData: nextJob.details ) ) } -// TODO: Check this - detailsForCurrentlyRunningJobs.mutate { $0 = $0.setting(JobKey(nextJob), nextJob.details) } SNLog("[JobRunner] \(queueContext) started \(nextJob.variant) job (\(executionType == .concurrent ? "\(numJobsRunning) currently running, " : "")\(numJobsRemaining) remaining)") /// As it turns out Combine doesn't plat too nicely with concurrent Dispatch Queues, in Combine events are dispatched asynchronously to @@ -1369,23 +1322,21 @@ public final class JobQueue { success: handleJobSucceeded, failure: handleJobFailed, deferred: handleJobDeferred, - dependencies: dependencies + using: dependencies ) // If this queue executes concurrently and there are still jobs remaining then immediately attempt // to start the next job if executionType == .concurrent && numJobsRemaining > 0 { - internalQueue.async { [weak self] in - self?.runNextJob(dependencies: dependencies) + internalQueue.async(using: dependencies) { [weak self] in + self?.runNextJob(using: dependencies) } } } - private func scheduleNextSoonestJob(dependencies: Dependencies) { - let jobIdsAlreadyRunning: Set = jobsCurrentlyRunning.wrappedValue - let nextJobTimestamp: TimeInterval? = dependencies.storage.read { db in - // TODO: Check this - //let jobIdsAlreadyRunning: Set = currentlyRunningJobIds.wrappedValue + private func scheduleNextSoonestJob(using dependencies: Dependencies) { + let jobIdsAlreadyRunning: Set = currentlyRunningJobIds.wrappedValue + let nextJobTimestamp: TimeInterval? = dependencies.storage.read(using: dependencies) { db in try Job .filterPendingJobs( variants: jobVariants, @@ -1400,21 +1351,15 @@ public final class JobQueue { // If there are no remaining jobs or the JobRunner isn't allowed to start any queues then trigger // the 'onQueueDrained' callback and stop - guard let nextJobTimestamp: TimeInterval = nextJobTimestamp, dependencies.jobRunner.canStart(queue: self) else { - if executionType != .concurrent || jobsCurrentlyRunning.wrappedValue.isEmpty { - // TODO: Chgeck this - //if executionType != .concurrent || currentlyRunningJobIds.wrappedValue.isEmpty { + guard let nextJobTimestamp: TimeInterval = nextJobTimestamp, canStart?(self) == true else { + if executionType != .concurrent || currentlyRunningJobIds.wrappedValue.isEmpty { self.onQueueDrained?() } return } // If the next job isn't scheduled in the future then just restart the JobRunner immediately - let secondsUntilNextJob: TimeInterval = { - guard !SNUtilitiesKit.isRunningTests else { return (nextJobTimestamp - TimeInterval(dependencies.fixedTime)) } - - return (nextJobTimestamp - dependencies.date.timeIntervalSince1970) - }() + let secondsUntilNextJob: TimeInterval = (nextJobTimestamp - dependencies.dateNow.timeIntervalSince1970) guard secondsUntilNextJob > 0 else { // Only log that the queue is getting restarted if this queue had actually been about to stop @@ -1429,8 +1374,8 @@ public final class JobQueue { // Trigger the 'start' function to load in any pending jobs that aren't already in the // queue (for concurrent queues we want to force them to load in pending jobs and add // them to the queue regardless of whether the queue is already running) - internalQueue.async { [weak self] in - self?.start(forceWhenAlreadyRunning: (self?.executionType == .concurrent), dependencies: dependencies) + internalQueue.async(using: dependencies) { [weak self] in + self?.start(forceWhenAlreadyRunning: (self?.executionType == .concurrent), using: dependencies) } return } @@ -1442,7 +1387,7 @@ public final class JobQueue { SNLog("[JobRunner] Stopping \(queueContext) until next job in \(Int(ceil(abs(secondsUntilNextJob)))) second\(Int(ceil(abs(secondsUntilNextJob))) == 1 ? "" : "s")") nextTrigger.mutate { trigger in trigger?.invalidate() // Need to invalidate the old trigger to prevent a memory leak - trigger = Trigger.create(queue: self, timestamp: nextJobTimestamp, dependencies: dependencies) + trigger = Trigger.create(queue: self, timestamp: nextJobTimestamp, using: dependencies) } } @@ -1452,17 +1397,17 @@ public final class JobQueue { private func handleJobSucceeded( _ job: Job, shouldStop: Bool, - dependencies: Dependencies + using dependencies: Dependencies ) { /// Retrieve the dependant jobs first (the `JobDependecies` table has cascading deletion when the original `Job` is /// removed so we need to retrieve these records before that happens) let dependantJobs: [Job] = dependencies.storage - .read { db in try job.dependantJobs.fetchAll(db) } + .read(using: dependencies) { db in try job.dependantJobs.fetchAll(db) } .defaulting(to: []) switch job.behaviour { case .runOnce, .runOnceNextLaunch: - dependencies.storage.write { db in + dependencies.storage.write(using: dependencies) { db in /// Since this job has been completed we can update the dependencies so other job that were dependant /// on this one can be run _ = try JobDependencies @@ -1473,7 +1418,7 @@ public final class JobQueue { } case .recurring where shouldStop == true: - dependencies.storage.write { db in + dependencies.storage.write(using: dependencies) { db in /// Since this job has been completed we can update the dependencies so other job that were dependant /// on this one can be run _ = try JobDependencies @@ -1485,16 +1430,16 @@ public final class JobQueue { /// For `recurring` jobs which have already run, they should automatically run again but we want at least 1 second /// to pass before doing so - the job itself should really update it's own `nextRunTimestamp` (this is just a safety net) - case .recurring where job.nextRunTimestamp <= Date().timeIntervalSince1970: + case .recurring where job.nextRunTimestamp <= dependencies.dateNow.timeIntervalSince1970: guard let jobId: Int64 = job.id else { break } - dependencies.storage.write { db in + dependencies.storage.write(using: dependencies) { db in _ = try Job .filter(id: jobId) .updateAll( db, Job.Columns.failureCount.set(to: 0), - Job.Columns.nextRunTimestamp.set(to: (Date().timeIntervalSince1970 + 1)) + Job.Columns.nextRunTimestamp.set(to: (dependencies.dateNow.timeIntervalSince1970 + 1)) ) } @@ -1507,7 +1452,7 @@ public final class JobQueue { job.nextRunTimestamp > TimeInterval.leastNonzeroMagnitude else { break } - dependencies.storage.write { db in + dependencies.storage.write(using: dependencies) { db in _ = try Job .filter(id: jobId) .updateAll( @@ -1527,9 +1472,7 @@ public final class JobQueue { /// **Note:** If any of these `dependantJobs` have other dependencies then when they attempt to start they will be /// removed from the queue, replaced by their dependencies if !dependantJobs.isEmpty { - let currentlyRunningJobIds: [Int64] = Array(detailsForCurrentlyRunningJobs.wrappedValue.keys.map { $0.id }) - // TODO: CHeck this - //let currentlyRunningJobIds: [Int64] = Array(currentlyRunningJobIds.wrappedValue) + let currentlyRunningJobIds: [Int64] = Array(currentlyRunningJobIds.wrappedValue) let dependantJobsNotCurrentlyRunning: [Job] = dependantJobs .filter { job in !currentlyRunningJobIds.contains(job.id ?? -1) } .sorted { lhs, rhs in (lhs.id ?? -1) < (rhs.id ?? -1) } @@ -1542,9 +1485,9 @@ public final class JobQueue { } // Perform job cleanup and start the next job - performCleanUp(for: job, result: .succeeded) - internalQueue.async { [weak self] in - self?.runNextJob(dependencies: dependencies) + performCleanUp(for: job, result: .succeeded, using: dependencies) + internalQueue.async(using: dependencies) { [weak self] in + self?.runNextJob(using: dependencies) } } @@ -1554,14 +1497,14 @@ public final class JobQueue { _ job: Job, error: Error?, permanentFailure: Bool, - dependencies: Dependencies + using dependencies: Dependencies ) { - guard dependencies.storage.read({ db in try Job.exists(db, id: job.id ?? -1) }) == true else { + guard dependencies.storage.read(using: dependencies, { db in try Job.exists(db, id: job.id ?? -1) }) == true else { SNLog("[JobRunner] \(queueContext) \(job.variant) job canceled") - performCleanUp(for: job, result: .failed) + performCleanUp(for: job, result: .failed, using: dependencies) - internalQueue.async { [weak self] in - self?.runNextJob(dependencies: dependencies) + internalQueue.async(using: dependencies) { [weak self] in + self?.runNextJob(using: dependencies) } return } @@ -1583,7 +1526,8 @@ public final class JobQueue { performCleanUp( for: job, result: .failed, - shouldTriggerCallbacks: wasPossibleDeferralLoop + shouldTriggerCallbacks: wasPossibleDeferralLoop, + using: dependencies ) // Only add it back to the queue if it wasn't a deferral loop @@ -1591,19 +1535,19 @@ public final class JobQueue { pendingJobsQueue.mutate { $0.insert(job, at: 0) } } - internalQueue.async { [weak self] in - self?.runNextJob(dependencies: dependencies) + internalQueue.async(using: dependencies) { [weak self] in + self?.runNextJob(using: dependencies) } return } // Get the max failure count for the job (a value of '-1' means it will retry indefinitely) let maxFailureCount: Int = (executorMap.wrappedValue[job.variant]?.maxFailureCount ?? 0) - let nextRunTimestamp: TimeInterval = (dependencies.date.timeIntervalSince1970 + JobRunner.getRetryInterval(for: job)) + let nextRunTimestamp: TimeInterval = (dependencies.dateNow.timeIntervalSince1970 + JobRunner.getRetryInterval(for: job)) var dependantJobIds: [Int64] = [] var failureText: String = "failed" - dependencies.storage.write { db in + dependencies.storage.write(using: dependencies) { db in /// Retrieve a list of dependant jobs so we can clear them from the queue dependantJobIds = try job.dependantJobs .select(.id) @@ -1662,9 +1606,9 @@ public final class JobQueue { } SNLog("[JobRunner] \(queueContext) \(job.variant) job \(failureText)") - performCleanUp(for: job, result: .failed) - internalQueue.async { [weak self] in - self?.runNextJob(dependencies: dependencies) + performCleanUp(for: job, result: .failed, using: dependencies) + internalQueue.async(using: dependencies) { [weak self] in + self?.runNextJob(using: dependencies) } } @@ -1672,7 +1616,7 @@ public final class JobQueue { /// on other jobs, and it should automatically manage those dependencies) public func handleJobDeferred( _ job: Job, - dependencies: Dependencies + using dependencies: Dependencies ) { var stuckInDeferLoop: Bool = false @@ -1680,15 +1624,15 @@ public final class JobQueue { guard let lastRecord: (count: Int, times: [TimeInterval]) = $0[job.id] else { $0 = $0.setting( job.id, - (1, [dependencies.date.timeIntervalSince1970]) + (1, [dependencies.dateNow.timeIntervalSince1970]) ) return } - let timeNow: TimeInterval = dependencies.date.timeIntervalSince1970 + let timeNow: TimeInterval = dependencies.dateNow.timeIntervalSince1970 stuckInDeferLoop = ( lastRecord.count >= JobQueue.deferralLoopThreshold && - (timeNow - lastRecord.times[0]) < CGFloat(lastRecord.count) + (timeNow - lastRecord.times[0]) < CGFloat(lastRecord.count * maxDeferralsPerSecond) ) $0 = $0.setting( @@ -1715,25 +1659,27 @@ public final class JobQueue { job, error: JobRunnerError.possibleDeferralLoop, permanentFailure: false, - dependencies: dependencies + using: dependencies ) return } - performCleanUp(for: job, result: .deferred) - internalQueue.async { [weak self] in - self?.runNextJob(dependencies: dependencies) + performCleanUp(for: job, result: .deferred, using: dependencies) + internalQueue.async(using: dependencies) { [weak self] in + self?.runNextJob(using: dependencies) } } - private func performCleanUp(for job: Job, result: JobRunner.JobResult, shouldTriggerCallbacks: Bool = true) { + private func performCleanUp( + for job: Job, + result: JobRunner.JobResult, + shouldTriggerCallbacks: Bool = true, + using dependencies: Dependencies + ) { // The job is removed from the queue before it runs so all we need to to is remove it // from the 'currentlyRunning' set - jobsCurrentlyRunning.mutate { $0 = $0.removing(job.id) } - detailsForCurrentlyRunningJobs.mutate { $0 = $0.removingValue(forKey: JobKey(job)) } - // TODO: CHeck this - //currentlyRunningJobIds.mutate { $0 = $0.removing(job.id) } - //currentlyRunningJobInfo.mutate { $0 = $0.removingValue(forKey: job.id) } + currentlyRunningJobIds.mutate { $0 = $0.removing(job.id) } + currentlyRunningJobInfo.mutate { $0 = $0.removingValue(forKey: job.id) } guard shouldTriggerCallbacks else { return } @@ -1744,7 +1690,7 @@ public final class JobQueue { jobCallbacks = jobCallbacks.removingValue(forKey: job.id) } - DispatchQueue.global(qos: .default).async { + DispatchQueue.global(qos: .default).async(using: dependencies) { jobCallbacksToRun.forEach { $0(result) } } } @@ -1761,12 +1707,12 @@ public extension JobRunner { instance.setExecutor(executor, for: variant) } - static func appDidFinishLaunching(dependencies: Dependencies = Dependencies()) { - instance.appDidFinishLaunching(dependencies: dependencies) + static func appDidFinishLaunching(using dependencies: Dependencies = Dependencies()) { + instance.appDidFinishLaunching(using: dependencies) } - static func appDidBecomeActive(dependencies: Dependencies = Dependencies()) { - instance.appDidBecomeActive(dependencies: dependencies) + static func appDidBecomeActive(using dependencies: Dependencies = Dependencies()) { + instance.appDidBecomeActive(using: dependencies) } static func afterBlockingQueue(callback: @escaping () -> ()) { @@ -1782,8 +1728,8 @@ public extension JobRunner { _ db: Database, job: Job?, canStartJob: Bool = true, - dependencies: Dependencies = Dependencies() - ) { instance.add(db, job: job, canStartJob: canStartJob, dependencies: dependencies) } + using dependencies: Dependencies = Dependencies() + ) { instance.add(db, job: job, canStartJob: canStartJob, using: dependencies) } /// Upsert a job onto the queue, if the queue isn't currently running and 'canStartJob' is true then this will start /// the JobRunner @@ -1794,15 +1740,14 @@ public extension JobRunner { _ db: Database, job: Job?, canStartJob: Bool = true, - dependencies: Dependencies = Dependencies() - ) { instance.upsert(db, job: job, canStartJob: canStartJob, dependencies: dependencies) } + using dependencies: Dependencies = Dependencies() + ) { instance.upsert(db, job: job, canStartJob: canStartJob, using: dependencies) } @discardableResult static func insert( _ db: Database, job: Job?, - before otherJob: Job, - dependencies: Dependencies = Dependencies() - ) -> (Int64, Job)? { instance.insert(db, job: job, before: otherJob, dependencies: dependencies) } + before otherJob: Job + ) -> (Int64, Job)? { instance.insert(db, job: job, before: otherJob) } /// Calling this will clear the JobRunner queues and stop it from running new jobs, any currently executing jobs will continue to run /// though (this means if we suspend the database it's likely that any currently running jobs will fail to complete and fail to record their diff --git a/SessionUtilitiesKit/Networking/NetworkType.swift b/SessionUtilitiesKit/Networking/NetworkType.swift new file mode 100644 index 000000000..7ded00ee3 --- /dev/null +++ b/SessionUtilitiesKit/Networking/NetworkType.swift @@ -0,0 +1,42 @@ +// Copyright © 2023 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import Combine + +public protocol NetworkType { + func send(_ request: Network.RequestType) -> AnyPublisher<(ResponseInfoType, T), Error> +} + +public class Network: NetworkType { + public struct RequestType { + public let id: String + public let url: String? + public let method: String? + public let headers: [String: String]? + public let body: Data? + public let args: [Any?] + public let generatePublisher: () -> AnyPublisher<(ResponseInfoType, T), Error> + + public init( + id: String, + url: String? = nil, + method: String? = nil, + headers: [String: String]? = nil, + body: Data? = nil, + args: [Any?] = [], + generatePublisher: @escaping () -> AnyPublisher<(ResponseInfoType, T), Error> + ) { + self.id = id + self.url = url + self.method = method + self.headers = headers + self.body = body + self.args = args + self.generatePublisher = generatePublisher + } + } + + public func send(_ request: RequestType) -> AnyPublisher<(ResponseInfoType, T), Error> { + return request.generatePublisher() + } +} diff --git a/SessionUtilitiesKit/Networking/SessionNetwork.swift b/SessionUtilitiesKit/Networking/SessionNetwork.swift deleted file mode 100644 index 04969735d..000000000 --- a/SessionUtilitiesKit/Networking/SessionNetwork.swift +++ /dev/null @@ -1,3 +0,0 @@ -// Copyright © 2023 Rangeproof Pty Ltd. All rights reserved. - -import Foundation diff --git a/SessionUtilitiesKit/Utilities/Crypto.swift b/SessionUtilitiesKit/Utilities/Crypto.swift new file mode 100644 index 000000000..2598ac56b --- /dev/null +++ b/SessionUtilitiesKit/Utilities/Crypto.swift @@ -0,0 +1,92 @@ +// Copyright © 2023 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import CryptoKit +import Clibsodium +import Sodium +import Curve25519Kit + +// MARK: - CryptoType + +public protocol CryptoType { + func size(_ size: Crypto.Size) -> Int + func perform(_ action: Crypto.Action) throws -> Array + func verify(_ verification: Crypto.Verification) -> Bool + func generate(_ keyPairType: Crypto.KeyPairType) -> KeyPair? +} + +// MARK: - CryptoError + +public enum CryptoError: LocalizedError { + case failedToGenerateOutput + + public var errorDescription: String? { + switch self { + case .failedToGenerateOutput: return "Failed to generate output." + } + } +} + +// MARK: - Crypto + +public struct Crypto: CryptoType { + public struct Size { + public let id: String + public let args: [Any?] + let get: () -> Int + + public init(id: String, args: [Any?] = [], get: @escaping () -> Int) { + self.id = id + self.args = args + self.get = get + } + } + + public struct Action { + public let id: String + public let args: [Any?] + let perform: () throws -> Array + + public init(id: String, args: [Any?] = [], perform: @escaping () throws -> Array) { + self.id = id + self.args = args + self.perform = perform + } + + public init(id: String, args: [Any?] = [], perform: @escaping () -> Array?) { + self.id = id + self.args = args + self.perform = { try perform() ?? { throw CryptoError.failedToGenerateOutput }() } + } + } + + public struct Verification { + public let id: String + public let args: [Any?] + let verify: () -> Bool + + public init(id: String, args: [Any?] = [], verify: @escaping () -> Bool) { + self.id = id + self.args = args + self.verify = verify + } + } + + public struct KeyPairType { + public let id: String + public let args: [Any?] + let generate: () -> KeyPair? + + public init(id: String, args: [Any?] = [], generate: @escaping () -> KeyPair?) { + self.id = id + self.args = args + self.generate = generate + } + } + + public init() {} + public func size(_ size: Crypto.Size) -> Int { return size.get() } + public func perform(_ action: Crypto.Action) throws -> Array { return try action.perform() } + public func verify(_ verification: Crypto.Verification) -> Bool { return verification.verify() } + public func generate(_ keyPairType: Crypto.KeyPairType) -> KeyPair? { return keyPairType.generate() } +} diff --git a/SessionUtilitiesKit/Utilities/CryptoType.swift b/SessionUtilitiesKit/Utilities/CryptoType.swift deleted file mode 100644 index 04969735d..000000000 --- a/SessionUtilitiesKit/Utilities/CryptoType.swift +++ /dev/null @@ -1,3 +0,0 @@ -// Copyright © 2023 Rangeproof Pty Ltd. All rights reserved. - -import Foundation diff --git a/SessionUtilitiesKit/Utilities/DispatchQueue+Utilities.swift b/SessionUtilitiesKit/Utilities/DispatchQueue+Utilities.swift new file mode 100644 index 000000000..6bfa322f3 --- /dev/null +++ b/SessionUtilitiesKit/Utilities/DispatchQueue+Utilities.swift @@ -0,0 +1,35 @@ +// Copyright © 2023 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +public extension DispatchQueue { + func async( + group: DispatchGroup? = nil, + qos: DispatchQoS = .unspecified, + flags: DispatchWorkItemFlags = [], + using dependencies: Dependencies, + execute work: @escaping () -> Void + ) { + guard !dependencies.forceSynchronous else { return work() } + + return self.async(group: group, qos: qos, flags: flags, execute: work) + } + + func asyncAfter( + deadline: DispatchTime, + qos: DispatchQoS = .unspecified, + flags: DispatchWorkItemFlags = [], + using dependencies: Dependencies, + execute work: @escaping () -> Void + ) { + guard !dependencies.forceSynchronous else { return work() } + + self.asyncAfter(deadline: deadline, qos: qos, flags: flags, execute: work) + } + + static func with(key: DispatchSpecificKey, matches context: String, using dependencies: Dependencies) -> Bool { + guard !dependencies.forceSynchronous else { return true } + + return (DispatchQueue.getSpecific(key: key) == context) + } +} diff --git a/SessionUtilitiesKit/Utilities/JSONEncoder+Utilities.swift b/SessionUtilitiesKit/Utilities/JSONEncoder+Utilities.swift new file mode 100644 index 000000000..e7511c9f8 --- /dev/null +++ b/SessionUtilitiesKit/Utilities/JSONEncoder+Utilities.swift @@ -0,0 +1,12 @@ +// Copyright © 2023 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +public extension JSONEncoder { + func with(outputFormatting: JSONEncoder.OutputFormatting) -> JSONEncoder { + let result: JSONEncoder = self + result.outputFormatting = outputFormatting + + return result + } +} diff --git a/SessionUtilitiesKitTests/Database/Models/IdentitySpec.swift b/SessionUtilitiesKitTests/Database/Models/IdentitySpec.swift index 7eeb39293..3a93e06a9 100644 --- a/SessionUtilitiesKitTests/Database/Models/IdentitySpec.swift +++ b/SessionUtilitiesKitTests/Database/Models/IdentitySpec.swift @@ -16,7 +16,7 @@ class IdentitySpec: QuickSpec { describe("an Identity") { beforeEach { - mockStorage = Storage( + mockStorage = SynchronousStorage( customWriter: try! DatabaseQueue(), customMigrationTargets: [ SNUtilitiesKit.self diff --git a/SessionUtilitiesKitTests/Database/Utilities/PersistableRecordUtilitiesSpec.swift b/SessionUtilitiesKitTests/Database/Utilities/PersistableRecordUtilitiesSpec.swift index e17536fe7..6b98ed4f4 100644 --- a/SessionUtilitiesKitTests/Database/Utilities/PersistableRecordUtilitiesSpec.swift +++ b/SessionUtilitiesKitTests/Database/Utilities/PersistableRecordUtilitiesSpec.swift @@ -9,8 +9,6 @@ import Nimble @testable import SessionUtilitiesKit class PersistableRecordUtilitiesSpec: QuickSpec { - static var customWriter: DatabaseQueue! - struct TestType: Codable, FetchableRecord, PersistableRecord, TableRecord, ColumnExpressible { public static var databaseTableName: String { "TestType" } @@ -104,8 +102,7 @@ class PersistableRecordUtilitiesSpec: QuickSpec { describe("a PersistableRecord") { beforeEach { customWriter = try! DatabaseQueue() - PersistableRecordUtilitiesSpec.customWriter = customWriter - mockStorage = Storage( + mockStorage = SynchronousStorage( customWriter: customWriter, customMigrationTargets: [ TestTarget.self diff --git a/SessionUtilitiesKitTests/General/DependenciesSpec.swift b/SessionUtilitiesKitTests/General/DependenciesSpec.swift new file mode 100644 index 000000000..60b0d0ed3 --- /dev/null +++ b/SessionUtilitiesKitTests/General/DependenciesSpec.swift @@ -0,0 +1,43 @@ +// Copyright © 2023 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +import Quick +import Nimble + +@testable import SessionUtilitiesKit + +class DependenciesSpec: QuickSpec { + // MARK: - Spec + + override func spec() { + var dependencies: Dependencies! + + describe("Dependencies") { + beforeEach { + dependencies = Dependencies() + } + + context("when accessing dateNow") { + it("creates a new date every time when not overwritten") { + let date1 = dependencies.dateNow + Thread.sleep(forTimeInterval: 0.05) + let date2 = dependencies.dateNow + + expect(date1.timeIntervalSince1970).toNot(equal(date2.timeIntervalSince1970)) + } + + it("returns the same new date every time when overwritten") { + dependencies.dateNow = Date(timeIntervalSince1970: 1234567890) + + let date1 = dependencies.dateNow + Thread.sleep(forTimeInterval: 0.05) + let date2 = dependencies.dateNow + + expect(date1.timeIntervalSince1970).to(equal(date2.timeIntervalSince1970)) + expect(date1.timeIntervalSince1970).to(equal(1234567890)) + } + } + } + } +} diff --git a/SessionUtilitiesKitTests/JobRunner/JobRunnerSpec.swift b/SessionUtilitiesKitTests/JobRunner/JobRunnerSpec.swift index 1c8d502b2..fff8e4c15 100644 --- a/SessionUtilitiesKitTests/JobRunner/JobRunnerSpec.swift +++ b/SessionUtilitiesKitTests/JobRunner/JobRunnerSpec.swift @@ -50,7 +50,7 @@ class JobRunnerSpec: QuickSpec { success: @escaping (Job, Bool, Dependencies) -> (), failure: @escaping (Job, Error?, Bool, Dependencies) -> (), deferred: @escaping (Job, Dependencies) -> (), - dependencies: Dependencies + using dependencies: Dependencies ) { guard let detailsData: Data = job.details, @@ -80,14 +80,14 @@ class JobRunnerSpec: QuickSpec { } } - guard dependencies.fixedTime < details.completeTime else { return completeJob() } - - DispatchQueue.global(qos: .default).async { - while dependencies.fixedTime < details.completeTime { - Thread.sleep(forTimeInterval: 0.01) // Wait for 10ms + guard dependencies.fixedTime < details.completeTime else { + return queue.async(using: dependencies) { + completeJob() } - - queue.async { + } + + dependencies.asyncExecutions.appendTo(details.completeTime) { + queue.async(using: dependencies) { completeJob() } } @@ -103,11 +103,11 @@ class JobRunnerSpec: QuickSpec { var mockStorage: Storage! var dependencies: Dependencies! - // MARK: - JobRunner - describe("a JobRunner") { + // MARK: - Configuration + beforeEach { - mockStorage = Storage( + mockStorage = SynchronousStorage( customWriter: try! DatabaseQueue(), customMigrationTargets: [ SNUtilitiesKit.self @@ -115,7 +115,8 @@ class JobRunnerSpec: QuickSpec { ) dependencies = Dependencies( storage: mockStorage, - date: Date(timeIntervalSince1970: 1234567890) + dateNow: Date(timeIntervalSince1970: 0), + forceSynchronous: true ) // Migrations add jobs which we don't want so delete them @@ -146,10 +147,10 @@ class JobRunnerSpec: QuickSpec { details: nil ) - jobRunner = JobRunner(isTestingJobRunner: true, dependencies: dependencies) + jobRunner = JobRunner(isTestingJobRunner: true, using: dependencies) jobRunner.setExecutor(TestJob.self, for: .messageSend) jobRunner.setExecutor(TestJob.self, for: .attachmentUpload) - jobRunner.setExecutor(TestJob.self, for: .attachmentDownload) + jobRunner.setExecutor(TestJob.self, for: .messageReceive) // Need to assign this to ensure it's used by nested dependencies dependencies.jobRunner = jobRunner @@ -163,9 +164,10 @@ class JobRunnerSpec: QuickSpec { mockStorage = nil dependencies = nil } - // MARK: -- when configuring + // MARK: - when configuring context("when configuring") { + // MARK: -- adds an executor correctly it("adds an executor correctly") { job1 = Job( id: 101, @@ -177,85 +179,89 @@ class JobRunnerSpec: QuickSpec { nextRunTimestamp: 0, threadId: nil, interactionId: nil, - details: try? JSONEncoder().encode(TestDetails(completeTime: 1)) + details: try? JSONEncoder() + .with(outputFormatting: .sortedKeys) + .encode(TestDetails(completeTime: 1)) ) - jobRunner.appDidFinishLaunching(dependencies: dependencies) + jobRunner.appDidFinishLaunching(using: dependencies) + jobRunner.appDidBecomeActive(using: dependencies) + // Save the job to the database + mockStorage.write { db in _ = try job1.inserted(db) } + expect(mockStorage.read { db in try Job.fetchCount(db) }).to(equal(1)) + + // Try to start the job mockStorage.write { db in jobRunner.upsert( db, job: job1, canStartJob: true, - dependencies: dependencies + using: dependencies ) } - expect(jobRunner.isCurrentlyRunning(job1)) - .toEventually( - beFalse(), - timeout: .milliseconds(50) - ) + // Ensure the job isn't running, and that it has been deleted (can't retry if there + // is no executer so no failure counts) + expect(jobRunner.isCurrentlyRunning(job1)).to(beFalse()) + expect(mockStorage.read { db in try Job.fetchCount(db) }).to(equal(0)) + // Add the executor and start the job again jobRunner.setExecutor(TestJob.self, for: .getSnodePool) mockStorage.write { db in - jobRunner.upsert( + jobRunner.add( db, job: job1, canStartJob: true, - dependencies: dependencies + using: dependencies ) } - expect(jobRunner.isCurrentlyRunning(job1)) - .toEventually( - beFalse(), - timeout: .milliseconds(50) - ) + // Job is now running + expect(jobRunner.isCurrentlyRunning(job1)).to(beTrue()) } } // MARK: -- when managing state - context("when managing state") { - // MARK: ---- by checking if a job is currently running - context("by checking if a job is currently running") { + // MARK: ------ returns false when not given a job it("returns false when not given a job") { expect(jobRunner.isCurrentlyRunning(nil)).to(beFalse()) } + // MARK: ------ returns false when given a job that has not been persisted it("returns false when given a job that has not been persisted") { job1 = Job(variant: .messageSend) expect(jobRunner.isCurrentlyRunning(job1)).to(beFalse()) } + // MARK: ------ returns false when given a job that is not running it("returns false when given a job that is not running") { expect(jobRunner.isCurrentlyRunning(job1)).to(beFalse()) } + // MARK: ------ returns true when given a non blocking job that is running it("returns true when given a non blocking job that is running") { job1 = job1.with(details: TestDetails(completeTime: 1)) - jobRunner.appDidFinishLaunching(dependencies: dependencies) + jobRunner.appDidFinishLaunching(using: dependencies) + jobRunner.appDidBecomeActive(using: dependencies) mockStorage.write { db in - jobRunner.upsert( + jobRunner.add( db, job: job1, canStartJob: true, - dependencies: dependencies + using: dependencies ) } - expect(jobRunner.isCurrentlyRunning(job1)) - .toEventually( - beTrue(), - timeout: .milliseconds(50) - ) + expect(jobRunner.isCurrentlyRunning(job1)).to(beTrue()) } + // MARK: ------ returns true when given a blocking job that is running it("returns true when given a blocking job that is running") { job2 = Job( id: 101, @@ -267,293 +273,314 @@ class JobRunnerSpec: QuickSpec { nextRunTimestamp: 0, threadId: nil, interactionId: nil, - details: try? JSONEncoder().encode(TestDetails(completeTime: 1)) + details: try? JSONEncoder() + .with(outputFormatting: .sortedKeys) + .encode(TestDetails(completeTime: 1)) ) mockStorage.write { db in - try job2.insert(db) - - jobRunner.upsert( + jobRunner.add( db, job: job2, canStartJob: true, - dependencies: dependencies + using: dependencies ) } - jobRunner.appDidFinishLaunching(dependencies: dependencies) + jobRunner.appDidFinishLaunching(using: dependencies) - expect(jobRunner.isCurrentlyRunning(job2)) - .toEventually( - beTrue(), - timeout: .milliseconds(50) - ) + expect(jobRunner.isCurrentlyRunning(job2)).to(beTrue()) } } // MARK: ---- by getting the details for jobs - context("by getting the details for jobs") { + // MARK: ------ returns an empty dictionary when there are no jobs it("returns an empty dictionary when there are no jobs") { expect(jobRunner.allJobInfo()).to(equal([:])) } + // MARK: ------ returns an empty dictionary when there are no jobs matching the filters it("returns an empty dictionary when there are no jobs matching the filters") { - jobRunner.appDidFinishLaunching(dependencies: dependencies) + jobRunner.appDidFinishLaunching(using: dependencies) + jobRunner.appDidBecomeActive(using: dependencies) mockStorage.write { db in - jobRunner.upsert( + jobRunner.add( db, job: job2, canStartJob: true, - dependencies: dependencies + using: dependencies ) } - expect(jobRunner.jobInfoFor(state: .running, variant: .messageSend)) - .toEventually( - equal([:]), - timeout: .milliseconds(50) - ) + expect(jobRunner.jobInfoFor(state: .running, variant: .messageSend)).to(equal([:])) } + // MARK: ------ can filter to specific jobs it("can filter to specific jobs") { - mockStorage.write { db in - jobRunner.upsert( - db, - job: job2, - /// The `canStartJob` value needs to be `true` for the job to be added to the queue but as - /// long as `appDidFinishLaunching` hasn't been called it won't actually start running and - /// as a result we can test the "pending" state - canStartJob: true, - dependencies: dependencies - ) - } - - // Wait for there to be data and the validate the filtering works - expect(jobRunner.allJobInfo()) - .toEventuallyNot( - beEmpty(), - timeout: .milliseconds(50) - ) - expect(jobRunner.jobInfoFor(jobs: [job1])).to(equal([:])) - expect(jobRunner.jobInfoFor(jobs: [job2])) - .to(equal( - [ - 101: JobRunner.JobInfo( - variant: .attachmentUpload, - threadId: nil, - interactionId: nil, - detailsData: job2.details - ) - ] - )) - } - - it("can filter to running jobs") { job1 = Job( id: 100, failureCount: 0, - variant: .attachmentDownload, + variant: .messageReceive, behaviour: .runOnce, shouldBlock: false, shouldSkipLaunchBecomeActive: false, nextRunTimestamp: 0, threadId: nil, interactionId: nil, - details: try? JSONEncoder().encode(TestDetails(completeTime: 1)) + details: try? JSONEncoder() + .with(outputFormatting: .sortedKeys) + .encode(TestDetails(completeTime: 1)) ) job2 = Job( id: 101, failureCount: 0, - variant: .attachmentDownload, + variant: .messageReceive, behaviour: .runOnce, shouldBlock: false, shouldSkipLaunchBecomeActive: false, nextRunTimestamp: 0, threadId: nil, interactionId: nil, - details: try? JSONEncoder().encode(TestDetails(completeTime: 1)) + details: nil ) - jobRunner.appDidFinishLaunching(dependencies: dependencies) + jobRunner.appDidFinishLaunching(using: dependencies) + jobRunner.appDidBecomeActive(using: dependencies) mockStorage.write { db in - try job1.insert(db) - try job2.insert(db) - - jobRunner.upsert( + jobRunner.add( db, job: job1, canStartJob: true, - dependencies: dependencies + using: dependencies ) - jobRunner.upsert( + jobRunner.add( + db, + job: job2, + canStartJob: true, + using: dependencies + ) + } + + // Validate the filtering works + expect(jobRunner.allJobInfo()).toNot(beEmpty()) + expect(jobRunner.jobInfoFor(jobs: [job1])) + .to(equal([ + 100: JobRunner.JobInfo( + variant: .messageReceive, + threadId: nil, + interactionId: nil, + detailsData: job1.details + ) + ])) + expect(jobRunner.jobInfoFor(jobs: [job2])) + .to(equal([ + 101: JobRunner.JobInfo( + variant: .messageReceive, + threadId: nil, + interactionId: nil, + detailsData: nil + ) + ])) + } + + // MARK: ------ can filter to running jobs + it("can filter to running jobs") { + job1 = Job( + id: 100, + failureCount: 0, + variant: .messageReceive, + behaviour: .runOnce, + shouldBlock: false, + shouldSkipLaunchBecomeActive: false, + nextRunTimestamp: 0, + threadId: nil, + interactionId: nil, + details: try? JSONEncoder() + .with(outputFormatting: .sortedKeys) + .encode(TestDetails(completeTime: 1)) + ) + job2 = Job( + id: 101, + failureCount: 0, + variant: .messageReceive, + behaviour: .runOnce, + shouldBlock: false, + shouldSkipLaunchBecomeActive: false, + nextRunTimestamp: 0, + threadId: nil, + interactionId: nil, + details: try? JSONEncoder() + .with(outputFormatting: .sortedKeys) + .encode(TestDetails(completeTime: 1)) + ) + jobRunner.appDidFinishLaunching(using: dependencies) + jobRunner.appDidBecomeActive(using: dependencies) + + mockStorage.write { db in + jobRunner.add( + db, + job: job1, + canStartJob: true, + using: dependencies + ) + + jobRunner.add( db, job: job2, canStartJob: false, - dependencies: dependencies + using: dependencies ) } // Wait for there to be data and the validate the filtering works expect(jobRunner.jobInfoFor(state: .running)) - .toEventually( - equal( - [ - 100: JobRunner.JobInfo( - variant: .attachmentUpload, - threadId: nil, - interactionId: nil, - detailsData: try! JSONEncoder().encode(TestDetails(completeTime: 1)) - ) - ] - ), - timeout: .milliseconds(50) - ) + .to(equal([ + 100: JobRunner.JobInfo( + variant: .messageReceive, + threadId: nil, + interactionId: nil, + detailsData: try! JSONEncoder() + .with(outputFormatting: .sortedKeys) + .encode(TestDetails(completeTime: 1)) + ) + ])) expect(Array(jobRunner.allJobInfo().keys).sorted()).to(equal([100, 101])) } + // MARK: ------ can filter to pending jobs it("can filter to pending jobs") { job1 = Job( id: 100, failureCount: 0, - variant: .attachmentDownload, + variant: .messageReceive, behaviour: .runOnce, shouldBlock: false, shouldSkipLaunchBecomeActive: false, nextRunTimestamp: 0, threadId: nil, interactionId: nil, - details: try? JSONEncoder().encode(TestDetails(completeTime: 1)) + details: try? JSONEncoder() + .with(outputFormatting: .sortedKeys) + .encode(TestDetails(completeTime: 1)) ) job2 = Job( id: 101, failureCount: 0, - variant: .attachmentDownload, + variant: .messageReceive, behaviour: .runOnce, shouldBlock: false, shouldSkipLaunchBecomeActive: false, nextRunTimestamp: 0, threadId: nil, interactionId: nil, - details: try? JSONEncoder().encode(TestDetails(completeTime: 1)) + details: try? JSONEncoder() + .with(outputFormatting: .sortedKeys) + .encode(TestDetails(completeTime: 1)) ) - jobRunner.appDidFinishLaunching(dependencies: dependencies) + jobRunner.appDidFinishLaunching(using: dependencies) + jobRunner.appDidBecomeActive(using: dependencies) mockStorage.write { db in - try job1.insert(db) - try job2.insert(db) - - jobRunner.upsert( + jobRunner.add( db, job: job1, canStartJob: true, - dependencies: dependencies + using: dependencies ) - jobRunner.upsert( + jobRunner.add( db, job: job2, canStartJob: true, - dependencies: dependencies + using: dependencies ) } // Wait for there to be data and the validate the filtering works expect(jobRunner.jobInfoFor(state: .pending)) - .toEventually( - equal( - [ - 101: JobRunner.JobInfo( - variant: .attachmentUpload, - threadId: nil, - interactionId: nil, - detailsData: try! JSONEncoder().encode(TestDetails(completeTime: 1)) - ) - ] - ), - timeout: .milliseconds(50) - ) + .to(equal([ + 101: JobRunner.JobInfo( + variant: .messageReceive, + threadId: nil, + interactionId: nil, + detailsData: try! JSONEncoder() + .with(outputFormatting: .sortedKeys) + .encode(TestDetails(completeTime: 1)) + ) + ])) expect(Array(jobRunner.allJobInfo().keys).sorted()).to(equal([100, 101])) } + // MARK: ------ can filter to specific variants it("can filter to specific variants") { job1 = job1.with(details: TestDetails(completeTime: 1)) job2 = job2.with(details: TestDetails(completeTime: 2)) - jobRunner.appDidFinishLaunching(dependencies: dependencies) + jobRunner.appDidFinishLaunching(using: dependencies) + jobRunner.appDidBecomeActive(using: dependencies) mockStorage.write { db in - try job1.insert(db) - try job2.insert(db) - - jobRunner.upsert( + jobRunner.add( db, job: job1, canStartJob: true, - dependencies: dependencies + using: dependencies ) - jobRunner.upsert( + jobRunner.add( db, job: job2, canStartJob: true, - dependencies: dependencies + using: dependencies ) } // Wait for there to be data and the validate the filtering works expect(jobRunner.jobInfoFor(variant: .attachmentUpload)) - .toEventually( - equal( - [ - 101: JobRunner.JobInfo( - variant: .attachmentUpload, - threadId: nil, - interactionId: nil, - detailsData: try! JSONEncoder().encode(TestDetails(completeTime: 2)) - ) - ] - ), - timeout: .milliseconds(50) - ) - expect(Array(jobRunner.allJobInfo().keys).sorted()) - .toEventually( - equal([100, 101]), - timeout: .milliseconds(50) - ) + .to(equal([ + 101: JobRunner.JobInfo( + variant: .attachmentUpload, + threadId: nil, + interactionId: nil, + detailsData: try! JSONEncoder() + .with(outputFormatting: .sortedKeys) + .encode(TestDetails(completeTime: 2)) + ) + ])) + expect(Array(jobRunner.allJobInfo().keys).sorted()).to(equal([100, 101])) } + // MARK: ------ includes non blocking jobs it("includes non blocking jobs") { job2 = job2.with(details: TestDetails(completeTime: 1)) - jobRunner.appDidFinishLaunching(dependencies: dependencies) + jobRunner.appDidFinishLaunching(using: dependencies) + jobRunner.appDidBecomeActive(using: dependencies) mockStorage.write { db in - jobRunner.upsert( + jobRunner.add( db, job: job2, canStartJob: true, - dependencies: dependencies + using: dependencies ) } expect(jobRunner.jobInfoFor(state: .running, variant: .attachmentUpload)) - .toEventually( - equal( - [ - 101: - JobRunner.JobInfo( - variant: .attachmentUpload, - threadId: nil, - interactionId: nil, - detailsData: try! JSONEncoder().encode(TestDetails(completeTime: 1)) - ) - ] - ), - timeout: .milliseconds(50) - ) + .to(equal([ + 101: JobRunner.JobInfo( + variant: .attachmentUpload, + threadId: nil, + interactionId: nil, + detailsData: try! JSONEncoder() + .with(outputFormatting: .sortedKeys) + .encode(TestDetails(completeTime: 1)) + ) + ])) } + // MARK: ------ includes blocking jobs it("includes blocking jobs") { job2 = Job( id: 101, @@ -565,108 +592,139 @@ class JobRunnerSpec: QuickSpec { nextRunTimestamp: 0, threadId: nil, interactionId: nil, - details: try! JSONEncoder().encode(TestDetails(completeTime: 1)) + details: try! JSONEncoder() + .with(outputFormatting: .sortedKeys) + .encode(TestDetails(completeTime: 1)) ) mockStorage.write { db in - try job2.insert(db) - - jobRunner.upsert( + jobRunner.add( db, job: job2, canStartJob: true, - dependencies: dependencies + using: dependencies ) } - jobRunner.appDidFinishLaunching(dependencies: dependencies) + jobRunner.appDidFinishLaunching(using: dependencies) expect(jobRunner.jobInfoFor(state: .running, variant: .attachmentUpload)) - .toEventually( - equal( - [ - 101: JobRunner.JobInfo( - variant: .attachmentUpload, - threadId: nil, - interactionId: nil, - detailsData: try! JSONEncoder().encode(TestDetails(completeTime: 1)) - ) - ] - ), - timeout: .milliseconds(50) - ) + .to(equal([ + 101: JobRunner.JobInfo( + variant: .attachmentUpload, + threadId: nil, + interactionId: nil, + detailsData: try! JSONEncoder() + .with(outputFormatting: .sortedKeys) + .encode(TestDetails(completeTime: 1)) + ) + ])) } } // MARK: ---- by checking for an existing job - context("by checking for an existing job") { + // MARK: ------ returns false for a queue that doesn't exist it("returns false for a queue that doesn't exist") { jobRunner = JobRunner( isTestingJobRunner: true, variantsToExclude: [.attachmentUpload], - dependencies: dependencies + using: dependencies ) expect(jobRunner.hasJob(of: .attachmentUpload, with: TestDetails())) .to(beFalse()) } + // MARK: ------ returns false when the provided details fail to decode it("returns false when the provided details fail to decode") { expect(jobRunner.hasJob(of: .attachmentUpload, with: InvalidDetails())) .to(beFalse()) } + // MARK: ------ returns false when there is not a pending or running job it("returns false when there is not a pending or running job") { expect(jobRunner.hasJob(of: .attachmentUpload, with: TestDetails())) .to(beFalse()) } + // MARK: ------ returns true when there is a pending job it("returns true when there is a pending job") { - job2 = job2.with(details: TestDetails(completeTime: 1)) + job1 = Job( + id: 100, + failureCount: 0, + variant: .messageReceive, + behaviour: .runOnce, + shouldBlock: false, + shouldSkipLaunchBecomeActive: false, + nextRunTimestamp: 0, + threadId: nil, + interactionId: nil, + details: try? JSONEncoder() + .with(outputFormatting: .sortedKeys) + .encode(TestDetails(completeTime: 1)) + ) + job2 = Job( + id: 101, + failureCount: 0, + variant: .messageReceive, + behaviour: .runOnce, + shouldBlock: false, + shouldSkipLaunchBecomeActive: false, + nextRunTimestamp: 0, + threadId: nil, + interactionId: nil, + details: try? JSONEncoder() + .with(outputFormatting: .sortedKeys) + .encode(TestDetails(completeTime: 2)) + ) + jobRunner.appDidFinishLaunching(using: dependencies) + jobRunner.appDidBecomeActive(using: dependencies) + mockStorage.write { db in - jobRunner.upsert( + jobRunner.add( + db, + job: job1, + canStartJob: true, + using: dependencies + ) + + jobRunner.add( db, job: job2, - /// The `canStartJob` value needs to be `true` for the job to be added to the queue but as - /// long as `appDidFinishLaunching` hasn't been called it won't actually start running and - /// as a result we can test the "pending" state canStartJob: true, - dependencies: dependencies + using: dependencies ) } - expect(Array(jobRunner.jobInfoFor(state: .pending, variant: .attachmentUpload).keys)) - .toEventually( - equal([101]), - timeout: .milliseconds(50) - ) - expect(jobRunner.hasJob(of: .attachmentUpload, with: TestDetails(completeTime: 1))) + expect(Array(jobRunner.jobInfoFor(state: .pending, variant: .messageReceive).keys)) + .to(equal([101])) + expect(jobRunner.hasJob(of: .messageReceive, with: TestDetails(completeTime: 2))) .to(beTrue()) } + // MARK: ------ returns true when there is a running job it("returns true when there is a running job") { job2 = job2.with(details: TestDetails(completeTime: 1)) - jobRunner.appDidFinishLaunching(dependencies: dependencies) + jobRunner.appDidFinishLaunching(using: dependencies) + jobRunner.appDidBecomeActive(using: dependencies) mockStorage.write { db in - jobRunner.upsert( + jobRunner.add( db, job: job2, canStartJob: true, - dependencies: dependencies + using: dependencies ) } expect(Array(jobRunner.jobInfoFor(state: .running, variant: .attachmentUpload).keys)) - .toEventually( - equal([101]), - timeout: .milliseconds(50) - ) + .to(equal([101])) expect(jobRunner.hasJob(of: .attachmentUpload, with: TestDetails(completeTime: 1))) .to(beTrue()) } + // MARK: ------ returns true when there is a blocking job it("returns true when there is a blocking job") { job2 = Job( id: 101, @@ -678,236 +736,107 @@ class JobRunnerSpec: QuickSpec { nextRunTimestamp: 0, threadId: nil, interactionId: nil, - details: try! JSONEncoder().encode(TestDetails(completeTime: 1)) + details: try! JSONEncoder() + .with(outputFormatting: .sortedKeys) + .encode(TestDetails(completeTime: 1)) ) mockStorage.write { db in - try job2.insert(db) - - jobRunner.upsert( + jobRunner.add( db, job: job2, canStartJob: true, - dependencies: dependencies + using: dependencies ) } - jobRunner.appDidFinishLaunching(dependencies: dependencies) + // Need to add the job before starting it since it's a 'runOnceNextLaunch' + jobRunner.appDidFinishLaunching(using: dependencies) + jobRunner.appDidBecomeActive(using: dependencies) expect(Array(jobRunner.jobInfoFor(state: .running, variant: .attachmentUpload).keys)) - .toEventually( - equal([101]), - timeout: .milliseconds(50) - ) + .to(equal([101])) expect(jobRunner.hasJob(of: .attachmentUpload, with: TestDetails(completeTime: 1))) .to(beTrue()) } + // MARK: ------ returns true when there is a non blocking job it("returns true when there is a non blocking job") { job2 = job2.with(details: TestDetails(completeTime: 1)) - jobRunner.appDidFinishLaunching(dependencies: dependencies) + jobRunner.appDidFinishLaunching(using: dependencies) + jobRunner.appDidBecomeActive(using: dependencies) mockStorage.write { db in - jobRunner.upsert( + jobRunner.add( db, job: job2, canStartJob: true, - dependencies: dependencies + using: dependencies ) } expect(Array(jobRunner.jobInfoFor(state: .running, variant: .attachmentUpload).keys)) - .toEventually( - equal([101]), - timeout: .milliseconds(50) - ) + .to(equal([101])) expect(jobRunner.hasJob(of: .attachmentUpload, with: TestDetails(completeTime: 1))) .to(beTrue()) } } // MARK: ---- by being notified of app launch - context("by being notified of app launch") { + // MARK: ------ does not start a job before getting the app launch call it("does not start a job before getting the app launch call") { job1 = job1.with(details: TestDetails(completeTime: 1)) mockStorage.write { db in - jobRunner.upsert( + jobRunner.add( db, job: job1, canStartJob: true, - dependencies: dependencies + using: dependencies ) } - expect(jobRunner.isCurrentlyRunning(job1)) - .toEventually( - beFalse(), - timeout: .milliseconds(50) - ) + expect(jobRunner.isCurrentlyRunning(job1)).to(beFalse()) } + // MARK: ------ starts the job queues if there are no app launch jobs it("does nothing if there are no app launch jobs") { job1 = job1.with(details: TestDetails(completeTime: 1)) mockStorage.write { db in - jobRunner.upsert( + jobRunner.add( db, job: job1, canStartJob: true, - dependencies: dependencies + using: dependencies ) } - jobRunner.appDidFinishLaunching(dependencies: dependencies) - - expect(jobRunner.isCurrentlyRunning(job1)) - .toEventually( - beFalse(), - timeout: .milliseconds(50) - ) - } - - it("starts the job queues after completing blocking app launch jobs") { - job1 = job1.with(details: TestDetails(completeTime: 2)) - job2 = Job( - id: 101, - failureCount: 0, - variant: .messageSend, - behaviour: .runOnceNextLaunch, - shouldBlock: true, - shouldSkipLaunchBecomeActive: false, - nextRunTimestamp: 0, - threadId: nil, - interactionId: nil, - details: try! JSONEncoder().encode(TestDetails(completeTime: 1)) - ) - - mockStorage.write { db in - try job2.insert(db) - - jobRunner.upsert( - db, - job: job1, - canStartJob: true, - dependencies: dependencies - ) - jobRunner.upsert( - db, - job: job2, - canStartJob: true, - dependencies: dependencies - ) - } - - // Not currently running - expect(jobRunner.isCurrentlyRunning(job1)) - .toEventually( - beFalse(), - timeout: .milliseconds(50) - ) - expect(jobRunner.isCurrentlyRunning(job2)).to(beFalse()) - - // Make sure it starts - jobRunner.appDidFinishLaunching(dependencies: dependencies) - - // Blocking job running but blocked job not - expect(jobRunner.isCurrentlyRunning(job2)) - .toEventually( - beTrue(), - timeout: .milliseconds(50) - ) - expect(jobRunner.isCurrentlyRunning(job1)).to(beFalse()) - - // Complete 'job2' - dependencies.fixedTime = 1 - - // Blocked job eventually starts - expect(jobRunner.isCurrentlyRunning(job1)) - .toEventually( - beTrue(), - timeout: .milliseconds(50) - ) - } - - it("starts the job queues alongside non blocking app launch jobs") { - job1 = job1.with(details: TestDetails(completeTime: 1)) - job2 = Job( - id: 101, - failureCount: 0, - variant: .messageSend, - behaviour: .runOnceNextLaunch, - shouldBlock: false, - shouldSkipLaunchBecomeActive: false, - nextRunTimestamp: 0, - threadId: nil, - interactionId: nil, - details: try! JSONEncoder().encode(TestDetails(completeTime: 1)) - ) - - mockStorage.write { db in - try job2.insert(db) - - jobRunner.upsert( - db, - job: job1, - canStartJob: true, - dependencies: dependencies - ) - jobRunner.upsert( - db, - job: job2, - canStartJob: true, - dependencies: dependencies - ) - } - - // Not currently running - expect(jobRunner.isCurrentlyRunning(job1)) - .toEventually( - beFalse(), - timeout: .milliseconds(50) - ) - - // Make sure it starts - jobRunner.appDidFinishLaunching(dependencies: dependencies) - - expect(jobRunner.isCurrentlyRunning(job1)) - .toEventually( - beTrue(), - timeout: .milliseconds(50) - ) - expect(jobRunner.isCurrentlyRunning(job2)) - .toEventually( - beTrue(), - timeout: .milliseconds(50) - ) + jobRunner.appDidFinishLaunching(using: dependencies) + expect(jobRunner.allJobInfo()).to(beEmpty()) } } // MARK: ---- by being notified of app becoming active - context("by being notified of app becoming active") { + // MARK: ------ does not start a job before getting the app active call it("does not start a job before getting the app active call") { job1 = job1.with(details: TestDetails(completeTime: 1)) mockStorage.write { db in - jobRunner.upsert( + jobRunner.add( db, job: job1, canStartJob: true, - dependencies: dependencies + using: dependencies ) } - expect(jobRunner.isCurrentlyRunning(job1)) - .toEventually( - beFalse(), - timeout: .milliseconds(50) - ) + expect(jobRunner.isCurrentlyRunning(job1)).to(beFalse()) } + // MARK: ------ does not start the job queues if there are no app active jobs and blocking jobs are running it("does not start the job queues if there are no app active jobs and blocking jobs are running") { job1 = job1.with(details: TestDetails(completeTime: 2)) job2 = Job( @@ -920,46 +849,39 @@ class JobRunnerSpec: QuickSpec { nextRunTimestamp: 0, threadId: nil, interactionId: nil, - details: try? JSONEncoder().encode(TestDetails(completeTime: 1)) + details: try? JSONEncoder() + .with(outputFormatting: .sortedKeys) + .encode(TestDetails(completeTime: 1)) ) mockStorage.write { db in - try job2.insert(db) - - jobRunner.upsert( + jobRunner.add( db, job: job1, canStartJob: true, - dependencies: dependencies + using: dependencies ) - jobRunner.upsert( + jobRunner.add( db, job: job2, canStartJob: true, - dependencies: dependencies + using: dependencies ) } // Not currently running - expect(jobRunner.isCurrentlyRunning(job1)) - .toEventually( - beFalse(), - timeout: .milliseconds(50) - ) + expect(jobRunner.isCurrentlyRunning(job1)).to(beFalse()) // Start the blocking job - jobRunner.appDidFinishLaunching(dependencies: dependencies) + jobRunner.appDidFinishLaunching(using: dependencies) // Make sure the other queues don't start - jobRunner.appDidBecomeActive(dependencies: dependencies) + jobRunner.appDidBecomeActive(using: dependencies) - expect(jobRunner.isCurrentlyRunning(job1)) - .toEventually( - beFalse(), - timeout: .milliseconds(50) - ) + expect(jobRunner.isCurrentlyRunning(job1)).to(beFalse()) } + // MARK: ------ does not start the job queues if there are app active jobs and blocking jobs are running it("does not start the job queues if there are app active jobs and blocking jobs are running") { job1 = Job( id: 100, @@ -971,7 +893,9 @@ class JobRunnerSpec: QuickSpec { nextRunTimestamp: 0, threadId: nil, interactionId: nil, - details: try? JSONEncoder().encode(TestDetails(completeTime: 2)) + details: try? JSONEncoder() + .with(outputFormatting: .sortedKeys) + .encode(TestDetails(completeTime: 2)) ) job2 = Job( id: 101, @@ -983,68 +907,62 @@ class JobRunnerSpec: QuickSpec { nextRunTimestamp: 0, threadId: nil, interactionId: nil, - details: try? JSONEncoder().encode(TestDetails(completeTime: 1)) + details: try? JSONEncoder() + .with(outputFormatting: .sortedKeys) + .encode(TestDetails(completeTime: 1)) ) mockStorage.write { db in - try job1.insert(db) - try job2.insert(db) - - jobRunner.upsert( + jobRunner.add( db, job: job1, canStartJob: true, - dependencies: dependencies + using: dependencies ) - jobRunner.upsert( + jobRunner.add( db, job: job2, canStartJob: true, - dependencies: dependencies + using: dependencies ) } // Not currently running - expect(jobRunner.isCurrentlyRunning(job1)) - .toEventually( - beFalse(), - timeout: .milliseconds(50) - ) + expect(jobRunner.isCurrentlyRunning(job1)).to(beFalse()) // Start the blocking queue - jobRunner.appDidFinishLaunching(dependencies: dependencies) + jobRunner.appDidFinishLaunching(using: dependencies) // Make sure the other queues don't start - jobRunner.appDidBecomeActive(dependencies: dependencies) + jobRunner.appDidBecomeActive(using: dependencies) - expect(jobRunner.isCurrentlyRunning(job1)) - .toEventually( - beFalse(), - timeout: .milliseconds(50) - ) + expect(jobRunner.isCurrentlyRunning(job1)).to(beFalse()) } + // MARK: ------ starts the job queues if there are no app active jobs it("starts the job queues if there are no app active jobs") { job1 = job1.with(details: TestDetails(completeTime: 1)) + jobRunner.appDidFinishLaunching(using: dependencies) mockStorage.write { db in - jobRunner.upsert( + jobRunner.add( db, job: job1, canStartJob: true, - dependencies: dependencies + using: dependencies ) } - jobRunner.appDidBecomeActive(dependencies: dependencies) + // Make sure it isn't already started + expect(jobRunner.isCurrentlyRunning(job1)).to(beFalse()) - expect(jobRunner.isCurrentlyRunning(job1)) - .toEventually( - beTrue(), - timeout: .milliseconds(50) - ) + // Make sure it starts after 'appDidBecomeActive' is called + jobRunner.appDidBecomeActive(using: dependencies) + + expect(jobRunner.isCurrentlyRunning(job1)).to(beTrue()) } + // MARK: ------ starts the job queues if there are app active jobs it("starts the job queues if there are app active jobs") { job1 = Job( id: 100, @@ -1056,7 +974,9 @@ class JobRunnerSpec: QuickSpec { nextRunTimestamp: 0, threadId: nil, interactionId: nil, - details: try? JSONEncoder().encode(TestDetails(completeTime: 1)) + details: try? JSONEncoder() + .with(outputFormatting: .sortedKeys) + .encode(TestDetails(completeTime: 1)) ) job2 = Job( id: 101, @@ -1068,78 +988,348 @@ class JobRunnerSpec: QuickSpec { nextRunTimestamp: 0, threadId: nil, interactionId: nil, - details: try? JSONEncoder().encode(TestDetails(completeTime: 1)) + details: try? JSONEncoder() + .with(outputFormatting: .sortedKeys) + .encode(TestDetails(completeTime: 1)) ) + jobRunner.appDidFinishLaunching(using: dependencies) mockStorage.write { db in - try job1.insert(db) - try job2.insert(db) - - jobRunner.upsert( + jobRunner.add( db, job: job1, canStartJob: true, - dependencies: dependencies + using: dependencies ) - jobRunner.upsert( + jobRunner.add( db, job: job2, canStartJob: true, - dependencies: dependencies + using: dependencies + ) + } + + // Not currently running + expect(jobRunner.isCurrentlyRunning(job1)).to(beFalse()) + expect(jobRunner.isCurrentlyRunning(job2)).to(beFalse()) + + // Make sure the queues are started + jobRunner.appDidBecomeActive(using: dependencies) + + expect(jobRunner.isCurrentlyRunning(job1)).to(beTrue()) + expect(jobRunner.isCurrentlyRunning(job2)).to(beTrue()) + } + + // MARK: ------ starts the job queues after completing blocking app launch jobs + it("starts the job queues after completing blocking app launch jobs") { + job1 = job1.with(details: TestDetails(completeTime: 2)) + job2 = Job( + id: 101, + failureCount: 0, + variant: .messageSend, + behaviour: .runOnceNextLaunch, + shouldBlock: true, + shouldSkipLaunchBecomeActive: false, + nextRunTimestamp: 0, + threadId: nil, + interactionId: nil, + details: try! JSONEncoder() + .with(outputFormatting: .sortedKeys) + .encode(TestDetails(completeTime: 1)) + ) + + mockStorage.write { db in + jobRunner.add( + db, + job: job1, + canStartJob: true, + using: dependencies + ) + jobRunner.add( + db, + job: job2, + canStartJob: true, + using: dependencies ) } // Not currently running expect(jobRunner.isCurrentlyRunning(job1)) - .toEventually( - beFalse(), - timeout: .milliseconds(50) - ) + .to(beFalse()) + expect(jobRunner.isCurrentlyRunning(job2)).to(beFalse()) - // Make sure the queues are started - jobRunner.appDidBecomeActive(dependencies: dependencies) + // Make sure it starts + jobRunner.appDidFinishLaunching(using: dependencies) + jobRunner.appDidBecomeActive(using: dependencies) - expect(jobRunner.isCurrentlyRunning(job1)) - .toEventually( - beTrue(), - timeout: .milliseconds(50) - ) + // Blocking job running but blocked job not expect(jobRunner.isCurrentlyRunning(job2)).to(beTrue()) + expect(jobRunner.isCurrentlyRunning(job1)).to(beFalse()) + + // Complete 'job2' + dependencies.stepForwardInTime() + + // Blocked job eventually starts + expect(jobRunner.isCurrentlyRunning(job2)).to(beFalse()) + expect(jobRunner.isCurrentlyRunning(job1)).to(beTrue()) + } + + // MARK: ------ starts the job queues alongside non blocking app launch jobs + it("starts the job queues alongside non blocking app launch jobs") { + job1 = job1.with(details: TestDetails(completeTime: 1)) + job2 = Job( + id: 101, + failureCount: 0, + variant: .messageSend, + behaviour: .runOnceNextLaunch, + shouldBlock: false, + shouldSkipLaunchBecomeActive: false, + nextRunTimestamp: 0, + threadId: nil, + interactionId: nil, + details: try! JSONEncoder() + .with(outputFormatting: .sortedKeys) + .encode(TestDetails(completeTime: 1)) + ) + + mockStorage.write { db in + jobRunner.add( + db, + job: job1, + canStartJob: true, + using: dependencies + ) + jobRunner.add( + db, + job: job2, + canStartJob: true, + using: dependencies + ) + } + + // Not currently running + expect(jobRunner.isCurrentlyRunning(job1)).to(beFalse()) + expect(jobRunner.isCurrentlyRunning(job2)).to(beFalse()) + + // Make sure it starts + jobRunner.appDidFinishLaunching(using: dependencies) + jobRunner.appDidBecomeActive(using: dependencies) + + expect(jobRunner.isCurrentlyRunning(job1)).to(beTrue()) + expect(jobRunner.isCurrentlyRunning(job2)).to(beTrue()) + } + } + + // MARK: ---- by checking if a job can be added to the queue + context("by checking if a job can be added to the queue") { + // MARK: ------ does not add a general job to the queue before launch + it("does not add a general job to the queue before launch") { + job1 = Job( + id: 100, + failureCount: 0, + variant: .messageSend, + behaviour: .runOnce, + shouldBlock: false, + shouldSkipLaunchBecomeActive: false, + nextRunTimestamp: 0, + threadId: nil, + interactionId: nil, + details: try? JSONEncoder() + .with(outputFormatting: .sortedKeys) + .encode(TestDetails(completeTime: 1)) + ) + + mockStorage.write { db in + jobRunner.add( + db, + job: job1, + canStartJob: true, + using: dependencies + ) + } + + expect(jobRunner.allJobInfo()).to(beEmpty()) + } + + // MARK: ------ adds a launch job to the queue in a pending state before launch + it("adds a launch job to the queue in a pending state before launch") { + job1 = Job( + id: 100, + failureCount: 0, + variant: .messageSend, + behaviour: .recurringOnLaunch, + shouldBlock: false, + shouldSkipLaunchBecomeActive: false, + nextRunTimestamp: 0, + threadId: nil, + interactionId: nil, + details: try? JSONEncoder() + .with(outputFormatting: .sortedKeys) + .encode(TestDetails(completeTime: 1)) + ) + + mockStorage.write { db in + jobRunner.add( + db, + job: job1, + canStartJob: true, + using: dependencies + ) + } + + expect(Array(jobRunner.jobInfoFor(state: [.pending]).keys)).to(equal([100])) + } + + // MARK: ------ does not add a general job to the queue after launch but before becoming active + it("does not add a general job to the queue after launch but before becoming active") { + job1 = Job( + id: 100, + failureCount: 0, + variant: .messageSend, + behaviour: .runOnce, + shouldBlock: false, + shouldSkipLaunchBecomeActive: false, + nextRunTimestamp: 0, + threadId: nil, + interactionId: nil, + details: try? JSONEncoder() + .with(outputFormatting: .sortedKeys) + .encode(TestDetails(completeTime: 1)) + ) + jobRunner.appDidFinishLaunching(using: dependencies) + + mockStorage.write { db in + jobRunner.add( + db, + job: job1, + canStartJob: true, + using: dependencies + ) + } + + expect(jobRunner.allJobInfo()).to(beEmpty()) + } + + // MARK: ------ adds a launch job to the queue in a pending state after launch but before becoming active + it("adds a launch job to the queue in a pending state after launch but before becoming active") { + job1 = Job( + id: 100, + failureCount: 0, + variant: .messageSend, + behaviour: .recurringOnLaunch, + shouldBlock: false, + shouldSkipLaunchBecomeActive: false, + nextRunTimestamp: 0, + threadId: nil, + interactionId: nil, + details: try? JSONEncoder() + .with(outputFormatting: .sortedKeys) + .encode(TestDetails(completeTime: 1)) + ) + jobRunner.appDidFinishLaunching(using: dependencies) + + mockStorage.write { db in + jobRunner.add( + db, + job: job1, + canStartJob: true, + using: dependencies + ) + } + + expect(Array(jobRunner.jobInfoFor(state: .pending).keys)).to(equal([100])) + } + + // MARK: ------ adds a general job to the queue after becoming active + it("adds a general job to the queue after becoming active") { + job1 = Job( + id: 100, + failureCount: 0, + variant: .messageSend, + behaviour: .runOnce, + shouldBlock: false, + shouldSkipLaunchBecomeActive: false, + nextRunTimestamp: 0, + threadId: nil, + interactionId: nil, + details: try? JSONEncoder() + .with(outputFormatting: .sortedKeys) + .encode(TestDetails(completeTime: 1)) + ) + jobRunner.appDidFinishLaunching(using: dependencies) + jobRunner.appDidBecomeActive(using: dependencies) + + mockStorage.write { db in + jobRunner.add( + db, + job: job1, + canStartJob: true, + using: dependencies + ) + } + + expect(Array(jobRunner.allJobInfo().keys)).to(equal([100])) + } + + // MARK: ------ adds a launch job to the queue and starts it after becoming active + it("adds a launch job to the queue and starts it after becoming active") { + job1 = Job( + id: 100, + failureCount: 0, + variant: .messageSend, + behaviour: .recurringOnLaunch, + shouldBlock: false, + shouldSkipLaunchBecomeActive: false, + nextRunTimestamp: 0, + threadId: nil, + interactionId: nil, + details: try? JSONEncoder() + .with(outputFormatting: .sortedKeys) + .encode(TestDetails(completeTime: 1)) + ) + jobRunner.appDidFinishLaunching(using: dependencies) + jobRunner.appDidBecomeActive(using: dependencies) + + mockStorage.write { db in + jobRunner.add( + db, + job: job1, + canStartJob: true, + using: dependencies + ) + } + + expect(Array(jobRunner.jobInfoFor(state: .running).keys)).to(equal([100])) } } } // MARK: -- when running jobs - context("when running jobs") { beforeEach { - jobRunner.appDidFinishLaunching(dependencies: dependencies) + jobRunner.appDidFinishLaunching(using: dependencies) + jobRunner.appDidBecomeActive(using: dependencies) } // MARK: ---- by adding - context("by adding") { + // MARK: ------ does not start until after the db transaction completes it("does not start until after the db transaction completes") { job1 = job1.with(details: TestDetails(completeTime: 1)) mockStorage.write { db in - jobRunner.add(db, job: job1, canStartJob: true, dependencies: dependencies) + jobRunner.add(db, job: job1, canStartJob: true, using: dependencies) - // Wait for 10ms to give the job the chance to be added - Thread.sleep(forTimeInterval: 0.01) - expect(Array(jobRunner.jobInfoFor(state: .running).keys)) - .to(beEmpty()) + expect(Array(jobRunner.jobInfoFor(state: .running).keys)).to(beEmpty()) } - // Wait for 10ms for the job to actually be added - Thread.sleep(forTimeInterval: 0.01) - expect(Array(jobRunner.jobInfoFor(state: .running).keys)) - .to(equal([100])) + expect(Array(jobRunner.jobInfoFor(state: .running).keys)).to(equal([100])) } } - // MARK: ---- with dependencies - context("with dependencies") { + // MARK: ---- with job dependencies + context("with job dependencies") { + // MARK: ------ starts dependencies first it("starts dependencies first") { job1 = job1.with(details: TestDetails(completeTime: 1)) job2 = job2.with(details: TestDetails(completeTime: 2)) @@ -1149,17 +1339,15 @@ class JobRunnerSpec: QuickSpec { try job2.insert(db) try JobDependencies(jobId: job1.id!, dependantId: job2.id!).insert(db) - jobRunner.upsert(db, job: job1, canStartJob: true, dependencies: dependencies) + jobRunner.upsert(db, job: job1, canStartJob: true, using: dependencies) } // Make sure the dependency is run expect(Array(jobRunner.jobInfoFor(state: .running, variant: .attachmentUpload).keys)) - .toEventually( - equal([101]), - timeout: .milliseconds(50) - ) + .to(equal([101])) } + // MARK: ------ removes the initial job from the queue it("removes the initial job from the queue") { job1 = job1.with(details: TestDetails(completeTime: 1)) job2 = job2.with(details: TestDetails(completeTime: 2)) @@ -1169,18 +1357,16 @@ class JobRunnerSpec: QuickSpec { try job2.insert(db) try JobDependencies(jobId: job1.id!, dependantId: job2.id!).insert(db) - jobRunner.upsert(db, job: job1, canStartJob: true, dependencies: dependencies) + jobRunner.upsert(db, job: job1, canStartJob: true, using: dependencies) } // Make sure the initial job is removed from the queue expect(Array(jobRunner.jobInfoFor(state: .running, variant: .attachmentUpload).keys)) - .toEventually( - equal([101]), - timeout: .milliseconds(50) - ) + .to(equal([101])) expect(jobRunner.jobInfoFor(state: .running, variant: .messageSend).keys).toNot(contain(100)) } - + + // MARK: ------ starts the initial job when the dependencies succeed it("starts the initial job when the dependencies succeed") { job1 = job1.with(details: TestDetails(completeTime: 2)) job2 = job2.with(details: TestDetails(completeTime: 1)) @@ -1190,26 +1376,21 @@ class JobRunnerSpec: QuickSpec { try job2.insert(db) try JobDependencies(jobId: job1.id!, dependantId: job2.id!).insert(db) - jobRunner.upsert(db, job: job1, canStartJob: true, dependencies: dependencies) + jobRunner.upsert(db, job: job1, canStartJob: true, using: dependencies) } // Make sure the dependency is run expect(Array(jobRunner.jobInfoFor(state: .running, variant: .attachmentUpload).keys)) - .toEventually( - equal([101]), - timeout: .milliseconds(50) - ) + .to(equal([101])) expect(jobRunner.jobInfoFor(state: .running, variant: .messageSend).keys).toNot(contain(100)) // Make sure the initial job starts - dependencies.fixedTime = 1 + dependencies.stepForwardInTime() expect(Array(jobRunner.jobInfoFor(state: .running, variant: .messageSend).keys)) - .toEventually( - equal([100]), - timeout: .milliseconds(50) - ) + .to(equal([100])) } + // MARK: ------ does not start the initial job if the dependencies are deferred it("does not start the initial job if the dependencies are deferred") { job1 = job1.with(details: TestDetails(result: .failure, completeTime: 2)) job2 = job2.with(details: TestDetails(result: .deferred, completeTime: 1)) @@ -1219,26 +1400,20 @@ class JobRunnerSpec: QuickSpec { try job2.insert(db) try JobDependencies(jobId: job1.id!, dependantId: job2.id!).insert(db) - jobRunner.upsert(db, job: job1, canStartJob: true, dependencies: dependencies) + jobRunner.upsert(db, job: job1, canStartJob: true, using: dependencies) } // Make sure the dependency is run expect(Array(jobRunner.jobInfoFor(state: .running, variant: .attachmentUpload).keys)) - .toEventually( - equal([101]), - timeout: .milliseconds(50) - ) + .to(equal([101])) expect(jobRunner.jobInfoFor(state: .running, variant: .messageSend).keys).toNot(contain(100)) // Make sure there are no running jobs - dependencies.fixedTime = 1 - expect(Array(jobRunner.jobInfoFor(state: .running).keys)) - .toEventually( - beEmpty(), - timeout: .milliseconds(50) - ) + dependencies.stepForwardInTime() + expect(Array(jobRunner.jobInfoFor(state: .running).keys)).to(beEmpty()) } + // MARK: ------ does not start the initial job if the dependencies fail it("does not start the initial job if the dependencies fail") { job1 = job1.with(details: TestDetails(result: .failure, completeTime: 2)) job2 = job2.with(details: TestDetails(result: .failure, completeTime: 1)) @@ -1248,26 +1423,20 @@ class JobRunnerSpec: QuickSpec { try job2.insert(db) try JobDependencies(jobId: job1.id!, dependantId: job2.id!).insert(db) - jobRunner.upsert(db, job: job1, canStartJob: true, dependencies: dependencies) + jobRunner.upsert(db, job: job1, canStartJob: true, using: dependencies) } // Make sure the dependency is run expect(Array(jobRunner.jobInfoFor(state: .running, variant: .attachmentUpload).keys)) - .toEventually( - equal([101]), - timeout: .milliseconds(50) - ) + .to(equal([101])) expect(jobRunner.jobInfoFor(state: .running, variant: .messageSend).keys).toNot(contain(100)) // Make sure there are no running jobs - dependencies.fixedTime = 1 - expect(Array(jobRunner.jobInfoFor(state: .running).keys)) - .toEventually( - beEmpty(), - timeout: .milliseconds(50) - ) + dependencies.stepForwardInTime() + expect(Array(jobRunner.jobInfoFor(state: .running).keys)).to(beEmpty()) } + // MARK: ------ does not delete the initial job if the dependencies fail it("does not delete the initial job if the dependencies fail") { job1 = job1.with(details: TestDetails(result: .failure, completeTime: 2)) job2 = job2.with(details: TestDetails(result: .failure, completeTime: 1)) @@ -1277,24 +1446,18 @@ class JobRunnerSpec: QuickSpec { try job2.insert(db) try JobDependencies(jobId: job1.id!, dependantId: job2.id!).insert(db) - jobRunner.upsert(db, job: job1, canStartJob: true, dependencies: dependencies) + jobRunner.upsert(db, job: job1, canStartJob: true, using: dependencies) } // Make sure the dependency is run expect(Array(jobRunner.jobInfoFor(state: .running, variant: .attachmentUpload).keys)) - .toEventually( - equal([101]), - timeout: .milliseconds(50) - ) + .to(equal([101])) expect(jobRunner.jobInfoFor(state: .running, variant: .messageSend).keys).toNot(contain(100)) // Make sure there are no running jobs - dependencies.fixedTime = 1 + dependencies.stepForwardInTime() expect(Array(jobRunner.jobInfoFor(state: .running, variant: .attachmentUpload).keys)) - .toEventually( - beEmpty(), - timeout: .milliseconds(50) - ) + .to(beEmpty()) // Stop the queues so it doesn't run out of retry attempts jobRunner.stopAndClearPendingJobs(exceptForVariant: nil, onComplete: nil) @@ -1303,6 +1466,7 @@ class JobRunnerSpec: QuickSpec { expect(mockStorage.read { db in try Job.fetchCount(db) }).to(equal(2)) } + // MARK: ------ deletes the initial job if the dependencies permanently fail it("deletes the initial job if the dependencies permanently fail") { job1 = job1.with(details: TestDetails(result: .failure, completeTime: 2)) job2 = job2.with(details: TestDetails(result: .permanentFailure, completeTime: 1)) @@ -1312,24 +1476,18 @@ class JobRunnerSpec: QuickSpec { try job2.insert(db) try JobDependencies(jobId: job1.id!, dependantId: job2.id!).insert(db) - jobRunner.upsert(db, job: job1, canStartJob: true, dependencies: dependencies) + jobRunner.upsert(db, job: job1, canStartJob: true, using: dependencies) } // Make sure the dependency is run expect(Array(jobRunner.jobInfoFor(state: .running, variant: .attachmentUpload).keys)) - .toEventually( - equal([101]), - timeout: .milliseconds(50) - ) + .to(equal([101])) expect(jobRunner.jobInfoFor(state: .running, variant: .messageSend).keys).toNot(contain(100)) // Make sure there are no running jobs - dependencies.fixedTime = 1 + dependencies.stepForwardInTime() expect(Array(jobRunner.jobInfoFor(state: .running, variant: .attachmentUpload).keys)) - .toEventually( - beEmpty(), - timeout: .milliseconds(50) - ) + .to(beEmpty()) // Make sure the jobs were deleted expect(mockStorage.read { db in try Job.fetchCount(db) }).to(equal(0)) @@ -1338,63 +1496,48 @@ class JobRunnerSpec: QuickSpec { } // MARK: -- when completing jobs - context("when completing jobs") { beforeEach { - jobRunner.appDidFinishLaunching(dependencies: dependencies) + jobRunner.appDidFinishLaunching(using: dependencies) + jobRunner.appDidBecomeActive(using: dependencies) } // MARK: ---- by succeeding - context("by succeeding") { + // MARK: ------ removes the job from the queue it("removes the job from the queue") { job1 = job1.with(details: TestDetails(result: .success, completeTime: 1)) mockStorage.write { db in try job1.insert(db) - jobRunner.upsert(db, job: job1, canStartJob: true, dependencies: dependencies) + jobRunner.upsert(db, job: job1, canStartJob: true, using: dependencies) } // Make sure the dependency is run - expect(Array(jobRunner.jobInfoFor(state: .running).keys)) - .toEventually( - equal([100]), - timeout: .milliseconds(50) - ) + expect(Array(jobRunner.jobInfoFor(state: .running).keys)).to(equal([100])) // Make sure there are no running jobs - dependencies.fixedTime = 1 - expect(Array(jobRunner.jobInfoFor(state: .running).keys)) - .toEventually( - beEmpty(), - timeout: .milliseconds(50) - ) + dependencies.stepForwardInTime() + expect(Array(jobRunner.jobInfoFor(state: .running).keys)).to(beEmpty()) } + // MARK: ------ deletes the job it("deletes the job") { job1 = job1.with(details: TestDetails(result: .success, completeTime: 1)) mockStorage.write { db in try job1.insert(db) - jobRunner.upsert(db, job: job1, canStartJob: true, dependencies: dependencies) + jobRunner.upsert(db, job: job1, canStartJob: true, using: dependencies) } // Make sure the dependency is run - expect(Array(jobRunner.jobInfoFor(state: .running).keys)) - .toEventually( - equal([100]), - timeout: .milliseconds(50) - ) + expect(Array(jobRunner.jobInfoFor(state: .running).keys)).to(equal([100])) // Make sure there are no running jobs - dependencies.fixedTime = 1 - expect(Array(jobRunner.jobInfoFor(state: .running).keys)) - .toEventually( - beEmpty(), - timeout: .milliseconds(50) - ) + dependencies.stepForwardInTime() + expect(Array(jobRunner.jobInfoFor(state: .running).keys)).to(beEmpty()) // Make sure the jobs were deleted expect(mockStorage.read { db in try Job.fetchCount(db) }).to(equal(0)) @@ -1402,168 +1545,121 @@ class JobRunnerSpec: QuickSpec { } // MARK: ---- by deferring - context("by deferring") { + // MARK: ------ reschedules the job to run again later it("reschedules the job to run again later") { job1 = job1.with(details: TestDetails(result: .deferred, completeTime: 1)) mockStorage.write { db in try job1.insert(db) - jobRunner.upsert(db, job: job1, canStartJob: true, dependencies: dependencies) + jobRunner.upsert(db, job: job1, canStartJob: true, using: dependencies) } // Make sure the dependency is run - expect(Array(jobRunner.jobInfoFor(state: .running).keys)) - .toEventually( - equal([100]), - timeout: .milliseconds(50) - ) + expect(Array(jobRunner.jobInfoFor(state: .running).keys)).to(equal([100])) // Make sure there are no running jobs - dependencies.fixedTime = 1 - expect(jobRunner.jobInfoFor(state: .running)) - .toEventually( - beEmpty(), - timeout: .milliseconds(50) - ) - expect(mockStorage.read { db in try Job.select(.details).asRequest(of: Data.self).fetchOne(db) }) - .to(equal(try! JSONEncoder().encode(TestDetails(result: .deferred, completeTime: 3)))) + dependencies.stepForwardInTime() + expect(jobRunner.jobInfoFor(state: .running)).to(beEmpty()) + expect { + mockStorage.read { db in try Job.select(.details).asRequest(of: Data.self).fetchOne(db) } + }.to(equal( + try! JSONEncoder() + .with(outputFormatting: .sortedKeys) + .encode(TestDetails(result: .deferred, completeTime: 3)) + )) } + // MARK: ------ does not delete the job it("does not delete the job") { job1 = job1.with(details: TestDetails(result: .deferred, completeTime: 1)) mockStorage.write { db in try job1.insert(db) - jobRunner.upsert(db, job: job1, canStartJob: true, dependencies: dependencies) + jobRunner.upsert(db, job: job1, canStartJob: true, using: dependencies) } // Make sure the dependency is run - expect(Array(jobRunner.jobInfoFor(state: .running).keys)) - .toEventually( - equal([100]), - timeout: .milliseconds(50) - ) + expect(Array(jobRunner.jobInfoFor(state: .running).keys)).to(equal([100])) // Make sure there are no running jobs - dependencies.fixedTime = 1 - expect(jobRunner.jobInfoFor(state: .running)) - .toEventually( - beEmpty(), - timeout: .milliseconds(50) - ) + dependencies.stepForwardInTime() + expect(jobRunner.jobInfoFor(state: .running)).to(beEmpty()) // Make sure the jobs were deleted expect(mockStorage.read { db in try Job.fetchCount(db) }).toNot(equal(0)) } + // MARK: ------ fails the job if it is deferred too many times it("fails the job if it is deferred too many times") { job1 = job1.with(details: TestDetails(result: .deferred, completeTime: 1)) mockStorage.write { db in try job1.insert(db) - jobRunner.upsert(db, job: job1, canStartJob: true, dependencies: dependencies) + jobRunner.upsert(db, job: job1, canStartJob: true, using: dependencies) } // Make sure it runs - expect(Array(jobRunner.jobInfoFor(state: .running).keys)) - .toEventually( - equal([100]), - timeout: .milliseconds(50) - ) - dependencies.fixedTime = 1 - expect(Array(jobRunner.jobInfoFor(state: .running).keys)) - .toEventually( - beEmpty(), - timeout: .milliseconds(50) - ) + expect(Array(jobRunner.jobInfoFor(state: .running).keys)).to(equal([100])) + dependencies.stepForwardInTime() + expect(Array(jobRunner.jobInfoFor(state: .running).keys)).to(beEmpty()) // Progress the time - dependencies.fixedTime = 2 + dependencies.stepForwardInTime() // Make sure it finishes once expect(jobRunner.jobInfoFor(state: .running)) - .toEventually( - equal( - [ - 100: JobRunner.JobInfo( - variant: .attachmentUpload, - threadId: nil, - interactionId: nil, - detailsData: try! JSONEncoder().encode(TestDetails( - result: .deferred, - completeTime: 3 - )) - ) - ] - ), - timeout: .milliseconds(50) - ) - dependencies.fixedTime = 3 - expect(Array(jobRunner.jobInfoFor(state: .running).keys)) - .toEventually( - beEmpty(), - timeout: .milliseconds(50) - ) + .to(equal([ + 100: JobRunner.JobInfo( + variant: .messageSend, + threadId: nil, + interactionId: nil, + detailsData: try! JSONEncoder() + .with(outputFormatting: .sortedKeys) + .encode(TestDetails(result: .deferred, completeTime: 3)) + ) + ])) + dependencies.stepForwardInTime() + expect(Array(jobRunner.jobInfoFor(state: .running).keys)).to(beEmpty()) // Progress the time - dependencies.fixedTime = 4 + dependencies.stepForwardInTime() // Make sure it finishes twice expect(jobRunner.jobInfoFor(state: .running)) - .toEventually( - equal( - [ - 100: JobRunner.JobInfo( - variant: .attachmentUpload, - threadId: nil, - interactionId: nil, - detailsData: try! JSONEncoder().encode(TestDetails( - result: .deferred, - completeTime: 5 - )) - ) - ] - ), - timeout: .milliseconds(50) - ) - dependencies.fixedTime = 5 - expect(Array(jobRunner.jobInfoFor(state: .running).keys)) - .toEventually( - beEmpty(), - timeout: .milliseconds(50) - ) + .to(equal([ + 100: JobRunner.JobInfo( + variant: .messageSend, + threadId: nil, + interactionId: nil, + detailsData: try! JSONEncoder() + .with(outputFormatting: .sortedKeys) + .encode(TestDetails(result: .deferred, completeTime: 5)) + ) + ])) + dependencies.stepForwardInTime() + expect(Array(jobRunner.jobInfoFor(state: .running).keys)).to(beEmpty()) // Progress the time - dependencies.fixedTime = 6 + dependencies.stepForwardInTime() // Make sure it's finishes the last time expect(jobRunner.jobInfoFor(state: .running)) - .toEventually( - equal( - [ - 100: JobRunner.JobInfo( - variant: .attachmentUpload, - threadId: nil, - interactionId: nil, - detailsData: try! JSONEncoder().encode(TestDetails( - result: .deferred, - completeTime: 7 - )) - ) - ] - ), - timeout: .milliseconds(50) - ) - dependencies.fixedTime = 7 - expect(Array(jobRunner.jobInfoFor(state: .running).keys)) - .toEventually( - beEmpty(), - timeout: .milliseconds(50) - ) + .to(equal([ + 100: JobRunner.JobInfo( + variant: .messageSend, + threadId: nil, + interactionId: nil, + detailsData: try! JSONEncoder() + .with(outputFormatting: .sortedKeys) + .encode(TestDetails(result: .deferred, completeTime: 7)) + ) + ])) + dependencies.stepForwardInTime() + expect(Array(jobRunner.jobInfoFor(state: .running).keys)).to(beEmpty()) // Make sure the job was marked as failed expect(mockStorage.read { db in try Job.fetchOne(db, id: 100)?.failureCount }).to(equal(1)) @@ -1571,56 +1667,41 @@ class JobRunnerSpec: QuickSpec { } // MARK: ---- by failing - context("by failing") { + // MARK: ------ removes the job from the queue it("removes the job from the queue") { job1 = job1.with(details: TestDetails(result: .failure, completeTime: 1)) mockStorage.write { db in try job1.insert(db) - jobRunner.upsert(db, job: job1, canStartJob: true, dependencies: dependencies) + jobRunner.upsert(db, job: job1, canStartJob: true, using: dependencies) } // Make sure the dependency is run - expect(Array(jobRunner.jobInfoFor(state: .running).keys)) - .toEventually( - equal([100]), - timeout: .milliseconds(50) - ) + expect(Array(jobRunner.jobInfoFor(state: .running).keys)).to(equal([100])) // Make sure there are no running jobs - dependencies.fixedTime = 1 - expect(Array(jobRunner.jobInfoFor(state: .running).keys)) - .toEventually( - beEmpty(), - timeout: .milliseconds(50) - ) + dependencies.stepForwardInTime() + expect(Array(jobRunner.jobInfoFor(state: .running).keys)).to(beEmpty()) } + // MARK: ------ does not delete the job it("does not delete the job") { job1 = job1.with(details: TestDetails(result: .failure, completeTime: 1)) mockStorage.write { db in try job1.insert(db) - jobRunner.upsert(db, job: job1, canStartJob: true, dependencies: dependencies) + jobRunner.upsert(db, job: job1, canStartJob: true, using: dependencies) } // Make sure the dependency is run - expect(Array(jobRunner.jobInfoFor(state: .running).keys)) - .toEventually( - equal([100]), - timeout: .milliseconds(50) - ) + expect(Array(jobRunner.jobInfoFor(state: .running).keys)).to(equal([100])) // Make sure there are no running jobs - dependencies.fixedTime = 1 - expect(Array(jobRunner.jobInfoFor(state: .running).keys)) - .toEventually( - beEmpty(), - timeout: .milliseconds(50) - ) + dependencies.stepForwardInTime() + expect(Array(jobRunner.jobInfoFor(state: .running).keys)).to(beEmpty()) // Make sure the jobs were deleted expect(mockStorage.read { db in try Job.fetchCount(db) }).toNot(equal(0)) @@ -1628,56 +1709,41 @@ class JobRunnerSpec: QuickSpec { } // MARK: ---- by permanently failing - context("by permanently failing") { + // MARK: ------ removes the job from the queue it("removes the job from the queue") { job1 = job1.with(details: TestDetails(result: .permanentFailure, completeTime: 1)) mockStorage.write { db in try job1.insert(db) - jobRunner.upsert(db, job: job1, canStartJob: true, dependencies: dependencies) + jobRunner.upsert(db, job: job1, canStartJob: true, using: dependencies) } // Make sure the dependency is run - expect(Array(jobRunner.jobInfoFor(state: .running).keys)) - .toEventually( - equal([100]), - timeout: .milliseconds(50) - ) + expect(Array(jobRunner.jobInfoFor(state: .running).keys)).to(equal([100])) // Make sure there are no running jobs - dependencies.fixedTime = 1 - expect(Array(jobRunner.jobInfoFor(state: .running).keys)) - .toEventually( - beEmpty(), - timeout: .milliseconds(50) - ) + dependencies.stepForwardInTime() + expect(Array(jobRunner.jobInfoFor(state: .running).keys)).to(beEmpty()) } + // MARK: ------ deletes the job it("deletes the job") { job1 = job1.with(details: TestDetails(result: .permanentFailure, completeTime: 1)) mockStorage.write { db in try job1.insert(db) - jobRunner.upsert(db, job: job1, canStartJob: true, dependencies: dependencies) + jobRunner.upsert(db, job: job1, canStartJob: true, using: dependencies) } // Make sure the dependency is run - expect(Array(jobRunner.jobInfoFor(state: .running).keys)) - .toEventually( - equal([100]), - timeout: .milliseconds(50) - ) + expect(Array(jobRunner.jobInfoFor(state: .running).keys)).to(equal([100])) // Make sure there are no running jobs - dependencies.fixedTime = 1 - expect(Array(jobRunner.jobInfoFor(state: .running).keys)) - .toEventually( - beEmpty(), - timeout: .milliseconds(50) - ) + dependencies.stepForwardInTime() + expect(Array(jobRunner.jobInfoFor(state: .running).keys)).to(beEmpty()) // Make sure the jobs were deleted expect(mockStorage.read { db in try Job.fetchCount(db) }).to(equal(0)) diff --git a/SessionUtilitiesKitTests/Networking/BatchResponseSpec.swift b/SessionUtilitiesKitTests/Networking/BatchResponseSpec.swift index 1030e6900..d3349a15a 100644 --- a/SessionUtilitiesKitTests/Networking/BatchResponseSpec.swift +++ b/SessionUtilitiesKitTests/Networking/BatchResponseSpec.swift @@ -89,7 +89,7 @@ class BatchResponseSpec: QuickSpec { "code": 200, "headers": { "testKey": "testValue" - }, + } } """ let subResponse: HTTP.BatchSubResponse? = try? JSONDecoder().decode( @@ -108,7 +108,7 @@ class BatchResponseSpec: QuickSpec { "code": 200, "headers": { "testKey": "testValue" - }, + } } """ let subResponse: HTTP.BatchSubResponse? = try? JSONDecoder().decode( @@ -149,7 +149,7 @@ class BatchResponseSpec: QuickSpec { testType2 = TestType2(intValue: 123, stringValue2: "test2") data = """ [\([ - try! JSONEncoder().encode( + try! JSONEncoder().with(outputFormatting: .sortedKeys).encode( HTTP.BatchSubResponse( code: 200, headers: [:], @@ -157,7 +157,7 @@ class BatchResponseSpec: QuickSpec { failedToParseBody: false ) ), - try! JSONEncoder().encode( + try! JSONEncoder().with(outputFormatting: .sortedKeys).encode( HTTP.BatchSubResponse( code: 200, headers: [:], @@ -200,8 +200,7 @@ class BatchResponseSpec: QuickSpec { .mapError { error.setting(to: $0) } .sinkUntilComplete() - expect(error?.localizedDescription) - .to(equal(HTTPError.parsingFailed.localizedDescription)) + expect(error).to(matchError(HTTPError.parsingFailed)) } it("fails if the data is not JSON") { @@ -213,8 +212,7 @@ class BatchResponseSpec: QuickSpec { .mapError { error.setting(to: $0) } .sinkUntilComplete() - expect(error?.localizedDescription) - .to(equal(HTTPError.parsingFailed.localizedDescription)) + expect(error).to(matchError(HTTPError.parsingFailed)) } it("fails if the data is not a JSON array") { @@ -226,8 +224,7 @@ class BatchResponseSpec: QuickSpec { .mapError { error.setting(to: $0) } .sinkUntilComplete() - expect(error?.localizedDescription) - .to(equal(HTTPError.parsingFailed.localizedDescription)) + expect(error).to(matchError(HTTPError.parsingFailed)) } it("fails if the JSON array does not have the same number of items as the expected types") { @@ -243,14 +240,13 @@ class BatchResponseSpec: QuickSpec { .mapError { error.setting(to: $0) } .sinkUntilComplete() - expect(error?.localizedDescription) - .to(equal(HTTPError.parsingFailed.localizedDescription)) + expect(error).to(matchError(HTTPError.parsingFailed)) } it("fails if one of the JSON array values fails to decode") { data = """ [\([ - try! JSONEncoder().encode( + try! JSONEncoder().with(outputFormatting: .sortedKeys).encode( HTTP.BatchSubResponse( code: 200, headers: [:], @@ -274,8 +270,7 @@ class BatchResponseSpec: QuickSpec { .mapError { error.setting(to: $0) } .sinkUntilComplete() - expect(error?.localizedDescription) - .to(equal(HTTPError.parsingFailed.localizedDescription)) + expect(error).to(matchError(HTTPError.parsingFailed)) } } } diff --git a/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentApprovalViewController.swift b/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentApprovalViewController.swift index a8c34aec9..39f6c73f5 100644 --- a/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentApprovalViewController.swift +++ b/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentApprovalViewController.swift @@ -15,7 +15,8 @@ public protocol AttachmentApprovalViewControllerDelegate: AnyObject { _ attachmentApproval: AttachmentApprovalViewController, didApproveAttachments attachments: [SignalAttachment], forThreadId threadId: String, - messageText: String? + messageText: String?, + using dependencies: Dependencies ) func attachmentApprovalDidCancel(_ attachmentApproval: AttachmentApprovalViewController) @@ -664,7 +665,7 @@ extension AttachmentApprovalViewController: AttachmentTextToolbarDelegate { func attachmentTextToolbarDidEndEditing(_ attachmentTextToolbar: AttachmentTextToolbar) {} - func attachmentTextToolbarDidTapSend(_ attachmentTextToolbar: AttachmentTextToolbar) { + func attachmentTextToolbarDidTapSend(_ attachmentTextToolbar: AttachmentTextToolbar, using dependencies: Dependencies) { // Toolbar flickers in and out if there are errors // and remains visible momentarily after share extension is dismissed. // It's easiest to just hide it at this point since we're done with it. @@ -672,7 +673,7 @@ extension AttachmentApprovalViewController: AttachmentTextToolbarDelegate { attachmentTextToolbar.isUserInteractionEnabled = false attachmentTextToolbar.isHidden = true - approvalDelegate?.attachmentApproval(self, didApproveAttachments: attachments, forThreadId: threadId, messageText: attachmentTextToolbar.messageText) + approvalDelegate?.attachmentApproval(self, didApproveAttachments: attachments, forThreadId: threadId, messageText: attachmentTextToolbar.messageText, using: dependencies) } func attachmentTextToolbarDidChange(_ attachmentTextToolbar: AttachmentTextToolbar) { diff --git a/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentTextToolbar.swift b/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentTextToolbar.swift index 53a29da81..79ac67d63 100644 --- a/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentTextToolbar.swift +++ b/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentTextToolbar.swift @@ -10,7 +10,7 @@ import PureLayout let kMaxMessageBodyCharacterCount = 2000 protocol AttachmentTextToolbarDelegate: AnyObject { - func attachmentTextToolbarDidTapSend(_ attachmentTextToolbar: AttachmentTextToolbar) + func attachmentTextToolbarDidTapSend(_ attachmentTextToolbar: AttachmentTextToolbar, using dependencies: Dependencies) func attachmentTextToolbarDidBeginEditing(_ attachmentTextToolbar: AttachmentTextToolbar) func attachmentTextToolbarDidEndEditing(_ attachmentTextToolbar: AttachmentTextToolbar) func attachmentTextToolbarDidChange(_ attachmentTextToolbar: AttachmentTextToolbar) @@ -210,9 +210,11 @@ class AttachmentTextToolbar: UIView, UITextViewDelegate { } // MARK: - Actions + + @objc func didTapSend() { onSend() } - @objc func didTapSend() { - attachmentTextToolbarDelegate?.attachmentTextToolbarDidTapSend(self) + private func onSend(using dependencies: Dependencies = Dependencies()) { + attachmentTextToolbarDelegate?.attachmentTextToolbarDidTapSend(self, using: dependencies) } // MARK: - UITextViewDelegate diff --git a/_SharedTestUtilities/CommonMockedExtensions.swift b/_SharedTestUtilities/CommonMockedExtensions.swift index 06876f038..853dc44f3 100644 --- a/_SharedTestUtilities/CommonMockedExtensions.swift +++ b/_SharedTestUtilities/CommonMockedExtensions.swift @@ -1,6 +1,7 @@ // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import Foundation +import Combine import GRDB import Sodium import Curve25519Kit @@ -37,3 +38,40 @@ extension Job: Mocked { extension Job.Variant: Mocked { static var mockValue: Job.Variant = .messageSend } + +extension Network.RequestType: MockedGeneric { + typealias Generic = T + + static func mockValue(type: T.Type) -> Network.RequestType { + return Network.RequestType(id: "mock") { Fail(error: MockError.mockedData).eraseToAnyPublisher() } + } +} + +extension AnyPublisher: MockedGeneric where Failure == Error { + typealias Generic = Output + + static func mockValue(type: Output.Type) -> AnyPublisher { + return Fail(error: MockError.mockedData).eraseToAnyPublisher() + } +} + +extension Array: MockedGeneric { + typealias Generic = Element + + static func mockValue(type: Element.Type) -> [Element] { return [] } +} + +extension Dictionary: MockedDoubleGeneric { + typealias GenericA = Key + typealias GenericB = Value + + static func mockValue(typeA: Key.Type, typeB: Value.Type) -> [Key: Value] { return [:] } +} + +extension URLRequest: Mocked { + static var mockValue: URLRequest = URLRequest(url: URL(fileURLWithPath: "mock")) +} + +extension NoResponse: Mocked { + static var mockValue: NoResponse = NoResponse() +} diff --git a/_SharedTestUtilities/Mock.swift b/_SharedTestUtilities/Mock.swift index 74e92d38a..aed05ca40 100644 --- a/_SharedTestUtilities/Mock.swift +++ b/_SharedTestUtilities/Mock.swift @@ -3,21 +3,11 @@ import Foundation import SessionUtilitiesKit -// MARK: - Mocked +// MARK: - MockError -protocol Mocked { static var mockValue: Self { get } } - -func any() -> R { R.mockValue } -func any() -> R { unsafeBitCast(0, to: R.self) } -func any() -> [K: V] { [:] } -func any() -> Float { 0 } -func any() -> Double { 0 } -func any() -> String { "" } -func any() -> Data { Data() } - -func anyAny() -> Any { 0 } // Unique name for compilation performance reasons -func anyArray() -> [R] { [] } // Unique name for compilation performance reasons -func anySet() -> Set { Set() } // Unique name for compilation performance reasons +public enum MockError: Error { + case mockedData +} // MARK: - Mock @@ -39,7 +29,12 @@ public class Mock { } @discardableResult internal func accept(funcName: String = #function, checkArgs: [Any?], actionArgs: [Any?]) -> Any? { - return functionHandler.accept(funcName, parameterSummary: summary(for: checkArgs), actionArgs: actionArgs) + return functionHandler.accept( + funcName, + parameterCount: checkArgs.count, + parameterSummary: summary(for: checkArgs), + actionArgs: actionArgs + ) } // MARK: - Functions @@ -73,6 +68,8 @@ public class Mock { .sorted() return "[\(sortedValues.joined(separator: ", "))]" + case let data as Data: return "Data(base64Encoded: \(data.base64EncodedString()))" + default: return String(reflecting: argument) // Default to the `debugDescription` if available } } @@ -81,19 +78,21 @@ public class Mock { // MARK: - MockFunctionHandler protocol MockFunctionHandler { - func accept(_ functionName: String, parameterSummary: String, actionArgs: [Any?]) -> Any? + func accept(_ functionName: String, parameterCount: Int, parameterSummary: String, actionArgs: [Any?]) -> Any? } // MARK: - MockFunction internal class MockFunction { var name: String + var parameterCount: Int var parameterSummary: String var actions: [([Any?]) -> Void] var returnValue: Any? - init(name: String, parameterSummary: String, actions: [([Any?]) -> Void], returnValue: Any?) { + init(name: String, parameterCount: Int, parameterSummary: String, actions: [([Any?]) -> Void], returnValue: Any?) { self.name = name + self.parameterCount = parameterCount self.parameterSummary = parameterSummary self.actions = actions self.returnValue = returnValue @@ -106,10 +105,11 @@ internal class MockFunctionBuilder: MockFunctionHandler { private let callBlock: (inout T) throws -> R private let mockInit: (MockFunctionHandler?) -> Mock private var functionName: String? + private var parameterCount: Int? private var parameterSummary: String? private var actions: [([Any?]) -> Void] = [] private var returnValue: R? - internal var returnValueGenerator: ((String, String) -> R?)? + internal var returnValueGenerator: ((String, Int, String) -> R?)? // MARK: - Initialization @@ -131,53 +131,63 @@ internal class MockFunctionBuilder: MockFunctionHandler { // MARK: - MockFunctionHandler - func accept(_ functionName: String, parameterSummary: String, actionArgs: [Any?]) -> Any? { + func accept(_ functionName: String, parameterCount: Int, parameterSummary: String, actionArgs: [Any?]) -> Any? { self.functionName = functionName + self.parameterCount = parameterCount self.parameterSummary = parameterSummary - return (returnValue ?? returnValueGenerator?(functionName, parameterSummary)) + return (returnValue ?? returnValueGenerator?(functionName, parameterCount, parameterSummary)) } // MARK: - Build func build() throws -> MockFunction { var completionMock = mockInit(self) as! T - _ = try callBlock(&completionMock) + _ = try? callBlock(&completionMock) - guard let name: String = functionName, let parameterSummary: String = parameterSummary else { + guard let name: String = functionName, let parameterCount: Int = parameterCount, let parameterSummary: String = parameterSummary else { preconditionFailure("Attempted to build the MockFunction before it was called") } - return MockFunction(name: name, parameterSummary: parameterSummary, actions: actions, returnValue: returnValue) + return MockFunction(name: name, parameterCount: parameterCount, parameterSummary: parameterSummary, actions: actions, returnValue: returnValue) } } // MARK: - FunctionConsumer internal class FunctionConsumer: MockFunctionHandler { + struct Key: Equatable, Hashable { + let name: String + let paramCount: Int + } + var trackCalls: Bool = true var functionBuilders: [() throws -> MockFunction?] = [] - var functionHandlers: [String: [String: MockFunction]] = [:] - var calls: Atomic<[String: [String]]> = Atomic([:]) + var functionHandlers: [Key: [String: MockFunction]] = [:] + var calls: Atomic<[Key: [String]]> = Atomic([:]) - func accept(_ functionName: String, parameterSummary: String, actionArgs: [Any?]) -> Any? { + func accept(_ functionName: String, parameterCount: Int, parameterSummary: String, actionArgs: [Any?]) -> Any? { + let key: Key = Key(name: functionName, paramCount: parameterCount) + if !functionBuilders.isEmpty { functionBuilders .compactMap { try? $0() } .forEach { function in - functionHandlers[function.name] = (functionHandlers[function.name] ?? [:]) + let key: Key = Key(name: function.name, paramCount: function.parameterCount) + + functionHandlers[key] = (functionHandlers[key] ?? [:]) .setting(function.parameterSummary, function) } functionBuilders.removeAll() } - guard let expectation: MockFunction = firstFunction(for: functionName, matchingParameterSummaryIfPossible: parameterSummary) else { + guard let expectation: MockFunction = firstFunction(for: key, matchingParameterSummaryIfPossible: parameterSummary) else { preconditionFailure("No expectations found for \(functionName)") } // Record the call so it can be validated later (assuming we are tracking calls) if trackCalls { - calls.mutate { $0[functionName] = ($0[functionName] ?? []).appending(parameterSummary) } + calls.mutate { $0[key] = ($0[key] ?? []).appending(parameterSummary) } } for action in expectation.actions { @@ -187,8 +197,8 @@ internal class FunctionConsumer: MockFunctionHandler { return expectation.returnValue } - func firstFunction(for name: String, matchingParameterSummaryIfPossible parameterSummary: String) -> MockFunction? { - guard let possibleExpectations: [String: MockFunction] = functionHandlers[name] else { return nil } + func firstFunction(for key: Key, matchingParameterSummaryIfPossible parameterSummary: String) -> MockFunction? { + guard let possibleExpectations: [String: MockFunction] = functionHandlers[key] else { return nil } guard let expectation: MockFunction = possibleExpectations[parameterSummary] else { // A `nil` response might be value but in a lot of places we will need to force-cast diff --git a/_SharedTestUtilities/MockCaches.swift b/_SharedTestUtilities/MockCaches.swift new file mode 100644 index 000000000..493f2c579 --- /dev/null +++ b/_SharedTestUtilities/MockCaches.swift @@ -0,0 +1,51 @@ +// Copyright © 2023 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import SessionUtilitiesKit + +class MockCaches: CachesType { + private var cacheInstances: [Int: MutableCacheType] = [:] + + // MARK: - Immutable Access + + public subscript(cache: CacheInfo.Config) -> I { + get { MockCaches.getValueSettingIfNull(cache: cache, &cacheInstances) } + } + + public subscript(cache: CacheInfo.Config) -> M? { + get { return (cacheInstances[cache.key] as? M) } + set { cacheInstances[cache.key] = newValue.map { cache.mutableInstance($0) } } + } + + // MARK: - Mutable Access + + @discardableResult public func mutate( + cache: CacheInfo.Config, + _ mutation: (inout M) -> R + ) -> R { + var value: M = ((cacheInstances[cache.key] as? M) ?? cache.createInstance()) + return mutation(&value) + } + + @discardableResult public func mutate( + cache: CacheInfo.Config, + _ mutation: (inout M) throws -> R + ) throws -> R { + var value: M = ((cacheInstances[cache.key] as? M) ?? cache.createInstance()) + return try mutation(&value) + } + + @discardableResult private static func getValueSettingIfNull( + cache: CacheInfo.Config, + _ store: inout [Int: MutableCacheType] + ) -> I { + guard let value: M = (store[cache.key] as? M) else { + let value: M = cache.createInstance() + let mutableInstance: MutableCacheType = cache.mutableInstance(value) + store[cache.key] = mutableInstance + return cache.immutableInstance(value) + } + + return cache.immutableInstance(value) + } +} diff --git a/_SharedTestUtilities/MockCrypto.swift b/_SharedTestUtilities/MockCrypto.swift new file mode 100644 index 000000000..15f7efbcd --- /dev/null +++ b/_SharedTestUtilities/MockCrypto.swift @@ -0,0 +1,24 @@ +// Copyright © 2023 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import SessionUtilitiesKit + +class MockCrypto: Mock, CryptoType { + func size(_ size: Crypto.Size) -> Int { + return accept(funcName: "size(\(size.id))", args: size.args) as! Int + } + + func perform(_ action: Crypto.Action) throws -> Array { + return try accept(funcName: "perform(\(action.id))", args: action.args) as? Array ?? { + throw CryptoError.failedToGenerateOutput + }() + } + + func verify(_ verification: Crypto.Verification) -> Bool { + return accept(funcName: "verify(\(verification.id))", args: verification.args) as! Bool + } + + func generate(_ keyPairType: Crypto.KeyPairType) -> KeyPair? { + return accept(funcName: "generate(\(keyPairType.id))", args: keyPairType.args) as? KeyPair + } +} diff --git a/_SharedTestUtilities/MockGeneralCache.swift b/_SharedTestUtilities/MockGeneralCache.swift index fe19b7a0f..554c325cd 100644 --- a/_SharedTestUtilities/MockGeneralCache.swift +++ b/_SharedTestUtilities/MockGeneralCache.swift @@ -3,7 +3,7 @@ import Foundation import SessionUtilitiesKit -class MockGeneralCache: Mock, MutableGeneralCacheType { +class MockGeneralCache: Mock, GeneralCacheType { var encodedPublicKey: String? { get { return accept() as? String } set { accept(args: [newValue]) } diff --git a/_SharedTestUtilities/MockJobRunner.swift b/_SharedTestUtilities/MockJobRunner.swift index 6f34caff6..bbf6a87c3 100644 --- a/_SharedTestUtilities/MockJobRunner.swift +++ b/_SharedTestUtilities/MockJobRunner.swift @@ -17,15 +17,19 @@ class MockJobRunner: Mock, JobRunnerType { return accept(args: [queue]) as! Bool } + func afterBlockingQueue(callback: @escaping () -> ()) { + callback() + } + // MARK: - State Management func jobInfoFor(jobs: [Job]?, state: JobRunner.JobState, variant: Job.Variant?) -> [Int64: JobRunner.JobInfo] { return accept(args: [jobs, state, variant]) as! [Int64: JobRunner.JobInfo] } - func appDidFinishLaunching(dependencies: Dependencies) {} - func appDidBecomeActive(dependencies: Dependencies) {} - func startNonBlockingQueues(dependencies: Dependencies) {} + func appDidFinishLaunching(using dependencies: Dependencies) {} + func appDidBecomeActive(using dependencies: Dependencies) {} + func startNonBlockingQueues(using dependencies: Dependencies) {} func stopAndClearPendingJobs(exceptForVariant: Job.Variant?, onComplete: (() -> ())?) { accept(args: [exceptForVariant, onComplete]) @@ -34,15 +38,15 @@ class MockJobRunner: Mock, JobRunnerType { // MARK: - Job Scheduling - @discardableResult func add(_ db: Database, job: Job?, canStartJob: Bool, dependencies: Dependencies) -> Job? { + @discardableResult func add(_ db: Database, job: Job?, canStartJob: Bool, using dependencies: Dependencies) -> Job? { return accept(args: [db, job, canStartJob]) as? Job } - func upsert(_ db: Database, job: Job?, canStartJob: Bool, dependencies: Dependencies) { + func upsert(_ db: Database, job: Job?, canStartJob: Bool, using dependencies: Dependencies) { accept(args: [db, job, canStartJob]) } - func insert(_ db: Database, job: Job?, before otherJob: Job, dependencies: Dependencies) -> (Int64, Job)? { + func insert(_ db: Database, job: Job?, before otherJob: Job) -> (Int64, Job)? { return accept(args: [db, job, otherJob]) as? (Int64, Job) } } diff --git a/_SharedTestUtilities/MockNetwork.swift b/_SharedTestUtilities/MockNetwork.swift new file mode 100644 index 000000000..e067d0971 --- /dev/null +++ b/_SharedTestUtilities/MockNetwork.swift @@ -0,0 +1,114 @@ +// Copyright © 2023 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import Combine +import SessionUtilitiesKit + +// MARK: - MockNetwork + +class MockNetwork: Mock, NetworkType { + var requestData: RequestData? + + func send(_ request: Network.RequestType) -> AnyPublisher<(ResponseInfoType, T), Error> { + requestData = request.data + + return accept(funcName: "send(\(request.id))", args: request.args) as! AnyPublisher<(ResponseInfoType, T), Error> + } + + static func response(info: MockResponseInfo = .mockValue, with value: T) -> AnyPublisher<(ResponseInfoType, Data?), Error> { + return Just((info, try? JSONEncoder().with(outputFormatting: .sortedKeys).encode(value))) + .setFailureType(to: Error.self) + .eraseToAnyPublisher() + } + + static func response(info: MockResponseInfo = .mockValue, type: T.Type) -> AnyPublisher<(ResponseInfoType, Data?), Error> { + return response(info: info, with: T.mockValue) + } + + static func response(info: MockResponseInfo = .mockValue, type: Array.Type) -> AnyPublisher<(ResponseInfoType, Data?), Error> { + return response(info: info, with: [T.mockValue]) + } + + static func batchResponseData( + info: MockResponseInfo = .mockValue, + with value: [(endpoint: E, data: Data)] + ) -> AnyPublisher<(ResponseInfoType, Data?), Error> { + let data: Data = "[\(value.map { String(data: $0.data, encoding: .utf8)! }.joined(separator: ","))]" + .data(using: .utf8)! + + return Just((info, data)) + .setFailureType(to: Error.self) + .eraseToAnyPublisher() + } + + static func response(info: MockResponseInfo = .mockValue, data: Data) -> AnyPublisher<(ResponseInfoType, Data?), Error> { + return Just((info, data)) + .setFailureType(to: Error.self) + .eraseToAnyPublisher() + } + + static func nullResponse(info: MockResponseInfo = .mockValue) -> AnyPublisher<(ResponseInfoType, Data?), Error> { + return Just((info, nil)) + .setFailureType(to: Error.self) + .eraseToAnyPublisher() + } +} + +// MARK: - MockResponseInfo + +struct MockResponseInfo: ResponseInfoType, Mocked { + static let mockValue: MockResponseInfo = MockResponseInfo(requestData: .fallbackData, code: 200, headers: [:]) + + let requestData: RequestData + let code: Int + let headers: [String: String] + + init(requestData: RequestData, code: Int, headers: [String: String]) { + self.requestData = requestData + self.code = code + self.headers = headers + } +} + +struct RequestData: Codable { + static let fallbackData: RequestData = RequestData(urlString: nil, httpMethod: "GET", headers: [:], body: nil) + + let urlString: String? + let httpMethod: String + let headers: [String: String] + let body: Data? +} + +extension Network.RequestType { + var data: RequestData { + return RequestData( + urlString: url, + httpMethod: (method ?? ""), + headers: (headers ?? [:]), + body: body + ) + } +} + +// MARK: - HTTP.BatchSubResponse Encoding Convenience + +extension Encodable where Self: Codable { + func batchSubResponse() -> Data { + return try! JSONEncoder().with(outputFormatting: .sortedKeys).encode( + HTTP.BatchSubResponse( + code: 200, + headers: [:], + body: self, + failedToParseBody: false + ) + ) + } +} + +extension Mocked where Self: Codable { + static func mockBatchSubResponse() -> Data { return mockValue.batchSubResponse() } +} + +extension Array where Element: Mocked, Element: Codable { + static func mockBatchSubResponse() -> Data { return [Element.mockValue].batchSubResponse() } +} diff --git a/_SharedTestUtilities/Mocked.swift b/_SharedTestUtilities/Mocked.swift new file mode 100644 index 000000000..e5e9e9cd2 --- /dev/null +++ b/_SharedTestUtilities/Mocked.swift @@ -0,0 +1,78 @@ +// Copyright © 2023 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import SessionUtilitiesKit + +// MARK: - Mocked + +protocol Mocked { static var mockValue: Self { get } } +protocol MockedGeneric { + associatedtype Generic + + static func mockValue(type: Generic.Type) -> Self +} +protocol MockedDoubleGeneric { + associatedtype GenericA + associatedtype GenericB + + static func mockValue(typeA: GenericA.Type, typeB: GenericB.Type) -> Self +} + +// MARK: - DSL + +func any() -> R { R.mockValue } +func any(type: R.Generic.Type) -> R { R.mockValue(type: type) } +func any(typeA: R.GenericA.Type, typeB: R.GenericB.Type) -> R { + R.mockValue(typeA: typeA, typeB: typeB) +} +func any() -> R { unsafeBitCast(0, to: R.self) } +func any() -> [K: V] { [:] } +func any() -> Float { 0 } +func any() -> Double { 0 } +func any() -> String { "" } +func any() -> Data { Data() } +func any() -> Bool { false } + +func anyAny() -> Any { 0 } // Unique name for compilation performance reasons +func anyArray() -> [R] { [] } // Unique name for compilation performance reasons +func anySet() -> Set { Set() } // Unique name for compilation performance reasons + +// MARK: - Extensions + +extension HTTP.BatchSubResponse: MockedGeneric where T: Mocked { + typealias Generic = T + + static func mockValue(type: Generic.Type) -> HTTP.BatchSubResponse { + return HTTP.BatchSubResponse( + code: 200, + headers: [:], + body: Generic.mockValue, + failedToParseBody: false + ) + } +} + +extension HTTP.BatchSubResponse { + static func mockArrayValue(type: M.Type) -> HTTP.BatchSubResponse> { + return HTTP.BatchSubResponse( + code: 200, + headers: [:], + body: [M.mockValue], + failedToParseBody: false + ) + } +} + +// MARK: - Encodable Convenience + +extension Mocked where Self: Encodable { + func encoded() -> Data { try! JSONEncoder().with(outputFormatting: .sortedKeys).encode(self) } +} + +extension MockedGeneric where Self: Encodable { + func encoded() -> Data { try! JSONEncoder().with(outputFormatting: .sortedKeys).encode(self) } +} + +extension Array where Element: Encodable { + func encoded() -> Data { try! JSONEncoder().with(outputFormatting: .sortedKeys).encode(self) } +} diff --git a/_SharedTestUtilities/NimbleExtensions.swift b/_SharedTestUtilities/NimbleExtensions.swift index d4f820ec9..12b5d740d 100644 --- a/_SharedTestUtilities/NimbleExtensions.swift +++ b/_SharedTestUtilities/NimbleExtensions.swift @@ -69,7 +69,7 @@ public func call( let actualMessage: String if callInfo.caughtException != nil { - actualMessage = "a thrown assertion (might not have been called or has no mocked return value)" + actualMessage = "a thrown assertion (invalid mock param, not called or no mocked return value)" } else if callInfo.function == nil { actualMessage = "no call details" @@ -78,7 +78,9 @@ public func call( actualMessage = "no calls" } else if !exclusiveCallsValid { - let otherFunctionsCalled: [String] = callInfo.allFunctionsCalled.filter { $0 != callInfo.functionName } + let otherFunctionsCalled: [String] = callInfo.allFunctionsCalled + .map { "\($0.name) (params: \($0.paramCount))" } + .filter { $0 != "\(callInfo.functionName) (params: \(callInfo.parameterCount))" } actualMessage = "calls to other functions: [\(otherFunctionsCalled.joined(separator: ", "))]" } @@ -132,10 +134,11 @@ fileprivate struct CallInfo { let didError: Bool let caughtException: BadInstructionException? let function: MockFunction? - let allFunctionsCalled: [String] + let allFunctionsCalled: [FunctionConsumer.Key] let desiredFunctionCalls: [String] var functionName: String { "\((function?.name).map { "\($0)" } ?? "a function")" } + var parameterCount: Int { (function?.parameterCount ?? 0) } var desiredParameters: String? { function?.parameterSummary } static var error: CallInfo { @@ -152,7 +155,7 @@ fileprivate struct CallInfo { didError: Bool = false, caughtException: BadInstructionException?, function: MockFunction?, - allFunctionsCalled: [String], + allFunctionsCalled: [FunctionConsumer.Key], desiredFunctionCalls: [String] ) { self.didError = didError @@ -169,13 +172,16 @@ fileprivate struct CallInfo { fileprivate func generateCallInfo(_ actualExpression: Expression, _ functionBlock: @escaping (inout T) throws -> R) -> CallInfo where M: Mock { var maybeFunction: MockFunction? - var allFunctionsCalled: [String] = [] + var allFunctionsCalled: [FunctionConsumer.Key] = [] var desiredFunctionCalls: [String] = [] let builderCreator: ((M) -> MockFunctionBuilder) = { validInstance in let builder: MockFunctionBuilder = MockFunctionBuilder(functionBlock, mockInit: type(of: validInstance).init) - builder.returnValueGenerator = { name, parameterSummary in + builder.returnValueGenerator = { name, parameterCount, parameterSummary in validInstance.functionConsumer - .firstFunction(for: name, matchingParameterSummaryIfPossible: parameterSummary)? + .firstFunction( + for: FunctionConsumer.Key(name: name, paramCount: parameterCount), + matchingParameterSummaryIfPossible: parameterSummary + )? .returnValue as? R } @@ -200,8 +206,13 @@ fileprivate func generateCallInfo(_ actualExpression: Expression, _ let builder: MockFunctionBuilder = builderCreator(validInstance) validInstance.functionConsumer.trackCalls = false maybeFunction = try? builder.build() + + let key: FunctionConsumer.Key = FunctionConsumer.Key( + name: (maybeFunction?.name ?? ""), + paramCount: (maybeFunction?.parameterCount ?? 0) + ) desiredFunctionCalls = validInstance.functionConsumer.calls - .wrappedValue[maybeFunction?.name ?? ""] + .wrappedValue[key] .defaulting(to: []) validInstance.functionConsumer.trackCalls = true } diff --git a/_SharedTestUtilities/SynchronousStorage.swift b/_SharedTestUtilities/SynchronousStorage.swift index 0e9e87655..86471f988 100644 --- a/_SharedTestUtilities/SynchronousStorage.swift +++ b/_SharedTestUtilities/SynchronousStorage.swift @@ -2,13 +2,60 @@ import Combine import GRDB -import SessionUtilitiesKit + +@testable import SessionUtilitiesKit class SynchronousStorage: Storage { + @discardableResult override func write( + fileName: String = #file, + functionName: String = #function, + lineNumber: Int = #line, + using dependencies: Dependencies = Dependencies(), + updates: @escaping (Database) throws -> T? + ) -> T? { + guard isValid, let dbWriter: DatabaseWriter = testDbWriter else { return nil } + + // If 'forceSynchronous' is true then it's likely that we will access the database in + // a reentrant way, the 'unsafeReentrant...' functions allow us to interact with the + // database without worrying about reentrant access during tests because we can be + // confident that the tests are running on the correct thread + guard !dependencies.forceSynchronous else { + return try? dbWriter.unsafeReentrantWrite(updates) + } + + return super.write( + fileName: fileName, + functionName: functionName, + lineNumber: lineNumber, + using: dependencies, + updates: updates + ) + } + + @discardableResult override func read( + using dependencies: Dependencies = Dependencies(), + _ value: (Database) throws -> T? + ) -> T? { + guard isValid, let dbWriter: DatabaseWriter = testDbWriter else { return nil } + + // If 'forceSynchronous' is true then it's likely that we will access the database in + // a reentrant way, the 'unsafeReentrant...' functions allow us to interact with the + // database without worrying about reentrant access during tests because we can be + // confident that the tests are running on the correct thread + guard !dependencies.forceSynchronous else { + return try? dbWriter.unsafeReentrantRead(value) + } + + return super.read(using: dependencies, value) + } + + // MARK: - Async Methods + override func readPublisher( + using dependencies: Dependencies = Dependencies(), value: @escaping (Database) throws -> T ) -> AnyPublisher { - guard let result: T = super.read(value) else { + guard let result: T = self.read(using: dependencies, value) else { return Fail(error: StorageError.generic) .eraseToAnyPublisher() } @@ -18,13 +65,31 @@ class SynchronousStorage: Storage { .eraseToAnyPublisher() } + override func writeAsync( + fileName: String = #file, + functionName: String = #function, + lineNumber: Int = #line, + using dependencies: Dependencies = Dependencies(), + updates: @escaping (Database) throws -> T, + completion: @escaping (Database, Result) throws -> Void + ) { + do { + let result: T = try write(using: dependencies, updates: updates) ?? { throw StorageError.failedToSave }() + write { db in try completion(db, Result.success(result)) } + } + catch { + write { db in try completion(db, Result.failure(error)) } + } + } + override func writePublisher( fileName: String = #file, functionName: String = #function, lineNumber: Int = #line, + using dependencies: Dependencies = Dependencies(), updates: @escaping (Database) throws -> T ) -> AnyPublisher { - guard let result: T = super.write(fileName: fileName, functionName: functionName, lineNumber: lineNumber, updates: updates) else { + guard let result: T = super.write(fileName: fileName, functionName: functionName, lineNumber: lineNumber, using: dependencies, updates: updates) else { return Fail(error: StorageError.generic) .eraseToAnyPublisher() } diff --git a/_SharedTestUtilities/TestExtensions.swift b/_SharedTestUtilities/TestExtensions.swift new file mode 100644 index 000000000..3086f6833 --- /dev/null +++ b/_SharedTestUtilities/TestExtensions.swift @@ -0,0 +1,9 @@ +// Copyright © 2023 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +public extension Collection { + subscript(test index: Index) -> Element? { + return (indices.contains(index) ? self[index] : nil) + } +}