diff --git a/.drone.jsonnet b/.drone.jsonnet index d1f21a6d6..b2546206e 100644 --- a/.drone.jsonnet +++ b/.drone.jsonnet @@ -13,7 +13,9 @@ local ci_dep_mirror(want_mirror) = (if want_mirror then ' -DLOCAL_MIRROR=https:/ // 'LANG' env var so we need to work around the with https://github.com/CocoaPods/CocoaPods/issues/6333 local install_cocoapods = { name: 'Install CocoaPods', - commands: ['LANG=en_US.UTF-8 pod install'] + commands: [' + LANG=en_US.UTF-8 pod install || rm -rf ./Pods && LANG=en_US.UTF-8 pod install + '] }; // Load from the cached CocoaPods directory (to speed up the build) @@ -21,8 +23,14 @@ local load_cocoapods_cache = { name: 'Load CocoaPods Cache', commands: [ ||| + LOOP_BREAK=0 while test -e /Users/drone/.cocoapods_cache.lock; do sleep 1 + LOOP_BREAK=$((LOOP_BREAK + 1)) + + if [[ $LOOP_BREAK -ge 600 ]]; then + rm -f /Users/drone/.cocoapods_cache.lock + fi done |||, 'touch /Users/drone/.cocoapods_cache.lock', @@ -31,7 +39,7 @@ local load_cocoapods_cache = { cp -r /Users/drone/.cocoapods_cache ./Pods fi |||, - 'rm /Users/drone/.cocoapods_cache.lock' + 'rm -f /Users/drone/.cocoapods_cache.lock' ] }; @@ -40,8 +48,14 @@ local update_cocoapods_cache = { name: 'Update CocoaPods Cache', commands: [ ||| + LOOP_BREAK=0 while test -e /Users/drone/.cocoapods_cache.lock; do sleep 1 + LOOP_BREAK=$((LOOP_BREAK + 1)) + + if [[ $LOOP_BREAK -ge 600 ]]; then + rm -f /Users/drone/.cocoapods_cache.lock + fi done |||, 'touch /Users/drone/.cocoapods_cache.lock', @@ -51,7 +65,7 @@ local update_cocoapods_cache = { cp -r ./Pods /Users/drone/.cocoapods_cache fi |||, - 'rm /Users/drone/.cocoapods_cache.lock' + 'rm -f /Users/drone/.cocoapods_cache.lock' ] }; @@ -71,7 +85,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 @@ -83,6 +97,7 @@ local update_cocoapods_cache = { type: 'exec', name: 'Simulator Build', platform: { os: 'darwin', arch: 'amd64' }, + trigger: { event: { exclude: [ 'pull_request' ] } }, steps: [ clone_submodules, load_cocoapods_cache, @@ -91,7 +106,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, @@ -110,6 +125,7 @@ local update_cocoapods_cache = { type: 'exec', name: 'AppStore Build', platform: { os: 'darwin', arch: 'amd64' }, + trigger: { event: { exclude: [ 'pull_request' ] } }, steps: [ clone_submodules, load_cocoapods_cache, @@ -118,7 +134,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 | ./Pods/xcbeautify/xcbeautify --is-ci' ], }, update_cocoapods_cache, diff --git a/LibSession-Util b/LibSession-Util index d8f07fa92..e3ccf29db 160000 --- a/LibSession-Util +++ b/LibSession-Util @@ -1 +1 @@ -Subproject commit d8f07fa92c12c5c2409774e03e03395d7847d1c2 +Subproject commit e3ccf29db08aaf0b9bb6bbe72ae5967cd183a78d diff --git a/Scripts/drone-static-upload.sh b/Scripts/drone-static-upload.sh index 4fd13faa5..681e58994 100755 --- a/Scripts/drone-static-upload.sh +++ b/Scripts/drone-static-upload.sh @@ -3,8 +3,6 @@ # Script used with Drone CI to upload build artifacts (because specifying all this in # .drone.jsonnet is too painful). - - set -o errexit if [ -z "$SSH_KEY" ]; then @@ -19,33 +17,36 @@ set -o xtrace # Don't start tracing until *after* we write the ssh key chmod 600 ssh_key -if [ -n "$DRONE_TAG" ]; then - # For a tag build use something like `session-ios-v1.2.3` - base="session-ios-$DRONE_TAG" -else - # Otherwise build a length name from the datetime and commit hash, such as: - # session-ios-20200522T212342Z-04d7dcc54 - base="session-ios-$(date --date=@$DRONE_BUILD_CREATED +%Y%m%dT%H%M%SZ)-${DRONE_COMMIT:0:9}" -fi - -mkdir -v "$base" - -# Copy over the build products +# Define the output paths prod_path="build/Session.xcarchive" sim_path="build/Session_sim.xcarchive/Products/Applications/Session.app" -mkdir build -echo "Test" > "build/test.txt" - -if [ ! -d $prod_path ]; then - cp -av $prod_path "$base" -else if [ ! -d $sim_path ]; then - cp -av $sim_path "$base" +# Validate the paths exist +if [ -d $prod_path ]; then + suffix="store" + target_path=$prod_path +elif [ -d $sim_path ]; then + suffix="sim" + target_path=$sim_path else - echo "Expected a file to upload, found none" >&2 + echo -e "\n\n\n\e[31;1mExpected a file to upload, found none\e[0m" >&2 exit 1 fi +if [ -n "$DRONE_TAG" ]; then + # For a tag build use something like `session-ios-v1.2.3` + base="session-ios-$DRONE_TAG-$suffix" +else + # Otherwise build a length name from the datetime and commit hash, such as: + # session-ios-20200522T212342Z-04d7dcc54 + base="session-ios-$(date --date=@$DRONE_BUILD_CREATED +%Y%m%dT%H%M%SZ)-${DRONE_COMMIT:0:9}-$suffix" +fi + +# Copy over the build products +mkdir -vp "$base" +mkdir -p build +cp -av $target_path "$base" + # tar dat shiz up yo archive="$base.tar.xz" tar cJvf "$archive" "$base" diff --git a/Session.xcodeproj/project.pbxproj b/Session.xcodeproj/project.pbxproj index 12341133d..d720d5f32 100644 --- a/Session.xcodeproj/project.pbxproj +++ b/Session.xcodeproj/project.pbxproj @@ -19,7 +19,6 @@ 3496956021A2FC8100DCFE74 /* CloudKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3496955F21A2FC8100DCFE74 /* CloudKit.framework */; }; 34A6C28021E503E700B5B12E /* OWSImagePickerController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 34A6C27F21E503E600B5B12E /* OWSImagePickerController.swift */; }; 34A8B3512190A40E00218A25 /* MediaAlbumView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 34A8B3502190A40E00218A25 /* MediaAlbumView.swift */; }; - 34B0796D1FCF46B100E248C2 /* MainAppContext.m in Sources */ = {isa = PBXBuildFile; fileRef = 34B0796B1FCF46B000E248C2 /* MainAppContext.m */; }; 34B6A903218B3F63007C4606 /* TypingIndicatorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 34B6A902218B3F62007C4606 /* TypingIndicatorView.swift */; }; 34BECE2E1F7ABCE000D7438D /* GifPickerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 34BECE2D1F7ABCE000D7438D /* GifPickerViewController.swift */; }; 34BECE301F7ABCF800D7438D /* GifPickerLayout.swift in Sources */ = {isa = PBXBuildFile; fileRef = 34BECE2F1F7ABCF800D7438D /* GifPickerLayout.swift */; }; @@ -128,7 +127,6 @@ 7B8C44C528B49DDA00FBE25F /* NewConversationVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B8C44C428B49DDA00FBE25F /* NewConversationVC.swift */; }; 7B8D5FC428332600008324D9 /* VisibleMessage+Reaction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B8D5FC328332600008324D9 /* VisibleMessage+Reaction.swift */; }; 7B93D06A27CF173D00811CB6 /* MessageRequestsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B93D06927CF173D00811CB6 /* MessageRequestsViewController.swift */; }; - 7B93D07027CF194000811CB6 /* ConfigurationMessage+Convenience.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B93D06E27CF194000811CB6 /* ConfigurationMessage+Convenience.swift */; }; 7B93D07127CF194000811CB6 /* MessageRequestResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B93D06F27CF194000811CB6 /* MessageRequestResponse.swift */; }; 7B93D07727CF1A8A00811CB6 /* MockDataGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B93D07527CF1A8900811CB6 /* MockDataGenerator.swift */; }; 7B9F71C928470667006DFE7B /* ReactionListSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B9F71C828470667006DFE7B /* ReactionListSheet.swift */; }; @@ -314,28 +312,13 @@ C33FDC29255A581F00E217F9 /* ReachabilityManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = C33FDA6F255A57FA00E217F9 /* ReachabilityManager.swift */; }; C33FDC45255A581F00E217F9 /* AppVersion.m in Sources */ = {isa = PBXBuildFile; fileRef = C33FDA8B255A57FD00E217F9 /* AppVersion.m */; }; C33FDC58255A582000E217F9 /* ReverseDispatchQueue.swift in Sources */ = {isa = PBXBuildFile; fileRef = C33FDA9E255A57FF00E217F9 /* ReverseDispatchQueue.swift */; }; - C33FDC78255A582000E217F9 /* TSConstants.m in Sources */ = {isa = PBXBuildFile; fileRef = C33FDABE255A580100E217F9 /* TSConstants.m */; }; C33FDC98255A582000E217F9 /* SwiftSingletons.swift in Sources */ = {isa = PBXBuildFile; fileRef = C33FDADE255A580400E217F9 /* SwiftSingletons.swift */; }; - C33FDC9A255A582000E217F9 /* ByteParser.m in Sources */ = {isa = PBXBuildFile; fileRef = C33FDAE0255A580400E217F9 /* ByteParser.m */; }; - C33FDCD1255A582000E217F9 /* FunctionalUtil.m in Sources */ = {isa = PBXBuildFile; fileRef = C33FDB17255A580800E217F9 /* FunctionalUtil.m */; }; - C33FDCFA255A582000E217F9 /* SignalIOSProto.swift in Sources */ = {isa = PBXBuildFile; fileRef = C33FDB40255A580C00E217F9 /* SignalIOSProto.swift */; }; C33FDD03255A582000E217F9 /* WeakTimer.swift in Sources */ = {isa = PBXBuildFile; fileRef = C33FDB49255A580C00E217F9 /* WeakTimer.swift */; }; C33FDD06255A582000E217F9 /* AppVersion.h in Headers */ = {isa = PBXBuildFile; fileRef = C33FDB4C255A580D00E217F9 /* AppVersion.h */; settings = {ATTRIBUTES = (Public, ); }; }; C33FDD23255A582000E217F9 /* FeatureFlags.swift in Sources */ = {isa = PBXBuildFile; fileRef = C33FDB69255A580F00E217F9 /* FeatureFlags.swift */; }; - C33FDD32255A582000E217F9 /* OWSOperation.m in Sources */ = {isa = PBXBuildFile; fileRef = C33FDB78255A581000E217F9 /* OWSOperation.m */; }; C33FDD3A255A582000E217F9 /* Notification+Loki.swift in Sources */ = {isa = PBXBuildFile; fileRef = C33FDB80255A581100E217F9 /* Notification+Loki.swift */; }; C33FDD49255A582000E217F9 /* ParamParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = C33FDB8F255A581200E217F9 /* ParamParser.swift */; }; - C33FDD5B255A582000E217F9 /* OWSOperation.h in Headers */ = {isa = PBXBuildFile; fileRef = C33FDBA1255A581400E217F9 /* OWSOperation.h */; settings = {ATTRIBUTES = (Public, ); }; }; - C33FDD6E255A582000E217F9 /* NSURLSessionDataTask+StatusCode.m in Sources */ = {isa = PBXBuildFile; fileRef = C33FDBB4255A581600E217F9 /* NSURLSessionDataTask+StatusCode.m */; }; - C33FDD7C255A582000E217F9 /* SSKAsserts.h in Headers */ = {isa = PBXBuildFile; fileRef = C33FDBC2255A581700E217F9 /* SSKAsserts.h */; settings = {ATTRIBUTES = (Public, ); }; }; C33FDD8D255A582000E217F9 /* OWSSignalAddress.swift in Sources */ = {isa = PBXBuildFile; fileRef = C33FDBD3255A581800E217F9 /* OWSSignalAddress.swift */; }; - C33FDD92255A582000E217F9 /* SignalIOS.pb.swift in Sources */ = {isa = PBXBuildFile; fileRef = C33FDBD8255A581900E217F9 /* SignalIOS.pb.swift */; }; - C33FDDB0255A582000E217F9 /* NSURLSessionDataTask+StatusCode.h in Headers */ = {isa = PBXBuildFile; fileRef = C33FDBF6255A581C00E217F9 /* NSURLSessionDataTask+StatusCode.h */; settings = {ATTRIBUTES = (Public, ); }; }; - C33FDDB3255A582000E217F9 /* OWSError.h in Headers */ = {isa = PBXBuildFile; fileRef = C33FDBF9255A581C00E217F9 /* OWSError.h */; settings = {ATTRIBUTES = (Public, ); }; }; - C33FDDBD255A582000E217F9 /* ByteParser.h in Headers */ = {isa = PBXBuildFile; fileRef = C33FDC03255A581D00E217F9 /* ByteParser.h */; settings = {ATTRIBUTES = (Public, ); }; }; - C33FDDC5255A582000E217F9 /* OWSError.m in Sources */ = {isa = PBXBuildFile; fileRef = C33FDC0B255A581D00E217F9 /* OWSError.m */; }; - C33FDDCC255A582000E217F9 /* TSConstants.h in Headers */ = {isa = PBXBuildFile; fileRef = C33FDC12255A581E00E217F9 /* TSConstants.h */; settings = {ATTRIBUTES = (Public, ); }; }; - C33FDDD0255A582000E217F9 /* FunctionalUtil.h in Headers */ = {isa = PBXBuildFile; fileRef = C33FDC16255A581E00E217F9 /* FunctionalUtil.h */; settings = {ATTRIBUTES = (Public, ); }; }; C3402FE52559036600EA6424 /* SessionUIKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C331FF1B2558F9D300070591 /* SessionUIKit.framework */; }; C3471ECB2555356A00297E91 /* MessageSender+Encryption.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3471ECA2555356A00297E91 /* MessageSender+Encryption.swift */; }; C3471F4C25553AB000297E91 /* MessageReceiver+Decryption.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3471F4B25553AB000297E91 /* MessageReceiver+Decryption.swift */; }; @@ -471,11 +454,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 */; }; @@ -533,6 +515,24 @@ 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 */; }; + FD1D732A2A85AA2000E3F410 /* Setting+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD1D73292A85AA2000E3F410 /* Setting+Utilities.swift */; }; + FD1D732E2A86114600E3F410 /* _015_BlockCommunityMessageRequests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD1D732D2A86114600E3F410 /* _015_BlockCommunityMessageRequests.swift */; }; + FD1F9C9F2A862BE60050F671 /* MigrationRequirement.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD1F9C9E2A862BE60050F671 /* MigrationRequirement.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 */; }; @@ -616,9 +616,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 */; }; @@ -635,11 +634,13 @@ 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 */; }; + FD5931A72A8DA5DA0040147D /* SQLInterpolation+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD5931A62A8DA5DA0040147D /* SQLInterpolation+Utilities.swift */; }; + FD5931AB2A8DCB0A0040147D /* ScopeAdapter+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD5931AA2A8DCB0A0040147D /* ScopeAdapter+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 */; }; FD5C72FD284F0EC90029977D /* MessageReceiver+ExpirationTimers.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD5C72FC284F0EC90029977D /* MessageReceiver+ExpirationTimers.swift */; }; - FD5C72FF284F0F120029977D /* MessageReceiver+ConfigurationMessages.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD5C72FE284F0F120029977D /* MessageReceiver+ConfigurationMessages.swift */; }; FD5C7301284F0F7A0029977D /* MessageReceiver+UnsendRequests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD5C7300284F0F7A0029977D /* MessageReceiver+UnsendRequests.swift */; }; FD5C7303284F0FA50029977D /* MessageReceiver+Calls.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD5C7302284F0FA50029977D /* MessageReceiver+Calls.swift */; }; FD5C7305284F0FF30029977D /* MessageReceiver+VisibleMessages.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD5C7304284F0FF30029977D /* MessageReceiver+VisibleMessages.swift */; }; @@ -649,6 +650,7 @@ FD6A7A692818BE7300035AC1 /* RetrieveDefaultOpenGroupRoomsJob.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD6A7A682818BE7300035AC1 /* RetrieveDefaultOpenGroupRoomsJob.swift */; }; FD6A7A6B2818C17C00035AC1 /* UpdateProfilePictureJob.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD6A7A6A2818C17C00035AC1 /* UpdateProfilePictureJob.swift */; }; FD6A7A6D2818C61500035AC1 /* _002_SetupStandardJobs.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD6A7A6C2818C61500035AC1 /* _002_SetupStandardJobs.swift */; }; + FD6E4C8A2A1AEE4700C7C243 /* LegacyUnsubscribeRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD6E4C892A1AEE4700C7C243 /* LegacyUnsubscribeRequest.swift */; }; FD705A92278D051200F16121 /* ReusableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD705A91278D051200F16121 /* ReusableView.swift */; }; FD7115EB28C5D78E00B47552 /* ThreadSettingsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD7115EA28C5D78E00B47552 /* ThreadSettingsViewModel.swift */; }; FD7115F228C6CB3900B47552 /* _010_AddThreadIdToFTS.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD7115F128C6CB3900B47552 /* _010_AddThreadIdToFTS.swift */; }; @@ -697,7 +699,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; }; @@ -709,6 +710,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 */; }; @@ -717,11 +719,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 */; }; @@ -739,11 +736,16 @@ FD9004142818AD0B00ABAAF6 /* _002_SetupStandardJobs.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD9004132818AD0B00ABAAF6 /* _002_SetupStandardJobs.swift */; }; FD9004152818B46300ABAAF6 /* JobRunner.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDF0B7432804EF1B004C14C5 /* JobRunner.swift */; }; FD9004162818B46700ABAAF6 /* JobRunnerError.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE77F68280F9EDA002CFC5D /* JobRunnerError.swift */; }; + FD96F3A529DBC3DC00401309 /* MessageSendJobSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD96F3A429DBC3DC00401309 /* MessageSendJobSpec.swift */; }; + FD96F3A729DBD43D00401309 /* MockJobRunner.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD96F3A629DBD43D00401309 /* MockJobRunner.swift */; }; FD97B2402A3FEB050027DD57 /* ARC4RandomNumberGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD97B23F2A3FEB050027DD57 /* ARC4RandomNumberGenerator.swift */; }; FD97B2422A3FEBF30027DD57 /* UnreadMarkerCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD97B2412A3FEBF30027DD57 /* UnreadMarkerCell.swift */; }; 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 */; }; @@ -758,6 +760,15 @@ FDBB25E12983909300F1508E /* ConfigConvoInfoVolatileSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDBB25E02983909300F1508E /* ConfigConvoInfoVolatileSpec.swift */; }; FDBB25E32988B13800F1508E /* _004_AddJobPriority.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDBB25E22988B13800F1508E /* _004_AddJobPriority.swift */; }; FDBB25E72988BBBE00F1508E /* UIContextualAction+Theming.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDBB25E62988BBBD00F1508E /* UIContextualAction+Theming.swift */; }; + FDC13D472A16E4CA007267C7 /* SubscribeRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC13D462A16E4CA007267C7 /* SubscribeRequest.swift */; }; + FDC13D492A16EC20007267C7 /* Service.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC13D482A16EC20007267C7 /* Service.swift */; }; + FDC13D4B2A16ECBA007267C7 /* SubscribeResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC13D4A2A16ECBA007267C7 /* SubscribeResponse.swift */; }; + FDC13D502A16EE50007267C7 /* PushNotificationAPIEndpoint.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC13D4F2A16EE50007267C7 /* PushNotificationAPIEndpoint.swift */; }; + FDC13D522A16F22E007267C7 /* PushNotificationAPIRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC13D512A16F22E007267C7 /* PushNotificationAPIRequest.swift */; }; + FDC13D542A16FF29007267C7 /* LegacyGroupRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC13D532A16FF29007267C7 /* LegacyGroupRequest.swift */; }; + FDC13D562A171FE4007267C7 /* UnsubscribeRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC13D552A171FE4007267C7 /* UnsubscribeRequest.swift */; }; + FDC13D582A17207D007267C7 /* UnsubscribeResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC13D572A17207D007267C7 /* UnsubscribeResponse.swift */; }; + FDC13D5A2A1721C5007267C7 /* LegacyNotifyRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC13D592A1721C5007267C7 /* LegacyNotifyRequest.swift */; }; FDC2908727D7047F005DAE71 /* RoomSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC2908627D7047F005DAE71 /* RoomSpec.swift */; }; FDC2908927D70656005DAE71 /* RoomPollInfoSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC2908827D70656005DAE71 /* RoomPollInfoSpec.swift */; }; FDC2908B27D707F3005DAE71 /* SendMessageRequestSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC2908A27D707F3005DAE71 /* SendMessageRequestSpec.swift */; }; @@ -768,17 +779,14 @@ 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 */; }; + FDC4382F27B383AF00C60D73 /* LegacyPushServerResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4382E27B383AF00C60D73 /* LegacyPushServerResponse.swift */; }; FDC4383827B3863200C60D73 /* VersionResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4383727B3863200C60D73 /* VersionResponse.swift */; }; FDC4385D27B4C18900C60D73 /* Room.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4385C27B4C18900C60D73 /* Room.swift */; }; FDC4385F27B4C4A200C60D73 /* PinnedMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4385E27B4C4A200C60D73 /* PinnedMessage.swift */; }; @@ -795,19 +803,19 @@ 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 */; }; FDC438CD27BC641200C60D73 /* Set+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC438CC27BC641200C60D73 /* Set+Utilities.swift */; }; FDC6D6F32860607300B04575 /* Environment.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDF0B7542807C4BB004C14C5 /* Environment.swift */; }; FDC6D7602862B3F600B04575 /* Dependencies.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC6D75F2862B3F600B04575 /* Dependencies.swift */; }; + FDCD2E032A41294E00964D6A /* LegacyGroupOnlyRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDCD2E022A41294E00964D6A /* LegacyGroupOnlyRequest.swift */; }; FDCDB8DE2810F73B00352A0C /* Differentiable+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDCDB8DD2810F73B00352A0C /* Differentiable+Utilities.swift */; }; FDCDB8E02811007F00352A0C /* HomeViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDCDB8DF2811007F00352A0C /* HomeViewModel.swift */; }; FDD2506E283711D600198BDA /* DifferenceKit+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDD2506D283711D600198BDA /* DifferenceKit+Utilities.swift */; }; FDD250702837199200198BDA /* GarbageCollectionJob.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDD2506F2837199200198BDA /* GarbageCollectionJob.swift */; }; FDD250722837234B00198BDA /* MediaGalleryNavigationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDD250712837234B00198BDA /* MediaGalleryNavigationController.swift */; }; + FDD82C3F2A205D0A00425F05 /* ProcessResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDD82C3E2A205D0A00425F05 /* ProcessResult.swift */; }; FDDC08F229A300E800BF9681 /* LibSessionTypeConversionUtilitiesSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDDC08F129A300E800BF9681 /* LibSessionTypeConversionUtilitiesSpec.swift */; }; FDDCBDA829E776BF00303C38 /* seed2-2023-2y.crt in Resources */ = {isa = PBXBuildFile; fileRef = FDDCBDA229E776BF00303C38 /* seed2-2023-2y.crt */; }; FDDCBDA929E776BF00303C38 /* seed1-2023-2y.crt in Resources */ = {isa = PBXBuildFile; fileRef = FDDCBDA329E776BF00303C38 /* seed1-2023-2y.crt */; }; @@ -816,6 +824,8 @@ 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 */; }; FDDF074429C3E3D000E5E8B5 /* FetchRequest+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDDF074329C3E3D000E5E8B5 /* FetchRequest+Utilities.swift */; }; + FDDF074A29DAB36900E5E8B5 /* JobRunnerSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDDF074929DAB36900E5E8B5 /* JobRunnerSpec.swift */; }; + FDE125232A837E4E002DA685 /* MainAppContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE125222A837E4E002DA685 /* MainAppContext.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 */; }; @@ -850,7 +860,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 */; }; @@ -901,6 +910,9 @@ FDF848F329413DB0007DCAE5 /* ImagePickerHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDF848F229413DB0007DCAE5 /* ImagePickerHandler.swift */; }; FDF848F529413EEC007DCAE5 /* SessionCell+Styling.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDF848F429413EEC007DCAE5 /* SessionCell+Styling.swift */; }; FDF848F729414477007DCAE5 /* CurrentUserPoller.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDF848F629414477007DCAE5 /* CurrentUserPoller.swift */; }; + FDFBB74B2A1EFF4900CA7350 /* Bencode.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDFBB74A2A1EFF4900CA7350 /* Bencode.swift */; }; + FDFBB74D2A1F3C4E00CA7350 /* NotificationMetadata.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDFBB74C2A1F3C4E00CA7350 /* NotificationMetadata.swift */; }; + FDFBB7542A2023EB00CA7350 /* BencodeSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDFBB7532A2023EB00CA7350 /* BencodeSpec.swift */; }; FDFC4D9A29F0C51500992FB6 /* String+Trimming.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3C2A5D22553860900C340D1 /* String+Trimming.swift */; }; FDFD645927F26C6800808CA1 /* Array+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3C2A5D12553860800C340D1 /* Array+Utilities.swift */; }; FDFD645D27F273F300808CA1 /* MockGeneralCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDFD645C27F273F300808CA1 /* MockGeneralCache.swift */; }; @@ -909,6 +921,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 */ @@ -1105,8 +1118,6 @@ 3496955F21A2FC8100DCFE74 /* CloudKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = CloudKit.framework; path = System/Library/Frameworks/CloudKit.framework; sourceTree = SDKROOT; }; 34A6C27F21E503E600B5B12E /* OWSImagePickerController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OWSImagePickerController.swift; sourceTree = ""; }; 34A8B3502190A40E00218A25 /* MediaAlbumView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MediaAlbumView.swift; sourceTree = ""; }; - 34B0796B1FCF46B000E248C2 /* MainAppContext.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = MainAppContext.m; sourceTree = ""; }; - 34B0796C1FCF46B000E248C2 /* MainAppContext.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = MainAppContext.h; sourceTree = ""; }; 34B0796E1FD07B1E00E248C2 /* SignalShareExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = SignalShareExtension.entitlements; sourceTree = ""; }; 34B6A902218B3F62007C4606 /* TypingIndicatorView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TypingIndicatorView.swift; sourceTree = ""; }; 34BECE2D1F7ABCE000D7438D /* GifPickerViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GifPickerViewController.swift; sourceTree = ""; }; @@ -1228,7 +1239,6 @@ 7B8C44C428B49DDA00FBE25F /* NewConversationVC.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewConversationVC.swift; sourceTree = ""; }; 7B8D5FC328332600008324D9 /* VisibleMessage+Reaction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "VisibleMessage+Reaction.swift"; sourceTree = ""; }; 7B93D06927CF173D00811CB6 /* MessageRequestsViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MessageRequestsViewController.swift; sourceTree = ""; }; - 7B93D06E27CF194000811CB6 /* ConfigurationMessage+Convenience.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "ConfigurationMessage+Convenience.swift"; sourceTree = ""; }; 7B93D06F27CF194000811CB6 /* MessageRequestResponse.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MessageRequestResponse.swift; sourceTree = ""; }; 7B93D07527CF1A8900811CB6 /* MockDataGenerator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MockDataGenerator.swift; sourceTree = ""; }; 7B9F71C828470667006DFE7B /* ReactionListSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReactionListSheet.swift; sourceTree = ""; }; @@ -1409,9 +1419,7 @@ C33FDA8E255A57FD00E217F9 /* OWSFileSystem.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = OWSFileSystem.m; sourceTree = ""; }; C33FDA9E255A57FF00E217F9 /* ReverseDispatchQueue.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ReverseDispatchQueue.swift; sourceTree = ""; }; C33FDAA8255A57FF00E217F9 /* BuildConfiguration.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BuildConfiguration.swift; sourceTree = ""; }; - C33FDABE255A580100E217F9 /* TSConstants.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = TSConstants.m; sourceTree = ""; }; C33FDADE255A580400E217F9 /* SwiftSingletons.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SwiftSingletons.swift; sourceTree = ""; }; - C33FDAE0255A580400E217F9 /* ByteParser.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = ByteParser.m; sourceTree = ""; }; C33FDAEF255A580500E217F9 /* NSData+Image.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = "NSData+Image.m"; sourceTree = ""; }; C33FDAF1255A580500E217F9 /* ThumbnailService.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ThumbnailService.swift; sourceTree = ""; }; C33FDAF2255A580500E217F9 /* ProxiedContentDownloader.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ProxiedContentDownloader.swift; sourceTree = ""; }; @@ -1419,7 +1427,6 @@ C33FDAFD255A580600E217F9 /* LRUCache.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LRUCache.swift; sourceTree = ""; }; C33FDB01255A580700E217F9 /* AppReadiness.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = AppReadiness.h; sourceTree = ""; }; C33FDB14255A580800E217F9 /* OWSMath.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = OWSMath.h; sourceTree = ""; }; - C33FDB17255A580800E217F9 /* FunctionalUtil.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = FunctionalUtil.m; sourceTree = ""; }; C33FDB1C255A580900E217F9 /* UIImage+OWS.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = "UIImage+OWS.h"; sourceTree = ""; }; C33FDB22255A580900E217F9 /* OWSMediaUtils.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OWSMediaUtils.swift; sourceTree = ""; }; C33FDB29255A580A00E217F9 /* NSData+Image.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = "NSData+Image.h"; sourceTree = ""; }; @@ -1427,7 +1434,6 @@ C33FDB38255A580B00E217F9 /* OWSBackgroundTask.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = OWSBackgroundTask.h; sourceTree = ""; }; C33FDB3A255A580B00E217F9 /* Poller.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Poller.swift; sourceTree = ""; }; C33FDB3F255A580C00E217F9 /* String+SSK.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "String+SSK.swift"; sourceTree = ""; }; - C33FDB40255A580C00E217F9 /* SignalIOSProto.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SignalIOSProto.swift; sourceTree = ""; }; C33FDB41255A580C00E217F9 /* MIMETypeUtil.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = MIMETypeUtil.m; sourceTree = ""; }; C33FDB49255A580C00E217F9 /* WeakTimer.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = WeakTimer.swift; sourceTree = ""; }; C33FDB4C255A580D00E217F9 /* AppVersion.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = AppVersion.h; sourceTree = ""; }; @@ -1438,28 +1444,17 @@ C33FDB6B255A580F00E217F9 /* SNUserDefaults.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SNUserDefaults.swift; sourceTree = ""; }; C33FDB75255A581000E217F9 /* AppReadiness.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = AppReadiness.m; sourceTree = ""; }; C33FDB77255A581000E217F9 /* NSUserDefaults+OWS.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = "NSUserDefaults+OWS.m"; sourceTree = ""; }; - C33FDB78255A581000E217F9 /* OWSOperation.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = OWSOperation.m; sourceTree = ""; }; C33FDB80255A581100E217F9 /* Notification+Loki.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Notification+Loki.swift"; sourceTree = ""; }; C33FDB81255A581100E217F9 /* UIImage+OWS.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = "UIImage+OWS.m"; sourceTree = ""; }; C33FDB85255A581100E217F9 /* AppContext.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = AppContext.m; sourceTree = ""; }; C33FDB8A255A581200E217F9 /* AppContext.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = AppContext.h; sourceTree = ""; }; C33FDB8F255A581200E217F9 /* ParamParser.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ParamParser.swift; sourceTree = ""; }; - C33FDBA1255A581400E217F9 /* OWSOperation.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = OWSOperation.h; sourceTree = ""; }; C33FDBA8255A581500E217F9 /* LinkPreviewDraft.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LinkPreviewDraft.swift; sourceTree = ""; }; C33FDBAB255A581500E217F9 /* OWSFileSystem.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = OWSFileSystem.h; sourceTree = ""; }; - C33FDBB4255A581600E217F9 /* NSURLSessionDataTask+StatusCode.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = "NSURLSessionDataTask+StatusCode.m"; sourceTree = ""; }; C33FDBB6255A581600E217F9 /* DataSource.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = DataSource.m; sourceTree = ""; }; C33FDBBC255A581600E217F9 /* SSKKeychainStorage.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SSKKeychainStorage.swift; sourceTree = ""; }; - C33FDBC2255A581700E217F9 /* SSKAsserts.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = SSKAsserts.h; sourceTree = ""; }; C33FDBD3255A581800E217F9 /* OWSSignalAddress.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OWSSignalAddress.swift; sourceTree = ""; }; - C33FDBD8255A581900E217F9 /* SignalIOS.pb.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SignalIOS.pb.swift; sourceTree = ""; }; C33FDBDE255A581900E217F9 /* PushNotificationAPI.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PushNotificationAPI.swift; sourceTree = ""; }; - C33FDBF6255A581C00E217F9 /* NSURLSessionDataTask+StatusCode.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = "NSURLSessionDataTask+StatusCode.h"; sourceTree = ""; }; - C33FDBF9255A581C00E217F9 /* OWSError.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = OWSError.h; sourceTree = ""; }; - C33FDC03255A581D00E217F9 /* ByteParser.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = ByteParser.h; sourceTree = ""; }; - C33FDC0B255A581D00E217F9 /* OWSError.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = OWSError.m; sourceTree = ""; }; - C33FDC12255A581E00E217F9 /* TSConstants.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = TSConstants.h; sourceTree = ""; }; - C33FDC16255A581E00E217F9 /* FunctionalUtil.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = FunctionalUtil.h; sourceTree = ""; }; C33FDC1B255A581F00E217F9 /* OWSBackgroundTask.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = OWSBackgroundTask.m; sourceTree = ""; }; C3471ECA2555356A00297E91 /* MessageSender+Encryption.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MessageSender+Encryption.swift"; sourceTree = ""; }; C3471F4B25553AB000297E91 /* MessageReceiver+Decryption.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MessageReceiver+Decryption.swift"; sourceTree = ""; }; @@ -1625,10 +1620,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 = ""; }; @@ -1684,6 +1676,18 @@ 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 = ""; }; + FD1D73292A85AA2000E3F410 /* Setting+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Setting+Utilities.swift"; sourceTree = ""; }; + FD1D732D2A86114600E3F410 /* _015_BlockCommunityMessageRequests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = _015_BlockCommunityMessageRequests.swift; sourceTree = ""; }; + FD1F9C9E2A862BE60050F671 /* MigrationRequirement.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MigrationRequirement.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 = ""; }; @@ -1735,9 +1739,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 = ""; }; @@ -1753,11 +1756,13 @@ 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 = ""; }; + FD5931A62A8DA5DA0040147D /* SQLInterpolation+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SQLInterpolation+Utilities.swift"; sourceTree = ""; }; + FD5931AA2A8DCB0A0040147D /* ScopeAdapter+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ScopeAdapter+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 = ""; }; FD5C72FC284F0EC90029977D /* MessageReceiver+ExpirationTimers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MessageReceiver+ExpirationTimers.swift"; sourceTree = ""; }; - FD5C72FE284F0F120029977D /* MessageReceiver+ConfigurationMessages.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MessageReceiver+ConfigurationMessages.swift"; sourceTree = ""; }; FD5C7300284F0F7A0029977D /* MessageReceiver+UnsendRequests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MessageReceiver+UnsendRequests.swift"; sourceTree = ""; }; FD5C7302284F0FA50029977D /* MessageReceiver+Calls.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MessageReceiver+Calls.swift"; sourceTree = ""; }; FD5C7304284F0FF30029977D /* MessageReceiver+VisibleMessages.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MessageReceiver+VisibleMessages.swift"; sourceTree = ""; }; @@ -1768,6 +1773,7 @@ FD6A7A682818BE7300035AC1 /* RetrieveDefaultOpenGroupRoomsJob.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RetrieveDefaultOpenGroupRoomsJob.swift; sourceTree = ""; }; FD6A7A6A2818C17C00035AC1 /* UpdateProfilePictureJob.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdateProfilePictureJob.swift; sourceTree = ""; }; FD6A7A6C2818C61500035AC1 /* _002_SetupStandardJobs.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = _002_SetupStandardJobs.swift; sourceTree = ""; }; + FD6E4C892A1AEE4700C7C243 /* LegacyUnsubscribeRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LegacyUnsubscribeRequest.swift; sourceTree = ""; }; FD705A91278D051200F16121 /* ReusableView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReusableView.swift; sourceTree = ""; }; FD7115EA28C5D78E00B47552 /* ThreadSettingsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThreadSettingsViewModel.swift; sourceTree = ""; }; FD7115EF28C5D7DE00B47552 /* SessionHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionHeaderView.swift; sourceTree = ""; }; @@ -1810,7 +1816,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; }; @@ -1821,6 +1826,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 = ""; }; @@ -1832,11 +1838,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 = ""; }; @@ -1851,10 +1852,13 @@ FD8ECF912938552800C0D1BB /* Threading.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Threading.swift; sourceTree = ""; }; FD8ECF93293856AF00C0D1BB /* Randomness.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Randomness.swift; sourceTree = ""; }; FD9004132818AD0B00ABAAF6 /* _002_SetupStandardJobs.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = _002_SetupStandardJobs.swift; sourceTree = ""; }; + FD96F3A429DBC3DC00401309 /* MessageSendJobSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageSendJobSpec.swift; sourceTree = ""; }; + FD96F3A629DBD43D00401309 /* MockJobRunner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockJobRunner.swift; sourceTree = ""; }; FD97B23F2A3FEB050027DD57 /* ARC4RandomNumberGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ARC4RandomNumberGenerator.swift; sourceTree = ""; }; 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 = ""; }; @@ -1869,6 +1873,15 @@ FDBB25E02983909300F1508E /* ConfigConvoInfoVolatileSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConfigConvoInfoVolatileSpec.swift; sourceTree = ""; }; FDBB25E22988B13800F1508E /* _004_AddJobPriority.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = _004_AddJobPriority.swift; sourceTree = ""; }; FDBB25E62988BBBD00F1508E /* UIContextualAction+Theming.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIContextualAction+Theming.swift"; sourceTree = ""; }; + FDC13D462A16E4CA007267C7 /* SubscribeRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SubscribeRequest.swift; sourceTree = ""; }; + FDC13D482A16EC20007267C7 /* Service.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Service.swift; sourceTree = ""; }; + FDC13D4A2A16ECBA007267C7 /* SubscribeResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SubscribeResponse.swift; sourceTree = ""; }; + FDC13D4F2A16EE50007267C7 /* PushNotificationAPIEndpoint.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PushNotificationAPIEndpoint.swift; sourceTree = ""; }; + FDC13D512A16F22E007267C7 /* PushNotificationAPIRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PushNotificationAPIRequest.swift; sourceTree = ""; }; + FDC13D532A16FF29007267C7 /* LegacyGroupRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LegacyGroupRequest.swift; sourceTree = ""; }; + FDC13D552A171FE4007267C7 /* UnsubscribeRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UnsubscribeRequest.swift; sourceTree = ""; }; + FDC13D572A17207D007267C7 /* UnsubscribeResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UnsubscribeResponse.swift; sourceTree = ""; }; + FDC13D592A1721C5007267C7 /* LegacyNotifyRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LegacyNotifyRequest.swift; sourceTree = ""; }; FDC2908627D7047F005DAE71 /* RoomSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomSpec.swift; sourceTree = ""; }; FDC2908827D70656005DAE71 /* RoomPollInfoSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomPollInfoSpec.swift; sourceTree = ""; }; FDC2908A27D707F3005DAE71 /* SendMessageRequestSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SendMessageRequestSpec.swift; sourceTree = ""; }; @@ -1879,16 +1892,13 @@ 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 = ""; }; + FDC4382E27B383AF00C60D73 /* LegacyPushServerResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LegacyPushServerResponse.swift; sourceTree = ""; }; FDC4383727B3863200C60D73 /* VersionResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VersionResponse.swift; sourceTree = ""; }; FDC4383D27B4708600C60D73 /* Atomic.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Atomic.swift; sourceTree = ""; }; FDC4385C27B4C18900C60D73 /* Room.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Room.swift; sourceTree = ""; }; @@ -1907,18 +1917,18 @@ 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 = ""; }; FDC438CC27BC641200C60D73 /* Set+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Set+Utilities.swift"; sourceTree = ""; }; FDC6D75F2862B3F600B04575 /* Dependencies.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Dependencies.swift; sourceTree = ""; }; + FDCD2E022A41294E00964D6A /* LegacyGroupOnlyRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LegacyGroupOnlyRequest.swift; sourceTree = ""; }; FDCDB8DD2810F73B00352A0C /* Differentiable+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Differentiable+Utilities.swift"; sourceTree = ""; }; FDCDB8DF2811007F00352A0C /* HomeViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeViewModel.swift; sourceTree = ""; }; FDD2506D283711D600198BDA /* DifferenceKit+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DifferenceKit+Utilities.swift"; sourceTree = ""; }; FDD2506F2837199200198BDA /* GarbageCollectionJob.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GarbageCollectionJob.swift; sourceTree = ""; }; FDD250712837234B00198BDA /* MediaGalleryNavigationController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaGalleryNavigationController.swift; sourceTree = ""; }; + FDD82C3E2A205D0A00425F05 /* ProcessResult.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProcessResult.swift; sourceTree = ""; }; FDDC08F129A300E800BF9681 /* LibSessionTypeConversionUtilitiesSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibSessionTypeConversionUtilitiesSpec.swift; sourceTree = ""; }; FDDCBDA229E776BF00303C38 /* seed2-2023-2y.crt */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text; path = "seed2-2023-2y.crt"; sourceTree = ""; }; FDDCBDA329E776BF00303C38 /* seed1-2023-2y.crt */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text; path = "seed1-2023-2y.crt"; sourceTree = ""; }; @@ -1927,6 +1937,8 @@ 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 = ""; }; 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 = ""; }; + FDE125222A837E4E002DA685 /* MainAppContext.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainAppContext.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 = ""; }; @@ -1964,7 +1976,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 = ""; }; @@ -2015,6 +2026,9 @@ FDF848F229413DB0007DCAE5 /* ImagePickerHandler.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ImagePickerHandler.swift; sourceTree = ""; }; FDF848F429413EEC007DCAE5 /* SessionCell+Styling.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "SessionCell+Styling.swift"; sourceTree = ""; }; FDF848F629414477007DCAE5 /* CurrentUserPoller.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CurrentUserPoller.swift; sourceTree = ""; }; + FDFBB74A2A1EFF4900CA7350 /* Bencode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Bencode.swift; sourceTree = ""; }; + FDFBB74C2A1F3C4E00CA7350 /* NotificationMetadata.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationMetadata.swift; sourceTree = ""; }; + FDFBB7532A2023EB00CA7350 /* BencodeSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BencodeSpec.swift; sourceTree = ""; }; FDFD645A27F26D4600808CA1 /* Data+Utilities.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Data+Utilities.swift"; sourceTree = ""; }; FDFD645C27F273F300808CA1 /* MockGeneralCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockGeneralCache.swift; sourceTree = ""; }; FDFDE123282D04F20098B17F /* MediaDismissAnimationController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaDismissAnimationController.swift; sourceTree = ""; }; @@ -2022,6 +2036,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 */ @@ -2552,6 +2567,7 @@ FDC438B227BB15B400C60D73 /* ResponseInfo.swift */, C33FDAF2255A580500E217F9 /* ProxiedContentDownloader.swift */, C3C2A5BC255385EE00C340D1 /* HTTP.swift */, + FD23CE1A2A651E6D0000B97C /* NetworkType.swift */, FDF848EE294067E4007DCAE5 /* URLResponse+Utilities.swift */, ); path = Networking; @@ -2579,14 +2595,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 */, @@ -2720,7 +2737,6 @@ C34A977325A3E34A00852C71 /* ClosedGroupControlMessage.swift */, FD8ECF8A2935DB4B00C0D1BB /* SharedConfigMessage.swift */, C3DA9C0625AE7396008F7C7E /* ConfigurationMessage.swift */, - 7B93D06E27CF194000811CB6 /* ConfigurationMessage+Convenience.swift */, B8F5F60225EDE16F003BF8D4 /* DataExtractionNotification.swift */, C300A5E62554B07300555489 /* ExpirationTimerUpdate.swift */, 7B93D06F27CF194000811CB6 /* MessageRequestResponse.swift */, @@ -3116,6 +3132,7 @@ C379DC6825672B5E0002D4EB /* Notifications */ = { isa = PBXGroup; children = ( + FDC13D4E2A16EE41007267C7 /* Types */, FDC4382D27B383A600C60D73 /* Models */, FDF0B7502807BA56004C14C5 /* NotificationsProtocol.swift */, C33FDBDE255A581900E217F9 /* PushNotificationAPI.swift */, @@ -3176,6 +3193,7 @@ C3A721332558BDDF0043A11F /* Open Groups */ = { isa = PBXGroup; children = ( + FD23CE202A661CE80000B97C /* Crypto */, FDC4381827B34EAD00C60D73 /* Models */, FDC4380727B31D3A00C60D73 /* Types */, FD3C907427E83AC200CD579F /* OpenGroupServerIdLookup.swift */, @@ -3200,9 +3218,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 */, @@ -3216,7 +3235,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 */, @@ -3235,7 +3253,6 @@ FDF8488C29405C04007DCAE5 /* Jobs */, FDF8489229405C1B007DCAE5 /* Networking */, C3C2A5CD255385F300C340D1 /* Utilities */, - FDF8488A29405BF2007DCAE5 /* SSKDependencies.swift */, C3C2A5B9255385ED00C340D1 /* Configuration.swift */, ); path = SessionSnodeKit; @@ -3302,7 +3319,6 @@ FD8ECF7529340F4800C0D1BB /* SessionUtil */, FD3E0C82283B581F002A425C /* Shared Models */, C3BBE0B32554F0D30050F1E3 /* Utilities */, - FDC438C027BB4E6800C60D73 /* SMKDependencies.swift */, FD245C612850664300B966DD /* Configuration.swift */, ); path = SessionMessagingKit; @@ -3359,35 +3375,20 @@ FD71161D28D9772700B47552 /* UIViewController+OWS.swift */, C38EF2B1255B6D9C007E1867 /* UIViewController+Utilities.swift */, C38EF307255B6DBE007E1867 /* UIGestureRecognizer+OWS.swift */, - C33FDBF9255A581C00E217F9 /* OWSError.h */, - C33FDC0B255A581D00E217F9 /* OWSError.m */, - C33FDBA1255A581400E217F9 /* OWSOperation.h */, - C33FDB78255A581000E217F9 /* OWSOperation.m */, C33FDBD3255A581800E217F9 /* OWSSignalAddress.swift */, C33FDA6F255A57FA00E217F9 /* ReachabilityManager.swift */, - C33FDBD8255A581900E217F9 /* SignalIOS.pb.swift */, - C33FDB40255A580C00E217F9 /* SignalIOSProto.swift */, - C33FDC12255A581E00E217F9 /* TSConstants.h */, - C33FDABE255A580100E217F9 /* TSConstants.m */, C33FDB4C255A580D00E217F9 /* AppVersion.h */, + C33FDA8B255A57FD00E217F9 /* AppVersion.m */, C38EF3E4255B6DF4007E1867 /* CommonStrings.swift */, C38EF304255B6DBE007E1867 /* ImageCache.swift */, C38EF2F2255B6DBC007E1867 /* Searcher.swift */, C3F0A52F255C80BC007BE2A3 /* NoopNotificationsManager.swift */, - C33FDA8B255A57FD00E217F9 /* AppVersion.m */, C33FDB69255A580F00E217F9 /* FeatureFlags.swift */, C33FDB80255A581100E217F9 /* Notification+Loki.swift */, - C33FDC16255A581E00E217F9 /* FunctionalUtil.h */, - C33FDB17255A580800E217F9 /* FunctionalUtil.m */, C33FDB8F255A581200E217F9 /* ParamParser.swift */, C33FDADE255A580400E217F9 /* SwiftSingletons.swift */, C33FDB49255A580C00E217F9 /* WeakTimer.swift */, - C33FDBC2255A581700E217F9 /* SSKAsserts.h */, C33FDA9E255A57FF00E217F9 /* ReverseDispatchQueue.swift */, - C33FDBF6255A581C00E217F9 /* NSURLSessionDataTask+StatusCode.h */, - C33FDBB4255A581600E217F9 /* NSURLSessionDataTask+StatusCode.m */, - C33FDC03255A581D00E217F9 /* ByteParser.h */, - C33FDAE0255A580400E217F9 /* ByteParser.m */, C38EF3DD255B6DF1007E1867 /* UIAlertController+OWS.swift */, C38EF241255B6D67007E1867 /* Collection+OWS.swift */, C38EF3AE255B6DE5007E1867 /* OrderedDictionary.swift */, @@ -3417,8 +3418,7 @@ 34330A581E7875FB00DF2FB9 /* Fonts */, B66DBF4919D5BBC8006EA940 /* Images.xcassets */, 45CB2FA71CB7146C00E1B343 /* Launch Screen.storyboard */, - 34B0796C1FCF46B000E248C2 /* MainAppContext.h */, - 34B0796B1FCF46B000E248C2 /* MainAppContext.m */, + FDE125222A837E4E002DA685 /* MainAppContext.swift */, C3CA3AA0255CDA7000F4C6D4 /* Mnemonic */, B67EBF5C19194AC60084CCFD /* Settings.bundle */, B657DDC91911A40500F45B0C /* Signal.entitlements */, @@ -3545,11 +3545,15 @@ FD09796527F6B0A800936362 /* Utilities */ = { isa = PBXGroup; children = ( + FDFBB74A2A1EFF4900CA7350 /* Bencode.swift */, FD97B23F2A3FEB050027DD57 /* ARC4RandomNumberGenerator.swift */, 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 */, @@ -3607,6 +3611,7 @@ FD368A6729DE8F9B000DBF1E /* _012_AddFTSIfNeeded.swift */, FD8ECF7C2934293A00C0D1BB /* _013_SessionUtilChanges.swift */, FD778B6329B189FF001BAC6B /* _014_GenerateInitialUserConfigDumps.swift */, + FD1D732D2A86114600E3F410 /* _015_BlockCommunityMessageRequests.swift */, ); path = Migrations; sourceTree = ""; @@ -3672,6 +3677,7 @@ children = ( FD17D7BE27F51F8200122BE0 /* ColumnExpressible.swift */, FD17D7B727F51ECA00122BE0 /* Migration.swift */, + FD1F9C9E2A862BE60050F671 /* MigrationRequirement.swift */, FD17D7B927F51F2100122BE0 /* TargetMigrations.swift */, FD17D7C027F5200100122BE0 /* TypedTableDefinition.swift */, FD37EA1028AB34B3003AE748 /* TypedTableAlteration.swift */, @@ -3691,6 +3697,8 @@ FDDF074329C3E3D000E5E8B5 /* FetchRequest+Utilities.swift */, FDF2220E281B55E6000A4995 /* QueryInterfaceRequest+Utilities.swift */, FD1A94FA2900D1C2000D73D3 /* PersistableRecord+Utilities.swift */, + FD5931AA2A8DCB0A0040147D /* ScopeAdapter+Utilities.swift */, + FD5931A62A8DA5DA0040147D /* SQLInterpolation+Utilities.swift */, ); path = Utilities; sourceTree = ""; @@ -3733,6 +3741,14 @@ path = Utilities; sourceTree = ""; }; + FD23CE202A661CE80000B97C /* Crypto */ = { + isa = PBXGroup; + children = ( + FD23CE212A661D000000B97C /* OpenGroupAPI+Crypto.swift */, + ); + path = Crypto; + sourceTree = ""; + }; FD29598E2A43BE5400888A17 /* Utilities */ = { isa = PBXGroup; children = ( @@ -3744,6 +3760,7 @@ FD2B4B022949886900AB4848 /* Database */ = { isa = PBXGroup; children = ( + FD1D73292A85AA2000E3F410 /* Setting+Utilities.swift */, FD2B4B032949887A00AB4848 /* QueryInterfaceRequest+Utilities.swift */, ); path = Database; @@ -3835,7 +3852,7 @@ FD3C906827E417B100CD579F /* Utilities */ = { isa = PBXGroup; children = ( - FD3C906927E417CE00CD579F /* SodiumUtilitiesSpec.swift */, + FD3C906927E417CE00CD579F /* CryptoSMKSpec.swift */, ); path = Utilities; sourceTree = ""; @@ -3873,6 +3890,7 @@ children = ( FD7115F628C8150D00B47552 /* Disposable Views */, FD7115FD28C8202D00B47552 /* ReplaySubject.swift */, + FD83DCDC2A739D350065FFAE /* RetryWithDependencies.swift */, FD7115FB28C8155800B47552 /* Publisher+Utilities.swift */, FD71160128C8255900B47552 /* UIControl+Combine.swift */, FD7115F928C8153400B47552 /* UIBarButtonItem+Combine.swift */, @@ -3982,7 +4000,6 @@ FD5C72F8284F0E880029977D /* MessageReceiver+TypingIndicators.swift */, FD5C72FA284F0EA10029977D /* MessageReceiver+DataExtractionNotification.swift */, FD5C72FC284F0EC90029977D /* MessageReceiver+ExpirationTimers.swift */, - FD5C72FE284F0F120029977D /* MessageReceiver+ConfigurationMessages.swift */, FD5C7300284F0F7A0029977D /* MessageReceiver+UnsendRequests.swift */, FD5C7302284F0FA50029977D /* MessageReceiver+Calls.swift */, FD5C7304284F0FF30029977D /* MessageReceiver+VisibleMessages.swift */, @@ -3997,7 +4014,9 @@ children = ( FD37EA1228AB3F60003AE748 /* Database */, FD83B9B927CF20A5005E1583 /* General */, + FDDF074829DAB35200E5E8B5 /* JobRunner */, FD9B30F1293EA0AF008DEE3E /* Networking */, + FDFBB7522A2023DE00CA7350 /* Utilities */, FD29598E2A43BE5400888A17 /* Utilities */, ); path = SessionUtilitiesKitTests; @@ -4007,6 +4026,7 @@ isa = PBXGroup; children = ( FD0B77B129B82B7A009169BA /* ArrayUtilitiesSpec.swift */, + FD23CE252A676B5B0000B97C /* DependenciesSpec.swift */, FD83B9BA27CF20AF005E1583 /* SessionIdSpec.swift */, ); path = General; @@ -4016,8 +4036,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 */, @@ -4094,6 +4120,22 @@ path = JobRunner; sourceTree = ""; }; + FD96F3A229DBC3BA00401309 /* Jobs */ = { + isa = PBXGroup; + children = ( + FD96F3A329DBC3D000401309 /* Types */, + ); + path = Jobs; + sourceTree = ""; + }; + FD96F3A329DBC3D000401309 /* Types */ = { + isa = PBXGroup; + children = ( + FD96F3A429DBC3DC00401309 /* MessageSendJobSpec.swift */, + ); + path = Types; + sourceTree = ""; + }; FD9B30F1293EA0AF008DEE3E /* Networking */ = { isa = PBXGroup; children = ( @@ -4113,6 +4155,16 @@ path = Configs; sourceTree = ""; }; + FDC13D4E2A16EE41007267C7 /* Types */ = { + isa = PBXGroup; + children = ( + FDC13D482A16EC20007267C7 /* Service.swift */, + FDD82C3E2A205D0A00425F05 /* ProcessResult.swift */, + FDC13D4F2A16EE50007267C7 /* PushNotificationAPIEndpoint.swift */, + ); + path = Types; + sourceTree = ""; + }; FDC2909227D710A9005DAE71 /* Types */ = { isa = PBXGroup; children = ( @@ -4120,7 +4172,6 @@ FDC2909727D7129B005DAE71 /* PersonalizationSpec.swift */, FDC2909327D710B4005DAE71 /* SOGSEndpointSpec.swift */, FDC2909527D71252005DAE71 /* SOGSErrorSpec.swift */, - FDC2909B27D713D2005DAE71 /* SodiumProtocolsSpec.swift */, ); path = Types; sourceTree = ""; @@ -4134,8 +4185,6 @@ FDC4380827B31D4E00C60D73 /* OpenGroupAPIError.swift */, FDC4381627B32EC700C60D73 /* Personalization.swift */, FD2959912A4417A900888A17 /* PreparedSendData.swift */, - FDC4381427B329CE00C60D73 /* NonceGenerator.swift */, - FDC438C227BB512200C60D73 /* SodiumProtocols.swift */, ); path = Types; sourceTree = ""; @@ -4166,7 +4215,17 @@ FDC4382D27B383A600C60D73 /* Models */ = { isa = PBXGroup; children = ( - FDC4382E27B383AF00C60D73 /* PushServerResponse.swift */, + FDC13D512A16F22E007267C7 /* PushNotificationAPIRequest.swift */, + FDC13D462A16E4CA007267C7 /* SubscribeRequest.swift */, + FDC13D4A2A16ECBA007267C7 /* SubscribeResponse.swift */, + FDC13D552A171FE4007267C7 /* UnsubscribeRequest.swift */, + FDC13D572A17207D007267C7 /* UnsubscribeResponse.swift */, + FD6E4C892A1AEE4700C7C243 /* LegacyUnsubscribeRequest.swift */, + FDC13D532A16FF29007267C7 /* LegacyGroupRequest.swift */, + FDCD2E022A41294E00964D6A /* LegacyGroupOnlyRequest.swift */, + FDC4382E27B383AF00C60D73 /* LegacyPushServerResponse.swift */, + FDC13D592A1721C5007267C7 /* LegacyNotifyRequest.swift */, + FDFBB74C2A1F3C4E00CA7350 /* NotificationMetadata.swift */, ); path = Models; sourceTree = ""; @@ -4202,6 +4261,7 @@ FDC4389B27BA01E300C60D73 /* _TestUtilities */, FD3C905D27E410DB00CD579F /* Common Networking */, FD3C906527E416A200CD579F /* Contacts */, + FD96F3A229DBC3BA00401309 /* Jobs */, FDC4389827BA001800C60D73 /* Open Groups */, FD3C906B27E43C2400CD579F /* Sending & Receiving */, FD7692F52A53A2C7000E4B70 /* Shared Models */, @@ -4226,19 +4286,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 = ""; @@ -4251,6 +4300,14 @@ path = Utilities; sourceTree = ""; }; + FDDF074829DAB35200E5E8B5 /* JobRunner */ = { + isa = PBXGroup; + children = ( + FDDF074929DAB36900E5E8B5 /* JobRunnerSpec.swift */, + ); + path = JobRunner; + sourceTree = ""; + }; FDE7214E287E50D50093DF33 /* Scripts */ = { isa = PBXGroup; children = ( @@ -4368,6 +4425,14 @@ path = Models; sourceTree = ""; }; + FDFBB7522A2023DE00CA7350 /* Utilities */ = { + isa = PBXGroup; + children = ( + FDFBB7532A2023EB00CA7350 /* BencodeSpec.swift */, + ); + path = Utilities; + sourceTree = ""; + }; FDFDE122282D04E30098B17F /* Transitions */ = { isa = PBXGroup; children = ( @@ -4394,13 +4459,6 @@ isa = PBXHeadersBuildPhase; buildActionMask = 2147483647; files = ( - C33FDDB0255A582000E217F9 /* NSURLSessionDataTask+StatusCode.h in Headers */, - C33FDDD0255A582000E217F9 /* FunctionalUtil.h in Headers */, - C33FDD5B255A582000E217F9 /* OWSOperation.h in Headers */, - C33FDD7C255A582000E217F9 /* SSKAsserts.h in Headers */, - C33FDDCC255A582000E217F9 /* TSConstants.h in Headers */, - C33FDDBD255A582000E217F9 /* ByteParser.h in Headers */, - C33FDDB3255A582000E217F9 /* OWSError.h in Headers */, C38EF35E255B6DCC007E1867 /* OWSViewController.h in Headers */, C33FD9AF255A548A00E217F9 /* SignalUtilitiesKit.h in Headers */, C33FDD06255A582000E217F9 /* AppVersion.h in Headers */, @@ -4599,9 +4657,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 = ( @@ -4701,6 +4759,7 @@ D221A080169C9E5E00537ABF /* Project object */ = { isa = PBXProject; attributes = { + BuildIndependentTargetsInParallel = YES; DefaultBuildSystemTypeForWorkspace = Original; LastSwiftUpdateCheck = 1430; LastTestingUpgradeCheck = 0600; @@ -5462,10 +5521,8 @@ C38EF30C255B6DBF007E1867 /* ScreenLock.swift in Sources */, C38EF363255B6DCC007E1867 /* ModalActivityIndicatorViewController.swift in Sources */, C38EF38A255B6DD2007E1867 /* AttachmentCaptionToolbar.swift in Sources */, - C33FDCD1255A582000E217F9 /* FunctionalUtil.m in Sources */, C38EF402255B6DF7007E1867 /* CommonStrings.swift in Sources */, C38EF3C1255B6DE7007E1867 /* ImageEditorBrushViewController.swift in Sources */, - C33FDD32255A582000E217F9 /* OWSOperation.m in Sources */, C3F0A530255C80BC007BE2A3 /* NoopNotificationsManager.swift in Sources */, C33FDD8D255A582000E217F9 /* OWSSignalAddress.swift in Sources */, C38EF388255B6DD2007E1867 /* AttachmentApprovalViewController.swift in Sources */, @@ -5473,7 +5530,6 @@ C33FDC29255A581F00E217F9 /* ReachabilityManager.swift in Sources */, C38EF407255B6DF7007E1867 /* Toast.swift in Sources */, C38EF38C255B6DD2007E1867 /* ApprovalRailCellView.swift in Sources */, - C33FDD92255A582000E217F9 /* SignalIOS.pb.swift in Sources */, C33FDC45255A581F00E217F9 /* AppVersion.m in Sources */, C38EF3C7255B6DE7007E1867 /* ImageEditorCanvasView.swift in Sources */, C38EF400255B6DF7007E1867 /* GalleryRailView.swift in Sources */, @@ -5482,7 +5538,6 @@ C38EF3BA255B6DE7007E1867 /* ImageEditorItem.swift in Sources */, C33FDD3A255A582000E217F9 /* Notification+Loki.swift in Sources */, C38EF24E255B6D67007E1867 /* Collection+OWS.swift in Sources */, - C33FDCFA255A582000E217F9 /* SignalIOSProto.swift in Sources */, C38EF24D255B6D67007E1867 /* UIView+OWS.swift in Sources */, C38EF38B255B6DD2007E1867 /* AttachmentPrepViewController.swift in Sources */, C38EF405255B6DF7007E1867 /* OWSButton.swift in Sources */, @@ -5498,9 +5553,7 @@ FD52090B28B59BB4006098F6 /* ScreenLockViewController.swift in Sources */, C38EF401255B6DF7007E1867 /* VideoPlayerView.swift in Sources */, C38EF3BD255B6DE7007E1867 /* ImageEditorTransform.swift in Sources */, - C33FDC9A255A582000E217F9 /* ByteParser.m in Sources */, C33FDC58255A582000E217F9 /* ReverseDispatchQueue.swift in Sources */, - C33FDC78255A582000E217F9 /* TSConstants.m in Sources */, C38EF324255B6DBF007E1867 /* Bench.swift in Sources */, FDCDB8DE2810F73B00352A0C /* Differentiable+Utilities.swift in Sources */, C38EF3F9255B6DF7007E1867 /* OWSLayerView.swift in Sources */, @@ -5512,12 +5565,10 @@ C38EF386255B6DD2007E1867 /* AttachmentApprovalInputAccessoryView.swift in Sources */, B8C2B2C82563685C00551B4D /* CircleView.swift in Sources */, C38EF331255B6DBF007E1867 /* UIGestureRecognizer+OWS.swift in Sources */, - C33FDDC5255A582000E217F9 /* OWSError.m in Sources */, FD848B9C284435D7000E298B /* AppSetup.swift in Sources */, C38EF31C255B6DBF007E1867 /* Searcher.swift in Sources */, C38EF2B3255B6D9C007E1867 /* UIViewController+Utilities.swift in Sources */, C38EF3BE255B6DE7007E1867 /* OrderedDictionary.swift in Sources */, - C33FDD6E255A582000E217F9 /* NSURLSessionDataTask+StatusCode.m in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -5525,7 +5576,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 */, @@ -5597,10 +5647,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 */, @@ -5618,6 +5671,8 @@ C3D9E4C02567767F0040E4F3 /* DataSource.m in Sources */, C32C5DD2256DD9E5003C73A2 /* LRUCache.swift in Sources */, FD71160228C8255900B47552 /* UIControl+Combine.swift in Sources */, + FDFBB74B2A1EFF4900CA7350 /* Bencode.swift in Sources */, + FD5931A72A8DA5DA0040147D /* SQLInterpolation+Utilities.swift in Sources */, FD9004152818B46300ABAAF6 /* JobRunner.swift in Sources */, FDF8487929405906007DCAE5 /* HTTPQueryParam.swift in Sources */, FD17D7CA27F546D900122BE0 /* _001_InitialSetupMigration.swift in Sources */, @@ -5629,10 +5684,12 @@ C3D9E4DA256778410040E4F3 /* UIImage+OWS.m in Sources */, C32C600F256E07F5003C73A2 /* NSUserDefaults+OWS.m in Sources */, FDE658A329418E2F00A33BC1 /* KeyPair.swift in Sources */, + FD5931AB2A8DCB0A0040147D /* ScopeAdapter+Utilities.swift in Sources */, FD37E9FF28A5F2CD003AE748 /* Configuration.swift in Sources */, FDB7400B28EB99A70094D718 /* TimeInterval+Utilities.swift in Sources */, C3D9E35E25675F640040E4F3 /* OWSFileSystem.m in Sources */, FD09797D27FBDB2000936362 /* Notification+Utilities.swift in Sources */, + FD1F9C9F2A862BE60050F671 /* MigrationRequirement.swift in Sources */, FDC6D7602862B3F600B04575 /* Dependencies.swift in Sources */, C3C2AC2E2553CBEB00C340D1 /* String+Trimming.swift in Sources */, FD17D7C727F5207C00122BE0 /* DatabaseMigrator+Utilities.swift in Sources */, @@ -5658,8 +5715,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 */, @@ -5669,6 +5728,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 */, @@ -5714,6 +5774,7 @@ FD245C672850665E00B966DD /* AttachmentDownloadJob.swift in Sources */, C300A5D32554B05A00555489 /* TypingIndicator.swift in Sources */, 7B521E0A29BFF84400C3C36A /* GroupLeavingJob.swift in Sources */, + FDC13D582A17207D007267C7 /* UnsubscribeResponse.swift in Sources */, FD09799927FFC1A300936362 /* Attachment.swift in Sources */, FD245C5F2850662200B966DD /* OWSWindowManager.m in Sources */, C3471ECB2555356A00297E91 /* MessageSender+Encryption.swift in Sources */, @@ -5721,7 +5782,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 */, @@ -5734,6 +5794,7 @@ FD245C5A2850660100B966DD /* LinkPreviewDraft.swift in Sources */, FDD250702837199200198BDA /* GarbageCollectionJob.swift in Sources */, FD6A7A6B2818C17C00035AC1 /* UpdateProfilePictureJob.swift in Sources */, + FDD82C3F2A205D0A00425F05 /* ProcessResult.swift in Sources */, FD716E6A2850327900C96BF4 /* EndCallMode.swift in Sources */, FDF0B75C2807F41D004C14C5 /* MessageSender+Convenience.swift in Sources */, 7B81682A28B6F1420069F315 /* ReactionResponse.swift in Sources */, @@ -5747,6 +5808,7 @@ FD245C57285065F100B966DD /* Poller.swift in Sources */, FDA8EAFE280E8B78002B68E5 /* FailedMessageSendsJob.swift in Sources */, FD245C6A2850666F00B966DD /* FileServerAPI.swift in Sources */, + FDFBB74D2A1F3C4E00CA7350 /* NotificationMetadata.swift in Sources */, FDC4386927B4E6B800C60D73 /* String+Utlities.swift in Sources */, FD716E6628502EE200C96BF4 /* CurrentCallProtocol.swift in Sources */, FD8ECF9029381FC200C0D1BB /* SessionUtil+UserProfile.swift in Sources */, @@ -5761,6 +5823,7 @@ FD37EA0F28AB3330003AE748 /* _006_FixHiddenModAdminSupport.swift in Sources */, FD2B4AFD294688D000AB4848 /* SessionUtil+Contacts.swift in Sources */, 7B81682328A4C1210069F315 /* UpdateTypes.swift in Sources */, + FDC13D472A16E4CA007267C7 /* SubscribeRequest.swift in Sources */, FD2B4AFF2946C93200AB4848 /* ConfigurationSyncJob.swift in Sources */, FDC438A627BB113A00C60D73 /* UserUnbanRequest.swift in Sources */, FD5C72FB284F0EA10029977D /* MessageReceiver+DataExtractionNotification.swift in Sources */, @@ -5769,18 +5832,22 @@ C379DCF4256735770002D4EB /* VisibleMessage+Attachment.swift in Sources */, FDB4BBC72838B91E00B7C95D /* LinkPreviewError.swift in Sources */, FD09798327FD1A1500936362 /* ClosedGroup.swift in Sources */, + FDC13D542A16FF29007267C7 /* LegacyGroupRequest.swift in Sources */, B8B320B7258C30D70020074B /* HTMLMetadata.swift in Sources */, FD8ECF8B2935DB4B00C0D1BB /* SharedConfigMessage.swift in Sources */, FD09798727FD1B7800936362 /* GroupMember.swift in Sources */, FDB4BBC92839BEF000B7C95D /* ProfileManagerError.swift in Sources */, + FDCD2E032A41294E00964D6A /* LegacyGroupOnlyRequest.swift in Sources */, FD3E0C84283B5835002A425C /* SessionThreadViewModel.swift in Sources */, FD09C5EC282B8F18000CE219 /* AttachmentError.swift in Sources */, FD17D79927F40AB800122BE0 /* _003_YDBToGRDBMigration.swift in Sources */, FDF0B7512807BA56004C14C5 /* NotificationsProtocol.swift in Sources */, B8DE1FB426C22F2F0079C9CE /* WebRTCSession.swift in Sources */, + FDC13D5A2A1721C5007267C7 /* LegacyNotifyRequest.swift in Sources */, FDC6D6F32860607300B04575 /* Environment.swift in Sources */, C3A3A171256E1D25004D228D /* SSKReachabilityManager.swift in Sources */, FD245C59285065FC00B966DD /* ControlMessage.swift in Sources */, + FD6E4C8A2A1AEE4700C7C243 /* LegacyUnsubscribeRequest.swift in Sources */, B8DE1FB626C22FCB0079C9CE /* CallMessage.swift in Sources */, FD245C50285065C700B966DD /* VisibleMessage+Quote.swift in Sources */, FD8ECF892935AB7200C0D1BB /* SessionUtilError.swift in Sources */, @@ -5806,23 +5873,24 @@ FD6A7A692818BE7300035AC1 /* RetrieveDefaultOpenGroupRoomsJob.swift in Sources */, FD09798D27FD1D8900936362 /* DisappearingMessageConfiguration.swift in Sources */, FDF0B75A2807F3A3004C14C5 /* MessageSenderError.swift in Sources */, - FDC4382F27B383AF00C60D73 /* PushServerResponse.swift in Sources */, + FDC4382F27B383AF00C60D73 /* LegacyPushServerResponse.swift in Sources */, FDC4386327B4D94E00C60D73 /* SOGSMessage.swift in Sources */, FD245C692850666800B966DD /* ExpirationTimerUpdate.swift in Sources */, FD42F9A8285064B800A0C77D /* PushNotificationAPI.swift in Sources */, FD245C6C2850669200B966DD /* MessageReceiveJob.swift in Sources */, FD43EE9D297A5190009C87C5 /* SessionUtil+UserGroups.swift in Sources */, FD83B9CC27D179BC005E1583 /* FSEndpoint.swift in Sources */, + FDC13D4B2A16ECBA007267C7 /* SubscribeResponse.swift in Sources */, FD7115F228C6CB3900B47552 /* _010_AddThreadIdToFTS.swift in Sources */, FD716E6428502DDD00C96BF4 /* CallManagerProtocol.swift in Sources */, FDC438C727BB6DF000C60D73 /* DirectMessage.swift in Sources */, + FDC13D502A16EE50007267C7 /* PushNotificationAPIEndpoint.swift in Sources */, 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 */, B8D0A25925E367AC00C1835E /* Notification+MessageReceiver.swift in Sources */, @@ -5832,7 +5900,6 @@ C32C599E256DB02B003C73A2 /* TypingIndicators.swift in Sources */, FD716E682850318E00C96BF4 /* CallMode.swift in Sources */, FD09799527FE7B8E00936362 /* Interaction.swift in Sources */, - FD5C72FF284F0F120029977D /* MessageReceiver+ConfigurationMessages.swift in Sources */, FD37EA0D28AB2A45003AE748 /* _005_FixDeletedMessageReadState.swift in Sources */, 7BAA7B6628D2DE4700AE1489 /* _009_OpenGroupPermission.swift in Sources */, FDC4380927B31D4E00C60D73 /* OpenGroupAPIError.swift in Sources */, @@ -5843,14 +5910,16 @@ FD09796E27FA6D0000936362 /* Contact.swift in Sources */, C38D5E8D2575011E00B6A65C /* MessageSender+ClosedGroups.swift in Sources */, FD5C72F7284F0E560029977D /* MessageReceiver+ReadReceipts.swift in Sources */, + FDC13D492A16EC20007267C7 /* Service.swift in Sources */, FD778B6429B189FF001BAC6B /* _014_GenerateInitialUserConfigDumps.swift in Sources */, + FDC13D562A171FE4007267C7 /* UnsubscribeRequest.swift in Sources */, C32C598A256D0664003C73A2 /* SNProtoEnvelope+Conversion.swift in Sources */, FDC438CB27BB7DB100C60D73 /* UpdateMessageRequest.swift in Sources */, FD8ECF7F2934298100C0D1BB /* ConfigDump.swift in Sources */, 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 */, @@ -5867,22 +5936,24 @@ FD245C682850666300B966DD /* Message+Destination.swift in Sources */, FDF8488029405994007DCAE5 /* HTTPQueryParam+OpenGroup.swift in Sources */, FD09798527FD1A6500936362 /* ClosedGroupKeyPair.swift in Sources */, + FDC13D522A16F22E007267C7 /* PushNotificationAPIRequest.swift in Sources */, FD245C632850664600B966DD /* Configuration.swift in Sources */, C32C5DBF256DD743003C73A2 /* ClosedGroupPoller.swift in Sources */, C352A35B2557824E00338F3E /* AttachmentUploadJob.swift in Sources */, FD2959922A4417A900888A17 /* PreparedSendData.swift in Sources */, FD5C7305284F0FF30029977D /* MessageReceiver+VisibleMessages.swift in Sources */, + FD1D732E2A86114600E3F410 /* _015_BlockCommunityMessageRequests.swift in Sources */, FD432437299DEA38008A0213 /* TypeConversion+Utilities.swift in Sources */, FD2B4B042949887A00AB4848 /* QueryInterfaceRequest+Utilities.swift in Sources */, FD09797027FA6FF300936362 /* Profile.swift in Sources */, FD245C56285065EA00B966DD /* SNProto.swift in Sources */, FD09798B27FD1CFE00936362 /* Capability.swift in Sources */, C3BBE0C72554F1570050F1E3 /* FixedWidthInteger+BigEndian.swift in Sources */, + FD1D732A2A85AA2000E3F410 /* Setting+Utilities.swift in Sources */, FD17D79C27F40B2E00122BE0 /* SMKLegacy.swift in Sources */, 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 */, @@ -5942,10 +6013,10 @@ B83F2B88240CB75A000A54AB /* UIImage+Scaling.swift in Sources */, 3430FE181F7751D4000EC51B /* GiphyAPI.swift in Sources */, 4C090A1B210FD9C7001FD7F9 /* HapticFeedback.swift in Sources */, + FDE125232A837E4E002DA685 /* MainAppContext.swift in Sources */, 7B9F71D12852EEE2006DFE7B /* EmojiWithSkinTones+String.swift in Sources */, 34F308A21ECB469700BB7697 /* OWSBezierPathView.m in Sources */, B886B4A92398BA1500211ABE /* QRCode.swift in Sources */, - 34B0796D1FCF46B100E248C2 /* MainAppContext.m in Sources */, 34A8B3512190A40E00218A25 /* MediaAlbumView.swift in Sources */, FD09C5E828264937000CE219 /* MediaDetailViewController.swift in Sources */, 3496955E219B605E00DCFE74 /* PhotoLibrary.swift in Sources */, @@ -6108,13 +6179,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 */, 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; @@ -6126,14 +6202,22 @@ FD2AAAF228ED57B500A49611 /* SynchronousStorage.swift in Sources */, FD078E4927E02576000769AF /* CommonMockedExtensions.swift in Sources */, FD83B9BF27CF2294005E1583 /* TestConstants.swift in Sources */, + FDFBB7542A2023EB00CA7350 /* BencodeSpec.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 */, ); @@ -6145,54 +6229,49 @@ files = ( FD3C905C27E3FBEF00CD579F /* BatchRequestInfoSpec.swift in Sources */, FDDC08F229A300E800BF9681 /* LibSessionTypeConversionUtilitiesSpec.swift in Sources */, - FD859EFA27C2F5C500510D0C /* MockGenericHash.swift in Sources */, FDC2909427D710B4005DAE71 /* SOGSEndpointSpec.swift in Sources */, - FDC290B327DFF9F5005DAE71 /* TestOnionRequestAPI.swift in Sources */, + FD96F3A529DBC3DC00401309 /* MessageSendJobSpec.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; }; @@ -6366,7 +6445,7 @@ "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Automatic; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 421; + CURRENT_PROJECT_VERSION = 422; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = SUQ8J2PCT7; FRAMEWORK_SEARCH_PATHS = "$(inherited)"; @@ -6390,7 +6469,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 2.3.2; + MARKETING_VERSION = 2.4.0; MTL_ENABLE_DEBUG_INFO = YES; PRODUCT_BUNDLE_IDENTIFIER = "com.loki-project.loki-messenger.ShareExtension"; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -6438,7 +6517,7 @@ "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Automatic; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 421; + CURRENT_PROJECT_VERSION = 422; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = SUQ8J2PCT7; ENABLE_NS_ASSERTIONS = NO; @@ -6467,7 +6546,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 2.3.2; + MARKETING_VERSION = 2.4.0; MTL_ENABLE_DEBUG_INFO = NO; PRODUCT_BUNDLE_IDENTIFIER = "com.loki-project.loki-messenger.ShareExtension"; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -6503,7 +6582,7 @@ "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Automatic; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 421; + CURRENT_PROJECT_VERSION = 422; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = SUQ8J2PCT7; FRAMEWORK_SEARCH_PATHS = "$(inherited)"; @@ -6526,7 +6605,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 2.3.2; + MARKETING_VERSION = 2.4.0; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; PRODUCT_BUNDLE_IDENTIFIER = "com.loki-project.loki-messenger.NotificationServiceExtension"; @@ -6577,7 +6656,7 @@ "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Automatic; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 421; + CURRENT_PROJECT_VERSION = 422; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = SUQ8J2PCT7; ENABLE_NS_ASSERTIONS = NO; @@ -6605,7 +6684,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 2.3.2; + MARKETING_VERSION = 2.4.0; MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; PRODUCT_BUNDLE_IDENTIFIER = "com.loki-project.loki-messenger.NotificationServiceExtension"; @@ -7537,7 +7616,7 @@ CODE_SIGN_ENTITLEMENTS = Session/Meta/Signal.entitlements; CODE_SIGN_IDENTITY = "iPhone Developer"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; - CURRENT_PROJECT_VERSION = 421; + CURRENT_PROJECT_VERSION = 422; DEVELOPMENT_TEAM = SUQ8J2PCT7; FRAMEWORK_SEARCH_PATHS = ( "$(inherited)", @@ -7575,7 +7654,7 @@ "$(SRCROOT)", ); LLVM_LTO = NO; - MARKETING_VERSION = 2.3.2; + MARKETING_VERSION = 2.4.0; OTHER_LDFLAGS = "$(inherited)"; OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\""; PRODUCT_BUNDLE_IDENTIFIER = "com.loki-project.loki-messenger"; @@ -7608,7 +7687,7 @@ CODE_SIGN_ENTITLEMENTS = Session/Meta/Signal.entitlements; CODE_SIGN_IDENTITY = "iPhone Developer"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; - CURRENT_PROJECT_VERSION = 421; + CURRENT_PROJECT_VERSION = 422; DEVELOPMENT_TEAM = SUQ8J2PCT7; FRAMEWORK_SEARCH_PATHS = ( "$(inherited)", @@ -7646,7 +7725,7 @@ "$(SRCROOT)", ); LLVM_LTO = NO; - MARKETING_VERSION = 2.3.2; + MARKETING_VERSION = 2.4.0; OTHER_LDFLAGS = "$(inherited)"; PRODUCT_BUNDLE_IDENTIFIER = "com.loki-project.loki-messenger"; PRODUCT_NAME = Session; diff --git a/Session/Calls/Call Management/SessionCall.swift b/Session/Calls/Call Management/SessionCall.swift index 53b392657..e0b92096e 100644 --- a/Session/Calls/Call Management/SessionCall.swift +++ b/Session/Calls/Call Management/SessionCall.swift @@ -9,6 +9,8 @@ import WebRTC import SessionUIKit import SignalUtilitiesKit import SessionMessagingKit +import SessionUtilitiesKit +import SessionSnodeKit public final class SessionCall: CurrentCallProtocol, WebRTCSessionDelegate { @objc static let isEnabled = true diff --git a/Session/Calls/Call Management/SessionCallManager+Action.swift b/Session/Calls/Call Management/SessionCallManager+Action.swift index 6ac7c49cd..639c00130 100644 --- a/Session/Calls/Call Management/SessionCallManager+Action.swift +++ b/Session/Calls/Call Management/SessionCallManager+Action.swift @@ -2,6 +2,7 @@ import UIKit import GRDB +import SessionUtilitiesKit extension SessionCallManager { @discardableResult diff --git a/Session/Calls/Call Management/SessionCallManager.swift b/Session/Calls/Call Management/SessionCallManager.swift index c89cd5e23..81b3879a8 100644 --- a/Session/Calls/Call Management/SessionCallManager.swift +++ b/Session/Calls/Call Management/SessionCallManager.swift @@ -6,6 +6,7 @@ import GRDB import SessionMessagingKit import SignalCoreKit import SignalUtilitiesKit +import SessionUtilitiesKit public final class SessionCallManager: NSObject, CallManagerProtocol { let provider: CXProvider? diff --git a/Session/Calls/Views & Modals/IncomingCallBanner.swift b/Session/Calls/Views & Modals/IncomingCallBanner.swift index 7646903a2..221531644 100644 --- a/Session/Calls/Views & Modals/IncomingCallBanner.swift +++ b/Session/Calls/Views & Modals/IncomingCallBanner.swift @@ -4,6 +4,7 @@ import UIKit import SessionUIKit import SessionMessagingKit import SignalUtilitiesKit +import SessionUtilitiesKit final class IncomingCallBanner: UIView, UIGestureRecognizerDelegate { private static let swipeToOperateThreshold: CGFloat = 60 diff --git a/Session/Calls/Views & Modals/MiniCallView.swift b/Session/Calls/Views & Modals/MiniCallView.swift index 8d060eed4..7c74a18df 100644 --- a/Session/Calls/Views & Modals/MiniCallView.swift +++ b/Session/Calls/Views & Modals/MiniCallView.swift @@ -3,6 +3,7 @@ import UIKit import WebRTC import SessionUIKit +import SessionUtilitiesKit final class MiniCallView: UIView, RTCVideoViewDelegate { var callVC: CallVC diff --git a/Session/Closed Groups/EditClosedGroupVC.swift b/Session/Closed Groups/EditClosedGroupVC.swift index 07475227c..2c408877e 100644 --- a/Session/Closed Groups/EditClosedGroupVC.swift +++ b/Session/Closed Groups/EditClosedGroupVC.swift @@ -7,6 +7,7 @@ import DifferenceKit import SessionUIKit import SessionMessagingKit import SignalUtilitiesKit +import SessionUtilitiesKit final class EditClosedGroupVC: BaseVC, UITableViewDataSource, UITableViewDelegate { private struct GroupMemberDisplayInfo: FetchableRecord, Equatable, Hashable, Decodable, Differentiable { diff --git a/Session/Conversations/Context Menu/ContextMenuVC+Action.swift b/Session/Conversations/Context Menu/ContextMenuVC+Action.swift index f71cfde88..327a76c49 100644 --- a/Session/Conversations/Context Menu/ContextMenuVC+Action.swift +++ b/Session/Conversations/Context Menu/ContextMenuVC+Action.swift @@ -2,6 +2,7 @@ import UIKit import SessionMessagingKit +import SessionUtilitiesKit extension ContextMenuVC { struct Action { @@ -35,15 +36,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 +52,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 +80,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 +151,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 +161,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 +229,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 +255,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/ConversationSearch.swift b/Session/Conversations/ConversationSearch.swift index 785578104..22c6539b4 100644 --- a/Session/Conversations/ConversationSearch.swift +++ b/Session/Conversations/ConversationSearch.swift @@ -5,6 +5,7 @@ import GRDB import SignalUtilitiesKit import SignalCoreKit import SessionUIKit +import SessionUtilitiesKit public class StyledSearchController: UISearchController { public override var preferredStatusBarStyle: UIStatusBarStyle { diff --git a/Session/Conversations/ConversationVC+Interaction.swift b/Session/Conversations/ConversationVC+Interaction.swift index e7a3889a8..9d7c469a1 100644 --- a/Session/Conversations/ConversationVC+Interaction.swift +++ b/Session/Conversations/ConversationVC+Interaction.swift @@ -11,6 +11,7 @@ import SessionUIKit import SessionMessagingKit import SessionUtilitiesKit import SignalUtilitiesKit +import SessionSnodeKit extension ConversationVC: InputViewDelegate, @@ -149,8 +150,15 @@ extension ConversationVC: dismiss(animated: true, completion: nil) } - func sendMediaNav(_ sendMediaNavigationController: SendMediaNavigationController, didApproveAttachments attachments: [SignalAttachment], forThreadId threadId: String, messageText: String?) { - sendMessage(text: (messageText ?? ""), attachments: attachments) + func sendMediaNav( + _ sendMediaNavigationController: SendMediaNavigationController, + didApproveAttachments attachments: [SignalAttachment], + forThreadId threadId: String, + threadVariant: SessionThread.Variant, + messageText: String?, + using dependencies: Dependencies + ) { + sendMessage(text: (messageText ?? ""), attachments: attachments, using: dependencies) resetMentions() dismiss(animated: true) { [weak self] in @@ -173,8 +181,15 @@ 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, + threadVariant: SessionThread.Variant, + messageText: String?, + using dependencies: Dependencies + ) { + sendMessage(text: (messageText ?? ""), attachments: attachments, using: dependencies) resetMentions() dismiss(animated: true) { [weak self] in @@ -248,11 +263,13 @@ extension ConversationVC: func handleLibraryButtonTapped() { let threadId: String = self.viewModel.threadData.threadId + let threadVariant: SessionThread.Variant = self.viewModel.threadData.threadVariant Permissions.requestLibraryPermissionIfNeeded { [weak self] in DispatchQueue.main.async { let sendMediaNavController = SendMediaNavigationController.showingMediaLibraryFirst( - threadId: threadId + threadId: threadId, + threadVariant: threadVariant ) sendMediaNavController.sendMediaNavDelegate = self sendMediaNavController.modalPresentationStyle = .fullScreen @@ -270,7 +287,10 @@ extension ConversationVC: SNLog("Proceeding without microphone access. Any recorded video will be silent.") } - let sendMediaNavController = SendMediaNavigationController.showingCameraFirst(threadId: self.viewModel.threadData.threadId) + let sendMediaNavController = SendMediaNavigationController.showingCameraFirst( + threadId: self.viewModel.threadData.threadId, + threadVariant: self.viewModel.threadData.threadVariant + ) sendMediaNavController.sendMediaNavDelegate = self sendMediaNavController.modalPresentationStyle = .fullScreen @@ -356,6 +376,7 @@ extension ConversationVC: func showAttachmentApprovalDialog(for attachments: [SignalAttachment]) { let navController = AttachmentApprovalViewController.wrappedInNavController( threadId: self.viewModel.threadData.threadId, + threadVariant: self.viewModel.threadData.threadVariant, attachments: attachments, approvalDelegate: self ) @@ -409,7 +430,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 } @@ -480,20 +502,23 @@ extension ConversationVC: quoteModel: quoteModel ) - sendMessage(optimisticData: optimisticData) + sendMessage(optimisticData: optimisticData, using: dependencies) } - private func sendMessage(optimisticData: ConversationViewModel.OptimisticMessageData) { + private func sendMessage( + optimisticData: ConversationViewModel.OptimisticMessageData, + using dependencies: Dependencies + ) { let threadId: String = self.viewModel.threadData.threadId let threadVariant: SessionThread.Variant = self.viewModel.threadData.threadVariant - DispatchQueue.global(qos:.userInitiated).async { + DispatchQueue.global(qos:.userInitiated).async(using: dependencies) { // Generate the quote thumbnail if needed (want this to happen outside of the DBWrite thread as // this can take up to 0.5s let quoteThumbnailAttachment: Attachment? = optimisticData.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 { @@ -541,7 +566,8 @@ extension ConversationVC: db, interaction: insertedInteraction, threadId: threadId, - threadVariant: threadVariant + threadVariant: threadVariant, + using: dependencies ) } .subscribe(on: DispatchQueue.global(qos: .userInitiated)) @@ -635,6 +661,7 @@ extension ConversationVC: let approvalVC = AttachmentApprovalViewController.wrappedInNavController( threadId: self.viewModel.threadData.threadId, + threadVariant: self.viewModel.threadData.threadVariant, attachments: [ attachment ], approvalDelegate: self ) @@ -798,10 +825,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 } @@ -875,8 +906,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, @@ -885,7 +916,9 @@ extension ConversationVC: details: AttachmentDownloadJob.Details( attachmentId: mediaView.attachment.id ) - ) + ), + canStartJob: true, + using: dependencies ) } break @@ -1024,8 +1057,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?) { @@ -1134,15 +1167,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 @@ -1219,7 +1252,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 || @@ -1237,7 +1270,7 @@ extension ConversationVC: return } - dependencies.mutableGeneralCache.mutate { + dependencies.caches.mutate(cache: .general) { $0.recentReactionTimestamps = Array($0.recentReactionTimestamps .suffix(19)) .appending(sentTimestamp) @@ -1272,9 +1305,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 @@ -1383,7 +1416,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) @@ -1393,7 +1427,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) @@ -1444,14 +1478,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() @@ -1467,7 +1501,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() : @@ -1494,7 +1528,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 @@ -1607,7 +1641,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), @@ -1618,8 +1652,7 @@ extension ConversationVC: navigationController?.pushViewController(mediaInfoVC, animated: true) } - func retry(_ cellViewModel: MessageViewModel) { - // If the failed message is an optimistic update then we need to do things differently + func retry(_ cellViewModel: MessageViewModel, using dependencies: Dependencies) { guard cellViewModel.id != MessageViewModel.optimisticUpdateId else { guard let optimisticMessageId: UUID = cellViewModel.optimisticMessageId, @@ -1640,11 +1673,11 @@ extension ConversationVC: } // Try to send the optimistic message again - self.sendMessage(optimisticData: optimisticMessageData) + sendMessage(optimisticData: optimisticMessageData, using: dependencies) return } - Storage.shared.writeAsync { [weak self] db in + dependencies.storage.writeAsync { [weak self] db in guard let threadId: String = self?.viewModel.threadData.threadId, let threadVariant: SessionThread.Variant = self?.viewModel.threadData.threadVariant, @@ -1685,12 +1718,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, @@ -1713,7 +1747,7 @@ extension ConversationVC: snInputView.becomeFirstResponder() } - func copy(_ cellViewModel: MessageViewModel) { + func copy(_ cellViewModel: MessageViewModel, using dependencies: Dependencies) { switch cellViewModel.cellType { case .typingIndicator, .dateHeader, .unreadMarker: break @@ -1751,7 +1785,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, @@ -1947,7 +1981,8 @@ extension ConversationVC: message: unsendRequest, threadId: cellViewModel.threadId, interactionId: nil, - to: .contact(publicKey: userPublicKey) + to: .contact(publicKey: userPublicKey), + using: dependencies ) } return @@ -1970,7 +2005,8 @@ extension ConversationVC: message: unsendRequest, threadId: cellViewModel.threadId, interactionId: nil, - to: .contact(publicKey: userPublicKey) + to: .contact(publicKey: userPublicKey), + using: dependencies ) } self?.showInputAccessoryView() @@ -1998,7 +2034,8 @@ extension ConversationVC: message: unsendRequest, interactionId: nil, threadId: cellViewModel.threadId, - threadVariant: cellViewModel.threadVariant + threadVariant: cellViewModel.threadVariant, + using: dependencies ) } @@ -2032,7 +2069,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 ?? []) @@ -2074,24 +2111,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 @@ -2147,7 +2170,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 @@ -2205,7 +2228,7 @@ extension ConversationVC: // MARK: - VoiceMessageRecordingViewDelegate - func startVoiceMessageRecording() { + func startVoiceMessageRecording(using dependencies: Dependencies) { // Request permission if needed Permissions.requestMicrophonePermissionIfNeeded() { [weak self] in DispatchQueue.main.async { @@ -2254,7 +2277,7 @@ extension ConversationVC: // Limit voice messages to a minute audioTimer = Timer.scheduledTimer(withTimeInterval: 180, repeats: false, block: { [weak self] _ in self?.snInputView.hideVoiceMessageUI() - self?.endVoiceMessageRecording() + self?.endVoiceMessageRecording(using: dependencies) }) // Prepare audio recorder @@ -2270,7 +2293,7 @@ extension ConversationVC: } } - func endVoiceMessageRecording() { + func endVoiceMessageRecording(using dependencies: Dependencies) { UIApplication.shared.isIdleTimerDisabled = true // Hide the UI @@ -2322,7 +2345,7 @@ extension ConversationVC: } // Send attachment - sendMessage(text: "", attachments: [attachment]) + sendMessage(text: "", attachments: [attachment], using: dependencies) } func cancelVoiceMessageRecording() { @@ -2339,23 +2362,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 ) } } @@ -2391,7 +2420,8 @@ extension ConversationVC { for threadId: String, threadVariant: SessionThread.Variant, isNewThread: Bool, - timestampMs: Int64 + timestampMs: Int64, + using dependencies: Dependencies = Dependencies() ) { guard threadVariant == .contact else { return } @@ -2432,7 +2462,8 @@ extension ConversationVC { ), interactionId: nil, threadId: threadId, - threadVariant: threadVariant + threadVariant: threadVariant, + using: dependencies ) } diff --git a/Session/Conversations/ConversationVC.swift b/Session/Conversations/ConversationVC.swift index fa5656fa1..f5f1ded92 100644 --- a/Session/Conversations/ConversationVC.swift +++ b/Session/Conversations/ConversationVC.swift @@ -208,17 +208,7 @@ final class ConversationVC: BaseVC, SessionUtilRespondingViewController, Convers }() private lazy var emptyStateLabel: UILabel = { - let text: String = String( - format: { - switch (viewModel.threadData.threadIsNoteToSelf, viewModel.threadData.canWrite) { - case (true, _): return "CONVERSATION_EMPTY_STATE_NOTE_TO_SELF".localized() - case (_, false): return "CONVERSATION_EMPTY_STATE_READ_ONLY".localized() - default: return "CONVERSATION_EMPTY_STATE".localized() - } - }(), - viewModel.threadData.displayName - ) - + let text: String = emptyStateText(for: viewModel.threadData) let result: UILabel = UILabel() result.accessibilityLabel = "Empty state label" result.translatesAutoresizingMaskIntoConstraints = false @@ -584,7 +574,7 @@ final class ConversationVC: BaseVC, SessionUtilRespondingViewController, Convers !SessionUtil.conversationInConfig( threadId: threadId, threadVariant: viewModel.threadData.threadVariant, - visibleOnly: true + visibleOnly: false ) { Storage.shared.writeAsync { db in @@ -698,6 +688,24 @@ final class ConversationVC: BaseVC, SessionUtilRespondingViewController, Convers self.viewModel.onInteractionChange = nil } + private func emptyStateText(for threadData: SessionThreadViewModel) -> String { + return String( + format: { + switch (threadData.threadIsNoteToSelf, threadData.canWrite) { + case (true, _): return "CONVERSATION_EMPTY_STATE_NOTE_TO_SELF".localized() + case (_, false): + return (threadData.profile?.blocksCommunityMessageRequests == true ? + "COMMUNITY_MESSAGE_REQUEST_DISABLED_EMPTY_STATE".localized() : + "CONVERSATION_EMPTY_STATE_READ_ONLY".localized() + ) + + default: return "CONVERSATION_EMPTY_STATE".localized() + } + }(), + threadData.displayName + ) + } + private func handleThreadUpdates(_ updatedThreadData: SessionThreadViewModel, initialLoad: Bool = false) { // Ensure the first load or a load when returning from a child screen runs without animations (if // we don't do this the cells will animate in from a frame of CGRect.zero or have a buggy transition) @@ -738,17 +746,7 @@ final class ConversationVC: BaseVC, SessionUtilRespondingViewController, Convers ) // Update the empty state - let text: String = String( - format: { - switch (updatedThreadData.threadIsNoteToSelf, updatedThreadData.canWrite) { - case (true, _): return "CONVERSATION_EMPTY_STATE_NOTE_TO_SELF".localized() - case (_, false): return "CONVERSATION_EMPTY_STATE_READ_ONLY".localized() - default: return "CONVERSATION_EMPTY_STATE".localized() - } - }(), - updatedThreadData.displayName - ) - + let text: String = emptyStateText(for: updatedThreadData) emptyStateLabel.attributedText = NSAttributedString(string: text) .adding( attributes: [.font: UIFont.boldSystemFont(ofSize: Values.verySmallFontSize)], @@ -791,8 +789,10 @@ final class ConversationVC: BaseVC, SessionUtilRespondingViewController, Convers updatedThreadData.threadRequiresApproval == true ) self?.messageRequestStackView.isHidden = ( - updatedThreadData.threadIsMessageRequest == false && - updatedThreadData.threadRequiresApproval == false + !updatedThreadData.canWrite || ( + updatedThreadData.threadIsMessageRequest == false && + updatedThreadData.threadRequiresApproval == false + ) ) self?.messageRequestBackgroundView.isHidden = (self?.messageRequestStackView.isHidden == true) self?.messageRequestDescriptionLabelBottomConstraint?.constant = (updatedThreadData.threadRequiresApproval == true ? -4 : -20) diff --git a/Session/Conversations/Input View/InputView.swift b/Session/Conversations/Input View/InputView.swift index 60e67ba4f..f4eb8e9e1 100644 --- a/Session/Conversations/Input View/InputView.swift +++ b/Session/Conversations/Input View/InputView.swift @@ -416,14 +416,14 @@ final class InputView: UIView, InputViewButtonDelegate, InputTextViewDelegate, M if inputViewButton == sendButton { delegate?.handleSendButtonTapped() } } - func handleInputViewButtonLongPressBegan(_ inputViewButton: InputViewButton?) { + func handleInputViewButtonLongPressBegan(_ inputViewButton: InputViewButton?, using dependencies: Dependencies) { guard inputViewButton == voiceMessageButton else { return } // Note: The 'showVoiceMessageUI' call MUST come before triggering 'startVoiceMessageRecording' // because if something goes wrong it'll trigger `hideVoiceMessageUI` and we don't want it to // end up in a state with the input content hidden showVoiceMessageUI() - delegate?.startVoiceMessageRecording() + delegate?.startVoiceMessageRecording(using: dependencies) } func handleInputViewButtonLongPressMoved(_ inputViewButton: InputViewButton, with touch: UITouch?) { diff --git a/Session/Conversations/Input View/InputViewButton.swift b/Session/Conversations/Input View/InputViewButton.swift index ca7a60d32..246510fed 100644 --- a/Session/Conversations/Input View/InputViewButton.swift +++ b/Session/Conversations/Input View/InputViewButton.swift @@ -2,6 +2,7 @@ import UIKit import SessionUIKit +import SessionUtilitiesKit final class InputViewButton: UIView { private let icon: UIImage? @@ -137,7 +138,9 @@ final class InputViewButton: UIView { // We want to detect both taps and long presses - override func touchesBegan(_ touches: Set, with event: UIEvent?) { + override func touchesBegan(_ touches: Set, with event: UIEvent?) { onTouchesBegan() } + + private func onTouchesBegan(using dependencies: Dependencies = Dependencies()) { guard isUserInteractionEnabled else { return } UIImpactFeedbackGenerator(style: .heavy).impactOccurred() @@ -145,7 +148,7 @@ final class InputViewButton: UIView { invalidateLongPressIfNeeded() longPressTimer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: false, block: { [weak self] _ in self?.isLongPress = true - self?.delegate?.handleInputViewButtonLongPressBegan(self) + self?.delegate?.handleInputViewButtonLongPressBegan(self, using: dependencies) }) } @@ -185,13 +188,13 @@ final class InputViewButton: UIView { protocol InputViewButtonDelegate: AnyObject { func handleInputViewButtonTapped(_ inputViewButton: InputViewButton) - func handleInputViewButtonLongPressBegan(_ inputViewButton: InputViewButton?) + func handleInputViewButtonLongPressBegan(_ inputViewButton: InputViewButton?, using dependencies: Dependencies) func handleInputViewButtonLongPressMoved(_ inputViewButton: InputViewButton, with touch: UITouch?) func handleInputViewButtonLongPressEnded(_ inputViewButton: InputViewButton, with touch: UITouch?) } extension InputViewButtonDelegate { - func handleInputViewButtonLongPressBegan(_ inputViewButton: InputViewButton?) { } + func handleInputViewButtonLongPressBegan(_ inputViewButton: InputViewButton?, using dependencies: Dependencies) { } func handleInputViewButtonLongPressMoved(_ inputViewButton: InputViewButton, with touch: UITouch?) { } func handleInputViewButtonLongPressEnded(_ inputViewButton: InputViewButton, with touch: UITouch?) { } } diff --git a/Session/Conversations/Input View/VoiceMessageRecordingView.swift b/Session/Conversations/Input View/VoiceMessageRecordingView.swift index b31b1e435..e0feffe1f 100644 --- a/Session/Conversations/Input View/VoiceMessageRecordingView.swift +++ b/Session/Conversations/Input View/VoiceMessageRecordingView.swift @@ -310,12 +310,12 @@ final class VoiceMessageRecordingView: UIView { } } - func handleLongPressEnded(at location: CGPoint) { + func handleLongPressEnded(at location: CGPoint, using dependencies: Dependencies = Dependencies()) { if pulseView.frame.contains(location) { - delegate?.endVoiceMessageRecording() + delegate?.endVoiceMessageRecording(using: dependencies) } else if isValidLockViewLocation(location) { - let tapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(handleCircleViewTap)) + let tapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(onCircleViewTap)) circleView.addGestureRecognizer(tapGestureRecognizer) UIView.animate(withDuration: 0.25, delay: 0, options: .transitionCrossDissolve, animations: { @@ -332,8 +332,10 @@ final class VoiceMessageRecordingView: UIView { } } - @objc private func handleCircleViewTap() { - delegate?.endVoiceMessageRecording() + @objc private func onCircleViewTap() { handleCircleViewTap() } + + private func handleCircleViewTap(using dependencies: Dependencies = Dependencies()) { + delegate?.endVoiceMessageRecording(using: dependencies) } @objc private func handleCancelButtonTapped() { @@ -474,7 +476,7 @@ extension VoiceMessageRecordingView { // MARK: - Delegate protocol VoiceMessageRecordingViewDelegate: AnyObject { - func startVoiceMessageRecording() - func endVoiceMessageRecording() + func startVoiceMessageRecording(using dependencies: Dependencies) + func endVoiceMessageRecording(using dependencies: Dependencies) func cancelVoiceMessageRecording() } diff --git a/Session/Conversations/Message Cells/CallMessageCell.swift b/Session/Conversations/Message Cells/CallMessageCell.swift index ef88e2082..337862eb5 100644 --- a/Session/Conversations/Message Cells/CallMessageCell.swift +++ b/Session/Conversations/Message Cells/CallMessageCell.swift @@ -3,6 +3,7 @@ import UIKit import SessionUIKit import SessionMessagingKit +import SessionUtilitiesKit final class CallMessageCell: MessageCell { private static let iconSize: CGFloat = 16 diff --git a/Session/Conversations/Message Cells/Content Views/MediaAlbumView.swift b/Session/Conversations/Message Cells/Content Views/MediaAlbumView.swift index 87ee2f937..eb410d306 100644 --- a/Session/Conversations/Message Cells/Content Views/MediaAlbumView.swift +++ b/Session/Conversations/Message Cells/Content Views/MediaAlbumView.swift @@ -3,6 +3,7 @@ import UIKit import SessionMessagingKit import SignalCoreKit +import SessionUtilitiesKit public class MediaAlbumView: UIStackView { private let items: [Attachment] diff --git a/Session/Conversations/Message Cells/Content Views/MediaView.swift b/Session/Conversations/Message Cells/Content Views/MediaView.swift index 0af198314..838eb94d2 100644 --- a/Session/Conversations/Message Cells/Content Views/MediaView.swift +++ b/Session/Conversations/Message Cells/Content Views/MediaView.swift @@ -6,6 +6,7 @@ import SessionUIKit import SessionMessagingKit import SignalCoreKit import SignalUtilitiesKit +import SessionUtilitiesKit public class MediaView: UIView { static let contentMode: UIView.ContentMode = .scaleAspectFill diff --git a/Session/Conversations/Message Cells/Content Views/QuoteView.swift b/Session/Conversations/Message Cells/Content Views/QuoteView.swift index bda79ae51..ea86a406c 100644 --- a/Session/Conversations/Message Cells/Content Views/QuoteView.swift +++ b/Session/Conversations/Message Cells/Content Views/QuoteView.swift @@ -156,7 +156,7 @@ final class QuoteView: UIView { if attachment.isVisualMedia { attachment.thumbnail( size: .small, - success: { image, _ in + success: { [imageView] image, _ in guard Thread.isMainThread else { DispatchQueue.main.async { imageView.image = image @@ -234,8 +234,6 @@ final class QuoteView: UIView { } // Label stack view - let bodyLabelSize = bodyLabel.systemLayoutSizeFitting(availableSpace) - let isCurrentUser: Bool = [ currentUserPublicKey, currentUserBlinded15PublicKey, @@ -288,9 +286,8 @@ final class QuoteView: UIView { cancelButton.set(.height, to: cancelButtonSize) cancelButton.addTarget(self, action: #selector(cancel), for: UIControl.Event.touchUpInside) - addSubview(cancelButton) + mainStackView.addArrangedSubview(cancelButton) cancelButton.center(.vertical, in: self) - cancelButton.pin(.right, to: .right, of: self) } } diff --git a/Session/Conversations/Message Cells/Content Views/TypingIndicatorView.swift b/Session/Conversations/Message Cells/Content Views/TypingIndicatorView.swift index 9af77393f..08ccc733c 100644 --- a/Session/Conversations/Message Cells/Content Views/TypingIndicatorView.swift +++ b/Session/Conversations/Message Cells/Content Views/TypingIndicatorView.swift @@ -3,6 +3,7 @@ import UIKit import SessionUIKit import SignalCoreKit +import SessionUtilitiesKit @objc class TypingIndicatorView: UIStackView { // This represents the spacing between the dots diff --git a/Session/Conversations/Message Cells/MessageCell.swift b/Session/Conversations/Message Cells/MessageCell.swift index dde344352..e32f7fa7f 100644 --- a/Session/Conversations/Message Cells/MessageCell.swift +++ b/Session/Conversations/Message Cells/MessageCell.swift @@ -2,6 +2,7 @@ import UIKit import SessionMessagingKit +import SessionUtilitiesKit public enum SwipeState { case began @@ -87,12 +88,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..c264472ea 100644 --- a/Session/Conversations/Message Cells/VisibleMessageCell.swift +++ b/Session/Conversations/Message Cells/VisibleMessageCell.swift @@ -52,7 +52,7 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate { profilePictureView, replyButton, timerView, - messageStatusImageView, + messageStatusContainerView, reactionContainerView ] @@ -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 { // MARK: - Config @@ -60,10 +61,10 @@ class ThreadSettingsViewModel: SessionTableViewModel () + didTriggerSearch: @escaping () -> (), + using dependencies: Dependencies = Dependencies() ) { self.dependencies = dependencies self.threadId = threadId @@ -178,6 +179,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) @@ -235,6 +237,8 @@ class ThreadSettingsViewModel: SessionTableViewModel = Atomic([:]) diff --git a/Session/Home/GlobalSearch/GlobalSearchViewController.swift b/Session/Home/GlobalSearch/GlobalSearchViewController.swift index 7d4103a85..fdb8ca753 100644 --- a/Session/Home/GlobalSearch/GlobalSearchViewController.swift +++ b/Session/Home/GlobalSearch/GlobalSearchViewController.swift @@ -203,6 +203,11 @@ class GlobalSearchViewController: BaseVC, SessionUtilRespondingViewController, U ]) } catch { + // Don't log the 'interrupt' error as that's just the user typing too fast + if (error as? DatabaseError)?.resultCode != DatabaseError.SQLITE_INTERRUPT { + SNLog("[GlobalSearch] Failed to find results due to error: \(error)") + } + return .failure(error) } } diff --git a/Session/Home/HomeVC.swift b/Session/Home/HomeVC.swift index 50a7b6151..21c87c49a 100644 --- a/Session/Home/HomeVC.swift +++ b/Session/Home/HomeVC.swift @@ -283,14 +283,6 @@ final class HomeVC: BaseVC, SessionUtilRespondingViewController, UITableViewData // Start polling if needed (i.e. if the user just created or restored their Session ID) if Identity.userExists(), let appDelegate: AppDelegate = UIApplication.shared.delegate as? AppDelegate { appDelegate.startPollersIfNeeded() - - // FIXME: Remove this once `useSharedUtilForUserConfig` is permanent - if !SessionUtil.userConfigsEnabled { - // Do this only if we created a new Session ID, or if we already received the initial configuration message - if UserDefaults.standard[.hasSyncedInitialConfiguration] { - appDelegate.syncConfigurationIfNeeded() - } - } } // Onion request path countries cache diff --git a/Session/Home/Message Requests/MessageRequestsViewController.swift b/Session/Home/Message Requests/MessageRequestsViewController.swift index 6b91630da..9684e44b1 100644 --- a/Session/Home/Message Requests/MessageRequestsViewController.swift +++ b/Session/Home/Message Requests/MessageRequestsViewController.swift @@ -6,6 +6,7 @@ import DifferenceKit import SessionUIKit import SessionMessagingKit import SignalUtilitiesKit +import SessionUtilitiesKit class MessageRequestsViewController: BaseVC, SessionUtilRespondingViewController, UITableViewDelegate, UITableViewDataSource { private static let loadingHeaderHeight: CGFloat = 40 diff --git a/Session/Home/Message Requests/MessageRequestsViewModel.swift b/Session/Home/Message Requests/MessageRequestsViewModel.swift index 08e6eff32..f633e3b36 100644 --- a/Session/Home/Message Requests/MessageRequestsViewModel.swift +++ b/Session/Home/Message Requests/MessageRequestsViewModel.swift @@ -4,6 +4,7 @@ import Foundation import GRDB import DifferenceKit import SignalUtilitiesKit +import SessionUtilitiesKit public class MessageRequestsViewModel { public typealias SectionModel = ArraySection diff --git a/Session/Home/New Conversation/NewDMVC.swift b/Session/Home/New Conversation/NewDMVC.swift index e4765fee1..d369dd933 100644 --- a/Session/Home/New Conversation/NewDMVC.swift +++ b/Session/Home/New Conversation/NewDMVC.swift @@ -7,6 +7,7 @@ import SessionUIKit import SessionMessagingKit import SessionUtilitiesKit import SignalUtilitiesKit +import SessionSnodeKit final class NewDMVC: BaseVC, UIPageViewControllerDataSource, UIPageViewControllerDelegate, QRScannerDelegate { private var shouldShowBackButton: Bool = true diff --git a/Session/Media Viewing & Editing/CropScaleImageViewController.swift b/Session/Media Viewing & Editing/CropScaleImageViewController.swift index df821b716..d307f9990 100644 --- a/Session/Media Viewing & Editing/CropScaleImageViewController.swift +++ b/Session/Media Viewing & Editing/CropScaleImageViewController.swift @@ -5,6 +5,7 @@ import MediaPlayer import SessionUIKit import SignalUtilitiesKit import SignalCoreKit +import SessionUtilitiesKit // This kind of view is tricky. I've tried to organize things in the // simplest possible way. @@ -359,54 +360,54 @@ import SignalCoreKit @objc func handlePinch(sender: UIPinchGestureRecognizer) { switch sender.state { - case .possible: - break - case .began: - srcTranslationAtPinchStart = srcTranslation - imageScaleAtPinchStart = imageScale + case .possible: break + case .began: + srcTranslationAtPinchStart = srcTranslation + imageScaleAtPinchStart = imageScale - lastPinchLocation = - sender.location(in: sender.view) - lastPinchScale = sender.scale - break - case .changed, .ended: - if sender.numberOfTouches > 1 { - let location = + lastPinchLocation = sender.location(in: sender.view) - let scaleDiff = sender.scale / lastPinchScale - - // Update scaling. - let srcCropSizeBeforeScalePoints = CGSize(width: srcDefaultCropSizePoints.width / imageScale, - height: srcDefaultCropSizePoints.height / imageScale) - imageScale = max(kMinImageScale, min(kMaxImageScale, imageScale * scaleDiff)) - let srcCropSizeAfterScalePoints = CGSize(width: srcDefaultCropSizePoints.width / imageScale, - height: srcDefaultCropSizePoints.height / imageScale) - // Since the translation state reflects the "upper left" corner of the crop region, we need to - // adjust the translation when scaling to preserve the "center" of the crop region. - srcTranslation.x += (srcCropSizeBeforeScalePoints.width - srcCropSizeAfterScalePoints.width) * 0.5 - srcTranslation.y += (srcCropSizeBeforeScalePoints.height - srcCropSizeAfterScalePoints.height) * 0.5 - - // Update translation. - let viewSizePoints = imageView.frame.size - let srcCropSizePoints = CGSize(width: srcDefaultCropSizePoints.width / imageScale, - height: srcDefaultCropSizePoints.height / imageScale) - - let viewToSrcRatio = srcCropSizePoints.width / viewSizePoints.width - - let gestureTranslation = CGPoint(x: location.x - lastPinchLocation.x, - y: location.y - lastPinchLocation.y) - - srcTranslation = CGPoint(x: srcTranslation.x + gestureTranslation.x * -viewToSrcRatio, - y: srcTranslation.y + gestureTranslation.y * -viewToSrcRatio) - - lastPinchLocation = location lastPinchScale = sender.scale - } - break - case .cancelled, .failed: - srcTranslation = srcTranslationAtPinchStart - imageScale = imageScaleAtPinchStart - break + + case .changed, .ended: + if sender.numberOfTouches > 1 { + let location = + sender.location(in: sender.view) + let scaleDiff = sender.scale / lastPinchScale + + // Update scaling. + let srcCropSizeBeforeScalePoints = CGSize(width: srcDefaultCropSizePoints.width / imageScale, + height: srcDefaultCropSizePoints.height / imageScale) + imageScale = max(kMinImageScale, min(kMaxImageScale, imageScale * scaleDiff)) + let srcCropSizeAfterScalePoints = CGSize(width: srcDefaultCropSizePoints.width / imageScale, + height: srcDefaultCropSizePoints.height / imageScale) + // Since the translation state reflects the "upper left" corner of the crop region, we need to + // adjust the translation when scaling to preserve the "center" of the crop region. + srcTranslation.x += (srcCropSizeBeforeScalePoints.width - srcCropSizeAfterScalePoints.width) * 0.5 + srcTranslation.y += (srcCropSizeBeforeScalePoints.height - srcCropSizeAfterScalePoints.height) * 0.5 + + // Update translation. + let viewSizePoints = imageView.frame.size + let srcCropSizePoints = CGSize(width: srcDefaultCropSizePoints.width / imageScale, + height: srcDefaultCropSizePoints.height / imageScale) + + let viewToSrcRatio = srcCropSizePoints.width / viewSizePoints.width + + let gestureTranslation = CGPoint(x: location.x - lastPinchLocation.x, + y: location.y - lastPinchLocation.y) + + srcTranslation = CGPoint(x: srcTranslation.x + gestureTranslation.x * -viewToSrcRatio, + y: srcTranslation.y + gestureTranslation.y * -viewToSrcRatio) + + lastPinchLocation = location + lastPinchScale = sender.scale + } + + case .cancelled, .failed: + srcTranslation = srcTranslationAtPinchStart + imageScale = imageScaleAtPinchStart + + @unknown default: break } updateImageLayout() @@ -416,29 +417,28 @@ import SignalCoreKit @objc func handlePan(sender: UIPanGestureRecognizer) { switch sender.state { - case .possible: - break - case .began: - srcTranslationAtPanStart = srcTranslation - break - case .changed, .ended: - let viewSizePoints = imageView.frame.size - let srcCropSizePoints = CGSize(width: srcDefaultCropSizePoints.width / imageScale, - height: srcDefaultCropSizePoints.height / imageScale) + case .possible: break + case .began: + srcTranslationAtPanStart = srcTranslation + + case .changed, .ended: + let viewSizePoints = imageView.frame.size + let srcCropSizePoints = CGSize(width: srcDefaultCropSizePoints.width / imageScale, + height: srcDefaultCropSizePoints.height / imageScale) - let viewToSrcRatio = srcCropSizePoints.width / viewSizePoints.width + let viewToSrcRatio = srcCropSizePoints.width / viewSizePoints.width - let gestureTranslation = - sender.translation(in: sender.view) + let gestureTranslation = + sender.translation(in: sender.view) - // Update translation. - srcTranslation = CGPoint(x: srcTranslationAtPanStart.x + gestureTranslation.x * -viewToSrcRatio, - y: srcTranslationAtPanStart.y + gestureTranslation.y * -viewToSrcRatio) - break - case .cancelled, .failed: - srcTranslation - = srcTranslationAtPanStart - break + // Update translation. + srcTranslation = CGPoint(x: srcTranslationAtPanStart.x + gestureTranslation.x * -viewToSrcRatio, + y: srcTranslationAtPanStart.y + gestureTranslation.y * -viewToSrcRatio) + + case .cancelled, .failed: + srcTranslation = srcTranslationAtPanStart + + @unknown default: break } updateImageLayout() diff --git a/Session/Media Viewing & Editing/DocumentTitleViewController.swift b/Session/Media Viewing & Editing/DocumentTitleViewController.swift index d7a84d87e..12ba3248c 100644 --- a/Session/Media Viewing & Editing/DocumentTitleViewController.swift +++ b/Session/Media Viewing & Editing/DocumentTitleViewController.swift @@ -7,6 +7,7 @@ import DifferenceKit import SessionUIKit import SignalUtilitiesKit import SignalCoreKit +import SessionUtilitiesKit public class DocumentTileViewController: UIViewController, UITableViewDelegate, UITableViewDataSource { @@ -46,6 +47,10 @@ public class DocumentTileViewController: UIViewController, UITableViewDelegate, // MARK: - UI override public var supportedInterfaceOrientations: UIInterfaceOrientationMask { + if UIDevice.current.isIPad { + return .all + } + return .allButUpsideDown } diff --git a/Session/Media Viewing & Editing/GIFs/GifPickerCell.swift b/Session/Media Viewing & Editing/GIFs/GifPickerCell.swift index 0967e8020..20575c640 100644 --- a/Session/Media Viewing & Editing/GIFs/GifPickerCell.swift +++ b/Session/Media Viewing & Editing/GIFs/GifPickerCell.swift @@ -5,6 +5,7 @@ import Combine import YYImage import SignalUtilitiesKit import SignalCoreKit +import SessionUtilitiesKit class GifPickerCell: UICollectionViewCell { diff --git a/Session/Media Viewing & Editing/GIFs/GifPickerViewController.swift b/Session/Media Viewing & Editing/GIFs/GifPickerViewController.swift index 4b2436258..d88e5bdb6 100644 --- a/Session/Media Viewing & Editing/GIFs/GifPickerViewController.swift +++ b/Session/Media Viewing & Editing/GIFs/GifPickerViewController.swift @@ -6,6 +6,7 @@ import Reachability import SignalUtilitiesKit import SessionUIKit import SignalCoreKit +import SessionUtilitiesKit class GifPickerViewController: OWSViewController, UISearchBarDelegate, UICollectionViewDataSource, UICollectionViewDelegate, GifPickerLayoutDelegate { diff --git a/Session/Media Viewing & Editing/GIFs/GiphyDownloader.swift b/Session/Media Viewing & Editing/GIFs/GiphyDownloader.swift index 3dd50b0c0..3afd8d56a 100644 --- a/Session/Media Viewing & Editing/GIFs/GiphyDownloader.swift +++ b/Session/Media Viewing & Editing/GIFs/GiphyDownloader.swift @@ -2,6 +2,7 @@ import Foundation import SignalUtilitiesKit +import SessionUtilitiesKit public class GiphyDownloader: ProxiedContentDownloader { diff --git a/Session/Media Viewing & Editing/ImagePickerController.swift b/Session/Media Viewing & Editing/ImagePickerController.swift index 37fb7f4a8..6983de804 100644 --- a/Session/Media Viewing & Editing/ImagePickerController.swift +++ b/Session/Media Viewing & Editing/ImagePickerController.swift @@ -6,6 +6,7 @@ import Photos import SessionUIKit import SignalUtilitiesKit import SignalCoreKit +import SessionUtilitiesKit protocol ImagePickerGridControllerDelegate: AnyObject { func imagePickerDidCompleteSelection(_ imagePicker: ImagePickerGridController) @@ -155,6 +156,8 @@ class ImagePickerGridController: UICollectionViewController, PhotoLibraryDelegat case .cancelled, .ended, .failed: collectionView.isUserInteractionEnabled = true collectionView.isScrollEnabled = true + + @unknown default: break } } diff --git a/Session/Media Viewing & Editing/MediaDetailViewController.swift b/Session/Media Viewing & Editing/MediaDetailViewController.swift index 1a7abeebe..f3eaf40e6 100644 --- a/Session/Media Viewing & Editing/MediaDetailViewController.swift +++ b/Session/Media Viewing & Editing/MediaDetailViewController.swift @@ -6,6 +6,7 @@ import SessionUIKit import SignalUtilitiesKit import SessionMessagingKit import SignalCoreKit +import SessionUtilitiesKit public enum MediaGalleryOption { case sliderEnabled diff --git a/Session/Media Viewing & Editing/MediaGalleryNavigationController.swift b/Session/Media Viewing & Editing/MediaGalleryNavigationController.swift index cd9062186..55283edec 100644 --- a/Session/Media Viewing & Editing/MediaGalleryNavigationController.swift +++ b/Session/Media Viewing & Editing/MediaGalleryNavigationController.swift @@ -44,6 +44,10 @@ class MediaGalleryNavigationController: UINavigationController { // MARK: - Orientation public override var supportedInterfaceOrientations: UIInterfaceOrientationMask { + if UIDevice.current.isIPad { + return .all + } + return .allButUpsideDown } diff --git a/Session/Media Viewing & Editing/MediaGalleryViewModel.swift b/Session/Media Viewing & Editing/MediaGalleryViewModel.swift index 10eff35e3..0ff182537 100644 --- a/Session/Media Viewing & Editing/MediaGalleryViewModel.swift +++ b/Session/Media Viewing & Editing/MediaGalleryViewModel.swift @@ -199,16 +199,18 @@ public class MediaGalleryViewModel { } } - public struct Item: FetchableRecordWithRowId, Decodable, Identifiable, Differentiable, Equatable, Hashable { - fileprivate static let interactionIdKey: SQL = SQL(stringLiteral: CodingKeys.interactionId.stringValue) - fileprivate static let interactionVariantKey: SQL = SQL(stringLiteral: CodingKeys.interactionVariant.stringValue) - fileprivate static let interactionAuthorIdKey: SQL = SQL(stringLiteral: CodingKeys.interactionAuthorId.stringValue) - fileprivate static let interactionTimestampMsKey: SQL = SQL(stringLiteral: CodingKeys.interactionTimestampMs.stringValue) - fileprivate static let rowIdKey: SQL = SQL(stringLiteral: CodingKeys.rowId.stringValue) - fileprivate static let attachmentKey: SQL = SQL(stringLiteral: CodingKeys.attachment.stringValue) - fileprivate static let attachmentAlbumIndexKey: SQL = SQL(stringLiteral: CodingKeys.attachmentAlbumIndex.stringValue) - - fileprivate static let attachmentString: String = CodingKeys.attachment.stringValue + public struct Item: FetchableRecordWithRowId, Decodable, Identifiable, Differentiable, Equatable, Hashable, ColumnExpressible { + public typealias Columns = CodingKeys + public enum CodingKeys: String, CodingKey, ColumnExpression, CaseIterable { + case interactionId + case interactionVariant + case interactionAuthorId + case interactionTimestampMs + + case rowId + case attachmentAlbumIndex + case attachment + } public var id: String { attachment.id } public var differenceIdentifier: String { attachment.id } @@ -306,7 +308,7 @@ public class MediaGalleryViewModel { let finalFilterSQL: SQL = { guard let customFilters: SQL = customFilters else { return """ - WHERE \(attachment.alias[Column.rowID]) IN \(rowIds) + WHERE \(attachment[.rowId]) IN \(rowIds) """ } @@ -318,14 +320,14 @@ public class MediaGalleryViewModel { }() let request: SQLRequest = """ SELECT - \(interaction[.id]) AS \(Item.interactionIdKey), - \(interaction[.variant]) AS \(Item.interactionVariantKey), - \(interaction[.authorId]) AS \(Item.interactionAuthorIdKey), - \(interaction[.timestampMs]) AS \(Item.interactionTimestampMsKey), + \(interaction[.id]) AS \(Item.Columns.interactionId), + \(interaction[.variant]) AS \(Item.Columns.interactionVariant), + \(interaction[.authorId]) AS \(Item.Columns.interactionAuthorId), + \(interaction[.timestampMs]) AS \(Item.Columns.interactionTimestampMs), - \(attachment.alias[Column.rowID]) AS \(Item.rowIdKey), - \(interactionAttachment[.albumIndex]) AS \(Item.attachmentAlbumIndexKey), - \(Item.attachmentKey).* + \(attachment[.rowId]) AS \(Item.Columns.rowId), + \(interactionAttachment[.albumIndex]) AS \(Item.Columns.attachmentAlbumIndex), + \(attachment.allColumns) FROM \(Attachment.self) \(joinSQL) \(finalFilterSQL) @@ -338,8 +340,8 @@ public class MediaGalleryViewModel { Attachment.numberOfSelectedColumns(db) ]) - return ScopeAdapter([ - Item.attachmentString: adapters[1] + return ScopeAdapter.with(Item.self, [ + .attachment: adapters[1] ]) } } diff --git a/Session/Media Viewing & Editing/MediaPageViewController.swift b/Session/Media Viewing & Editing/MediaPageViewController.swift index 59cf1a58f..1e14b2bdd 100644 --- a/Session/Media Viewing & Editing/MediaPageViewController.swift +++ b/Session/Media Viewing & Editing/MediaPageViewController.swift @@ -6,6 +6,8 @@ import SessionUIKit import SessionMessagingKit import SignalUtilitiesKit import SignalCoreKit +import SessionUtilitiesKit +import SessionSnodeKit class MediaPageViewController: UIPageViewController, UIPageViewControllerDataSource, UIPageViewControllerDelegate, MediaDetailViewControllerDelegate, InteractivelyDismissableViewController { class DynamicallySizedView: UIView { @@ -505,8 +507,9 @@ class MediaPageViewController: UIPageViewController, UIPageViewControllerDataSou dismissSelf(animated: true) } - @objc - public func didPressShare(_ sender: Any) { + @objc public func didPressShare(_ sender: Any) { share() } + + public func share(using dependencies: Dependencies = Dependencies()) { guard let currentViewController = self.viewControllers?[0] as? MediaDetailViewController else { owsFailDebug("currentViewController was unexpectedly nil") return @@ -553,7 +556,8 @@ class MediaPageViewController: UIPageViewController, UIPageViewControllerDataSou ), interactionId: nil, // Show no interaction for the current user threadId: threadId, - threadVariant: threadVariant + threadVariant: threadVariant, + using: dependencies ) } } diff --git a/Session/Media Viewing & Editing/MediaTileViewController.swift b/Session/Media Viewing & Editing/MediaTileViewController.swift index 5d24475b9..98d58dd5f 100644 --- a/Session/Media Viewing & Editing/MediaTileViewController.swift +++ b/Session/Media Viewing & Editing/MediaTileViewController.swift @@ -7,6 +7,7 @@ import DifferenceKit import SessionUIKit import SignalUtilitiesKit import SignalCoreKit +import SessionUtilitiesKit public class MediaTileViewController: UIViewController, UICollectionViewDataSource, UICollectionViewDelegate, UICollectionViewDelegateFlowLayout { @@ -54,6 +55,10 @@ public class MediaTileViewController: UIViewController, UICollectionViewDataSour // MARK: - UI override public var supportedInterfaceOrientations: UIInterfaceOrientationMask { + if UIDevice.current.isIPad { + return .all + } + return .allButUpsideDown } diff --git a/Session/Media Viewing & Editing/PhotoCaptureViewController.swift b/Session/Media Viewing & Editing/PhotoCaptureViewController.swift index 91e43eb61..3dd46b425 100644 --- a/Session/Media Viewing & Editing/PhotoCaptureViewController.swift +++ b/Session/Media Viewing & Editing/PhotoCaptureViewController.swift @@ -6,6 +6,7 @@ import AVFoundation import SessionUIKit import SignalUtilitiesKit import SignalCoreKit +import SessionUtilitiesKit protocol PhotoCaptureViewControllerDelegate: AnyObject { func photoCaptureViewController(_ photoCaptureViewController: PhotoCaptureViewController, didFinishProcessingAttachment attachment: SignalAttachment) diff --git a/Session/Media Viewing & Editing/PhotoLibrary.swift b/Session/Media Viewing & Editing/PhotoLibrary.swift index 5bc29f7e5..0825a42cf 100644 --- a/Session/Media Viewing & Editing/PhotoLibrary.swift +++ b/Session/Media Viewing & Editing/PhotoLibrary.swift @@ -6,6 +6,7 @@ import Photos import CoreServices import SignalUtilitiesKit import SignalCoreKit +import SessionUtilitiesKit protocol PhotoLibraryDelegate: AnyObject { func photoLibraryDidChange(_ photoLibrary: PhotoLibrary) diff --git a/Session/Media Viewing & Editing/SendMediaNavigationController.swift b/Session/Media Viewing & Editing/SendMediaNavigationController.swift index a9f5780d1..e706d08be 100644 --- a/Session/Media Viewing & Editing/SendMediaNavigationController.swift +++ b/Session/Media Viewing & Editing/SendMediaNavigationController.swift @@ -6,6 +6,7 @@ import Photos import SignalUtilitiesKit import SignalCoreKit import SessionUIKit +import SessionUtilitiesKit class SendMediaNavigationController: UINavigationController { public override var preferredStatusBarStyle: UIStatusBarStyle { @@ -17,12 +18,14 @@ class SendMediaNavigationController: UINavigationController { static let bottomButtonsCenterOffset: CGFloat = -50 private let threadId: String + private let threadVariant: SessionThread.Variant private var disposables: Set = Set() // MARK: - Initialization - init(threadId: String) { + init(threadId: String, threadVariant: SessionThread.Variant) { self.threadId = threadId + self.threadVariant = threadVariant super.init(nibName: nil, bundle: nil) } @@ -73,17 +76,15 @@ class SendMediaNavigationController: UINavigationController { public weak var sendMediaNavDelegate: SendMediaNavDelegate? - @objc - public class func showingCameraFirst(threadId: String) -> SendMediaNavigationController { - let navController = SendMediaNavigationController(threadId: threadId) + public class func showingCameraFirst(threadId: String, threadVariant: SessionThread.Variant) -> SendMediaNavigationController { + let navController = SendMediaNavigationController(threadId: threadId, threadVariant: threadVariant) navController.viewControllers = [navController.captureViewController] return navController } - @objc - public class func showingMediaLibraryFirst(threadId: String) -> SendMediaNavigationController { - let navController = SendMediaNavigationController(threadId: threadId) + public class func showingMediaLibraryFirst(threadId: String, threadVariant: SessionThread.Variant) -> SendMediaNavigationController { + let navController = SendMediaNavigationController(threadId: threadId, threadVariant: threadVariant) navController.viewControllers = [navController.mediaLibraryViewController] return navController @@ -232,6 +233,7 @@ class SendMediaNavigationController: UINavigationController { let approvalViewController = AttachmentApprovalViewController( mode: .sharedNavigation, threadId: self.threadId, + threadVariant: self.threadVariant, attachments: self.attachments ) approvalViewController.approvalDelegate = self @@ -430,8 +432,22 @@ extension SendMediaNavigationController: AttachmentApprovalViewControllerDelegat attachmentDraftCollection.remove(attachment: attachment) } - func attachmentApproval(_ attachmentApproval: AttachmentApprovalViewController, didApproveAttachments attachments: [SignalAttachment], forThreadId threadId: String, messageText: String?) { - sendMediaNavDelegate?.sendMediaNav(self, didApproveAttachments: attachments, forThreadId: threadId, messageText: messageText) + func attachmentApproval( + _ attachmentApproval: AttachmentApprovalViewController, + didApproveAttachments attachments: [SignalAttachment], + forThreadId threadId: String, + threadVariant: SessionThread.Variant, + messageText: String?, + using dependencies: Dependencies + ) { + sendMediaNavDelegate?.sendMediaNav( + self, + didApproveAttachments: attachments, + forThreadId: threadId, + threadVariant: threadVariant, + messageText: messageText, + using: dependencies + ) } func attachmentApprovalDidCancel(_ attachmentApproval: AttachmentApprovalViewController) { @@ -539,8 +555,8 @@ private struct MediaLibrarySelection: Hashable, Equatable { let asset: PHAsset let signalAttachmentPublisher: AnyPublisher - var hashValue: Int { - return asset.hashValue + func hash(into hasher: inout Hasher) { + asset.hash(into: &hasher) } var publisher: AnyPublisher { @@ -559,8 +575,8 @@ private struct MediaLibraryAttachment: Hashable, Equatable { let asset: PHAsset let signalAttachment: SignalAttachment - public var hashValue: Int { - return asset.hashValue + func hash(into hasher: inout Hasher) { + asset.hash(into: &hasher) } public static func == (lhs: MediaLibraryAttachment, rhs: MediaLibraryAttachment) -> Bool { @@ -764,7 +780,7 @@ private class DoneButton: UIView { protocol SendMediaNavDelegate: AnyObject { func sendMediaNavDidCancel(_ sendMediaNavigationController: SendMediaNavigationController?) - func sendMediaNav(_ sendMediaNavigationController: SendMediaNavigationController, didApproveAttachments attachments: [SignalAttachment], forThreadId threadId: String, messageText: String?) + func sendMediaNav(_ sendMediaNavigationController: SendMediaNavigationController, didApproveAttachments attachments: [SignalAttachment], forThreadId threadId: String, threadVariant: SessionThread.Variant, messageText: String?, using dependencies: Dependencies) func sendMediaNavInitialMessageText(_ sendMediaNavigationController: SendMediaNavigationController) -> String? func sendMediaNav(_ sendMediaNavigationController: SendMediaNavigationController, didChangeMessageText newMessageText: String?) diff --git a/Session/Meta/AppDelegate.swift b/Session/Meta/AppDelegate.swift index efb70ee58..283baeaf5 100644 --- a/Session/Meta/AppDelegate.swift +++ b/Session/Meta/AppDelegate.swift @@ -9,6 +9,7 @@ import SessionMessagingKit import SessionUtilitiesKit import SignalUtilitiesKit import SignalCoreKit +import SessionSnodeKit @UIApplicationMain class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterDelegate { @@ -92,7 +93,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD } // No point continuing if we are running tests - guard !CurrentAppContext().isRunningTests else { return true } + guard !SNUtilitiesKit.isRunningTests else { return true } self.window = mainWindow CurrentAppContext().mainWindow = mainWindow @@ -212,7 +213,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD } func applicationDidBecomeActive(_ application: UIApplication) { - guard !CurrentAppContext().isRunningTests else { return } + guard !SNUtilitiesKit.isRunningTests else { return } UserDefaults.sharedLokiProject?[.isMainAppActive] = true @@ -248,7 +249,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD func application(_ application: UIApplication, supportedInterfaceOrientationsFor window: UIWindow?) -> UIInterfaceOrientationMask { if UIDevice.current.isIPad { - return .allButUpsideDown + return .all } return .portrait @@ -314,7 +315,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD private func completePostMigrationSetup(calledFrom lifecycleMethod: LifecycleMethod, needsConfigSync: Bool) { SNLog("Migrations completed, performing setup and ensuring rootViewController") Configuration.performMainSetup() - JobRunner.add(executor: SyncPushTokensJob.self, for: .syncPushTokens) + JobRunner.setExecutor(SyncPushTokensJob.self, for: .syncPushTokens) // Setup the UI if needed, then trigger any post-UI setup actions self.ensureRootViewController(calledFrom: lifecycleMethod) { [weak self] success in @@ -522,7 +523,6 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD startPollersIfNeeded() if CurrentAppContext().isMainApp { - syncConfigurationIfNeeded() handleAppActivatedWithOngoingCallIfNeeded() } } @@ -868,36 +868,6 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD presentingVC.present(callVC, animated: true, completion: nil) } - - // MARK: - Config Sync - - func syncConfigurationIfNeeded() { - // FIXME: Remove this once `useSharedUtilForUserConfig` is permanent - guard !SessionUtil.userConfigsEnabled else { return } - - let lastSync: Date = (UserDefaults.standard[.lastConfigurationSync] ?? .distantPast) - - guard Date().timeIntervalSince(lastSync) > (7 * 24 * 60 * 60) else { return } // Sync every 2 days - - Storage.shared - .writeAsync( - updates: { db in - ConfigurationSyncJob.enqueue(db, publicKey: getUserHexEncodedPublicKey(db)) - }, - completion: { _, result in - switch result { - case .failure: break - case .success: - // Only update the 'lastConfigurationSync' timestamp if we have done the - // first sync (Don't want a new device config sync to override config - // syncs from other devices) - if UserDefaults.standard[.hasSyncedInitialConfiguration] { - UserDefaults.standard[.lastConfigurationSync] = Date() - } - } - } - ) - } } // MARK: - LifecycleMethod diff --git a/Session/Meta/AppEnvironment.swift b/Session/Meta/AppEnvironment.swift index 3efc61905..35e906c9f 100644 --- a/Session/Meta/AppEnvironment.swift +++ b/Session/Meta/AppEnvironment.swift @@ -1,8 +1,10 @@ // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import Foundation +import SessionUtilitiesKit import SignalUtilitiesKit import SignalCoreKit +import SessionMessagingKit public class AppEnvironment { @@ -11,7 +13,7 @@ public class AppEnvironment { public class var shared: AppEnvironment { get { return _shared } set { - guard CurrentAppContext().isRunningTests else { + guard SNUtilitiesKit.isRunningTests else { owsFailDebug("Can only switch environments in tests.") return } diff --git a/Session/Meta/MainAppContext.h b/Session/Meta/MainAppContext.h deleted file mode 100644 index 6fab6a1ad..000000000 --- a/Session/Meta/MainAppContext.h +++ /dev/null @@ -1,17 +0,0 @@ -// -// Copyright (c) 2019 Open Whisper Systems. All rights reserved. -// - -#import - -NS_ASSUME_NONNULL_BEGIN - -extern NSString *const ReportedApplicationStateDidChangeNotification; - -@interface MainAppContext : NSObject - -- (instancetype)init; - -@end - -NS_ASSUME_NONNULL_END diff --git a/Session/Meta/MainAppContext.m b/Session/Meta/MainAppContext.m deleted file mode 100644 index 3d0b92d1d..000000000 --- a/Session/Meta/MainAppContext.m +++ /dev/null @@ -1,321 +0,0 @@ -// -// Copyright (c) 2019 Open Whisper Systems. All rights reserved. -// - -#import "MainAppContext.h" -#import "Session-Swift.h" -#import -#import - -NS_ASSUME_NONNULL_BEGIN - -NSString *const ReportedApplicationStateDidChangeNotification = @"ReportedApplicationStateDidChangeNotification"; - -@interface MainAppContext () - -@property (atomic) UIApplicationState reportedApplicationState; - -@property (nonatomic, nullable) NSMutableArray *appActiveBlocks; - -@end - -#pragma mark - - -@implementation MainAppContext - -@synthesize mainWindow = _mainWindow; -@synthesize appLaunchTime = _appLaunchTime; -@synthesize wasWokenUpByPushNotification = _wasWokenUpByPushNotification; - -- (instancetype)init -{ - self = [super init]; - - if (!self) { - return self; - } - - self.reportedApplicationState = UIApplicationStateInactive; - - _appLaunchTime = [NSDate new]; - _wasWokenUpByPushNotification = false; - - [[NSNotificationCenter defaultCenter] addObserver:self - selector:@selector(applicationWillEnterForeground:) - name:UIApplicationWillEnterForegroundNotification - object:nil]; - [[NSNotificationCenter defaultCenter] addObserver:self - selector:@selector(applicationDidEnterBackground:) - name:UIApplicationDidEnterBackgroundNotification - object:nil]; - [[NSNotificationCenter defaultCenter] addObserver:self - selector:@selector(applicationWillResignActive:) - name:UIApplicationWillResignActiveNotification - object:nil]; - [[NSNotificationCenter defaultCenter] addObserver:self - selector:@selector(applicationDidBecomeActive:) - name:UIApplicationDidBecomeActiveNotification - object:nil]; - [[NSNotificationCenter defaultCenter] addObserver:self - selector:@selector(applicationWillTerminate:) - name:UIApplicationWillTerminateNotification - object:nil]; - - // We can't use OWSSingletonAssert() since it uses the app context. - - self.appActiveBlocks = [NSMutableArray new]; - - return self; -} - -- (void)dealloc -{ - [[NSNotificationCenter defaultCenter] removeObserver:self]; -} - -#pragma mark - Notifications - -- (void)setReportedApplicationState:(UIApplicationState)reportedApplicationState -{ - OWSAssertIsOnMainThread(); - - if (_reportedApplicationState == reportedApplicationState) { - return; - } - _reportedApplicationState = reportedApplicationState; - - [[NSNotificationCenter defaultCenter] postNotificationName:ReportedApplicationStateDidChangeNotification - object:nil - userInfo:nil]; -} - -- (void)applicationWillEnterForeground:(NSNotification *)notification -{ - OWSAssertIsOnMainThread(); - - self.reportedApplicationState = UIApplicationStateInactive; - - OWSLogInfo(@""); - - [NSNotificationCenter.defaultCenter postNotificationName:OWSApplicationWillEnterForegroundNotification object:nil]; -} - -- (void)applicationDidEnterBackground:(NSNotification *)notification -{ - OWSAssertIsOnMainThread(); - - self.reportedApplicationState = UIApplicationStateBackground; - - OWSLogInfo(@""); - [DDLog flushLog]; - - [NSNotificationCenter.defaultCenter postNotificationName:OWSApplicationDidEnterBackgroundNotification object:nil]; -} - -- (void)applicationWillResignActive:(NSNotification *)notification -{ - OWSAssertIsOnMainThread(); - - self.reportedApplicationState = UIApplicationStateInactive; - - OWSLogInfo(@""); - [DDLog flushLog]; - - [NSNotificationCenter.defaultCenter postNotificationName:OWSApplicationWillResignActiveNotification object:nil]; -} - -- (void)applicationDidBecomeActive:(NSNotification *)notification -{ - OWSAssertIsOnMainThread(); - - self.reportedApplicationState = UIApplicationStateActive; - - OWSLogInfo(@""); - - [NSNotificationCenter.defaultCenter postNotificationName:OWSApplicationDidBecomeActiveNotification object:nil]; - - [self runAppActiveBlocks]; -} - -- (void)applicationWillTerminate:(NSNotification *)notification -{ - OWSAssertIsOnMainThread(); - - OWSLogInfo(@""); - [DDLog flushLog]; -} - -#pragma mark - - -- (BOOL)isMainApp -{ - return YES; -} - -- (BOOL)isMainAppAndActive -{ - return [UIApplication sharedApplication].applicationState == UIApplicationStateActive; -} - -- (BOOL)isShareExtension { - return NO; -} - -- (BOOL)isRTL -{ - // FIXME: We should try to remove this as we've had to add a hack to ensure the first call to this runs on the main thread - static BOOL isRTL = NO; - static dispatch_once_t onceToken; - dispatch_once(&onceToken, ^{ - isRTL = [[UIApplication sharedApplication] userInterfaceLayoutDirection] - == UIUserInterfaceLayoutDirectionRightToLeft; - }); - return isRTL; -} - -- (void)setStatusBarHidden:(BOOL)isHidden animated:(BOOL)isAnimated -{ - [[UIApplication sharedApplication] setStatusBarHidden:isHidden animated:isAnimated]; -} - -- (CGFloat)statusBarHeight -{ - return [UIApplication sharedApplication].statusBarFrame.size.height; -} - -- (BOOL)isInBackground -{ - return self.reportedApplicationState == UIApplicationStateBackground; -} - -- (BOOL)isAppForegroundAndActive -{ - return self.reportedApplicationState == UIApplicationStateActive; -} - -- (UIBackgroundTaskIdentifier)beginBackgroundTaskWithExpirationHandler: - (BackgroundTaskExpirationHandler)expirationHandler -{ - return [[UIApplication sharedApplication] beginBackgroundTaskWithExpirationHandler:expirationHandler]; -} - -- (void)endBackgroundTask:(UIBackgroundTaskIdentifier)backgroundTaskIdentifier -{ - [UIApplication.sharedApplication endBackgroundTask:backgroundTaskIdentifier]; -} - -- (void)ensureSleepBlocking:(BOOL)shouldBeBlocking blockingObjects:(NSArray *)blockingObjects -{ - if (UIApplication.sharedApplication.isIdleTimerDisabled != shouldBeBlocking) { - if (shouldBeBlocking) { - NSMutableString *logString = - [NSMutableString stringWithFormat:@"Blocking sleep because of: %@", blockingObjects.firstObject]; - if (blockingObjects.count > 1) { - [logString appendString:[NSString stringWithFormat:@"(and %lu others)", blockingObjects.count - 1]]; - } - OWSLogInfo(@"%@", logString); - } else { - OWSLogInfo(@"Unblocking Sleep."); - } - } - UIApplication.sharedApplication.idleTimerDisabled = shouldBeBlocking; -} - -- (void)setMainAppBadgeNumber:(NSInteger)value -{ - [[UIApplication sharedApplication] setApplicationIconBadgeNumber:value]; - [[NSUserDefaults sharedLokiProject] setInteger:value forKey:@"currentBadgeNumber"]; - [[NSUserDefaults sharedLokiProject] synchronize]; -} - -- (nullable UIViewController *)frontmostViewController -{ - return UIApplication.sharedApplication.frontmostViewControllerIgnoringAlerts; -} - -- (nullable UIAlertAction *)openSystemSettingsAction -{ - return [UIAlertAction actionWithTitle:CommonStrings.openSettingsButton - accessibilityIdentifier:[NSString stringWithFormat:@"%@.%@", self.class, @"system_settings"] - style:UIAlertActionStyleDefault - handler:^(UIAlertAction *_Nonnull action) { - [UIApplication.sharedApplication openSystemSettings]; - }]; -} - -- (BOOL)isRunningTests -{ - return (NSProcessInfo.processInfo.environment[@"XCTestConfigurationFilePath"] != nil); -} - -- (void)setNetworkActivityIndicatorVisible:(BOOL)value -{ - [[UIApplication sharedApplication] setNetworkActivityIndicatorVisible:value]; -} - -#pragma mark - - -- (void)runNowOrWhenMainAppIsActive:(AppActiveBlock)block -{ - OWSAssertDebug(block); - - [Threading dispatchMainThreadSafe:^{ - if (self.isMainAppAndActive) { - // App active blocks typically will be used to safely access the - // shared data container, so use a background task to protect this - // work. - OWSBackgroundTask *_Nullable backgroundTask = - [OWSBackgroundTask backgroundTaskWithLabelStr:__PRETTY_FUNCTION__]; - block(); - OWSAssertDebug(backgroundTask); - backgroundTask = nil; - return; - } - - [self.appActiveBlocks addObject:block]; - }]; -} - -- (void)runAppActiveBlocks -{ - OWSAssertIsOnMainThread(); - OWSAssertDebug(self.isMainAppAndActive); - - // App active blocks typically will be used to safely access the - // shared data container, so use a background task to protect this - // work. - OWSBackgroundTask *_Nullable backgroundTask = [OWSBackgroundTask backgroundTaskWithLabelStr:__PRETTY_FUNCTION__]; - - NSArray *appActiveBlocks = [self.appActiveBlocks copy]; - [self.appActiveBlocks removeAllObjects]; - for (AppActiveBlock block in appActiveBlocks) { - block(); - } - - OWSAssertDebug(backgroundTask); - backgroundTask = nil; -} - -- (NSString *)appDocumentDirectoryPath -{ - NSFileManager *fileManager = [NSFileManager defaultManager]; - NSURL *documentDirectoryURL = - [[fileManager URLsForDirectory:NSDocumentDirectory inDomains:NSUserDomainMask] lastObject]; - return [documentDirectoryURL path]; -} - -- (NSString *)appSharedDataDirectoryPath -{ - NSURL *groupContainerDirectoryURL = - [[NSFileManager defaultManager] containerURLForSecurityApplicationGroupIdentifier:SignalApplicationGroup]; - return [groupContainerDirectoryURL path]; -} - -- (NSUserDefaults *)appUserDefaults -{ - return [[NSUserDefaults alloc] initWithSuiteName:SignalApplicationGroup]; -} - -@end - -NS_ASSUME_NONNULL_END diff --git a/Session/Meta/MainAppContext.swift b/Session/Meta/MainAppContext.swift new file mode 100644 index 000000000..949176192 --- /dev/null +++ b/Session/Meta/MainAppContext.swift @@ -0,0 +1,253 @@ +// Copyright © 2023 Rangeproof Pty Ltd. All rights reserved. + +import UIKit +import SignalCoreKit +import SessionUtilitiesKit + +final class MainAppContext: NSObject, AppContext { + var reportedApplicationState: UIApplication.State + + let appLaunchTime = Date() + let isMainApp: Bool = true + var isMainAppAndActive: Bool { UIApplication.shared.applicationState == .active } + var isShareExtension: Bool = false + var appActiveBlocks: [AppActiveBlock] = [] + + var mainWindow: UIWindow? + var wasWokenUpByPushNotification: Bool = false + + private static var _isRTL: Bool = { + return (UIApplication.shared.userInterfaceLayoutDirection == .rightToLeft) + }() + + var isRTL: Bool { return MainAppContext._isRTL } + + var statusBarHeight: CGFloat { UIApplication.shared.statusBarFrame.size.height } + var openSystemSettingsAction: UIAlertAction? { + let result = UIAlertAction( + title: "OPEN_SETTINGS_BUTTON".localized(), + style: .default + ) { _ in UIApplication.shared.openSystemSettings() } + result.accessibilityIdentifier = "\(type(of: self)).system_settings" + + return result + } + + // MARK: - Initialization + + override init() { + self.reportedApplicationState = .inactive + + super.init() + + NotificationCenter.default.addObserver( + self, + selector: #selector(applicationWillEnterForeground(notification:)), + name: UIApplication.willEnterForegroundNotification, + object: nil + ) + NotificationCenter.default.addObserver( + self, + selector: #selector(applicationDidEnterBackground(notification:)), + name: UIApplication.didEnterBackgroundNotification, + object: nil + ) + NotificationCenter.default.addObserver( + self, + selector: #selector(applicationWillResignActive(notification:)), + name: UIApplication.willResignActiveNotification, + object: nil + ) + NotificationCenter.default.addObserver( + self, + selector: #selector(applicationDidBecomeActive(notification:)), + name: UIApplication.didBecomeActiveNotification, + object: nil + ) + NotificationCenter.default.addObserver( + self, + selector: #selector(applicationWillTerminate(notification:)), + name: UIApplication.willTerminateNotification, + object: nil + ) + } + + deinit { + NotificationCenter.default.removeObserver(self) + } + + // MARK: - Notifications + + @objc private func applicationWillEnterForeground(notification: NSNotification) { + AssertIsOnMainThread() + + self.reportedApplicationState = .inactive + OWSLogger.info("") + + NotificationCenter.default.post( + name: .OWSApplicationWillEnterForeground, + object: nil + ) + } + + @objc private func applicationDidEnterBackground(notification: NSNotification) { + AssertIsOnMainThread() + + self.reportedApplicationState = .background + + OWSLogger.info("") + DDLog.flushLog() + + NotificationCenter.default.post( + name: .OWSApplicationDidEnterBackground, + object: nil + ) + } + + @objc private func applicationWillResignActive(notification: NSNotification) { + AssertIsOnMainThread() + + self.reportedApplicationState = .inactive + + OWSLogger.info("") + DDLog.flushLog() + + NotificationCenter.default.post( + name: .OWSApplicationWillResignActive, + object: nil + ) + } + + @objc private func applicationDidBecomeActive(notification: NSNotification) { + AssertIsOnMainThread() + + self.reportedApplicationState = .active + + OWSLogger.info("") + + NotificationCenter.default.post( + name: .OWSApplicationDidBecomeActive, + object: nil + ) + + self.runAppActiveBlocks() + } + + @objc private func applicationWillTerminate(notification: NSNotification) { + AssertIsOnMainThread() + + OWSLogger.info("") + DDLog.flushLog() + } + + // MARK: - AppContext Functions + + func setStatusBarHidden(_ isHidden: Bool, animated isAnimated: Bool) { + UIApplication.shared.setStatusBarHidden(isHidden, with: (isAnimated ? .slide : .none)) + } + + func isAppForegroundAndActive() -> Bool { + return (reportedApplicationState == .active) + } + + func isInBackground() -> Bool { + return (reportedApplicationState == .background) + } + + func beginBackgroundTask(expirationHandler: @escaping BackgroundTaskExpirationHandler) -> UIBackgroundTaskIdentifier { + return UIApplication.shared.beginBackgroundTask(expirationHandler: expirationHandler) + } + + func endBackgroundTask(_ backgroundTaskIdentifier: UIBackgroundTaskIdentifier) { + UIApplication.shared.endBackgroundTask(backgroundTaskIdentifier) + } + + func ensureSleepBlocking(_ shouldBeBlocking: Bool, blockingObjects: [Any]) { + if UIApplication.shared.isIdleTimerDisabled != shouldBeBlocking { + if shouldBeBlocking { + var logString: String = "Blocking sleep because of: \(String(describing: blockingObjects.first))" + + if blockingObjects.count > 1 { + logString = "\(logString) (and \(blockingObjects.count - 1) others)" + } + OWSLogger.info(logString) + } + else { + OWSLogger.info("Unblocking Sleep.") + } + } + UIApplication.shared.isIdleTimerDisabled = shouldBeBlocking + } + + func setMainAppBadgeNumber(_ value: Int) { + UIApplication.shared.applicationIconBadgeNumber = value + UserDefaults.sharedLokiProject?.setValue(value, forKey: "currentBadgeNumber") + } + + func frontmostViewController() -> UIViewController? { + UIApplication.shared.frontmostViewControllerIgnoringAlerts + } + + func setNetworkActivityIndicatorVisible(_ value: Bool) { + UIApplication.shared.isNetworkActivityIndicatorVisible = value + } + + // MARK: - + + func runNowOr(whenMainAppIsActive block: @escaping AppActiveBlock) { + Threading.dispatchMainThreadSafe { [weak self] in + if self?.isMainAppAndActive == true { + // App active blocks typically will be used to safely access the + // shared data container, so use a background task to protect this + // work. + var backgroundTask: OWSBackgroundTask? = OWSBackgroundTask(label: #function) + block() + if backgroundTask != nil { backgroundTask = nil } + return + } + + self?.appActiveBlocks.append(block) + } + } + + func runAppActiveBlocks() { + // App active blocks typically will be used to safely access the + // shared data container, so use a background task to protect this + // work. + var backgroundTask: OWSBackgroundTask? = OWSBackgroundTask(label: #function) + + let appActiveBlocks: [AppActiveBlock] = self.appActiveBlocks + self.appActiveBlocks.removeAll() + + appActiveBlocks.forEach { $0() } + if backgroundTask != nil { backgroundTask = nil } + } + + func appDocumentDirectoryPath() -> String { + let targetPath: String? = FileManager.default + .urls( + for: .documentDirectory, + in: .userDomainMask + ) + .last? + .path + owsAssertDebug(targetPath != nil) + + return (targetPath ?? "") + } + + func appSharedDataDirectoryPath() -> String { + let targetPath: String? = FileManager.default + .containerURL(forSecurityApplicationGroupIdentifier: UserDefaults.applicationGroup)? + .path + owsAssertDebug(targetPath != nil) + + return (targetPath ?? "") + } + + func appUserDefaults() -> UserDefaults { + owsAssertDebug(UserDefaults.sharedLokiProject != nil) + + return (UserDefaults.sharedLokiProject ?? UserDefaults.standard) + } +} diff --git a/Session/Meta/Session-Info.plist b/Session/Meta/Session-Info.plist index 87cdc72f3..6e30c5bc2 100644 --- a/Session/Meta/Session-Info.plist +++ b/Session/Meta/Session-Info.plist @@ -140,6 +140,7 @@ UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown UIViewControllerBasedStatusBarAppearance diff --git a/Session/Meta/Settings.bundle/Pods-GlobalDependencies-Session-settings-metadata.plist b/Session/Meta/Settings.bundle/Pods-GlobalDependencies-Session-settings-metadata.plist new file mode 100644 index 000000000..bcd827b41 --- /dev/null +++ b/Session/Meta/Settings.bundle/Pods-GlobalDependencies-Session-settings-metadata.plist @@ -0,0 +1,1889 @@ + + + + + PreferenceSpecifiers + + + FooterText + This application makes use of the following third party libraries: + Title + Acknowledgements + Type + PSGroupSpecifier + + + FooterText + + Title + xcbeautify + Type + PSGroupSpecifier + + + FooterText + BSD 3-Clause License + +Copyright (c) 2010-2022, Deusty, LLC +All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. + +3. Neither the name of Deusty nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission of Deusty, LLC. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + + Title + CocoaLumberjack + Type + PSGroupSpecifier + + + FooterText + Copyright (C) 2015-2023 Gwendal Roué + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + + Title + GRDB.swift + Type + PSGroupSpecifier + + + FooterText + Copyright (c) 2008, ZETETIC LLC +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + * Neither the name of the ZETETIC LLC nor the + names of its contributors may be used to endorse or promote products + derived from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY ZETETIC LLC ''AS IS'' AND ANY +EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL ZETETIC LLC BE LIABLE FOR ANY +DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND +ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + + Title + SQLCipher + Type + PSGroupSpecifier + + + FooterText + ISC License + +Copyright (c) 2014-2020, Frank Denis <j at pureftpd dot org> + +Permission to use, copy, modify, and/or distribute this software for any +purpose with or without fee is hereby granted, provided that the above +copyright notice and this permission notice appear in all copies. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF +OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + + + Title + Sodium + Type + PSGroupSpecifier + + + FooterText + Copyright (c) 2011, The WebRTC project authors. All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + + * Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in + the documentation and/or other materials provided with the + distribution. + + * Neither the name of Google nor the names of its contributors may + be used to endorse or promote products derived from this software + without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + + Title + WebRTC-lib + Type + PSGroupSpecifier + + + FooterText + Software License Agreement (BSD License) + +Copyright (c) 2013, yap.TV Inc. +All rights reserved. + +Redistribution and use of this software in source and binary forms, +with or without modification, are permitted provided that the following conditions are met: + +* Redistributions of source code must retain the above + copyright notice, this list of conditions and the + following disclaimer. + +* Neither the name of yap.TV nor the names of its + contributors may be used to endorse or promote products + derived from this software without specific prior + written permission of yap.TV Inc. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + Title + YapDatabase + Type + PSGroupSpecifier + + + FooterText + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + Title + DifferenceKit + Type + PSGroupSpecifier + + + FooterText + The MIT License (MIT) + +Copyright (c) 2016 Vinh Nguyen + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + + Title + NVActivityIndicatorView + Type + PSGroupSpecifier + + + FooterText + This code is distributed under the terms and conditions of the MIT license. + +Copyright (c) 2014-2015 Tyler Fox + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + + Title + PureLayout + Type + PSGroupSpecifier + + + FooterText + Copyright (c) 2011, Tony Million. +All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + + Title + Reachability + Type + PSGroupSpecifier + + + FooterText + The MIT License (MIT) + +Copyright (c) 2015 ibireme <ibireme@gmail.com> + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + + + Title + YYImage + Type + PSGroupSpecifier + + + FooterText + Copyright (c) 2010, Google Inc. All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + + * Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in + the documentation and/or other materials provided with the + distribution. + + * Neither the name of Google nor the names of its contributors may + be used to endorse or promote products derived from this software + without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + + + Title + libwebp + Type + PSGroupSpecifier + + + FooterText + GNU GENERAL PUBLIC LICENSE + Version 2, June 1991 + + Copyright (C) 1989, 1991 Free Software Foundation, Inc., <http://fsf.org/> + 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The licenses for most software are designed to take away your +freedom to share and change it. By contrast, the GNU General Public +License is intended to guarantee your freedom to share and change free +software--to make sure the software is free for all its users. This +General Public License applies to most of the Free Software +Foundation's software and to any other program whose authors commit to +using it. (Some other Free Software Foundation software is covered by +the GNU Lesser General Public License instead.) You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +this service if you wish), that you receive source code or can get it +if you want it, that you can change the software or use pieces of it +in new free programs; and that you know you can do these things. + + To protect your rights, we need to make restrictions that forbid +anyone to deny you these rights or to ask you to surrender the rights. +These restrictions translate to certain responsibilities for you if you +distribute copies of the software, or if you modify it. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must give the recipients all the rights that +you have. You must make sure that they, too, receive or can get the +source code. And you must show them these terms so they know their +rights. + + We protect your rights with two steps: (1) copyright the software, and +(2) offer you this license which gives you legal permission to copy, +distribute and/or modify the software. + + Also, for each author's protection and ours, we want to make certain +that everyone understands that there is no warranty for this free +software. If the software is modified by someone else and passed on, we +want its recipients to know that what they have is not the original, so +that any problems introduced by others will not reflect on the original +authors' reputations. + + Finally, any free program is threatened constantly by software +patents. We wish to avoid the danger that redistributors of a free +program will individually obtain patent licenses, in effect making the +program proprietary. To prevent this, we have made it clear that any +patent must be licensed for everyone's free use or not licensed at all. + + The precise terms and conditions for copying, distribution and +modification follow. + + GNU GENERAL PUBLIC LICENSE + TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION + + 0. This License applies to any program or other work which contains +a notice placed by the copyright holder saying it may be distributed +under the terms of this General Public License. The "Program", below, +refers to any such program or work, and a "work based on the Program" +means either the Program or any derivative work under copyright law: +that is to say, a work containing the Program or a portion of it, +either verbatim or with modifications and/or translated into another +language. (Hereinafter, translation is included without limitation in +the term "modification".) Each licensee is addressed as "you". + +Activities other than copying, distribution and modification are not +covered by this License; they are outside its scope. The act of +running the Program is not restricted, and the output from the Program +is covered only if its contents constitute a work based on the +Program (independent of having been made by running the Program). +Whether that is true depends on what the Program does. + + 1. You may copy and distribute verbatim copies of the Program's +source code as you receive it, in any medium, provided that you +conspicuously and appropriately publish on each copy an appropriate +copyright notice and disclaimer of warranty; keep intact all the +notices that refer to this License and to the absence of any warranty; +and give any other recipients of the Program a copy of this License +along with the Program. + +You may charge a fee for the physical act of transferring a copy, and +you may at your option offer warranty protection in exchange for a fee. + + 2. You may modify your copy or copies of the Program or any portion +of it, thus forming a work based on the Program, and copy and +distribute such modifications or work under the terms of Section 1 +above, provided that you also meet all of these conditions: + + a) You must cause the modified files to carry prominent notices + stating that you changed the files and the date of any change. + + b) You must cause any work that you distribute or publish, that in + whole or in part contains or is derived from the Program or any + part thereof, to be licensed as a whole at no charge to all third + parties under the terms of this License. + + c) If the modified program normally reads commands interactively + when run, you must cause it, when started running for such + interactive use in the most ordinary way, to print or display an + announcement including an appropriate copyright notice and a + notice that there is no warranty (or else, saying that you provide + a warranty) and that users may redistribute the program under + these conditions, and telling the user how to view a copy of this + License. (Exception: if the Program itself is interactive but + does not normally print such an announcement, your work based on + the Program is not required to print an announcement.) + +These requirements apply to the modified work as a whole. If +identifiable sections of that work are not derived from the Program, +and can be reasonably considered independent and separate works in +themselves, then this License, and its terms, do not apply to those +sections when you distribute them as separate works. But when you +distribute the same sections as part of a whole which is a work based +on the Program, the distribution of the whole must be on the terms of +this License, whose permissions for other licensees extend to the +entire whole, and thus to each and every part regardless of who wrote it. + +Thus, it is not the intent of this section to claim rights or contest +your rights to work written entirely by you; rather, the intent is to +exercise the right to control the distribution of derivative or +collective works based on the Program. + +In addition, mere aggregation of another work not based on the Program +with the Program (or with a work based on the Program) on a volume of +a storage or distribution medium does not bring the other work under +the scope of this License. + + 3. You may copy and distribute the Program (or a work based on it, +under Section 2) in object code or executable form under the terms of +Sections 1 and 2 above provided that you also do one of the following: + + a) Accompany it with the complete corresponding machine-readable + source code, which must be distributed under the terms of Sections + 1 and 2 above on a medium customarily used for software interchange; or, + + b) Accompany it with a written offer, valid for at least three + years, to give any third party, for a charge no more than your + cost of physically performing source distribution, a complete + machine-readable copy of the corresponding source code, to be + distributed under the terms of Sections 1 and 2 above on a medium + customarily used for software interchange; or, + + c) Accompany it with the information you received as to the offer + to distribute corresponding source code. (This alternative is + allowed only for noncommercial distribution and only if you + received the program in object code or executable form with such + an offer, in accord with Subsection b above.) + +The source code for a work means the preferred form of the work for +making modifications to it. For an executable work, complete source +code means all the source code for all modules it contains, plus any +associated interface definition files, plus the scripts used to +control compilation and installation of the executable. However, as a +special exception, the source code distributed need not include +anything that is normally distributed (in either source or binary +form) with the major components (compiler, kernel, and so on) of the +operating system on which the executable runs, unless that component +itself accompanies the executable. + +If distribution of executable or object code is made by offering +access to copy from a designated place, then offering equivalent +access to copy the source code from the same place counts as +distribution of the source code, even though third parties are not +compelled to copy the source along with the object code. + + 4. You may not copy, modify, sublicense, or distribute the Program +except as expressly provided under this License. Any attempt +otherwise to copy, modify, sublicense or distribute the Program is +void, and will automatically terminate your rights under this License. +However, parties who have received copies, or rights, from you under +this License will not have their licenses terminated so long as such +parties remain in full compliance. + + 5. You are not required to accept this License, since you have not +signed it. However, nothing else grants you permission to modify or +distribute the Program or its derivative works. These actions are +prohibited by law if you do not accept this License. Therefore, by +modifying or distributing the Program (or any work based on the +Program), you indicate your acceptance of this License to do so, and +all its terms and conditions for copying, distributing or modifying +the Program or works based on it. + + 6. Each time you redistribute the Program (or any work based on the +Program), the recipient automatically receives a license from the +original licensor to copy, distribute or modify the Program subject to +these terms and conditions. You may not impose any further +restrictions on the recipients' exercise of the rights granted herein. +You are not responsible for enforcing compliance by third parties to +this License. + + 7. If, as a consequence of a court judgment or allegation of patent +infringement or for any other reason (not limited to patent issues), +conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot +distribute so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you +may not distribute the Program at all. For example, if a patent +license would not permit royalty-free redistribution of the Program by +all those who receive copies directly or indirectly through you, then +the only way you could satisfy both it and this License would be to +refrain entirely from distribution of the Program. + +If any portion of this section is held invalid or unenforceable under +any particular circumstance, the balance of the section is intended to +apply and the section as a whole is intended to apply in other +circumstances. + +It is not the purpose of this section to induce you to infringe any +patents or other property right claims or to contest validity of any +such claims; this section has the sole purpose of protecting the +integrity of the free software distribution system, which is +implemented by public license practices. Many people have made +generous contributions to the wide range of software distributed +through that system in reliance on consistent application of that +system; it is up to the author/donor to decide if he or she is willing +to distribute software through any other system and a licensee cannot +impose that choice. + +This section is intended to make thoroughly clear what is believed to +be a consequence of the rest of this License. + + 8. If the distribution and/or use of the Program is restricted in +certain countries either by patents or by copyrighted interfaces, the +original copyright holder who places the Program under this License +may add an explicit geographical distribution limitation excluding +those countries, so that distribution is permitted only in or among +countries not thus excluded. In such case, this License incorporates +the limitation as if written in the body of this License. + + 9. The Free Software Foundation may publish revised and/or new versions +of the General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + +Each version is given a distinguishing version number. If the Program +specifies a version number of this License which applies to it and "any +later version", you have the option of following the terms and conditions +either of that version or of any later version published by the Free +Software Foundation. If the Program does not specify a version number of +this License, you may choose any version ever published by the Free Software +Foundation. + + 10. If you wish to incorporate parts of the Program into other free +programs whose distribution conditions are different, write to the author +to ask for permission. For software which is copyrighted by the Free +Software Foundation, write to the Free Software Foundation; we sometimes +make exceptions for this. Our decision will be guided by the two goals +of preserving the free status of all derivatives of our free software and +of promoting the sharing and reuse of software generally. + + NO WARRANTY + + 11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY +FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN +OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES +PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED +OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS +TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE +PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, +REPAIR OR CORRECTION. + + 12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR +REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, +INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING +OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED +TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY +YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER +PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE +POSSIBILITY OF SUCH DAMAGES. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +convey the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + {description} + Copyright (C) {year} {fullname} + + This program is free software; you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation; either version 2 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along + with this program; if not, write to the Free Software Foundation, Inc., + 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +Also add information on how to contact you by electronic and paper mail. + +If the program is interactive, make it output a short notice like this +when it starts in an interactive mode: + + Gnomovision version 69, Copyright (C) year name of author + Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, the commands you use may +be called something other than `show w' and `show c'; they could even be +mouse-clicks or menu items--whatever suits your program. + +You should also get your employer (if you work as a programmer) or your +school, if any, to sign a "copyright disclaimer" for the program, if +necessary. Here is a sample; alter the names: + + Yoyodyne, Inc., hereby disclaims all copyright interest in the program + `Gnomovision' (which makes passes at compilers) written by James Hacker. + + {signature of Ty Coon}, 1 April 1989 + Ty Coon, President of Vice + +This General Public License does not permit incorporating your program into +proprietary programs. If your program is a subroutine library, you may +consider it more useful to permit linking proprietary applications with the +library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. + Title + Curve25519Kit + Type + PSGroupSpecifier + + + FooterText + + LICENSE ISSUES + ============== + + The OpenSSL toolkit stays under a dual license, i.e. both the conditions of + the OpenSSL License and the original SSLeay license apply to the toolkit. + See below for the actual license texts. Actually both licenses are BSD-style + Open Source licenses. In case of any license issues related to OpenSSL + please contact openssl-core@openssl.org. + + OpenSSL License + --------------- + +/* ==================================================================== + * Copyright (c) 1998-2008 The OpenSSL Project. All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions + * are met: + * + * 1. Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright + * notice, this list of conditions and the following disclaimer in + * the documentation and/or other materials provided with the + * distribution. + * + * 3. All advertising materials mentioning features or use of this + * software must display the following acknowledgment: + * "This product includes software developed by the OpenSSL Project + * for use in the OpenSSL Toolkit. (http://www.openssl.org/)" + * + * 4. The names "OpenSSL Toolkit" and "OpenSSL Project" must not be used to + * endorse or promote products derived from this software without + * prior written permission. For written permission, please contact + * openssl-core@openssl.org. + * + * 5. Products derived from this software may not be called "OpenSSL" + * nor may "OpenSSL" appear in their names without prior written + * permission of the OpenSSL Project. + * + * 6. Redistributions of any form whatsoever must retain the following + * acknowledgment: + * "This product includes software developed by the OpenSSL Project + * for use in the OpenSSL Toolkit (http://www.openssl.org/)" + * + * THIS SOFTWARE IS PROVIDED BY THE OpenSSL PROJECT ``AS IS'' AND ANY + * EXPRESSED OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR + * PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE OpenSSL PROJECT OR + * ITS CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT + * NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; + * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) + * HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, + * STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED + * OF THE POSSIBILITY OF SUCH DAMAGE. + * ==================================================================== + * + * This product includes cryptographic software written by Eric Young + * (eay@cryptsoft.com). This product includes software written by Tim + * Hudson (tjh@cryptsoft.com). + * + */ + + Original SSLeay License + ----------------------- + +/* Copyright (C) 1995-1998 Eric Young (eay@cryptsoft.com) + * All rights reserved. + * + * This package is an SSL implementation written + * by Eric Young (eay@cryptsoft.com). + * The implementation was written so as to conform with Netscapes SSL. + * + * This library is free for commercial and non-commercial use as long as + * the following conditions are aheared to. The following conditions + * apply to all code found in this distribution, be it the RC4, RSA, + * lhash, DES, etc., code; not just the SSL code. The SSL documentation + * included with this distribution is covered by the same copyright terms + * except that the holder is Tim Hudson (tjh@cryptsoft.com). + * + * Copyright remains Eric Young's, and as such any Copyright notices in + * the code are not to be removed. + * If this package is used in a product, Eric Young should be given attribution + * as the author of the parts of the library used. + * This can be in the form of a textual message at program startup or + * in documentation (online or textual) provided with the package. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions + * are met: + * 1. Redistributions of source code must retain the copyright + * notice, this list of conditions and the following disclaimer. + * 2. Redistributions in binary form must reproduce the above copyright + * notice, this list of conditions and the following disclaimer in the + * documentation and/or other materials provided with the distribution. + * 3. All advertising materials mentioning features or use of this software + * must display the following acknowledgement: + * "This product includes cryptographic software written by + * Eric Young (eay@cryptsoft.com)" + * The word 'cryptographic' can be left out if the rouines from the library + * being used are not cryptographic related :-). + * 4. If you include any Windows specific code (or a derivative thereof) from + * the apps directory (application code) you must include an acknowledgement: + * "This product includes software written by Tim Hudson (tjh@cryptsoft.com)" + * + * THIS SOFTWARE IS PROVIDED BY ERIC YOUNG ``AS IS'' AND + * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE + * ARE DISCLAIMED. IN NO EVENT SHALL THE AUTHOR OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS + * OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) + * HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT + * LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY + * OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF + * SUCH DAMAGE. + * + * The licence and distribution terms for any publically available version or + * derivative of this code cannot be changed. i.e. this code cannot simply be + * copied and put under another distribution licence + * [including the GNU Public Licence.] + */ + + + Title + OpenSSL-Universal + Type + PSGroupSpecifier + + + FooterText + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. <http://fsf.org/> + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Use with the GNU Affero General Public License. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + Title + SignalCoreKit + Type + PSGroupSpecifier + + + FooterText + Copyright (c) 2010-2016 Sam Soffes, http://soff.es + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + + Title + SAMKeychain + Type + PSGroupSpecifier + + + FooterText + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + + + +## Runtime Library Exception to the Apache 2.0 License: ## + + + As an exception, if you use this Software to compile your source code and + portions of this Software are embedded into the binary product as a result, + you may redistribute such product without providing attribution as would + otherwise be required by Sections 4(a), 4(b) and 4(d) of the License. + + Title + SwiftProtobuf + Type + PSGroupSpecifier + + + FooterText + Generated by CocoaPods - https://cocoapods.org + Title + + Type + PSGroupSpecifier + + + StringsTable + Acknowledgements + Title + Acknowledgements + + diff --git a/Session/Meta/Settings.bundle/Root.plist b/Session/Meta/Settings.bundle/Root.plist index b1b6fea5d..2b5fc900a 100644 --- a/Session/Meta/Settings.bundle/Root.plist +++ b/Session/Meta/Settings.bundle/Root.plist @@ -7,54 +7,12 @@ PreferenceSpecifiers - Type - PSGroupSpecifier + File + Pods-GlobalDependencies-Session-settings-metadata Title - Group - - + Acknowledgements Type - PSTextFieldSpecifier - Title - Name - Key - name_preference - DefaultValue - - IsSecure - - KeyboardType - Alphabet - AutocapitalizationType - None - AutocorrectionType - No - - - Type - PSToggleSwitchSpecifier - Title - Enabled - Key - enabled_preference - DefaultValue - - - - Type - PSSliderSpecifier - Key - slider_preference - DefaultValue - 0.5 - MinimumValue - 0 - MaximumValue - 1 - MinimumValueImage - - MaximumValueImage - + PSChildPaneSpecifier diff --git a/Session/Meta/Settings.bundle/en.lproj/Root.strings b/Session/Meta/Settings.bundle/en.lproj/Root.strings deleted file mode 100644 index 8cd87b9d6..000000000 Binary files a/Session/Meta/Settings.bundle/en.lproj/Root.strings and /dev/null differ diff --git a/Session/Meta/Signal-Bridging-Header.h b/Session/Meta/Signal-Bridging-Header.h index a745cd256..c9b6f4634 100644 --- a/Session/Meta/Signal-Bridging-Header.h +++ b/Session/Meta/Signal-Bridging-Header.h @@ -7,4 +7,3 @@ #import "OWSBezierPathView.h" #import "OWSMessageTimerView.h" #import "OWSWindowManager.h" -#import "MainAppContext.h" diff --git a/Session/Meta/Translations/de.lproj/Localizable.strings b/Session/Meta/Translations/de.lproj/Localizable.strings index c3fda3b34..8bcb25dae 100644 --- a/Session/Meta/Translations/de.lproj/Localizable.strings +++ b/Session/Meta/Translations/de.lproj/Localizable.strings @@ -483,6 +483,9 @@ "PRIVACY_SECTION_SCREEN_SECURITY" = "Bildschirmschutz"; "PRIVACY_SCREEN_SECURITY_LOCK_SESSION_TITLE" = "Lock Session"; "PRIVACY_SCREEN_SECURITY_LOCK_SESSION_DESCRIPTION" = "Require Touch ID, Face ID or your passcode to unlock Session."; +"PRIVACY_SECTION_MESSAGE_REQUESTS" = "Message Requests"; +"PRIVACY_SCREEN_MESSAGE_REQUESTS_COMMUNITY_TITLE" = "Community Message Requests"; +"PRIVACY_SCREEN_MESSAGE_REQUESTS_COMMUNITY_DESCRIPTION" = "Allow message requests from Community conversations."; "PRIVACY_SECTION_READ_RECEIPTS" = "Lesebestätigungen"; "PRIVACY_READ_RECEIPTS_TITLE" = "Lesebestätigungen"; "PRIVACY_READ_RECEIPTS_DESCRIPTION" = "Send read receipts in one-to-one chats."; @@ -645,6 +648,8 @@ "CONVERSATION_EMPTY_STATE" = "You have no messages from %@. Send a message to start the conversation!"; "CONVERSATION_EMPTY_STATE_READ_ONLY" = "There are no messages in %@."; "CONVERSATION_EMPTY_STATE_NOTE_TO_SELF" = "You have no messages in %@."; +"COMMUNITY_MESSAGE_REQUEST_DISABLED_EMPTY_STATE" = "%@ has message requests from Community conversations turned off, so you cannot send them a message."; "USER_CONFIG_OUTDATED_WARNING" = "Some of your devices are using outdated versions. Syncing may be unreliable until they are updated."; "LOAD_RECOVERY_PASSWORD_ERROR" = "An error occurred when trying to load your recovery password.\n\nPlease export your logs, then upload the file though Session's Help Desk to help resolve this issue."; "FAILED_TO_STORE_OUTGOING_MESSAGE" = "An error occurred when trying to store the outgoing message for sending, you may need to restart the app before you can send messages."; +"database_inaccessible_error" = "There is an issue opening the database. Please restart the app and try again."; diff --git a/Session/Meta/Translations/en.lproj/Localizable.strings b/Session/Meta/Translations/en.lproj/Localizable.strings index d40fa50bd..73a0a30ef 100644 --- a/Session/Meta/Translations/en.lproj/Localizable.strings +++ b/Session/Meta/Translations/en.lproj/Localizable.strings @@ -483,6 +483,9 @@ "PRIVACY_SECTION_SCREEN_SECURITY" = "Screen Security"; "PRIVACY_SCREEN_SECURITY_LOCK_SESSION_TITLE" = "Lock Session"; "PRIVACY_SCREEN_SECURITY_LOCK_SESSION_DESCRIPTION" = "Require Touch ID, Face ID or your passcode to unlock Session."; +"PRIVACY_SECTION_MESSAGE_REQUESTS" = "Message Requests"; +"PRIVACY_SCREEN_MESSAGE_REQUESTS_COMMUNITY_TITLE" = "Community Message Requests"; +"PRIVACY_SCREEN_MESSAGE_REQUESTS_COMMUNITY_DESCRIPTION" = "Allow message requests from Community conversations."; "PRIVACY_SECTION_READ_RECEIPTS" = "Read Receipts"; "PRIVACY_READ_RECEIPTS_TITLE" = "Read Receipts"; "PRIVACY_READ_RECEIPTS_DESCRIPTION" = "Send read receipts in one-to-one chats."; @@ -645,6 +648,8 @@ "CONVERSATION_EMPTY_STATE" = "You have no messages from %@. Send a message to start the conversation!"; "CONVERSATION_EMPTY_STATE_READ_ONLY" = "There are no messages in %@."; "CONVERSATION_EMPTY_STATE_NOTE_TO_SELF" = "You have no messages in %@."; +"COMMUNITY_MESSAGE_REQUEST_DISABLED_EMPTY_STATE" = "%@ has message requests from Community conversations turned off, so you cannot send them a message."; "USER_CONFIG_OUTDATED_WARNING" = "Some of your devices are using outdated versions. Syncing may be unreliable until they are updated."; "LOAD_RECOVERY_PASSWORD_ERROR" = "An error occurred when trying to load your recovery password.\n\nPlease export your logs, then upload the file though Session's Help Desk to help resolve this issue."; "FAILED_TO_STORE_OUTGOING_MESSAGE" = "An error occurred when trying to store the outgoing message for sending, you may need to restart the app before you can send messages."; +"database_inaccessible_error" = "There is an issue opening the database. Please restart the app and try again."; diff --git a/Session/Meta/Translations/es.lproj/Localizable.strings b/Session/Meta/Translations/es.lproj/Localizable.strings index 8e83718e1..a6443d9b5 100644 --- a/Session/Meta/Translations/es.lproj/Localizable.strings +++ b/Session/Meta/Translations/es.lproj/Localizable.strings @@ -483,6 +483,9 @@ "PRIVACY_SECTION_SCREEN_SECURITY" = "Protección de pantalla"; "PRIVACY_SCREEN_SECURITY_LOCK_SESSION_TITLE" = "Lock Session"; "PRIVACY_SCREEN_SECURITY_LOCK_SESSION_DESCRIPTION" = "Require Touch ID, Face ID or your passcode to unlock Session."; +"PRIVACY_SECTION_MESSAGE_REQUESTS" = "Message Requests"; +"PRIVACY_SCREEN_MESSAGE_REQUESTS_COMMUNITY_TITLE" = "Community Message Requests"; +"PRIVACY_SCREEN_MESSAGE_REQUESTS_COMMUNITY_DESCRIPTION" = "Allow message requests from Community conversations."; "PRIVACY_SECTION_READ_RECEIPTS" = "Notificaciones de lectura"; "PRIVACY_READ_RECEIPTS_TITLE" = "Notificaciones de lectura"; "PRIVACY_READ_RECEIPTS_DESCRIPTION" = "Send read receipts in one-to-one chats."; @@ -645,6 +648,8 @@ "CONVERSATION_EMPTY_STATE" = "You have no messages from %@. Send a message to start the conversation!"; "CONVERSATION_EMPTY_STATE_READ_ONLY" = "There are no messages in %@."; "CONVERSATION_EMPTY_STATE_NOTE_TO_SELF" = "You have no messages in %@."; +"COMMUNITY_MESSAGE_REQUEST_DISABLED_EMPTY_STATE" = "%@ has message requests from Community conversations turned off, so you cannot send them a message."; "USER_CONFIG_OUTDATED_WARNING" = "Some of your devices are using outdated versions. Syncing may be unreliable until they are updated."; "LOAD_RECOVERY_PASSWORD_ERROR" = "An error occurred when trying to load your recovery password.\n\nPlease export your logs, then upload the file though Session's Help Desk to help resolve this issue."; "FAILED_TO_STORE_OUTGOING_MESSAGE" = "An error occurred when trying to store the outgoing message for sending, you may need to restart the app before you can send messages."; +"database_inaccessible_error" = "There is an issue opening the database. Please restart the app and try again."; diff --git a/Session/Meta/Translations/fa.lproj/Localizable.strings b/Session/Meta/Translations/fa.lproj/Localizable.strings index 7f48d7e42..93eb19aee 100644 --- a/Session/Meta/Translations/fa.lproj/Localizable.strings +++ b/Session/Meta/Translations/fa.lproj/Localizable.strings @@ -483,6 +483,9 @@ "PRIVACY_SECTION_SCREEN_SECURITY" = "امنیت صفحه"; "PRIVACY_SCREEN_SECURITY_LOCK_SESSION_TITLE" = "قفل Session"; "PRIVACY_SCREEN_SECURITY_LOCK_SESSION_DESCRIPTION" = " برای باز کردن قفل Session به شناسه لمسی، شناسه صورت و یا رمز عبوری ضرورت است."; +"PRIVACY_SECTION_MESSAGE_REQUESTS" = "Message Requests"; +"PRIVACY_SCREEN_MESSAGE_REQUESTS_COMMUNITY_TITLE" = "Community Message Requests"; +"PRIVACY_SCREEN_MESSAGE_REQUESTS_COMMUNITY_DESCRIPTION" = "Allow message requests from Community conversations."; "PRIVACY_SECTION_READ_RECEIPTS" = "رسیدهای خواندن"; "PRIVACY_READ_RECEIPTS_TITLE" = "رسیدهای خواندن"; "PRIVACY_READ_RECEIPTS_DESCRIPTION" = "رسیدهای خواندن در چت‌های یک به یک روان شود."; @@ -645,6 +648,8 @@ "CONVERSATION_EMPTY_STATE" = "You have no messages from %@. Send a message to start the conversation!"; "CONVERSATION_EMPTY_STATE_READ_ONLY" = "There are no messages in %@."; "CONVERSATION_EMPTY_STATE_NOTE_TO_SELF" = "You have no messages in %@."; +"COMMUNITY_MESSAGE_REQUEST_DISABLED_EMPTY_STATE" = "%@ has message requests from Community conversations turned off, so you cannot send them a message."; "USER_CONFIG_OUTDATED_WARNING" = "Some of your devices are using outdated versions. Syncing may be unreliable until they are updated."; "LOAD_RECOVERY_PASSWORD_ERROR" = "An error occurred when trying to load your recovery password.\n\nPlease export your logs, then upload the file though Session's Help Desk to help resolve this issue."; "FAILED_TO_STORE_OUTGOING_MESSAGE" = "An error occurred when trying to store the outgoing message for sending, you may need to restart the app before you can send messages."; +"database_inaccessible_error" = "There is an issue opening the database. Please restart the app and try again."; diff --git a/Session/Meta/Translations/fi.lproj/Localizable.strings b/Session/Meta/Translations/fi.lproj/Localizable.strings index 9167e07b6..12b1118c9 100644 --- a/Session/Meta/Translations/fi.lproj/Localizable.strings +++ b/Session/Meta/Translations/fi.lproj/Localizable.strings @@ -483,6 +483,9 @@ "PRIVACY_SECTION_SCREEN_SECURITY" = "Näytön suojaus"; "PRIVACY_SCREEN_SECURITY_LOCK_SESSION_TITLE" = "Lock Session"; "PRIVACY_SCREEN_SECURITY_LOCK_SESSION_DESCRIPTION" = "Require Touch ID, Face ID or your passcode to unlock Session."; +"PRIVACY_SECTION_MESSAGE_REQUESTS" = "Message Requests"; +"PRIVACY_SCREEN_MESSAGE_REQUESTS_COMMUNITY_TITLE" = "Community Message Requests"; +"PRIVACY_SCREEN_MESSAGE_REQUESTS_COMMUNITY_DESCRIPTION" = "Allow message requests from Community conversations."; "PRIVACY_SECTION_READ_RECEIPTS" = "Lukukuittaukset"; "PRIVACY_READ_RECEIPTS_TITLE" = "Lukukuittaukset"; "PRIVACY_READ_RECEIPTS_DESCRIPTION" = "Send read receipts in one-to-one chats."; @@ -645,6 +648,8 @@ "CONVERSATION_EMPTY_STATE" = "You have no messages from %@. Send a message to start the conversation!"; "CONVERSATION_EMPTY_STATE_READ_ONLY" = "There are no messages in %@."; "CONVERSATION_EMPTY_STATE_NOTE_TO_SELF" = "You have no messages in %@."; +"COMMUNITY_MESSAGE_REQUEST_DISABLED_EMPTY_STATE" = "%@ has message requests from Community conversations turned off, so you cannot send them a message."; "USER_CONFIG_OUTDATED_WARNING" = "Some of your devices are using outdated versions. Syncing may be unreliable until they are updated."; "LOAD_RECOVERY_PASSWORD_ERROR" = "An error occurred when trying to load your recovery password.\n\nPlease export your logs, then upload the file though Session's Help Desk to help resolve this issue."; "FAILED_TO_STORE_OUTGOING_MESSAGE" = "An error occurred when trying to store the outgoing message for sending, you may need to restart the app before you can send messages."; +"database_inaccessible_error" = "There is an issue opening the database. Please restart the app and try again."; diff --git a/Session/Meta/Translations/fr.lproj/Localizable.strings b/Session/Meta/Translations/fr.lproj/Localizable.strings index 6b78436ee..13368d020 100644 --- a/Session/Meta/Translations/fr.lproj/Localizable.strings +++ b/Session/Meta/Translations/fr.lproj/Localizable.strings @@ -483,6 +483,9 @@ "PRIVACY_SECTION_SCREEN_SECURITY" = "Sécurité de l’écran"; "PRIVACY_SCREEN_SECURITY_LOCK_SESSION_TITLE" = "Verrouiller Session"; "PRIVACY_SCREEN_SECURITY_LOCK_SESSION_DESCRIPTION" = "Requiert Touch ID, Face ID ou votre code pour déverrouiller Session."; +"PRIVACY_SECTION_MESSAGE_REQUESTS" = "Message Requests"; +"PRIVACY_SCREEN_MESSAGE_REQUESTS_COMMUNITY_TITLE" = "Community Message Requests"; +"PRIVACY_SCREEN_MESSAGE_REQUESTS_COMMUNITY_DESCRIPTION" = "Allow message requests from Community conversations."; "PRIVACY_SECTION_READ_RECEIPTS" = "Accusés de lecture"; "PRIVACY_READ_RECEIPTS_TITLE" = "Accusés de lecture"; "PRIVACY_READ_RECEIPTS_DESCRIPTION" = "Envoyer un accusé réception dans les conversations 1 à 1."; @@ -645,6 +648,8 @@ "CONVERSATION_EMPTY_STATE" = "You have no messages from %@. Send a message to start the conversation!"; "CONVERSATION_EMPTY_STATE_READ_ONLY" = "There are no messages in %@."; "CONVERSATION_EMPTY_STATE_NOTE_TO_SELF" = "You have no messages in %@."; +"COMMUNITY_MESSAGE_REQUEST_DISABLED_EMPTY_STATE" = "%@ has message requests from Community conversations turned off, so you cannot send them a message."; "USER_CONFIG_OUTDATED_WARNING" = "Some of your devices are using outdated versions. Syncing may be unreliable until they are updated."; "LOAD_RECOVERY_PASSWORD_ERROR" = "An error occurred when trying to load your recovery password.\n\nPlease export your logs, then upload the file though Session's Help Desk to help resolve this issue."; "FAILED_TO_STORE_OUTGOING_MESSAGE" = "An error occurred when trying to store the outgoing message for sending, you may need to restart the app before you can send messages."; +"database_inaccessible_error" = "There is an issue opening the database. Please restart the app and try again."; diff --git a/Session/Meta/Translations/hi.lproj/Localizable.strings b/Session/Meta/Translations/hi.lproj/Localizable.strings index 4ceb134f9..c129fe04d 100644 --- a/Session/Meta/Translations/hi.lproj/Localizable.strings +++ b/Session/Meta/Translations/hi.lproj/Localizable.strings @@ -483,6 +483,9 @@ "PRIVACY_SECTION_SCREEN_SECURITY" = "Screen Security"; "PRIVACY_SCREEN_SECURITY_LOCK_SESSION_TITLE" = "Lock Session"; "PRIVACY_SCREEN_SECURITY_LOCK_SESSION_DESCRIPTION" = "Require Touch ID, Face ID or your passcode to unlock Session."; +"PRIVACY_SECTION_MESSAGE_REQUESTS" = "Message Requests"; +"PRIVACY_SCREEN_MESSAGE_REQUESTS_COMMUNITY_TITLE" = "Community Message Requests"; +"PRIVACY_SCREEN_MESSAGE_REQUESTS_COMMUNITY_DESCRIPTION" = "Allow message requests from Community conversations."; "PRIVACY_SECTION_READ_RECEIPTS" = "Read Receipts"; "PRIVACY_READ_RECEIPTS_TITLE" = "Read Receipts"; "PRIVACY_READ_RECEIPTS_DESCRIPTION" = "Send read receipts in one-to-one chats."; @@ -645,6 +648,8 @@ "CONVERSATION_EMPTY_STATE" = "You have no messages from %@. Send a message to start the conversation!"; "CONVERSATION_EMPTY_STATE_READ_ONLY" = "There are no messages in %@."; "CONVERSATION_EMPTY_STATE_NOTE_TO_SELF" = "You have no messages in %@."; +"COMMUNITY_MESSAGE_REQUEST_DISABLED_EMPTY_STATE" = "%@ has message requests from Community conversations turned off, so you cannot send them a message."; "USER_CONFIG_OUTDATED_WARNING" = "Some of your devices are using outdated versions. Syncing may be unreliable until they are updated."; "LOAD_RECOVERY_PASSWORD_ERROR" = "An error occurred when trying to load your recovery password.\n\nPlease export your logs, then upload the file though Session's Help Desk to help resolve this issue."; "FAILED_TO_STORE_OUTGOING_MESSAGE" = "An error occurred when trying to store the outgoing message for sending, you may need to restart the app before you can send messages."; +"database_inaccessible_error" = "There is an issue opening the database. Please restart the app and try again."; diff --git a/Session/Meta/Translations/hr.lproj/Localizable.strings b/Session/Meta/Translations/hr.lproj/Localizable.strings index 1e9412ce3..f0165ad1c 100644 --- a/Session/Meta/Translations/hr.lproj/Localizable.strings +++ b/Session/Meta/Translations/hr.lproj/Localizable.strings @@ -483,6 +483,9 @@ "PRIVACY_SECTION_SCREEN_SECURITY" = "Sigurnost zaslona"; "PRIVACY_SCREEN_SECURITY_LOCK_SESSION_TITLE" = "Lock Session"; "PRIVACY_SCREEN_SECURITY_LOCK_SESSION_DESCRIPTION" = "Require Touch ID, Face ID or your passcode to unlock Session."; +"PRIVACY_SECTION_MESSAGE_REQUESTS" = "Message Requests"; +"PRIVACY_SCREEN_MESSAGE_REQUESTS_COMMUNITY_TITLE" = "Community Message Requests"; +"PRIVACY_SCREEN_MESSAGE_REQUESTS_COMMUNITY_DESCRIPTION" = "Allow message requests from Community conversations."; "PRIVACY_SECTION_READ_RECEIPTS" = "Potvrda o čitanju"; "PRIVACY_READ_RECEIPTS_TITLE" = "Potvrda o čitanju"; "PRIVACY_READ_RECEIPTS_DESCRIPTION" = "Send read receipts in one-to-one chats."; @@ -645,6 +648,8 @@ "CONVERSATION_EMPTY_STATE" = "You have no messages from %@. Send a message to start the conversation!"; "CONVERSATION_EMPTY_STATE_READ_ONLY" = "There are no messages in %@."; "CONVERSATION_EMPTY_STATE_NOTE_TO_SELF" = "You have no messages in %@."; +"COMMUNITY_MESSAGE_REQUEST_DISABLED_EMPTY_STATE" = "%@ has message requests from Community conversations turned off, so you cannot send them a message."; "USER_CONFIG_OUTDATED_WARNING" = "Some of your devices are using outdated versions. Syncing may be unreliable until they are updated."; "LOAD_RECOVERY_PASSWORD_ERROR" = "An error occurred when trying to load your recovery password.\n\nPlease export your logs, then upload the file though Session's Help Desk to help resolve this issue."; "FAILED_TO_STORE_OUTGOING_MESSAGE" = "An error occurred when trying to store the outgoing message for sending, you may need to restart the app before you can send messages."; +"database_inaccessible_error" = "There is an issue opening the database. Please restart the app and try again."; diff --git a/Session/Meta/Translations/id-ID.lproj/Localizable.strings b/Session/Meta/Translations/id-ID.lproj/Localizable.strings index 12013921b..f2f2d802c 100644 --- a/Session/Meta/Translations/id-ID.lproj/Localizable.strings +++ b/Session/Meta/Translations/id-ID.lproj/Localizable.strings @@ -483,6 +483,9 @@ "PRIVACY_SECTION_SCREEN_SECURITY" = "Layar Aman"; "PRIVACY_SCREEN_SECURITY_LOCK_SESSION_TITLE" = "Lock Session"; "PRIVACY_SCREEN_SECURITY_LOCK_SESSION_DESCRIPTION" = "Require Touch ID, Face ID or your passcode to unlock Session."; +"PRIVACY_SECTION_MESSAGE_REQUESTS" = "Message Requests"; +"PRIVACY_SCREEN_MESSAGE_REQUESTS_COMMUNITY_TITLE" = "Community Message Requests"; +"PRIVACY_SCREEN_MESSAGE_REQUESTS_COMMUNITY_DESCRIPTION" = "Allow message requests from Community conversations."; "PRIVACY_SECTION_READ_RECEIPTS" = "Pesan terbaca diterima"; "PRIVACY_READ_RECEIPTS_TITLE" = "Pesan terbaca diterima"; "PRIVACY_READ_RECEIPTS_DESCRIPTION" = "Send read receipts in one-to-one chats."; @@ -645,6 +648,8 @@ "CONVERSATION_EMPTY_STATE" = "You have no messages from %@. Send a message to start the conversation!"; "CONVERSATION_EMPTY_STATE_READ_ONLY" = "There are no messages in %@."; "CONVERSATION_EMPTY_STATE_NOTE_TO_SELF" = "You have no messages in %@."; +"COMMUNITY_MESSAGE_REQUEST_DISABLED_EMPTY_STATE" = "%@ has message requests from Community conversations turned off, so you cannot send them a message."; "USER_CONFIG_OUTDATED_WARNING" = "Some of your devices are using outdated versions. Syncing may be unreliable until they are updated."; "LOAD_RECOVERY_PASSWORD_ERROR" = "An error occurred when trying to load your recovery password.\n\nPlease export your logs, then upload the file though Session's Help Desk to help resolve this issue."; "FAILED_TO_STORE_OUTGOING_MESSAGE" = "An error occurred when trying to store the outgoing message for sending, you may need to restart the app before you can send messages."; +"database_inaccessible_error" = "There is an issue opening the database. Please restart the app and try again."; diff --git a/Session/Meta/Translations/it.lproj/Localizable.strings b/Session/Meta/Translations/it.lproj/Localizable.strings index f301b4c65..65ea7de30 100644 --- a/Session/Meta/Translations/it.lproj/Localizable.strings +++ b/Session/Meta/Translations/it.lproj/Localizable.strings @@ -483,6 +483,9 @@ "PRIVACY_SECTION_SCREEN_SECURITY" = "Sicurezza schermo"; "PRIVACY_SCREEN_SECURITY_LOCK_SESSION_TITLE" = "Lock Session"; "PRIVACY_SCREEN_SECURITY_LOCK_SESSION_DESCRIPTION" = "Require Touch ID, Face ID or your passcode to unlock Session."; +"PRIVACY_SECTION_MESSAGE_REQUESTS" = "Message Requests"; +"PRIVACY_SCREEN_MESSAGE_REQUESTS_COMMUNITY_TITLE" = "Community Message Requests"; +"PRIVACY_SCREEN_MESSAGE_REQUESTS_COMMUNITY_DESCRIPTION" = "Allow message requests from Community conversations."; "PRIVACY_SECTION_READ_RECEIPTS" = "Ricevute di lettura"; "PRIVACY_READ_RECEIPTS_TITLE" = "Ricevute di lettura"; "PRIVACY_READ_RECEIPTS_DESCRIPTION" = "Send read receipts in one-to-one chats."; @@ -645,6 +648,8 @@ "CONVERSATION_EMPTY_STATE" = "You have no messages from %@. Send a message to start the conversation!"; "CONVERSATION_EMPTY_STATE_READ_ONLY" = "There are no messages in %@."; "CONVERSATION_EMPTY_STATE_NOTE_TO_SELF" = "You have no messages in %@."; +"COMMUNITY_MESSAGE_REQUEST_DISABLED_EMPTY_STATE" = "%@ has message requests from Community conversations turned off, so you cannot send them a message."; "USER_CONFIG_OUTDATED_WARNING" = "Some of your devices are using outdated versions. Syncing may be unreliable until they are updated."; "LOAD_RECOVERY_PASSWORD_ERROR" = "An error occurred when trying to load your recovery password.\n\nPlease export your logs, then upload the file though Session's Help Desk to help resolve this issue."; "FAILED_TO_STORE_OUTGOING_MESSAGE" = "An error occurred when trying to store the outgoing message for sending, you may need to restart the app before you can send messages."; +"database_inaccessible_error" = "There is an issue opening the database. Please restart the app and try again."; diff --git a/Session/Meta/Translations/ja.lproj/Localizable.strings b/Session/Meta/Translations/ja.lproj/Localizable.strings index e141682fa..e8d9a1ec8 100644 --- a/Session/Meta/Translations/ja.lproj/Localizable.strings +++ b/Session/Meta/Translations/ja.lproj/Localizable.strings @@ -483,6 +483,9 @@ "PRIVACY_SECTION_SCREEN_SECURITY" = "スクリーン・セキュリティ"; "PRIVACY_SCREEN_SECURITY_LOCK_SESSION_TITLE" = "Lock Session"; "PRIVACY_SCREEN_SECURITY_LOCK_SESSION_DESCRIPTION" = "Require Touch ID, Face ID or your passcode to unlock Session."; +"PRIVACY_SECTION_MESSAGE_REQUESTS" = "Message Requests"; +"PRIVACY_SCREEN_MESSAGE_REQUESTS_COMMUNITY_TITLE" = "Community Message Requests"; +"PRIVACY_SCREEN_MESSAGE_REQUESTS_COMMUNITY_DESCRIPTION" = "Allow message requests from Community conversations."; "PRIVACY_SECTION_READ_RECEIPTS" = "既読確認"; "PRIVACY_READ_RECEIPTS_TITLE" = "既読確認"; "PRIVACY_READ_RECEIPTS_DESCRIPTION" = "Send read receipts in one-to-one chats."; @@ -645,6 +648,8 @@ "CONVERSATION_EMPTY_STATE" = "You have no messages from %@. Send a message to start the conversation!"; "CONVERSATION_EMPTY_STATE_READ_ONLY" = "There are no messages in %@."; "CONVERSATION_EMPTY_STATE_NOTE_TO_SELF" = "You have no messages in %@."; +"COMMUNITY_MESSAGE_REQUEST_DISABLED_EMPTY_STATE" = "%@ has message requests from Community conversations turned off, so you cannot send them a message."; "USER_CONFIG_OUTDATED_WARNING" = "Some of your devices are using outdated versions. Syncing may be unreliable until they are updated."; "LOAD_RECOVERY_PASSWORD_ERROR" = "An error occurred when trying to load your recovery password.\n\nPlease export your logs, then upload the file though Session's Help Desk to help resolve this issue."; "FAILED_TO_STORE_OUTGOING_MESSAGE" = "An error occurred when trying to store the outgoing message for sending, you may need to restart the app before you can send messages."; +"database_inaccessible_error" = "There is an issue opening the database. Please restart the app and try again."; diff --git a/Session/Meta/Translations/nl.lproj/Localizable.strings b/Session/Meta/Translations/nl.lproj/Localizable.strings index 6dbbda2b3..6a76cd068 100644 --- a/Session/Meta/Translations/nl.lproj/Localizable.strings +++ b/Session/Meta/Translations/nl.lproj/Localizable.strings @@ -483,6 +483,9 @@ "PRIVACY_SECTION_SCREEN_SECURITY" = "Scherm beveiliging"; "PRIVACY_SCREEN_SECURITY_LOCK_SESSION_TITLE" = "Lock Session"; "PRIVACY_SCREEN_SECURITY_LOCK_SESSION_DESCRIPTION" = "Require Touch ID, Face ID or your passcode to unlock Session."; +"PRIVACY_SECTION_MESSAGE_REQUESTS" = "Message Requests"; +"PRIVACY_SCREEN_MESSAGE_REQUESTS_COMMUNITY_TITLE" = "Community Message Requests"; +"PRIVACY_SCREEN_MESSAGE_REQUESTS_COMMUNITY_DESCRIPTION" = "Allow message requests from Community conversations."; "PRIVACY_SECTION_READ_RECEIPTS" = "Leesbevestigingen"; "PRIVACY_READ_RECEIPTS_TITLE" = "Leesbevestigingen"; "PRIVACY_READ_RECEIPTS_DESCRIPTION" = "Send read receipts in one-to-one chats."; @@ -645,6 +648,8 @@ "CONVERSATION_EMPTY_STATE" = "You have no messages from %@. Send a message to start the conversation!"; "CONVERSATION_EMPTY_STATE_READ_ONLY" = "There are no messages in %@."; "CONVERSATION_EMPTY_STATE_NOTE_TO_SELF" = "You have no messages in %@."; +"COMMUNITY_MESSAGE_REQUEST_DISABLED_EMPTY_STATE" = "%@ has message requests from Community conversations turned off, so you cannot send them a message."; "USER_CONFIG_OUTDATED_WARNING" = "Some of your devices are using outdated versions. Syncing may be unreliable until they are updated."; "LOAD_RECOVERY_PASSWORD_ERROR" = "An error occurred when trying to load your recovery password.\n\nPlease export your logs, then upload the file though Session's Help Desk to help resolve this issue."; "FAILED_TO_STORE_OUTGOING_MESSAGE" = "An error occurred when trying to store the outgoing message for sending, you may need to restart the app before you can send messages."; +"database_inaccessible_error" = "There is an issue opening the database. Please restart the app and try again."; diff --git a/Session/Meta/Translations/pl.lproj/Localizable.strings b/Session/Meta/Translations/pl.lproj/Localizable.strings index 7613d5d0c..2c70b58fe 100644 --- a/Session/Meta/Translations/pl.lproj/Localizable.strings +++ b/Session/Meta/Translations/pl.lproj/Localizable.strings @@ -483,6 +483,9 @@ "PRIVACY_SECTION_SCREEN_SECURITY" = "Ochrona ekranu"; "PRIVACY_SCREEN_SECURITY_LOCK_SESSION_TITLE" = "Lock Session"; "PRIVACY_SCREEN_SECURITY_LOCK_SESSION_DESCRIPTION" = "Require Touch ID, Face ID or your passcode to unlock Session."; +"PRIVACY_SECTION_MESSAGE_REQUESTS" = "Message Requests"; +"PRIVACY_SCREEN_MESSAGE_REQUESTS_COMMUNITY_TITLE" = "Community Message Requests"; +"PRIVACY_SCREEN_MESSAGE_REQUESTS_COMMUNITY_DESCRIPTION" = "Allow message requests from Community conversations."; "PRIVACY_SECTION_READ_RECEIPTS" = "Potwierdzenia odczytania"; "PRIVACY_READ_RECEIPTS_TITLE" = "Potwierdzenia odczytania"; "PRIVACY_READ_RECEIPTS_DESCRIPTION" = "Send read receipts in one-to-one chats."; @@ -645,6 +648,8 @@ "CONVERSATION_EMPTY_STATE" = "You have no messages from %@. Send a message to start the conversation!"; "CONVERSATION_EMPTY_STATE_READ_ONLY" = "There are no messages in %@."; "CONVERSATION_EMPTY_STATE_NOTE_TO_SELF" = "You have no messages in %@."; +"COMMUNITY_MESSAGE_REQUEST_DISABLED_EMPTY_STATE" = "%@ has message requests from Community conversations turned off, so you cannot send them a message."; "USER_CONFIG_OUTDATED_WARNING" = "Some of your devices are using outdated versions. Syncing may be unreliable until they are updated."; "LOAD_RECOVERY_PASSWORD_ERROR" = "An error occurred when trying to load your recovery password.\n\nPlease export your logs, then upload the file though Session's Help Desk to help resolve this issue."; "FAILED_TO_STORE_OUTGOING_MESSAGE" = "An error occurred when trying to store the outgoing message for sending, you may need to restart the app before you can send messages."; +"database_inaccessible_error" = "There is an issue opening the database. Please restart the app and try again."; diff --git a/Session/Meta/Translations/pt_BR.lproj/Localizable.strings b/Session/Meta/Translations/pt_BR.lproj/Localizable.strings index 0dc850760..9c70d1638 100644 --- a/Session/Meta/Translations/pt_BR.lproj/Localizable.strings +++ b/Session/Meta/Translations/pt_BR.lproj/Localizable.strings @@ -483,6 +483,9 @@ "PRIVACY_SECTION_SCREEN_SECURITY" = "Segurança de Tela"; "PRIVACY_SCREEN_SECURITY_LOCK_SESSION_TITLE" = "Lock Session"; "PRIVACY_SCREEN_SECURITY_LOCK_SESSION_DESCRIPTION" = "Require Touch ID, Face ID or your passcode to unlock Session."; +"PRIVACY_SECTION_MESSAGE_REQUESTS" = "Message Requests"; +"PRIVACY_SCREEN_MESSAGE_REQUESTS_COMMUNITY_TITLE" = "Community Message Requests"; +"PRIVACY_SCREEN_MESSAGE_REQUESTS_COMMUNITY_DESCRIPTION" = "Allow message requests from Community conversations."; "PRIVACY_SECTION_READ_RECEIPTS" = "Confirmações de Leitura"; "PRIVACY_READ_RECEIPTS_TITLE" = "Confirmações de Leitura"; "PRIVACY_READ_RECEIPTS_DESCRIPTION" = "Send read receipts in one-to-one chats."; @@ -645,6 +648,8 @@ "CONVERSATION_EMPTY_STATE" = "You have no messages from %@. Send a message to start the conversation!"; "CONVERSATION_EMPTY_STATE_READ_ONLY" = "There are no messages in %@."; "CONVERSATION_EMPTY_STATE_NOTE_TO_SELF" = "You have no messages in %@."; +"COMMUNITY_MESSAGE_REQUEST_DISABLED_EMPTY_STATE" = "%@ has message requests from Community conversations turned off, so you cannot send them a message."; "USER_CONFIG_OUTDATED_WARNING" = "Some of your devices are using outdated versions. Syncing may be unreliable until they are updated."; "LOAD_RECOVERY_PASSWORD_ERROR" = "An error occurred when trying to load your recovery password.\n\nPlease export your logs, then upload the file though Session's Help Desk to help resolve this issue."; "FAILED_TO_STORE_OUTGOING_MESSAGE" = "An error occurred when trying to store the outgoing message for sending, you may need to restart the app before you can send messages."; +"database_inaccessible_error" = "There is an issue opening the database. Please restart the app and try again."; diff --git a/Session/Meta/Translations/ru.lproj/Localizable.strings b/Session/Meta/Translations/ru.lproj/Localizable.strings index 33d5a4288..70d23dffd 100644 --- a/Session/Meta/Translations/ru.lproj/Localizable.strings +++ b/Session/Meta/Translations/ru.lproj/Localizable.strings @@ -483,6 +483,9 @@ "PRIVACY_SECTION_SCREEN_SECURITY" = "Защита экрана"; "PRIVACY_SCREEN_SECURITY_LOCK_SESSION_TITLE" = "Lock Session"; "PRIVACY_SCREEN_SECURITY_LOCK_SESSION_DESCRIPTION" = "Require Touch ID, Face ID or your passcode to unlock Session."; +"PRIVACY_SECTION_MESSAGE_REQUESTS" = "Message Requests"; +"PRIVACY_SCREEN_MESSAGE_REQUESTS_COMMUNITY_TITLE" = "Community Message Requests"; +"PRIVACY_SCREEN_MESSAGE_REQUESTS_COMMUNITY_DESCRIPTION" = "Allow message requests from Community conversations."; "PRIVACY_SECTION_READ_RECEIPTS" = "Уведомления о прочтении"; "PRIVACY_READ_RECEIPTS_TITLE" = "Уведомления о прочтении"; "PRIVACY_READ_RECEIPTS_DESCRIPTION" = "Send read receipts in one-to-one chats."; @@ -645,6 +648,8 @@ "CONVERSATION_EMPTY_STATE" = "You have no messages from %@. Send a message to start the conversation!"; "CONVERSATION_EMPTY_STATE_READ_ONLY" = "There are no messages in %@."; "CONVERSATION_EMPTY_STATE_NOTE_TO_SELF" = "You have no messages in %@."; +"COMMUNITY_MESSAGE_REQUEST_DISABLED_EMPTY_STATE" = "%@ has message requests from Community conversations turned off, so you cannot send them a message."; "USER_CONFIG_OUTDATED_WARNING" = "Some of your devices are using outdated versions. Syncing may be unreliable until they are updated."; "LOAD_RECOVERY_PASSWORD_ERROR" = "An error occurred when trying to load your recovery password.\n\nPlease export your logs, then upload the file though Session's Help Desk to help resolve this issue."; "FAILED_TO_STORE_OUTGOING_MESSAGE" = "An error occurred when trying to store the outgoing message for sending, you may need to restart the app before you can send messages."; +"database_inaccessible_error" = "There is an issue opening the database. Please restart the app and try again."; diff --git a/Session/Meta/Translations/si.lproj/Localizable.strings b/Session/Meta/Translations/si.lproj/Localizable.strings index 8804247ad..ef9f0e74d 100644 --- a/Session/Meta/Translations/si.lproj/Localizable.strings +++ b/Session/Meta/Translations/si.lproj/Localizable.strings @@ -483,6 +483,9 @@ "PRIVACY_SECTION_SCREEN_SECURITY" = "තිරයේ ආරක්ෂාව"; "PRIVACY_SCREEN_SECURITY_LOCK_SESSION_TITLE" = "Lock Session"; "PRIVACY_SCREEN_SECURITY_LOCK_SESSION_DESCRIPTION" = "Require Touch ID, Face ID or your passcode to unlock Session."; +"PRIVACY_SECTION_MESSAGE_REQUESTS" = "Message Requests"; +"PRIVACY_SCREEN_MESSAGE_REQUESTS_COMMUNITY_TITLE" = "Community Message Requests"; +"PRIVACY_SCREEN_MESSAGE_REQUESTS_COMMUNITY_DESCRIPTION" = "Allow message requests from Community conversations."; "PRIVACY_SECTION_READ_RECEIPTS" = "කියවූ බවට ලදුපත්"; "PRIVACY_READ_RECEIPTS_TITLE" = "කියවූ බවට ලදුපත්"; "PRIVACY_READ_RECEIPTS_DESCRIPTION" = "Send read receipts in one-to-one chats."; @@ -645,6 +648,8 @@ "CONVERSATION_EMPTY_STATE" = "You have no messages from %@. Send a message to start the conversation!"; "CONVERSATION_EMPTY_STATE_READ_ONLY" = "There are no messages in %@."; "CONVERSATION_EMPTY_STATE_NOTE_TO_SELF" = "You have no messages in %@."; +"COMMUNITY_MESSAGE_REQUEST_DISABLED_EMPTY_STATE" = "%@ has message requests from Community conversations turned off, so you cannot send them a message."; "USER_CONFIG_OUTDATED_WARNING" = "Some of your devices are using outdated versions. Syncing may be unreliable until they are updated."; "LOAD_RECOVERY_PASSWORD_ERROR" = "An error occurred when trying to load your recovery password.\n\nPlease export your logs, then upload the file though Session's Help Desk to help resolve this issue."; "FAILED_TO_STORE_OUTGOING_MESSAGE" = "An error occurred when trying to store the outgoing message for sending, you may need to restart the app before you can send messages."; +"database_inaccessible_error" = "There is an issue opening the database. Please restart the app and try again."; diff --git a/Session/Meta/Translations/sk.lproj/Localizable.strings b/Session/Meta/Translations/sk.lproj/Localizable.strings index 69eefbe68..619ad474c 100644 --- a/Session/Meta/Translations/sk.lproj/Localizable.strings +++ b/Session/Meta/Translations/sk.lproj/Localizable.strings @@ -483,6 +483,9 @@ "PRIVACY_SECTION_SCREEN_SECURITY" = "Zabezpečenie obrazovky"; "PRIVACY_SCREEN_SECURITY_LOCK_SESSION_TITLE" = "Lock Session"; "PRIVACY_SCREEN_SECURITY_LOCK_SESSION_DESCRIPTION" = "Require Touch ID, Face ID or your passcode to unlock Session."; +"PRIVACY_SECTION_MESSAGE_REQUESTS" = "Message Requests"; +"PRIVACY_SCREEN_MESSAGE_REQUESTS_COMMUNITY_TITLE" = "Community Message Requests"; +"PRIVACY_SCREEN_MESSAGE_REQUESTS_COMMUNITY_DESCRIPTION" = "Allow message requests from Community conversations."; "PRIVACY_SECTION_READ_RECEIPTS" = "Potvrdenia o prečítaní"; "PRIVACY_READ_RECEIPTS_TITLE" = "Potvrdenia o prečítaní"; "PRIVACY_READ_RECEIPTS_DESCRIPTION" = "Send read receipts in one-to-one chats."; @@ -645,6 +648,8 @@ "CONVERSATION_EMPTY_STATE" = "You have no messages from %@. Send a message to start the conversation!"; "CONVERSATION_EMPTY_STATE_READ_ONLY" = "There are no messages in %@."; "CONVERSATION_EMPTY_STATE_NOTE_TO_SELF" = "You have no messages in %@."; +"COMMUNITY_MESSAGE_REQUEST_DISABLED_EMPTY_STATE" = "%@ has message requests from Community conversations turned off, so you cannot send them a message."; "USER_CONFIG_OUTDATED_WARNING" = "Some of your devices are using outdated versions. Syncing may be unreliable until they are updated."; "LOAD_RECOVERY_PASSWORD_ERROR" = "An error occurred when trying to load your recovery password.\n\nPlease export your logs, then upload the file though Session's Help Desk to help resolve this issue."; "FAILED_TO_STORE_OUTGOING_MESSAGE" = "An error occurred when trying to store the outgoing message for sending, you may need to restart the app before you can send messages."; +"database_inaccessible_error" = "There is an issue opening the database. Please restart the app and try again."; diff --git a/Session/Meta/Translations/sv.lproj/Localizable.strings b/Session/Meta/Translations/sv.lproj/Localizable.strings index c7d37b567..944c0917c 100644 --- a/Session/Meta/Translations/sv.lproj/Localizable.strings +++ b/Session/Meta/Translations/sv.lproj/Localizable.strings @@ -483,6 +483,9 @@ "PRIVACY_SECTION_SCREEN_SECURITY" = "Skärmsäkerhet"; "PRIVACY_SCREEN_SECURITY_LOCK_SESSION_TITLE" = "Lock Session"; "PRIVACY_SCREEN_SECURITY_LOCK_SESSION_DESCRIPTION" = "Require Touch ID, Face ID or your passcode to unlock Session."; +"PRIVACY_SECTION_MESSAGE_REQUESTS" = "Message Requests"; +"PRIVACY_SCREEN_MESSAGE_REQUESTS_COMMUNITY_TITLE" = "Community Message Requests"; +"PRIVACY_SCREEN_MESSAGE_REQUESTS_COMMUNITY_DESCRIPTION" = "Allow message requests from Community conversations."; "PRIVACY_SECTION_READ_RECEIPTS" = "Läskvittenser"; "PRIVACY_READ_RECEIPTS_TITLE" = "Läskvittenser"; "PRIVACY_READ_RECEIPTS_DESCRIPTION" = "Send read receipts in one-to-one chats."; @@ -645,6 +648,8 @@ "CONVERSATION_EMPTY_STATE" = "You have no messages from %@. Send a message to start the conversation!"; "CONVERSATION_EMPTY_STATE_READ_ONLY" = "There are no messages in %@."; "CONVERSATION_EMPTY_STATE_NOTE_TO_SELF" = "You have no messages in %@."; +"COMMUNITY_MESSAGE_REQUEST_DISABLED_EMPTY_STATE" = "%@ has message requests from Community conversations turned off, so you cannot send them a message."; "USER_CONFIG_OUTDATED_WARNING" = "Some of your devices are using outdated versions. Syncing may be unreliable until they are updated."; "LOAD_RECOVERY_PASSWORD_ERROR" = "An error occurred when trying to load your recovery password.\n\nPlease export your logs, then upload the file though Session's Help Desk to help resolve this issue."; "FAILED_TO_STORE_OUTGOING_MESSAGE" = "An error occurred when trying to store the outgoing message for sending, you may need to restart the app before you can send messages."; +"database_inaccessible_error" = "There is an issue opening the database. Please restart the app and try again."; diff --git a/Session/Meta/Translations/th.lproj/Localizable.strings b/Session/Meta/Translations/th.lproj/Localizable.strings index 75612d387..576744efe 100644 --- a/Session/Meta/Translations/th.lproj/Localizable.strings +++ b/Session/Meta/Translations/th.lproj/Localizable.strings @@ -483,6 +483,9 @@ "PRIVACY_SECTION_SCREEN_SECURITY" = "ความปลอดภัยหน้าจอ"; "PRIVACY_SCREEN_SECURITY_LOCK_SESSION_TITLE" = "Lock Session"; "PRIVACY_SCREEN_SECURITY_LOCK_SESSION_DESCRIPTION" = "Require Touch ID, Face ID or your passcode to unlock Session."; +"PRIVACY_SECTION_MESSAGE_REQUESTS" = "Message Requests"; +"PRIVACY_SCREEN_MESSAGE_REQUESTS_COMMUNITY_TITLE" = "Community Message Requests"; +"PRIVACY_SCREEN_MESSAGE_REQUESTS_COMMUNITY_DESCRIPTION" = "Allow message requests from Community conversations."; "PRIVACY_SECTION_READ_RECEIPTS" = "แจ้งการอ่านข้อความ"; "PRIVACY_READ_RECEIPTS_TITLE" = "แจ้งการอ่านข้อความ"; "PRIVACY_READ_RECEIPTS_DESCRIPTION" = "Send read receipts in one-to-one chats."; @@ -645,6 +648,8 @@ "CONVERSATION_EMPTY_STATE" = "You have no messages from %@. Send a message to start the conversation!"; "CONVERSATION_EMPTY_STATE_READ_ONLY" = "There are no messages in %@."; "CONVERSATION_EMPTY_STATE_NOTE_TO_SELF" = "You have no messages in %@."; +"COMMUNITY_MESSAGE_REQUEST_DISABLED_EMPTY_STATE" = "%@ has message requests from Community conversations turned off, so you cannot send them a message."; "USER_CONFIG_OUTDATED_WARNING" = "Some of your devices are using outdated versions. Syncing may be unreliable until they are updated."; "LOAD_RECOVERY_PASSWORD_ERROR" = "An error occurred when trying to load your recovery password.\n\nPlease export your logs, then upload the file though Session's Help Desk to help resolve this issue."; "FAILED_TO_STORE_OUTGOING_MESSAGE" = "An error occurred when trying to store the outgoing message for sending, you may need to restart the app before you can send messages."; +"database_inaccessible_error" = "There is an issue opening the database. Please restart the app and try again."; diff --git a/Session/Meta/Translations/vi-VN.lproj/Localizable.strings b/Session/Meta/Translations/vi-VN.lproj/Localizable.strings index bc19cad24..9e7dab1f1 100644 --- a/Session/Meta/Translations/vi-VN.lproj/Localizable.strings +++ b/Session/Meta/Translations/vi-VN.lproj/Localizable.strings @@ -483,6 +483,9 @@ "PRIVACY_SECTION_SCREEN_SECURITY" = "Screen Security"; "PRIVACY_SCREEN_SECURITY_LOCK_SESSION_TITLE" = "Lock Session"; "PRIVACY_SCREEN_SECURITY_LOCK_SESSION_DESCRIPTION" = "Require Touch ID, Face ID or your passcode to unlock Session."; +"PRIVACY_SECTION_MESSAGE_REQUESTS" = "Message Requests"; +"PRIVACY_SCREEN_MESSAGE_REQUESTS_COMMUNITY_TITLE" = "Community Message Requests"; +"PRIVACY_SCREEN_MESSAGE_REQUESTS_COMMUNITY_DESCRIPTION" = "Allow message requests from Community conversations."; "PRIVACY_SECTION_READ_RECEIPTS" = "Read Receipts"; "PRIVACY_READ_RECEIPTS_TITLE" = "Read Receipts"; "PRIVACY_READ_RECEIPTS_DESCRIPTION" = "Send read receipts in one-to-one chats."; @@ -645,6 +648,8 @@ "CONVERSATION_EMPTY_STATE" = "You have no messages from %@. Send a message to start the conversation!"; "CONVERSATION_EMPTY_STATE_READ_ONLY" = "There are no messages in %@."; "CONVERSATION_EMPTY_STATE_NOTE_TO_SELF" = "You have no messages in %@."; +"COMMUNITY_MESSAGE_REQUEST_DISABLED_EMPTY_STATE" = "%@ has message requests from Community conversations turned off, so you cannot send them a message."; "USER_CONFIG_OUTDATED_WARNING" = "Some of your devices are using outdated versions. Syncing may be unreliable until they are updated."; "LOAD_RECOVERY_PASSWORD_ERROR" = "An error occurred when trying to load your recovery password.\n\nPlease export your logs, then upload the file though Session's Help Desk to help resolve this issue."; "FAILED_TO_STORE_OUTGOING_MESSAGE" = "An error occurred when trying to store the outgoing message for sending, you may need to restart the app before you can send messages."; +"database_inaccessible_error" = "There is an issue opening the database. Please restart the app and try again."; diff --git a/Session/Meta/Translations/zh-Hant.lproj/Localizable.strings b/Session/Meta/Translations/zh-Hant.lproj/Localizable.strings index e80f39d42..cea8a4480 100644 --- a/Session/Meta/Translations/zh-Hant.lproj/Localizable.strings +++ b/Session/Meta/Translations/zh-Hant.lproj/Localizable.strings @@ -483,6 +483,9 @@ "PRIVACY_SECTION_SCREEN_SECURITY" = "螢幕顯示安全"; "PRIVACY_SCREEN_SECURITY_LOCK_SESSION_TITLE" = "Lock Session"; "PRIVACY_SCREEN_SECURITY_LOCK_SESSION_DESCRIPTION" = "Require Touch ID, Face ID or your passcode to unlock Session."; +"PRIVACY_SECTION_MESSAGE_REQUESTS" = "Message Requests"; +"PRIVACY_SCREEN_MESSAGE_REQUESTS_COMMUNITY_TITLE" = "Community Message Requests"; +"PRIVACY_SCREEN_MESSAGE_REQUESTS_COMMUNITY_DESCRIPTION" = "Allow message requests from Community conversations."; "PRIVACY_SECTION_READ_RECEIPTS" = "已讀回條"; "PRIVACY_READ_RECEIPTS_TITLE" = "已讀回條"; "PRIVACY_READ_RECEIPTS_DESCRIPTION" = "Send read receipts in one-to-one chats."; @@ -645,6 +648,8 @@ "CONVERSATION_EMPTY_STATE" = "You have no messages from %@. Send a message to start the conversation!"; "CONVERSATION_EMPTY_STATE_READ_ONLY" = "There are no messages in %@."; "CONVERSATION_EMPTY_STATE_NOTE_TO_SELF" = "You have no messages in %@."; +"COMMUNITY_MESSAGE_REQUEST_DISABLED_EMPTY_STATE" = "%@ has message requests from Community conversations turned off, so you cannot send them a message."; "USER_CONFIG_OUTDATED_WARNING" = "Some of your devices are using outdated versions. Syncing may be unreliable until they are updated."; "LOAD_RECOVERY_PASSWORD_ERROR" = "An error occurred when trying to load your recovery password.\n\nPlease export your logs, then upload the file though Session's Help Desk to help resolve this issue."; "FAILED_TO_STORE_OUTGOING_MESSAGE" = "An error occurred when trying to store the outgoing message for sending, you may need to restart the app before you can send messages."; +"database_inaccessible_error" = "There is an issue opening the database. Please restart the app and try again."; diff --git a/Session/Meta/Translations/zh_CN.lproj/Localizable.strings b/Session/Meta/Translations/zh_CN.lproj/Localizable.strings index cb780247e..736085a01 100644 --- a/Session/Meta/Translations/zh_CN.lproj/Localizable.strings +++ b/Session/Meta/Translations/zh_CN.lproj/Localizable.strings @@ -483,6 +483,9 @@ "PRIVACY_SECTION_SCREEN_SECURITY" = "屏幕安全"; "PRIVACY_SCREEN_SECURITY_LOCK_SESSION_TITLE" = "Lock Session"; "PRIVACY_SCREEN_SECURITY_LOCK_SESSION_DESCRIPTION" = "Require Touch ID, Face ID or your passcode to unlock Session."; +"PRIVACY_SECTION_MESSAGE_REQUESTS" = "Message Requests"; +"PRIVACY_SCREEN_MESSAGE_REQUESTS_COMMUNITY_TITLE" = "Community Message Requests"; +"PRIVACY_SCREEN_MESSAGE_REQUESTS_COMMUNITY_DESCRIPTION" = "Allow message requests from Community conversations."; "PRIVACY_SECTION_READ_RECEIPTS" = "已读回执"; "PRIVACY_READ_RECEIPTS_TITLE" = "已读回执"; "PRIVACY_READ_RECEIPTS_DESCRIPTION" = "Send read receipts in one-to-one chats."; @@ -645,6 +648,8 @@ "CONVERSATION_EMPTY_STATE" = "You have no messages from %@. Send a message to start the conversation!"; "CONVERSATION_EMPTY_STATE_READ_ONLY" = "There are no messages in %@."; "CONVERSATION_EMPTY_STATE_NOTE_TO_SELF" = "You have no messages in %@."; +"COMMUNITY_MESSAGE_REQUEST_DISABLED_EMPTY_STATE" = "%@ has message requests from Community conversations turned off, so you cannot send them a message."; "USER_CONFIG_OUTDATED_WARNING" = "Some of your devices are using outdated versions. Syncing may be unreliable until they are updated."; "LOAD_RECOVERY_PASSWORD_ERROR" = "An error occurred when trying to load your recovery password.\n\nPlease export your logs, then upload the file though Session's Help Desk to help resolve this issue."; "FAILED_TO_STORE_OUTGOING_MESSAGE" = "An error occurred when trying to store the outgoing message for sending, you may need to restart the app before you can send messages."; +"database_inaccessible_error" = "There is an issue opening the database. Please restart the app and try again."; diff --git a/Session/Notifications/AppNotifications.swift b/Session/Notifications/AppNotifications.swift index 8f4bb263a..2c0774346 100644 --- a/Session/Notifications/AppNotifications.swift +++ b/Session/Notifications/AppNotifications.swift @@ -6,6 +6,8 @@ import GRDB import SessionMessagingKit import SignalUtilitiesKit import SignalCoreKit +import SessionUtilitiesKit +import SessionSnodeKit /// There are two primary components in our system notification integration: /// @@ -553,7 +555,8 @@ class NotificationActionHandler { func reply( userInfo: [AnyHashable: Any], replyText: String, - applicationState: UIApplication.State + applicationState: UIApplication.State, + using dependencies: Dependencies = Dependencies() ) -> AnyPublisher { guard let threadId = userInfo[AppNotificationUserInfoKey.threadId] as? String else { return Fail(error: NotificationError.failDebug("threadId was unexpectedly nil")) @@ -599,10 +602,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/PushRegistrationManager.swift b/Session/Notifications/PushRegistrationManager.swift index 04d4a4e0b..6645428a5 100644 --- a/Session/Notifications/PushRegistrationManager.swift +++ b/Session/Notifications/PushRegistrationManager.swift @@ -4,8 +4,10 @@ import Foundation import Combine import PushKit import GRDB +import SessionMessagingKit import SignalUtilitiesKit import SignalCoreKit +import SessionUtilitiesKit public enum PushRegistrationError: Error { case assertionError(description: String) @@ -53,8 +55,6 @@ public enum PushRegistrationError: Error { Logger.info("") return registerUserNotificationSettings() - .subscribe(on: DispatchQueue.global(qos: .default)) - .receive(on: DispatchQueue.main) // MUST be on main thread .setFailureType(to: Error.self) .tryFlatMap { _ -> AnyPublisher<(pushToken: String, voipToken: String), Error> in #if targetEnvironment(simulator) @@ -75,24 +75,27 @@ public enum PushRegistrationError: Error { // MARK: Vanilla push token // Vanilla push token is obtained from the system via AppDelegate - public func didReceiveVanillaPushToken(_ tokenData: Data) { + public func didReceiveVanillaPushToken(_ tokenData: Data, using dependencies: Dependencies = Dependencies()) { guard let vanillaTokenResolver = self.vanillaTokenResolver else { owsFailDebug("publisher completion in \(#function) unexpectedly nil") return } - vanillaTokenResolver(Result.success(tokenData)) + DispatchQueue.global(qos: .default).async(using: dependencies) { + vanillaTokenResolver(Result.success(tokenData)) + } } // Vanilla push token is obtained from the system via AppDelegate - @objc - public func didFailToReceiveVanillaPushToken(error: Error) { + public func didFailToReceiveVanillaPushToken(error: Error, using dependencies: Dependencies = Dependencies()) { guard let vanillaTokenResolver = self.vanillaTokenResolver else { owsFailDebug("publisher completion in \(#function) unexpectedly nil") return } - vanillaTokenResolver(Result.failure(error)) + DispatchQueue.global(qos: .default).async(using: dependencies) { + vanillaTokenResolver(Result.failure(error)) + } } // MARK: helpers @@ -111,9 +114,8 @@ public enum PushRegistrationError: Error { * in this case we've verified that we *have* properly registered notification settings. */ private var isSusceptibleToFailedPushRegistration: Bool { - // Only affects users who have disabled both: background refresh *and* notifications - guard UIApplication.shared.backgroundRefreshStatus == .denied else { + guard DispatchQueue.main.sync(execute: { UIApplication.shared.backgroundRefreshStatus }) == .denied else { return false } @@ -128,10 +130,7 @@ public enum PushRegistrationError: Error { return true } - // FIXME: Might be nice to try to avoid having this required to run on the main thread (follow a similar approach to the 'SyncPushTokensJob' & `Atomic`?) private func registerForVanillaPushToken() -> AnyPublisher { - AssertIsOnMainThread() - // Use the existing publisher if it exists if let vanillaTokenPublisher: AnyPublisher = self.vanillaTokenPublisher { return vanillaTokenPublisher @@ -139,19 +138,23 @@ public enum PushRegistrationError: Error { .eraseToAnyPublisher() } - UIApplication.shared.registerForRemoteNotifications() - // No pending vanilla token yet; create a new publisher let publisher: AnyPublisher = Deferred { - Future { self.vanillaTokenResolver = $0 } + Future { + self.vanillaTokenResolver = $0 + + // Tell the device to register for remote notifications + DispatchQueue.main.sync { UIApplication.shared.registerForRemoteNotifications() } + } } + .shareReplay(1) .eraseToAnyPublisher() self.vanillaTokenPublisher = publisher return publisher .timeout( .seconds(10), - scheduler: DispatchQueue.main, + scheduler: DispatchQueue.global(qos: .default), customError: { PushRegistrationError.timeout } ) .catch { error -> AnyPublisher in @@ -200,9 +203,8 @@ public enum PushRegistrationError: Error { } public func createVoipRegistryIfNecessary() { - AssertIsOnMainThread() - guard voipRegistry == nil else { return } + let voipRegistry = PKPushRegistry(queue: nil) self.voipRegistry = voipRegistry voipRegistry.desiredPushTypes = [.voIP] @@ -210,8 +212,6 @@ public enum PushRegistrationError: Error { } private func registerForVoipPushToken() -> AnyPublisher { - AssertIsOnMainThread() - // Use the existing publisher if it exists if let voipTokenPublisher: AnyPublisher = self.voipTokenPublisher { return voipTokenPublisher diff --git a/Session/Notifications/SyncPushTokensJob.swift b/Session/Notifications/SyncPushTokensJob.swift index 2df9657b3..fac7107fa 100644 --- a/Session/Notifications/SyncPushTokensJob.swift +++ b/Session/Notifications/SyncPushTokensJob.swift @@ -17,37 +17,18 @@ public enum SyncPushTokensJob: JobExecutor { public static func run( _ job: Job, queue: DispatchQueue, - success: @escaping (Job, Bool) -> (), - failure: @escaping (Job, Error?, Bool) -> (), - deferred: @escaping (Job) -> () + success: @escaping (Job, Bool, Dependencies) -> (), + failure: @escaping (Job, Error?, Bool, Dependencies) -> (), + deferred: @escaping (Job, 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 { - return deferred(job) // Don't need to do anything if it's not the main app + return deferred(job, dependencies) // Don't need to do anything if it's not the main app } guard Identity.userCompletedRequiredOnboarding() else { SNLog("[SyncPushTokensJob] Deferred due to incomplete registration") - return deferred(job) - } - - // We need to check a UIApplication setting which needs to run on the main thread so synchronously - // retrieve the value so we can continue - let isRegisteredForRemoteNotifications: Bool = { - guard !Thread.isMainThread else { - return UIApplication.shared.isRegisteredForRemoteNotifications - } - - return DispatchQueue.main.sync { - return UIApplication.shared.isRegisteredForRemoteNotifications - } - }() - - // Apple's documentation states that we should re-register for notifications on every launch: - // https://developer.apple.com/library/archive/documentation/NetworkingInternet/Conceptual/RemoteNotificationsPG/HandlingRemoteNotifications.html#//apple_ref/doc/uid/TP40008194-CH6-SW1 - guard job.behaviour == .runOnce || !isRegisteredForRemoteNotifications else { - SNLog("[SyncPushTokensJob] Deferred due to Fast Mode disabled") - deferred(job) // Don't need to do anything if push notifications are already registered - return + return deferred(job, dependencies) } // Determine if the device has 'Fast Mode' (APNS) enabled @@ -56,33 +37,33 @@ public enum SyncPushTokensJob: JobExecutor { // If the job is running and 'Fast Mode' is disabled then we should try to unregister the existing // token guard isUsingFullAPNs else { - Just(Storage.shared[.lastRecordedPushToken]) + Just(dependencies.storage[.lastRecordedPushToken]) .setFailureType(to: Error.self) - .flatMap { lastRecordedPushToken in + .flatMap { lastRecordedPushToken -> AnyPublisher in + // Tell the device to unregister for remote notifications (essentially try to invalidate + // the token if needed - we do this first to avoid wrid race conditions which could be + // triggered by the user immediately re-registering) + DispatchQueue.main.sync { UIApplication.shared.unregisterForRemoteNotifications() } + + // Clear the old token + dependencies.storage.write(using: dependencies) { db in + db[.lastRecordedPushToken] = nil + } + + // Unregister from our server if let existingToken: String = lastRecordedPushToken { SNLog("[SyncPushTokensJob] Unregister using last recorded push token: \(redact(existingToken))") - return Just(existingToken) - .setFailureType(to: Error.self) + return PushNotificationAPI.unsubscribe(token: Data(hex: existingToken)) + .map { _ in () } .eraseToAnyPublisher() } - SNLog("[SyncPushTokensJob] Unregister using live token provided from device") - return PushRegistrationManager.shared.requestPushTokens() - .map { token, _ in token } + SNLog("[SyncPushTokensJob] No previous token stored just triggering device unregister") + return Just(()) + .setFailureType(to: Error.self) .eraseToAnyPublisher() } - .flatMap { pushToken in PushNotificationAPI.unregister(Data(hex: pushToken)) } - .map { - // Tell the device to unregister for remote notifications (essentially try to invalidate - // the token if needed - DispatchQueue.main.sync { UIApplication.shared.unregisterForRemoteNotifications() } - - Storage.shared.write { db in - db[.lastRecordedPushToken] = nil - } - return () - } - .subscribe(on: queue) + .subscribe(on: queue, using: dependencies) .sinkUntilComplete( receiveCompletion: { result in switch result { @@ -91,23 +72,26 @@ public enum SyncPushTokensJob: JobExecutor { } // We want to complete this job regardless of success or failure - success(job, false) + success(job, false, dependencies) } ) return } - // Perform device registration + /// Perform device registration + /// + /// **Note:** Apple's documentation states that we should re-register for notifications on every launch: + /// https://developer.apple.com/library/archive/documentation/NetworkingInternet/Conceptual/RemoteNotificationsPG/HandlingRemoteNotifications.html#//apple_ref/doc/uid/TP40008194-CH6-SW1 Logger.info("Re-registering for remote notifications.") PushRegistrationManager.shared.requestPushTokens() .flatMap { (pushToken: String, voipToken: String) -> AnyPublisher in PushNotificationAPI - .register( - with: Data(hex: pushToken), - publicKey: getUserHexEncodedPublicKey(), - isForcedUpdate: true + .subscribe( + token: Data(hex: pushToken), + isForcedUpdate: true, + using: dependencies ) - .retry(3) + .retry(3, using: dependencies) .handleEvents( receiveCompletion: { result in switch result { @@ -117,9 +101,9 @@ public enum SyncPushTokensJob: JobExecutor { case .finished: Logger.warn("Recording push tokens locally. pushToken: \(redact(pushToken)), voipToken: \(redact(voipToken))") SNLog("[SyncPushTokensJob] Completed") - UserDefaults.standard[.lastPushNotificationSync] = Date() + dependencies.standardUserDefaults[.lastPushNotificationSync] = dependencies.dateNow - Storage.shared.write { db in + dependencies.storage.write(using: dependencies) { db in db[.lastRecordedPushToken] = pushToken db[.lastRecordedVoipToken] = voipToken } @@ -129,10 +113,10 @@ public enum SyncPushTokensJob: JobExecutor { .map { _ in () } .eraseToAnyPublisher() } - .subscribe(on: queue) + .subscribe(on: queue, using: dependencies) .sinkUntilComplete( // We want to complete this job regardless of success or failure - receiveCompletion: { _ in success(job, false) } + receiveCompletion: { _ in success(job, false, dependencies) } ) } @@ -149,9 +133,9 @@ public enum SyncPushTokensJob: JobExecutor { SyncPushTokensJob.run( job, queue: DispatchQueue.global(qos: .default), - success: { _, _ in }, - failure: { _, _, _ in }, - deferred: { _ in } + success: { _, _, _ in }, + failure: { _, _, _, _ in }, + deferred: { _, _ in } ) } } @@ -167,5 +151,9 @@ extension SyncPushTokensJob { // MARK: - Convenience private func redact(_ string: String) -> String { - return OWSIsDebugBuild() ? string : "[ READACTED \(string.prefix(2))...\(string.suffix(2)) ]" +#if DEBUG + return string +#else + return "[ READACTED \(string.prefix(2))...\(string.suffix(2)) ]" +#endif } diff --git a/Session/Notifications/UserNotificationsAdaptee.swift b/Session/Notifications/UserNotificationsAdaptee.swift index 15c14a53a..383d1877f 100644 --- a/Session/Notifications/UserNotificationsAdaptee.swift +++ b/Session/Notifications/UserNotificationsAdaptee.swift @@ -6,6 +6,7 @@ import UserNotifications import SessionMessagingKit import SignalCoreKit import SignalUtilitiesKit +import SessionUtilitiesKit class UserNotificationConfig { diff --git a/Session/Onboarding/Onboarding.swift b/Session/Onboarding/Onboarding.swift index 97607724d..7aab0b08d 100644 --- a/Session/Onboarding/Onboarding.swift +++ b/Session/Onboarding/Onboarding.swift @@ -26,14 +26,10 @@ enum Onboarding { return existingPublisher } - private static func createProfileNameRetrievalPublisher(_ requestId: UUID) -> AnyPublisher { - // FIXME: Remove this once `useSharedUtilForUserConfig` is permanent - guard SessionUtil.userConfigsEnabled else { - return Just(nil) - .setFailureType(to: Error.self) - .eraseToAnyPublisher() - } - + private static func createProfileNameRetrievalPublisher( + _ requestId: UUID, + using dependencies: Dependencies = Dependencies() + ) -> AnyPublisher { let userPublicKey: String = getUserHexEncodedPublicKey() return SnodeAPI.getSwarm(for: userPublicKey) @@ -99,7 +95,8 @@ enum Onboarding { ) }(), sentTimestamp: TimeInterval((message.sentTimestamp ?? 0) / 1000), - calledFromConfigHandling: false + calledFromConfigHandling: false, + using: dependencies ) } return () @@ -254,9 +251,9 @@ enum Onboarding { // Notify the app that registration is complete Identity.didRegister() - // Now that we have registered get the Snode pool and sync push tokens + // Now that we have registered get the Snode pool (just in case) - other non-blocking + // launch jobs will automatically be run because the app activation was triggered GetSnodePoolJob.run() - SyncPushTokensJob.run(uploadOnlyIfStale: false) } } } diff --git a/Session/Onboarding/PNModeVC.swift b/Session/Onboarding/PNModeVC.swift index bf4884a29..c389e7b9b 100644 --- a/Session/Onboarding/PNModeVC.swift +++ b/Session/Onboarding/PNModeVC.swift @@ -6,6 +6,7 @@ import SessionUIKit import SessionMessagingKit import SessionSnodeKit import SignalUtilitiesKit +import SessionUtilitiesKit final class PNModeVC: BaseVC, OptionViewDelegate { private let flow: Onboarding.Flow diff --git a/Session/Onboarding/RegisterVC.swift b/Session/Onboarding/RegisterVC.swift index 52cc441a6..ef9cb8228 100644 --- a/Session/Onboarding/RegisterVC.swift +++ b/Session/Onboarding/RegisterVC.swift @@ -4,6 +4,7 @@ import UIKit import Sodium import SessionUIKit import SignalUtilitiesKit +import SessionUtilitiesKit final class RegisterVC : BaseVC { private var seed: Data! { didSet { updateKeyPair() } } diff --git a/Session/Settings/AppearanceViewController.swift b/Session/Settings/AppearanceViewController.swift index 10336d3ed..cca4a0b8f 100644 --- a/Session/Settings/AppearanceViewController.swift +++ b/Session/Settings/AppearanceViewController.swift @@ -3,6 +3,7 @@ import UIKit import SessionUIKit import SignalUtilitiesKit +import SessionUtilitiesKit final class AppearanceViewController: BaseVC { // MARK: - Components diff --git a/Session/Settings/BlockedContactsViewModel.swift b/Session/Settings/BlockedContactsViewModel.swift index 871bae502..0a612d0b0 100644 --- a/Session/Settings/BlockedContactsViewModel.swift +++ b/Session/Settings/BlockedContactsViewModel.swift @@ -6,6 +6,7 @@ import GRDB import DifferenceKit import SessionUIKit import SignalUtilitiesKit +import SessionUtilitiesKit class BlockedContactsViewModel: SessionTableViewModel { // MARK: - Section @@ -257,11 +258,12 @@ class BlockedContactsViewModel: SessionTableViewModel = """ SELECT - \(profile.alias[Column.rowID]) AS \(DataModel.rowIdKey), - \(DataModel.profileKey).* + \(profile[.rowId]) AS \(DataModel.Columns.rowId), + \(profile.allColumns) FROM \(Profile.self) - WHERE \(profile.alias[Column.rowID]) IN \(rowIds) + WHERE \(profile[.rowId]) IN \(rowIds) ORDER BY \(orderSQL) """ @@ -299,8 +301,8 @@ class BlockedContactsViewModel: SessionTableViewModel [SectionModel] in + .trackingConstantRegion { [weak self] db -> State in + State( + trimOpenGroupMessagesOlderThanSixMonths: db[.trimOpenGroupMessagesOlderThanSixMonths], + shouldAutoPlayConsecutiveAudioMessages: db[.shouldAutoPlayConsecutiveAudioMessages] + ) + } + .removeDuplicates() + .handleEvents(didFail: { SNLog("[ConversationSettingsViewModel] Observation failed with error: \($0)") }) + .publisher(in: Storage.shared) + .withPrevious() + .map { (previous: State?, current: State) -> [SectionModel] in return [ SectionModel( model: .messageTrimming, @@ -55,7 +70,11 @@ class ConversationSettingsViewModel: SessionTableViewModel Void diff --git a/Session/Settings/NotificationSettingsViewModel.swift b/Session/Settings/NotificationSettingsViewModel.swift index 8c0221165..046d0dfa3 100644 --- a/Session/Settings/NotificationSettingsViewModel.swift +++ b/Session/Settings/NotificationSettingsViewModel.swift @@ -7,7 +7,7 @@ import SessionUIKit import SessionMessagingKit import SessionUtilitiesKit -class NotificationSettingsViewModel: SessionTableViewModel { +class NotificationSettingsViewModel: SessionTableViewModel { // MARK: - Config public enum Section: SessionTableSection { @@ -31,7 +31,7 @@ class NotificationSettingsViewModel: SessionTableViewModel [SectionModel] in - let notificationSound: Preferences.Sound = db[.defaultNotificationSound] - .defaulting(to: Preferences.Sound.defaultNotificationSound) - let previewType: Preferences.NotificationPreviewType = db[.preferencesNotificationPreviewType] - .defaulting(to: Preferences.NotificationPreviewType.defaultPreviewType) - + .trackingConstantRegion { db -> State in + State( + isUsingFullAPNs: false, // Set later the the data flow + notificationSound: db[.defaultNotificationSound] + .defaulting(to: Preferences.Sound.defaultNotificationSound), + playNotificationSoundInForeground: db[.playNotificationSoundInForeground], + previewType: db[.preferencesNotificationPreviewType] + .defaulting(to: Preferences.NotificationPreviewType.defaultPreviewType) + ) + } + .removeDuplicates() + .handleEvents(didFail: { SNLog("[NotificationSettingsViewModel] Observation failed with error: \($0)") }) + .publisher(in: Storage.shared) + .manualRefreshFrom(forcedRefresh) + .map { dbState -> State in + State( + isUsingFullAPNs: UserDefaults.standard[.isUsingFullAPNs], + notificationSound: dbState.notificationSound, + playNotificationSoundInForeground: dbState.playNotificationSoundInForeground, + previewType: dbState.previewType + ) + } + .withPrevious() + .map { (previous: State?, current: State) -> [SectionModel] in return [ SectionModel( model: .strategy, @@ -68,20 +93,24 @@ class NotificationSettingsViewModel: SessionTableViewModel [SectionModel] in + .trackingConstantRegion { [weak self] db -> State in + State( + isScreenLockEnabled: db[.isScreenLockEnabled], + checkForCommunityMessageRequests: db[.checkForCommunityMessageRequests], + areReadReceiptsEnabled: db[.areReadReceiptsEnabled], + typingIndicatorsEnabled: db[.typingIndicatorsEnabled], + areLinkPreviewsEnabled: db[.areLinkPreviewsEnabled], + areCallsEnabled: db[.areCallsEnabled] + ) + } + .removeDuplicates() + .handleEvents(didFail: { SNLog("[PrivacySettingsViewModel] Observation failed with error: \($0)") }) + .publisher(in: Storage.shared) + .withPrevious() + .map { (previous: State?, current: State) -> [SectionModel] in return [ SectionModel( model: .screenSecurity, @@ -96,7 +122,13 @@ class PrivacySettingsViewModel: SessionTableViewModel 100 } // Prevent too many changes from causing performance issues ) { [weak self] updatedData in self?.viewModel.updateTableData(updatedData) @@ -339,6 +339,7 @@ class SessionTableViewController { Just(nil).eraseToAnyPublisher() } open var rightNavItems: AnyPublisher<[NavItem]?, Never> { Just(nil).eraseToAnyPublisher() } + private let _forcedRefresh: PassthroughSubject = PassthroughSubject() + lazy var forcedRefresh: AnyPublisher = _forcedRefresh + .shareReplay(0) private let _showToast: PassthroughSubject<(String, ThemeValue), Never> = PassthroughSubject() lazy var showToast: AnyPublisher<(String, ThemeValue), Never> = _showToast .shareReplay(0) @@ -62,6 +65,10 @@ class SessionTableViewModel( for viewModel: SessionTableViewModel ) -> AnyPublisher<(Output, StagedChangeset), Failure> where Output == [ArraySection>] { diff --git a/Session/Shared/Types/SessionCell+Accessory.swift b/Session/Shared/Types/SessionCell+Accessory.swift index af9d617eb..4bacade56 100644 --- a/Session/Shared/Types/SessionCell+Accessory.swift +++ b/Session/Shared/Types/SessionCell+Accessory.swift @@ -394,19 +394,30 @@ extension SessionCell.Accessory { extension SessionCell.Accessory { public enum DataSource: Hashable, Equatable { - case boolValue(Bool) + case boolValue(key: String, value: Bool, oldValue: Bool) case dynamicString(() -> String?) - case userDefaults(UserDefaults, key: String) - case settingBool(key: Setting.BoolKey) + + static func boolValue(_ value: Bool, oldValue: Bool) -> DataSource { + return .boolValue(key: "", value: value, oldValue: oldValue) + } + + static func boolValue(key: Setting.BoolKey, value: Bool, oldValue: Bool) -> DataSource { + return .boolValue(key: key.rawValue, value: value, oldValue: oldValue) + } // MARK: - Convenience public var currentBoolValue: Bool { switch self { - case .boolValue(let value): return value + case .boolValue(_, let value, _): return value case .dynamicString: return false - case .userDefaults(let defaults, let key): return defaults.bool(forKey: key) - case .settingBool(let key): return Storage.shared[key] + } + } + + public var oldBoolValue: Bool { + switch self { + case .boolValue(_, _, let oldValue): return oldValue + default: return false } } @@ -421,27 +432,27 @@ extension SessionCell.Accessory { public func hash(into hasher: inout Hasher) { switch self { - case .boolValue(let value): value.hash(into: &hasher) + case .boolValue(let key, let value, let oldValue): + key.hash(into: &hasher) + value.hash(into: &hasher) + oldValue.hash(into: &hasher) + case .dynamicString(let generator): generator().hash(into: &hasher) - case .userDefaults(_, let key): key.hash(into: &hasher) - case .settingBool(let key): key.hash(into: &hasher) } } public static func == (lhs: DataSource, rhs: DataSource) -> Bool { switch (lhs, rhs) { - case (.boolValue(let lhsValue), .boolValue(let rhsValue)): - return (lhsValue == rhsValue) + case (.boolValue(let lhsKey, let lhsValue, let lhsOldValue), .boolValue(let rhsKey, let rhsValue, let rhsOldValue)): + return ( + lhsKey == rhsKey && + lhsValue == rhsValue && + lhsOldValue == rhsOldValue + ) case (.dynamicString(let lhsGenerator), .dynamicString(let rhsGenerator)): return (lhsGenerator() == rhsGenerator()) - case (.userDefaults(_, let lhsKey), .userDefaults(_, let rhsKey)): - return (lhsKey == rhsKey) - - case (.settingBool(let lhsKey), .settingBool(let rhsKey)): - return (lhsKey == rhsKey) - default: return false } } diff --git a/Session/Shared/Types/SessionCell+Styling.swift b/Session/Shared/Types/SessionCell+Styling.swift index 948cd1631..e7a454388 100644 --- a/Session/Shared/Types/SessionCell+Styling.swift +++ b/Session/Shared/Types/SessionCell+Styling.swift @@ -1,6 +1,6 @@ // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. -import Foundation +import UIKit import SessionUIKit // MARK: - Main Types diff --git a/Session/Shared/Views/SessionCell+AccessoryView.swift b/Session/Shared/Views/SessionCell+AccessoryView.swift index 44c81b9eb..39ca4344a 100644 --- a/Session/Shared/Views/SessionCell+AccessoryView.swift +++ b/Session/Shared/Views/SessionCell+AccessoryView.swift @@ -277,7 +277,8 @@ extension SessionCell { public func update( with accessory: Accessory?, tintColor: ThemeValue, - isEnabled: Bool + isEnabled: Bool, + isManualReload: Bool ) { guard let accessory: Accessory = accessory else { return } @@ -356,10 +357,15 @@ extension SessionCell { fixedWidthConstraint.isActive = true toggleSwitchConstraints.forEach { $0.isActive = true } - let newValue: Bool = dataSource.currentBoolValue - - if newValue != toggleSwitch.isOn { - toggleSwitch.setOn(newValue, animated: true) + if !isManualReload { + toggleSwitch.setOn(dataSource.oldBoolValue, animated: false) + + // Dispatch so the cell reload doesn't conflict with the setting change animation + if dataSource.oldBoolValue != dataSource.currentBoolValue { + DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(10)) { [weak toggleSwitch] in + toggleSwitch?.setOn(dataSource.currentBoolValue, animated: true) + } + } } case .dropDown(let dataSource, let accessibility): diff --git a/Session/Shared/Views/SessionCell.swift b/Session/Shared/Views/SessionCell.swift index 912fb37a9..1b2b8630e 100644 --- a/Session/Shared/Views/SessionCell.swift +++ b/Session/Shared/Views/SessionCell.swift @@ -313,7 +313,7 @@ public class SessionCell: UITableViewCell { botSeparator.isHidden = true } - public func update(with info: Info) { + public func update(with info: Info, isManualReload: Bool = false) { interactionMode = (info.title?.interaction ?? .none) shouldHighlightTitle = (info.title?.interaction != .copy) titleExtraView = info.title?.extraViewGenerator?() @@ -332,7 +332,8 @@ public class SessionCell: UITableViewCell { leftAccessoryView.update( with: info.leftAccessory, tintColor: info.styling.tintColor, - isEnabled: info.isEnabled + isEnabled: info.isEnabled, + isManualReload: isManualReload ) titleStackView.isHidden = (info.title == nil && info.subtitle == nil) titleLabel.isUserInteractionEnabled = (info.title?.interaction == .copy) @@ -356,7 +357,8 @@ public class SessionCell: UITableViewCell { rightAccessoryView.update( with: info.rightAccessory, tintColor: info.styling.tintColor, - isEnabled: info.isEnabled + isEnabled: info.isEnabled, + isManualReload: isManualReload ) contentStackViewLeadingConstraint.isActive = (info.styling.alignment == .leading) 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/Session/Utilities/Date+Utilities.swift b/Session/Utilities/Date+Utilities.swift index ed7aab4f4..c6e440f3f 100644 --- a/Session/Utilities/Date+Utilities.swift +++ b/Session/Utilities/Date+Utilities.swift @@ -5,6 +5,9 @@ import SessionUtilitiesKit public extension Date { var formattedForDisplay: String { + // If we don't have a date then + guard self.timeIntervalSince1970 > 0 else { return "" } + let dateNow: Date = Date() guard Calendar.current.isDate(self, equalTo: dateNow, toGranularity: .year) else { diff --git a/Session/Utilities/IP2Country.swift b/Session/Utilities/IP2Country.swift index b27698f5c..326afc83e 100644 --- a/Session/Utilities/IP2Country.swift +++ b/Session/Utilities/IP2Country.swift @@ -1,6 +1,7 @@ import Foundation import GRDB import SessionSnodeKit +import SessionUtilitiesKit final class IP2Country { static var isInitialized = false @@ -12,16 +13,16 @@ final class IP2Country { /// the **lower** bound of an IP range and the "registered_country_geoname_id" column contains the ID of the country corresponding /// to that range. We look up an IP by finding the first index in the network column where the value is greater than the IP we're looking /// up (converted to an integer). The IP we're looking up must then be in the range **before** that range. - private lazy var ipv4Table: [String:[Int]] = { + private lazy var ipv4Table: [String: [Int]] = { let url = Bundle.main.url(forResource: "GeoLite2-Country-Blocks-IPv4", withExtension: nil)! let data = try! Data(contentsOf: url) - return try! NSKeyedUnarchiver.unarchiveTopLevelObjectWithData(data) as! [String:[Int]] + return try! NSKeyedUnarchiver.unarchiveTopLevelObjectWithData(data) as! [String: [Int]] }() - private lazy var countryNamesTable: [String:[String]] = { + private lazy var countryNamesTable: [String: [String]] = { let url = Bundle.main.url(forResource: "GeoLite2-Country-Locations-English", withExtension: nil)! let data = try! Data(contentsOf: url) - return try! NSKeyedUnarchiver.unarchiveTopLevelObjectWithData(data) as! [String:[String]] + return try! NSKeyedUnarchiver.unarchiveTopLevelObjectWithData(data) as! [String: [String]] }() // MARK: Lifecycle diff --git a/Session/Utilities/MockDataGenerator.swift b/Session/Utilities/MockDataGenerator.swift index 1cbf94fbe..e41c9be22 100644 --- a/Session/Utilities/MockDataGenerator.swift +++ b/Session/Utilities/MockDataGenerator.swift @@ -4,6 +4,7 @@ import Foundation import GRDB import Curve25519Kit import SessionMessagingKit +import SessionUtilitiesKit enum MockDataGenerator { // MARK: - Generation @@ -99,7 +100,8 @@ enum MockDataGenerator { .compactMap { _ in stringContent.randomElement(using: &dmThreadRandomGenerator) } .joined(), lastNameUpdate: Date().timeIntervalSince1970, - lastProfilePictureUpdate: Date().timeIntervalSince1970 + lastProfilePictureUpdate: Date().timeIntervalSince1970, + lastBlocksCommunityMessageRequests: 0 ) .saved(db) @@ -180,7 +182,8 @@ enum MockDataGenerator { .compactMap { _ in stringContent.randomElement(using: &cgThreadRandomGenerator) } .joined(), lastNameUpdate: Date().timeIntervalSince1970, - lastProfilePictureUpdate: Date().timeIntervalSince1970 + lastProfilePictureUpdate: Date().timeIntervalSince1970, + lastBlocksCommunityMessageRequests: 0 ) .saved(db) @@ -310,7 +313,8 @@ enum MockDataGenerator { .compactMap { _ in stringContent.randomElement(using: &ogThreadRandomGenerator) } .joined(), lastNameUpdate: Date().timeIntervalSince1970, - lastProfilePictureUpdate: Date().timeIntervalSince1970 + lastProfilePictureUpdate: Date().timeIntervalSince1970, + lastBlocksCommunityMessageRequests: 0 ) .saved(db) diff --git a/Session/Utilities/UIContextualAction+Utilities.swift b/Session/Utilities/UIContextualAction+Utilities.swift index 1d33a47c6..3f26aa57c 100644 --- a/Session/Utilities/UIContextualAction+Utilities.swift +++ b/Session/Utilities/UIContextualAction+Utilities.swift @@ -3,6 +3,7 @@ import UIKit import SessionMessagingKit import SessionUIKit +import SessionUtilitiesKit protocol SwipeActionOptimisticCell { func optimisticUpdate(isMuted: Bool?, isBlocked: Bool?, isPinned: Bool?, hasUnread: Bool?) @@ -163,7 +164,7 @@ public extension UIContextualAction { db, threadId: threadViewModel.threadId, threadVariant: threadViewModel.threadVariant, - groupLeaveType: .forced, + groupLeaveType: .silent, calledFromConfigHandling: false ) } 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/Configuration.swift b/SessionMessagingKit/Configuration.swift index 384aa7249..2644c73ec 100644 --- a/SessionMessagingKit/Configuration.swift +++ b/SessionMessagingKit/Configuration.swift @@ -31,34 +31,29 @@ public enum SNMessagingKit: MigratableTarget { // Just to make the external API _011_AddPendingReadReceipts.self, _012_AddFTSIfNeeded.self, _013_SessionUtilChanges.self, - // Wait until the feature is turned on before doing the migration that generates - // the config dump data - // FIXME: Remove this once `useSharedUtilForUserConfig` is permanent - (Features.useSharedUtilForUserConfig(db) ? - _014_GenerateInitialUserConfigDumps.self : - (nil as Migration.Type?) - ) - ].compactMap { $0 } + _014_GenerateInitialUserConfigDumps.self, + _015_BlockCommunityMessageRequests.self + ] ] ) } public static func configure() { // Configure the job executors - JobRunner.add(executor: DisappearingMessagesJob.self, for: .disappearingMessages) - JobRunner.add(executor: FailedMessageSendsJob.self, for: .failedMessageSends) - JobRunner.add(executor: FailedAttachmentDownloadsJob.self, for: .failedAttachmentDownloads) - JobRunner.add(executor: UpdateProfilePictureJob.self, for: .updateProfilePicture) - JobRunner.add(executor: RetrieveDefaultOpenGroupRoomsJob.self, for: .retrieveDefaultOpenGroupRooms) - JobRunner.add(executor: GarbageCollectionJob.self, for: .garbageCollection) - JobRunner.add(executor: MessageSendJob.self, for: .messageSend) - JobRunner.add(executor: MessageReceiveJob.self, for: .messageReceive) - JobRunner.add(executor: NotifyPushServerJob.self, for: .notifyPushServer) - JobRunner.add(executor: SendReadReceiptsJob.self, for: .sendReadReceipts) - JobRunner.add(executor: AttachmentUploadJob.self, for: .attachmentUpload) - JobRunner.add(executor: GroupLeavingJob.self, for: .groupLeaving) - JobRunner.add(executor: AttachmentDownloadJob.self, for: .attachmentDownload) - JobRunner.add(executor: ConfigurationSyncJob.self, for: .configurationSync) - JobRunner.add(executor: ConfigMessageReceiveJob.self, for: .configMessageReceive) + JobRunner.setExecutor(DisappearingMessagesJob.self, for: .disappearingMessages) + JobRunner.setExecutor(FailedMessageSendsJob.self, for: .failedMessageSends) + JobRunner.setExecutor(FailedAttachmentDownloadsJob.self, for: .failedAttachmentDownloads) + JobRunner.setExecutor(UpdateProfilePictureJob.self, for: .updateProfilePicture) + JobRunner.setExecutor(RetrieveDefaultOpenGroupRoomsJob.self, for: .retrieveDefaultOpenGroupRooms) + JobRunner.setExecutor(GarbageCollectionJob.self, for: .garbageCollection) + JobRunner.setExecutor(MessageSendJob.self, for: .messageSend) + JobRunner.setExecutor(MessageReceiveJob.self, for: .messageReceive) + JobRunner.setExecutor(NotifyPushServerJob.self, for: .notifyPushServer) + JobRunner.setExecutor(SendReadReceiptsJob.self, for: .sendReadReceipts) + JobRunner.setExecutor(AttachmentUploadJob.self, for: .attachmentUpload) + JobRunner.setExecutor(GroupLeavingJob.self, for: .groupLeaving) + JobRunner.setExecutor(AttachmentDownloadJob.self, for: .attachmentDownload) + JobRunner.setExecutor(ConfigurationSyncJob.self, for: .configurationSync) + JobRunner.setExecutor(ConfigMessageReceiveJob.self, for: .configMessageReceive) } } diff --git a/SessionMessagingKit/Database/LegacyDatabase/SMKLegacy.swift b/SessionMessagingKit/Database/LegacyDatabase/SMKLegacy.swift index 7967974ac..f950fde8f 100644 --- a/SessionMessagingKit/Database/LegacyDatabase/SMKLegacy.swift +++ b/SessionMessagingKit/Database/LegacyDatabase/SMKLegacy.swift @@ -649,23 +649,18 @@ public enum SMKLegacy { @objc(SNConfigurationMessage) internal final class _ConfigurationMessage: _ControlMessage { - internal var closedGroups: Set<_CMClosedGroup> = [] - internal var openGroups: Set = [] internal var displayName: String? internal var profilePictureURL: String? internal var profileKey: Data? - internal var contacts: Set<_CMContact> = [] // MARK: NSCoding public required init?(coder: NSCoder) { super.init(coder: coder) - if let closedGroups = coder.decodeObject(forKey: "closedGroups") as! Set<_CMClosedGroup>? { self.closedGroups = closedGroups } - if let openGroups = coder.decodeObject(forKey: "openGroups") as! Set? { self.openGroups = openGroups } + if let displayName = coder.decodeObject(forKey: "displayName") as! String? { self.displayName = displayName } if let profilePictureURL = coder.decodeObject(forKey: "profilePictureURL") as! String? { self.profilePictureURL = profilePictureURL } if let profileKey = coder.decodeObject(forKey: "profileKey") as! Data? { self.profileKey = profileKey } - if let contacts = coder.decodeObject(forKey: "contacts") as! Set<_CMContact>? { self.contacts = contacts } } public override func encode(with coder: NSCoder) { @@ -679,126 +674,12 @@ public enum SMKLegacy { ConfigurationMessage( displayName: displayName, profilePictureUrl: profilePictureURL, - profileKey: profileKey, - closedGroups: closedGroups - .map { $0.toNonLegacy() } - .asSet(), - openGroups: openGroups, - contacts: contacts - .map { $0.toNonLegacy() } - .asSet() + profileKey: profileKey ) ) } } - // MARK: - Config Message Closed Group - - @objc(CMClosedGroup) - internal final class _CMClosedGroup: NSObject, NSCoding { - internal let publicKey: String - internal let name: String - internal let encryptionKeyPair: SUKLegacy.KeyPair - internal let members: Set - internal let admins: Set - internal let expirationTimer: UInt32 - - // MARK: NSCoding - - public required init?(coder: NSCoder) { - guard - let publicKey = coder.decodeObject(forKey: "publicKey") as! String?, - let name = coder.decodeObject(forKey: "name") as! String?, - let encryptionKeyPair = coder.decodeObject(forKey: "encryptionKeyPair") as! SUKLegacy.KeyPair?, - let members = coder.decodeObject(forKey: "members") as! Set?, - let admins = coder.decodeObject(forKey: "admins") as! Set? - else { return nil } - - self.publicKey = publicKey - self.name = name - self.encryptionKeyPair = encryptionKeyPair - self.members = members - self.admins = admins - self.expirationTimer = (coder.decodeObject(forKey: "expirationTimer") as? UInt32 ?? 0) - } - - public func encode(with coder: NSCoder) { - fatalError("encode(with:) should never be called for legacy types") - } - - // MARK: Non-Legacy Conversion - - internal func toNonLegacy() -> ConfigurationMessage.CMClosedGroup { - return ConfigurationMessage.CMClosedGroup( - publicKey: publicKey, - name: name, - encryptionKeyPublicKey: encryptionKeyPair.publicKey, - encryptionKeySecretKey: encryptionKeyPair.privateKey, - members: members, - admins: admins, - expirationTimer: expirationTimer - ) - } - } - - // MARK: - Config Message Contact - - @objc(SNConfigurationMessageContact) - internal final class _CMContact: NSObject, NSCoding { - internal var publicKey: String? - internal var displayName: String? - internal var profilePictureURL: String? - internal var profileKey: Data? - - internal var hasIsApproved: Bool - internal var isApproved: Bool - internal var hasIsBlocked: Bool - internal var isBlocked: Bool - internal var hasDidApproveMe: Bool - internal var didApproveMe: Bool - - // MARK: NSCoding - - public required init?(coder: NSCoder) { - guard - let publicKey = coder.decodeObject(forKey: "publicKey") as! String?, - let displayName = coder.decodeObject(forKey: "displayName") as! String? - else { return nil } - - self.publicKey = publicKey - self.displayName = displayName - self.profilePictureURL = coder.decodeObject(forKey: "profilePictureURL") as! String? - self.profileKey = coder.decodeObject(forKey: "profileKey") as! Data? - self.hasIsApproved = (coder.decodeObject(forKey: "hasIsApproved") as? Bool ?? false) - self.isApproved = (coder.decodeObject(forKey: "isApproved") as? Bool ?? false) - self.hasIsBlocked = (coder.decodeObject(forKey: "hasIsBlocked") as? Bool ?? false) - self.isBlocked = (coder.decodeObject(forKey: "isBlocked") as? Bool ?? false) - self.hasDidApproveMe = (coder.decodeObject(forKey: "hasDidApproveMe") as? Bool ?? false) - self.didApproveMe = (coder.decodeObject(forKey: "didApproveMe") as? Bool ?? false) - } - - public func encode(with coder: NSCoder) { - fatalError("encode(with:) should never be called for legacy types") - } - - // MARK: Non-Legacy Conversion - - internal func toNonLegacy() -> ConfigurationMessage.CMContact { - return ConfigurationMessage.CMContact( - publicKey: publicKey, - displayName: displayName, - profilePictureUrl: profilePictureURL, - profileKey: profileKey, - hasIsApproved: hasIsApproved, - isApproved: isApproved, - hasIsBlocked: hasIsBlocked, - isBlocked: isBlocked, - hasDidApproveMe: hasDidApproveMe, - didApproveMe: didApproveMe - ) - } - } - // MARK: - Unsend Request @objc(SNUnsendRequest) diff --git a/SessionMessagingKit/Database/Migrations/_003_YDBToGRDBMigration.swift b/SessionMessagingKit/Database/Migrations/_003_YDBToGRDBMigration.swift index 8918c1c9b..d7ac2eabf 100644 --- a/SessionMessagingKit/Database/Migrations/_003_YDBToGRDBMigration.swift +++ b/SessionMessagingKit/Database/Migrations/_003_YDBToGRDBMigration.swift @@ -422,7 +422,8 @@ enum _003_YDBToGRDBMigration: Migration { profilePictureUrl: legacyContact.profilePictureURL, profilePictureFileName: legacyContact.profilePictureFileName, profileEncryptionKey: legacyContact.profileEncryptionKey?.keyData, - lastProfilePictureUpdate: 0 + lastProfilePictureUpdate: 0, + lastBlocksCommunityMessageRequests: 0 ).migrationSafeInsert(db) /// **Note:** The blow "shouldForce" flags are here to allow us to avoid having to run legacy migrations they @@ -645,7 +646,8 @@ enum _003_YDBToGRDBMigration: Migration { id: profileId, name: profileId, lastNameUpdate: 0, - lastProfilePictureUpdate: 0 + lastProfilePictureUpdate: 0, + lastBlocksCommunityMessageRequests: 0 ).migrationSafeSave(db) } @@ -1059,7 +1061,8 @@ enum _003_YDBToGRDBMigration: Migration { id: quotedMessage.authorId, name: quotedMessage.authorId, lastNameUpdate: 0, - lastProfilePictureUpdate: 0 + lastProfilePictureUpdate: 0, + lastBlocksCommunityMessageRequests: 0 ).migrationSafeSave(db) } @@ -1851,14 +1854,6 @@ enum _003_YDBToGRDBMigration: Migration { SMKLegacy._ConfigurationMessage.self, forClassName: "SNConfigurationMessage" ) - NSKeyedUnarchiver.setClass( - SMKLegacy._CMClosedGroup.self, - forClassName: "SNClosedGroup" - ) - NSKeyedUnarchiver.setClass( - SMKLegacy._CMContact.self, - forClassName: "SNConfigurationMessage.SNConfigurationMessageContact" - ) NSKeyedUnarchiver.setClass( SMKLegacy._UnsendRequest.self, forClassName: "SNUnsendRequest" diff --git a/SessionMessagingKit/Database/Migrations/_005_FixDeletedMessageReadState.swift b/SessionMessagingKit/Database/Migrations/_005_FixDeletedMessageReadState.swift index 65e68507c..235a217d3 100644 --- a/SessionMessagingKit/Database/Migrations/_005_FixDeletedMessageReadState.swift +++ b/SessionMessagingKit/Database/Migrations/_005_FixDeletedMessageReadState.swift @@ -9,7 +9,7 @@ enum _005_FixDeletedMessageReadState: Migration { static let target: TargetMigrations.Identifier = .messagingKit static let identifier: String = "FixDeletedMessageReadState" static let needsConfigSync: Bool = false - static let minExpectedRunDuration: TimeInterval = 0.1 + static let minExpectedRunDuration: TimeInterval = 0.01 static func migrate(_ db: Database) throws { _ = try Interaction diff --git a/SessionMessagingKit/Database/Migrations/_006_FixHiddenModAdminSupport.swift b/SessionMessagingKit/Database/Migrations/_006_FixHiddenModAdminSupport.swift index c1097eb94..b746c6362 100644 --- a/SessionMessagingKit/Database/Migrations/_006_FixHiddenModAdminSupport.swift +++ b/SessionMessagingKit/Database/Migrations/_006_FixHiddenModAdminSupport.swift @@ -10,7 +10,7 @@ enum _006_FixHiddenModAdminSupport: Migration { static let target: TargetMigrations.Identifier = .messagingKit static let identifier: String = "FixHiddenModAdminSupport" static let needsConfigSync: Bool = false - static let minExpectedRunDuration: TimeInterval = 0.1 + static let minExpectedRunDuration: TimeInterval = 0.01 static func migrate(_ db: Database) throws { try db.alter(table: GroupMember.self) { t in diff --git a/SessionMessagingKit/Database/Migrations/_007_HomeQueryOptimisationIndexes.swift b/SessionMessagingKit/Database/Migrations/_007_HomeQueryOptimisationIndexes.swift index b468098f7..5e53bb6ee 100644 --- a/SessionMessagingKit/Database/Migrations/_007_HomeQueryOptimisationIndexes.swift +++ b/SessionMessagingKit/Database/Migrations/_007_HomeQueryOptimisationIndexes.swift @@ -9,7 +9,7 @@ enum _007_HomeQueryOptimisationIndexes: Migration { static let target: TargetMigrations.Identifier = .messagingKit static let identifier: String = "HomeQueryOptimisationIndexes" static let needsConfigSync: Bool = false - static let minExpectedRunDuration: TimeInterval = 0.1 + static let minExpectedRunDuration: TimeInterval = 0.01 static func migrate(_ db: Database) throws { try db.create( diff --git a/SessionMessagingKit/Database/Migrations/_008_EmojiReacts.swift b/SessionMessagingKit/Database/Migrations/_008_EmojiReacts.swift index b06687dca..399dba483 100644 --- a/SessionMessagingKit/Database/Migrations/_008_EmojiReacts.swift +++ b/SessionMessagingKit/Database/Migrations/_008_EmojiReacts.swift @@ -9,7 +9,7 @@ enum _008_EmojiReacts: Migration { static let target: TargetMigrations.Identifier = .messagingKit static let identifier: String = "EmojiReacts" static let needsConfigSync: Bool = false - static let minExpectedRunDuration: TimeInterval = 0.1 + static let minExpectedRunDuration: TimeInterval = 0.01 static func migrate(_ db: Database) throws { try db.create(table: Reaction.self) { t in diff --git a/SessionMessagingKit/Database/Migrations/_009_OpenGroupPermission.swift b/SessionMessagingKit/Database/Migrations/_009_OpenGroupPermission.swift index 4f6036a2d..f4c7e8617 100644 --- a/SessionMessagingKit/Database/Migrations/_009_OpenGroupPermission.swift +++ b/SessionMessagingKit/Database/Migrations/_009_OpenGroupPermission.swift @@ -8,7 +8,7 @@ enum _009_OpenGroupPermission: Migration { static let target: TargetMigrations.Identifier = .messagingKit static let identifier: String = "OpenGroupPermission" static let needsConfigSync: Bool = false - static let minExpectedRunDuration: TimeInterval = 0.1 + static let minExpectedRunDuration: TimeInterval = 0.01 static func migrate(_ db: GRDB.Database) throws { try db.alter(table: OpenGroup.self) { t in diff --git a/SessionMessagingKit/Database/Migrations/_011_AddPendingReadReceipts.swift b/SessionMessagingKit/Database/Migrations/_011_AddPendingReadReceipts.swift index 9c2e228d5..2fb57b2cf 100644 --- a/SessionMessagingKit/Database/Migrations/_011_AddPendingReadReceipts.swift +++ b/SessionMessagingKit/Database/Migrations/_011_AddPendingReadReceipts.swift @@ -10,7 +10,7 @@ enum _011_AddPendingReadReceipts: Migration { static let target: TargetMigrations.Identifier = .messagingKit static let identifier: String = "AddPendingReadReceipts" static let needsConfigSync: Bool = false - static let minExpectedRunDuration: TimeInterval = 0.1 + static let minExpectedRunDuration: TimeInterval = 0.01 static func migrate(_ db: Database) throws { try db.create(table: PendingReadReceipt.self) { t in diff --git a/SessionMessagingKit/Database/Migrations/_012_AddFTSIfNeeded.swift b/SessionMessagingKit/Database/Migrations/_012_AddFTSIfNeeded.swift index d994b6a90..57cb66e7d 100644 --- a/SessionMessagingKit/Database/Migrations/_012_AddFTSIfNeeded.swift +++ b/SessionMessagingKit/Database/Migrations/_012_AddFTSIfNeeded.swift @@ -9,7 +9,7 @@ enum _012_AddFTSIfNeeded: Migration { static let target: TargetMigrations.Identifier = .messagingKit static let identifier: String = "AddFTSIfNeeded" static let needsConfigSync: Bool = false - static let minExpectedRunDuration: TimeInterval = 0.1 + static let minExpectedRunDuration: TimeInterval = 0.01 static func migrate(_ db: Database) throws { // Fix an issue that the fullTextSearchTable was dropped unintentionally and global search won't work. diff --git a/SessionMessagingKit/Database/Migrations/_014_GenerateInitialUserConfigDumps.swift b/SessionMessagingKit/Database/Migrations/_014_GenerateInitialUserConfigDumps.swift index 04b565056..442431a70 100644 --- a/SessionMessagingKit/Database/Migrations/_014_GenerateInitialUserConfigDumps.swift +++ b/SessionMessagingKit/Database/Migrations/_014_GenerateInitialUserConfigDumps.swift @@ -6,8 +6,6 @@ import SessionUtil import SessionUtilitiesKit /// This migration goes through the current state of the database and generates config dumps for the user config types -/// -/// **Note:** This migration won't be run until the `useSharedUtilForUserConfig` feature flag is enabled enum _014_GenerateInitialUserConfigDumps: Migration { static let target: TargetMigrations.Identifier = .messagingKit static let identifier: String = "GenerateInitialUserConfigDumps" diff --git a/SessionMessagingKit/Database/Migrations/_015_BlockCommunityMessageRequests.swift b/SessionMessagingKit/Database/Migrations/_015_BlockCommunityMessageRequests.swift new file mode 100644 index 000000000..b512101b2 --- /dev/null +++ b/SessionMessagingKit/Database/Migrations/_015_BlockCommunityMessageRequests.swift @@ -0,0 +1,44 @@ +// Copyright © 2023 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import GRDB +import SessionUtilitiesKit + +/// This migration adds a flag indicating whether a profile has indicated it is blocking community message requests +enum _015_BlockCommunityMessageRequests: Migration { + static let target: TargetMigrations.Identifier = .messagingKit + static let identifier: String = "BlockCommunityMessageRequests" + static let needsConfigSync: Bool = false + static let minExpectedRunDuration: TimeInterval = 0.01 + static var requirements: [MigrationRequirement] = [.sessionUtilStateLoaded] + + static func migrate(_ db: Database) throws { + // Add the new 'Profile' properties + try db.alter(table: Profile.self) { t in + t.add(.blocksCommunityMessageRequests, .boolean) + t.add(.lastBlocksCommunityMessageRequests, .integer) + .notNull() + .defaults(to: 0) + } + + // If the user exists and the 'checkForCommunityMessageRequests' hasn't already been set then default it to "false" + if + Identity.userExists(db), + (try Setting.exists(db, id: Setting.BoolKey.checkForCommunityMessageRequests.rawValue)) == false + { + let rawBlindedMessageRequestValue: Int32 = try SessionUtil + .config(for: .userProfile, publicKey: getUserHexEncodedPublicKey(db)) + .wrappedValue + .map { conf -> Int32 in try SessionUtil.rawBlindedMessageRequestValue(in: conf) } + .defaulting(to: -1) + + // Use the value in the config if we happen to have one, otherwise use the default + db[.checkForCommunityMessageRequests] = (rawBlindedMessageRequestValue < 0 ? + true : + (rawBlindedMessageRequestValue > 0) + ) + } + + Storage.update(progress: 1, for: self, in: target) // In case this is the last migration + } +} diff --git a/SessionMessagingKit/Database/Models/Attachment.swift b/SessionMessagingKit/Database/Models/Attachment.swift index bb575a6fa..9c1733e6c 100644 --- a/SessionMessagingKit/Database/Models/Attachment.swift +++ b/SessionMessagingKit/Database/Models/Attachment.swift @@ -522,7 +522,7 @@ extension Attachment { \(interaction[.id]) = \(interactionAttachment[.interactionId]) OR ( \(interaction[.linkPreviewUrl]) = \(linkPreview[.url]) AND - \(Interaction.linkPreviewFilterLiteral) + \(Interaction.linkPreviewFilterLiteral()) ) ) @@ -568,7 +568,7 @@ extension Attachment { \(interaction[.id]) = \(interactionAttachment[.interactionId]) OR ( \(interaction[.linkPreviewUrl]) = \(linkPreview[.url]) AND - \(Interaction.linkPreviewFilterLiteral) + \(Interaction.linkPreviewFilterLiteral()) ) ) @@ -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/ClosedGroup.swift b/SessionMessagingKit/Database/Models/ClosedGroup.swift index 43e922ed0..49ab5dfcf 100644 --- a/SessionMessagingKit/Database/Models/ClosedGroup.swift +++ b/SessionMessagingKit/Database/Models/ClosedGroup.swift @@ -95,6 +95,19 @@ public extension ClosedGroup { } } +// MARK: - Search Queries + +public extension ClosedGroup { + struct FullTextSearch: Decodable, ColumnExpressible { + public typealias Columns = CodingKeys + public enum CodingKeys: String, CodingKey, ColumnExpression, CaseIterable { + case name + } + + let name: String + } +} + // MARK: - Convenience public extension ClosedGroup { @@ -144,10 +157,9 @@ public extension ClosedGroup { ClosedGroupPoller.shared.stopPolling(for: threadId) PushNotificationAPI - .performOperation( - .unsubscribe, - for: threadId, - publicKey: userPublicKey + .unsubscribeFromLegacyGroup( + legacyGroupId: threadId, + currentUserPublicKey: userPublicKey ) .sinkUntilComplete() } 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/ControlMessageProcessRecord.swift b/SessionMessagingKit/Database/Models/ControlMessageProcessRecord.swift index 938737190..e23f54879 100644 --- a/SessionMessagingKit/Database/Models/ControlMessageProcessRecord.swift +++ b/SessionMessagingKit/Database/Models/ControlMessageProcessRecord.swift @@ -163,7 +163,7 @@ internal extension ControlMessageProcessRecord { .infoClosedGroupCreated: return nil - case .infoClosedGroupUpdated, .infoClosedGroupCurrentUserLeft, .infoClosedGroupCurrentUserLeaving, .infoClosedGroupCurrentUserErrorLeaving: + case .infoClosedGroupUpdated, .infoClosedGroupCurrentUserLeft, .infoClosedGroupCurrentUserLeaving, .infoClosedGroupCurrentUserErrorLeaving: self.variant = .closedGroupControlMessage case .infoDisappearingMessagesUpdate: diff --git a/SessionMessagingKit/Database/Models/Interaction.swift b/SessionMessagingKit/Database/Models/Interaction.swift index 21b29295b..9d93212cd 100644 --- a/SessionMessagingKit/Database/Models/Interaction.swift +++ b/SessionMessagingKit/Database/Models/Interaction.swift @@ -29,13 +29,14 @@ public struct Interaction: Codable, Identifiable, Equatable, FetchableRecord, Mu /// Whenever using this `linkPreview` association make sure to filter the result using /// `.filter(literal: Interaction.linkPreviewFilterLiteral)` to ensure the correct LinkPreview is returned public static let linkPreview = hasOne(LinkPreview.self, using: LinkPreview.interactionForeignKey) - public static var linkPreviewFilterLiteral: SQL = { - let interaction: TypedTableAlias = TypedTableAlias() - let linkPreview: TypedTableAlias = TypedTableAlias() + public static func linkPreviewFilterLiteral( + interaction: TypedTableAlias = TypedTableAlias(), + linkPreview: TypedTableAlias = TypedTableAlias() + ) -> SQL { let halfResolution: Double = LinkPreview.timstampResolution return "(\(interaction[.timestampMs]) BETWEEN (\(linkPreview[.timestamp]) - \(halfResolution)) * 1000 AND (\(linkPreview[.timestamp]) + \(halfResolution)) * 1000)" - }() + } public static let recipientStates = hasMany(RecipientState.self, using: RecipientState.interactionForeignKey) public typealias Columns = CodingKeys @@ -695,6 +696,17 @@ public extension Interaction { // MARK: - Search Queries public extension Interaction { + struct FullTextSearch: Decodable, ColumnExpressible { + public typealias Columns = CodingKeys + public enum CodingKeys: String, CodingKey, ColumnExpression, CaseIterable { + case threadId + case body + } + + let threadId: String + let body: String + } + struct TimestampInfo: FetchableRecord, Codable { public let id: Int64 public let timestampMs: Int64 @@ -710,8 +722,7 @@ public extension Interaction { static func idsForTermWithin(threadId: String, pattern: FTS5Pattern) -> SQLRequest { let interaction: TypedTableAlias = TypedTableAlias() - let interactionFullTextSearch: SQL = SQL(stringLiteral: Interaction.fullTextSearchTableName) - let threadIdLiteral: SQL = SQL(stringLiteral: Interaction.Columns.threadId.name) + let interactionFullTextSearch: TypedTableAlias = TypedTableAlias(name: Interaction.fullTextSearchTableName) let request: SQLRequest = """ SELECT @@ -719,9 +730,9 @@ public extension Interaction { \(interaction[.timestampMs]) FROM \(Interaction.self) JOIN \(interactionFullTextSearch) ON ( - \(interactionFullTextSearch).rowid = \(interaction.alias[Column.rowID]) AND - \(SQL("\(interactionFullTextSearch).\(threadIdLiteral) = \(threadId)")) AND - \(interactionFullTextSearch).\(SQL(stringLiteral: Interaction.Columns.body.name)) MATCH \(pattern) + \(interactionFullTextSearch[.rowId]) = \(interaction[.rowId]) AND + \(SQL("\(interactionFullTextSearch[.threadId]) = \(threadId)")) AND + \(interactionFullTextSearch[.body]) MATCH \(pattern) ) ORDER BY \(interaction[.timestampMs].desc) @@ -797,22 +808,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/OpenGroup.swift b/SessionMessagingKit/Database/Models/OpenGroup.swift index 55da87c1d..d4b27a35c 100644 --- a/SessionMessagingKit/Database/Models/OpenGroup.swift +++ b/SessionMessagingKit/Database/Models/OpenGroup.swift @@ -215,6 +215,19 @@ public extension OpenGroup { } } +// MARK: - Search Queries + +public extension OpenGroup { + struct FullTextSearch: Decodable, ColumnExpressible { + public typealias Columns = CodingKeys + public enum CodingKeys: String, CodingKey, ColumnExpression, CaseIterable { + case name + } + + let name: String + } +} + // MARK: - Convenience public extension OpenGroup { diff --git a/SessionMessagingKit/Database/Models/Profile.swift b/SessionMessagingKit/Database/Models/Profile.swift index 08761ada9..7b8695929 100644 --- a/SessionMessagingKit/Database/Models/Profile.swift +++ b/SessionMessagingKit/Database/Models/Profile.swift @@ -27,6 +27,9 @@ public struct Profile: Codable, Identifiable, Equatable, Hashable, FetchableReco case profilePictureFileName case profileEncryptionKey case lastProfilePictureUpdate + + case blocksCommunityMessageRequests + case lastBlocksCommunityMessageRequests } /// The id for the user that owns the profile (Note: This could be a sessionId, a blindedId or some future variant) @@ -53,6 +56,12 @@ public struct Profile: Codable, Identifiable, Equatable, Hashable, FetchableReco /// The timestamp (in seconds since epoch) that the profile picture was last updated public let lastProfilePictureUpdate: TimeInterval + /// A flag indicating whether this profile has reported that it blocks community message requests + public let blocksCommunityMessageRequests: Bool? + + /// The timestamp (in seconds since epoch) that the `blocksCommunityMessageRequests` setting was last updated + public let lastBlocksCommunityMessageRequests: TimeInterval + // MARK: - Initialization public init( @@ -63,7 +72,9 @@ public struct Profile: Codable, Identifiable, Equatable, Hashable, FetchableReco profilePictureUrl: String? = nil, profilePictureFileName: String? = nil, profileEncryptionKey: Data? = nil, - lastProfilePictureUpdate: TimeInterval + lastProfilePictureUpdate: TimeInterval, + blocksCommunityMessageRequests: Bool? = nil, + lastBlocksCommunityMessageRequests: TimeInterval ) { self.id = id self.name = name @@ -73,6 +84,8 @@ public struct Profile: Codable, Identifiable, Equatable, Hashable, FetchableReco self.profilePictureFileName = profilePictureFileName self.profileEncryptionKey = profileEncryptionKey self.lastProfilePictureUpdate = lastProfilePictureUpdate + self.blocksCommunityMessageRequests = blocksCommunityMessageRequests + self.lastBlocksCommunityMessageRequests = lastBlocksCommunityMessageRequests } // MARK: - Description @@ -114,7 +127,9 @@ public extension Profile { profilePictureUrl: profilePictureUrl, profilePictureFileName: try? container.decode(String.self, forKey: .profilePictureFileName), profileEncryptionKey: profileKey, - lastProfilePictureUpdate: try container.decode(TimeInterval.self, forKey: .lastProfilePictureUpdate) + lastProfilePictureUpdate: try container.decode(TimeInterval.self, forKey: .lastProfilePictureUpdate), + blocksCommunityMessageRequests: try? container.decode(Bool.self, forKey: .blocksCommunityMessageRequests), + lastBlocksCommunityMessageRequests: try container.decode(TimeInterval.self, forKey: .lastBlocksCommunityMessageRequests) ) } @@ -129,6 +144,8 @@ public extension Profile { try container.encodeIfPresent(profilePictureFileName, forKey: .profilePictureFileName) try container.encodeIfPresent(profileEncryptionKey, forKey: .profileEncryptionKey) try container.encode(lastProfilePictureUpdate, forKey: .lastProfilePictureUpdate) + try container.encodeIfPresent(blocksCommunityMessageRequests, forKey: .blocksCommunityMessageRequests) + try container.encode(lastBlocksCommunityMessageRequests, forKey: .lastBlocksCommunityMessageRequests) } } @@ -156,7 +173,9 @@ public extension Profile { profilePictureUrl: profilePictureUrl, profilePictureFileName: nil, profileEncryptionKey: profileKey, - lastProfilePictureUpdate: sentTimestamp + lastProfilePictureUpdate: sentTimestamp, + blocksCommunityMessageRequests: (proto.hasBlocksCommunityMessageRequests ? proto.blocksCommunityMessageRequests : nil), + lastBlocksCommunityMessageRequests: (proto.hasBlocksCommunityMessageRequests ? sentTimestamp : 0) ) } @@ -242,7 +261,9 @@ public extension Profile { profilePictureUrl: nil, profilePictureFileName: nil, profileEncryptionKey: nil, - lastProfilePictureUpdate: 0 + lastProfilePictureUpdate: 0, + blocksCommunityMessageRequests: nil, + lastBlocksCommunityMessageRequests: 0 ) } @@ -250,11 +271,11 @@ 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) -> Profile { + static func fetchOrCreateCurrentUser(_ db: Database? = nil, using dependencies: Dependencies = Dependencies()) -> Profile { let userPublicKey: String = getUserHexEncodedPublicKey(db) guard let db: Database = db else { - return Storage.shared + return dependencies.storage .read { db in fetchOrCreateCurrentUser(db) } .defaulting(to: defaultFor(userPublicKey)) } @@ -277,6 +298,21 @@ public extension Profile { } } +// MARK: - Search Queries + +public extension Profile { + struct FullTextSearch: Decodable, ColumnExpressible { + public typealias Columns = CodingKeys + public enum CodingKeys: String, CodingKey, ColumnExpression, CaseIterable { + case nickname + case name + } + + let nickname: String? + let name: String + } +} + // MARK: - Convenience public extension Profile { diff --git a/SessionMessagingKit/Database/Models/SessionThread.swift b/SessionMessagingKit/Database/Models/SessionThread.swift index 958e7fc30..414c04ba4 100644 --- a/SessionMessagingKit/Database/Models/SessionThread.swift +++ b/SessionMessagingKit/Database/Models/SessionThread.swift @@ -296,31 +296,44 @@ public extension SessionThread { calledFromConfigHandling: Bool ) throws { let currentUserPublicKey: String = getUserHexEncodedPublicKey(db) - let remainingThreadIds: [String] = threadIds.filter { $0 != currentUserPublicKey } + let remainingThreadIds: Set = threadIds.asSet().removing(currentUserPublicKey) switch (threadVariant, groupLeaveType) { - case (.contact, _): + case (.contact, .standard), (.contact, .silent): + // Clear any interactions for the deleted thread + _ = try Interaction + .filter(threadIds.contains(Interaction.Columns.threadId)) + .deleteAll(db) + // We need to custom handle the 'Note to Self' conversation (it should just be - // hidden rather than deleted + // hidden locally rather than deleted) if threadIds.contains(currentUserPublicKey) { - _ = try Interaction - .filter(Interaction.Columns.threadId == currentUserPublicKey) - .deleteAll(db) - _ = try SessionThread .filter(id: currentUserPublicKey) .updateAllAndConfig( db, + calledFromConfig: calledFromConfigHandling, SessionThread.Columns.pinnedPriority.set(to: 0), SessionThread.Columns.shouldBeVisible.set(to: false) ) - return } + // Update any other threads to be hidden (don't want to actually delete the thread + // record in case it's settings get changed while it's not visible) + _ = try SessionThread + .filter(ids: remainingThreadIds) + .updateAllAndConfig( + db, + calledFromConfig: calledFromConfigHandling, + SessionThread.Columns.pinnedPriority.set(to: SessionUtil.hiddenPriority), + SessionThread.Columns.shouldBeVisible.set(to: false) + ) + + case (.contact, .forced): // If this wasn't called from config handling then we need to hide the conversation if !calledFromConfigHandling { try SessionUtil - .hide(db, contactIds: threadIds) + .remove(db, contactIds: Array(remainingThreadIds)) } _ = try SessionThread @@ -365,7 +378,7 @@ public extension SessionThread { let contact: TypedTableAlias = TypedTableAlias() return """ - SELECT \(thread.allColumns()) + SELECT \(thread.allColumns) FROM \(SessionThread.self) LEFT JOIN \(Contact.self) ON \(contact[.id]) = \(thread[.id]) WHERE ( @@ -525,12 +538,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 +579,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 d42f7078c..3eb523085 100644 --- a/SessionMessagingKit/Jobs/Types/AttachmentDownloadJob.swift +++ b/SessionMessagingKit/Jobs/Types/AttachmentDownloadJob.swift @@ -14,9 +14,10 @@ public enum AttachmentDownloadJob: JobExecutor { public static func run( _ job: Job, queue: DispatchQueue, - success: @escaping (Job, Bool) -> (), - failure: @escaping (Job, Error?, Bool) -> (), - deferred: @escaping (Job) -> () + success: @escaping (Job, Bool, Dependencies) -> (), + failure: @escaping (Job, Error?, Bool, Dependencies) -> (), + deferred: @escaping (Job, Dependencies) -> (), + using dependencies: Dependencies ) { guard let threadId: String = job.threadId, @@ -25,7 +26,7 @@ public enum AttachmentDownloadJob: JobExecutor { let attachment: Attachment = Storage.shared .read({ db in try Attachment.fetchOne(db, id: details.attachmentId) }) else { - failure(job, JobRunnerError.missingRequiredDetails, true) + failure(job, JobRunnerError.missingRequiredDetails, true, dependencies) return } @@ -33,7 +34,7 @@ public enum AttachmentDownloadJob: JobExecutor { // an AttachmentDownloadJob to get created for an attachment which has already been // downloaded/uploaded so in those cases just succeed immediately guard attachment.state != .downloaded && attachment.state != .uploaded else { - success(job, false) + success(job, false, dependencies) return } @@ -41,8 +42,8 @@ public enum AttachmentDownloadJob: JobExecutor { // the same attachment multiple times at the same time (it also adds a "clean up" mechanism // if an attachment ends up stuck in a "downloading" state incorrectly guard attachment.state != .downloading else { - let otherCurrentJobAttachmentIds: Set = JobRunner - .infoForCurrentlyRunningJobs(of: .attachmentDownload) + let otherCurrentJobAttachmentIds: Set = dependencies.jobRunner + .jobInfoFor(state: .running, variant: .attachmentDownload) .filter { key, _ in key != job.id } .values .compactMap { info -> String? in @@ -57,7 +58,7 @@ public enum AttachmentDownloadJob: JobExecutor { // then we should update the state of the attachment to be failed to avoid having attachments // appear in an endlessly downloading state if !otherCurrentJobAttachmentIds.contains(attachment.id) { - Storage.shared.write { db in + dependencies.storage.write { db in _ = try Attachment .filter(id: attachment.id) .updateAll(db, Attachment.Columns.state.set(to: Attachment.State.failedDownload)) @@ -70,12 +71,12 @@ public enum AttachmentDownloadJob: JobExecutor { // If there is another current job then just fail this one permanently, otherwise let it // retry (if there are more retry attempts available) and in the next retry it's state should // be 'failedDownload' so we won't get stuck in a loop - failure(job, nil, otherCurrentJobAttachmentIds.contains(attachment.id)) + failure(job, nil, otherCurrentJobAttachmentIds.contains(attachment.id), dependencies) return } // Update to the 'downloading' state (no need to update the 'attachment' instance) - Storage.shared.write { db in + dependencies.storage.write { db in try Attachment .filter(id: attachment.id) .updateAll(db, Attachment.Columns.state.set(to: Attachment.State.downloading)) @@ -155,16 +156,16 @@ public enum AttachmentDownloadJob: JobExecutor { } .sinkUntilComplete( receiveCompletion: { result in + // Remove the temporary file + OWSFileSystem.deleteFile(temporaryFileUrl.path) + switch result { case .finished: - // Remove the temporary file - OWSFileSystem.deleteFile(temporaryFileUrl.path) - /// Update the attachment state /// /// **Note:** We **MUST** use the `'with()` function here as it will update the /// `isValid` and `duration` values based on the downloaded data and the state - Storage.shared.write { db in + dependencies.storage.write { db in _ = try attachment .with( state: .downloaded, @@ -177,11 +178,9 @@ public enum AttachmentDownloadJob: JobExecutor { .saved(db) } - success(job, false) + success(job, false, dependencies) case .failure(let error): - OWSFileSystem.deleteFile(temporaryFileUrl.path) - let targetState: Attachment.State let permanentFailure: Bool @@ -211,14 +210,14 @@ public enum AttachmentDownloadJob: JobExecutor { /// /// **Note:** We **MUST** use the `'with()` function here as it will update the /// `isValid` and `duration` values based on the downloaded data and the state - Storage.shared.write { db in + dependencies.storage.write { db in _ = try Attachment .filter(id: attachment.id) .updateAll(db, Attachment.Columns.state.set(to: targetState)) } /// Trigger the failure and provide the `permanentFailure` value defined above - failure(job, error, permanentFailure) + failure(job, error, permanentFailure, dependencies) } } ) diff --git a/SessionMessagingKit/Jobs/Types/AttachmentUploadJob.swift b/SessionMessagingKit/Jobs/Types/AttachmentUploadJob.swift index 9d0f14ee4..99a7a6bbd 100644 --- a/SessionMessagingKit/Jobs/Types/AttachmentUploadJob.swift +++ b/SessionMessagingKit/Jobs/Types/AttachmentUploadJob.swift @@ -14,16 +14,17 @@ public enum AttachmentUploadJob: JobExecutor { public static func run( _ job: Job, queue: DispatchQueue, - success: @escaping (Job, Bool) -> (), - failure: @escaping (Job, Error?, Bool) -> (), - deferred: @escaping (Job) -> () + success: @escaping (Job, Bool, Dependencies) -> (), + failure: @escaping (Job, Error?, Bool, Dependencies) -> (), + deferred: @escaping (Job, Dependencies) -> (), + using dependencies: Dependencies ) { guard let threadId: String = job.threadId, let interactionId: Int64 = job.interactionId, let detailsData: Data = job.details, let details: Details = try? JSONDecoder().decode(Details.self, from: detailsData), - let (attachment, openGroup): (Attachment, OpenGroup?) = Storage.shared.read({ db in + let (attachment, openGroup): (Attachment, OpenGroup?) = dependencies.storage.read({ db in guard let attachment: Attachment = try Attachment.fetchOne(db, id: details.attachmentId) else { return nil } @@ -32,29 +33,26 @@ public enum AttachmentUploadJob: JobExecutor { }) else { SNLog("[AttachmentUploadJob] Failed due to missing details") - failure(job, JobRunnerError.missingRequiredDetails, true) - return + return failure(job, JobRunnerError.missingRequiredDetails, true, dependencies) } // If the original interaction no longer exists then don't bother uploading the attachment (ie. the // message was deleted before it even got sent) - guard Storage.shared.read({ db in try Interaction.exists(db, id: interactionId) }) == true else { + guard dependencies.storage.read({ db in try Interaction.exists(db, id: interactionId) }) == true else { SNLog("[AttachmentUploadJob] Failed due to missing interaction") - failure(job, StorageError.objectNotFound, true) - return + return failure(job, StorageError.objectNotFound, true, dependencies) } // If the attachment is still pending download the hold off on running this job guard attachment.state != .pendingDownload && attachment.state != .downloading else { SNLog("[AttachmentUploadJob] Deferred as attachment is still being downloaded") - deferred(job) - return + return deferred(job, dependencies) } // If this upload is related to sending a message then trigger the 'handleMessageWillSend' logic // as if this is a retry the logic wouldn't run until after the upload has completed resulting in // a potentially incorrect delivery status - Storage.shared.write { db in + dependencies.storage.write { db in guard let sendJob: Job = try Job.fetchOne(db, id: details.messageSendJobId), let sendJobDetails: Data = sendJob.details, @@ -74,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( @@ -84,7 +82,7 @@ public enum AttachmentUploadJob: JobExecutor { // If this upload is related to sending a message then trigger the // 'handleFailedMessageSend' logic as we want to ensure the message // has the correct delivery status - Storage.shared.read { db in + dependencies.storage.read { db in guard let sendJob: Job = try Job.fetchOne(db, id: details.messageSendJobId), let sendJobDetails: Data = sendJob.details, @@ -97,14 +95,15 @@ public enum AttachmentUploadJob: JobExecutor { message: details.message, with: .other(error), interactionId: interactionId, - isSyncMessage: details.isSyncMessage + isSyncMessage: details.isSyncMessage, + using: dependencies ) } SNLog("[AttachmentUploadJob] Failed due to error: \(error)") - failure(job, error, false) + failure(job, error, false, dependencies) - case .finished: success(job, false) + case .finished: success(job, false, dependencies) } } ) diff --git a/SessionMessagingKit/Jobs/Types/ConfigMessageReceiveJob.swift b/SessionMessagingKit/Jobs/Types/ConfigMessageReceiveJob.swift index 29fe85ab0..ee5c00c9c 100644 --- a/SessionMessagingKit/Jobs/Types/ConfigMessageReceiveJob.swift +++ b/SessionMessagingKit/Jobs/Types/ConfigMessageReceiveJob.swift @@ -12,9 +12,10 @@ public enum ConfigMessageReceiveJob: JobExecutor { public static func run( _ job: Job, queue: DispatchQueue, - success: @escaping (Job, Bool) -> (), - failure: @escaping (Job, Error?, Bool) -> (), - deferred: @escaping (Job) -> () + success: @escaping (Job, Bool, Dependencies) -> (), + failure: @escaping (Job, Error?, Bool, Dependencies) -> (), + deferred: @escaping (Job, 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 @@ -24,7 +25,7 @@ public enum ConfigMessageReceiveJob: JobExecutor { let removeDependencyOnMessageReceiveJobs: () -> () = { guard let jobId: Int64 = job.id else { return } - Storage.shared.write { db in + dependencies.storage.write { db in try JobDependencies .filter(JobDependencies.Columns.dependantId == jobId) .joining( @@ -40,23 +41,21 @@ public enum ConfigMessageReceiveJob: JobExecutor { let details: Details = try? JSONDecoder().decode(Details.self, from: detailsData) else { removeDependencyOnMessageReceiveJobs() - failure(job, JobRunnerError.missingRequiredDetails, true) - return + return failure(job, JobRunnerError.missingRequiredDetails, true, dependencies) } // Ensure no standard messages are sent through this job guard !details.messages.contains(where: { $0.variant != .sharedConfigMessage }) else { SNLog("[ConfigMessageReceiveJob] Standard messages incorrectly sent to the 'configMessageReceive' job") removeDependencyOnMessageReceiveJobs() - failure(job, MessageReceiverError.invalidMessage, true) - return + return failure(job, MessageReceiverError.invalidMessage, true, dependencies) } var lastError: Error? let sharedConfigMessages: [SharedConfigMessage] = details.messages .compactMap { $0.message as? SharedConfigMessage } - Storage.shared.write { db in + dependencies.storage.write { db in // Send any SharedConfigMessages to the SessionUtil to handle it do { try SessionUtil.handleConfigMessages( @@ -72,9 +71,9 @@ public enum ConfigMessageReceiveJob: JobExecutor { switch lastError { case .some(let error): removeDependencyOnMessageReceiveJobs() - failure(job, error, true) + failure(job, error, true, dependencies) - case .none: success(job, false) + case .none: success(job, false, dependencies) } } } diff --git a/SessionMessagingKit/Jobs/Types/ConfigurationSyncJob.swift b/SessionMessagingKit/Jobs/Types/ConfigurationSyncJob.swift index 5ffb9a304..4b1d8011f 100644 --- a/SessionMessagingKit/Jobs/Types/ConfigurationSyncJob.swift +++ b/SessionMessagingKit/Jobs/Types/ConfigurationSyncJob.swift @@ -15,22 +15,21 @@ public enum ConfigurationSyncJob: JobExecutor { public static func run( _ job: Job, queue: DispatchQueue, - success: @escaping (Job, Bool) -> (), - failure: @escaping (Job, Error?, Bool) -> (), - deferred: @escaping (Job) -> () + success: @escaping (Job, Bool, Dependencies) -> (), + failure: @escaping (Job, Error?, Bool, Dependencies) -> (), + deferred: @escaping (Job, Dependencies) -> (), + using dependencies: Dependencies ) { - guard - SessionUtil.userConfigsEnabled, - Identity.userCompletedRequiredOnboarding() - else { return success(job, true) } + guard Identity.userCompletedRequiredOnboarding() else { return success(job, true, dependencies) } // It's possible for multiple ConfigSyncJob's with the same target (user/group) to try to run at the // same time since as soon as one is started we will enqueue a second one, rather than adding dependencies // between the jobs we just continue to defer the subsequent job while the first one is running in // order to prevent multiple configurationSync jobs with the same target from running at the same time guard - JobRunner - .infoForCurrentlyRunningJobs(of: .configurationSync) + dependencies + .jobRunner + .jobInfoFor(state: .running, variant: .configurationSync) .filter({ key, info in key != job.id && // Exclude this job info.threadId == job.threadId // Exclude jobs for different ids @@ -39,14 +38,14 @@ public enum ConfigurationSyncJob: JobExecutor { else { // Defer the job to run 'maxRunFrequency' from when this one ran (if we don't it'll try start // it again immediately which is pointless) - let updatedJob: Job? = Storage.shared.write { db in + let updatedJob: Job? = dependencies.storage.write { db in try job - .with(nextRunTimestamp: Date().timeIntervalSince1970 + maxRunFrequency) + .with(nextRunTimestamp: dependencies.dateNow.timeIntervalSince1970 + maxRunFrequency) .saved(db) } SNLog("[ConfigurationSyncJob] For \(job.threadId ?? "UnknownId") deferred due to in progress job") - return deferred(updatedJob ?? job) + return deferred(updatedJob ?? job, dependencies) } // If we don't have a userKeyPair yet then there is no need to sync the configuration @@ -58,14 +57,14 @@ public enum ConfigurationSyncJob: JobExecutor { .read({ db in try SessionUtil.pendingChanges(db, publicKey: publicKey) }) else { SNLog("[ConfigurationSyncJob] For \(job.threadId ?? "UnknownId") failed due to invalid data") - return failure(job, StorageError.generic, false) + return failure(job, StorageError.generic, false, dependencies) } // If there are no pending changes then the job can just complete (next time something // is updated we want to try and run immediately so don't scuedule another run in this case) guard !pendingConfigChanges.isEmpty else { SNLog("[ConfigurationSyncJob] For \(publicKey) completed with no pending changes") - return success(job, true) + return success(job, true, dependencies) } // Identify the destination and merge all obsolete hashes into a single set @@ -77,10 +76,10 @@ 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")") - Storage.shared + dependencies.storage .readPublisher { db in try pendingConfigChanges.map { change -> MessageSender.PreparedSendData in try MessageSender.preparedSendData( @@ -103,7 +102,8 @@ public enum ConfigurationSyncJob: JobExecutor { return (snodeMessage, namespace) }, - allObsoleteHashes: Array(allObsoleteHashes) + allObsoleteHashes: Array(allObsoleteHashes), + using: dependencies ) } .subscribe(on: queue) @@ -138,7 +138,7 @@ public enum ConfigurationSyncJob: JobExecutor { case .finished: SNLog("[ConfigurationSyncJob] For \(publicKey) completed") case .failure(let error): SNLog("[ConfigurationSyncJob] For \(publicKey) failed due to error: \(error)") - failure(job, error, false) + failure(job, error, false, dependencies) } }, receiveValue: { (configDumps: [ConfigDump]) in @@ -146,7 +146,7 @@ public enum ConfigurationSyncJob: JobExecutor { var shouldFinishCurrentJob: Bool = false // Lastly we need to save the updated dumps to the database - let updatedJob: Job? = Storage.shared.write { db in + let updatedJob: Job? = dependencies.storage.write { db in // Save the updated dumps to the database try configDumps.forEach { try $0.save(db) } @@ -167,7 +167,7 @@ public enum ConfigurationSyncJob: JobExecutor { { // If the next job isn't currently running then delay it's start time // until the 'nextRunTimestamp' - if !JobRunner.isCurrentlyRunning(existingJob) { + if !dependencies.jobRunner.isCurrentlyRunning(existingJob) { _ = try existingJob .with(nextRunTimestamp: nextRunTimestamp) .saved(db) @@ -183,7 +183,7 @@ public enum ConfigurationSyncJob: JobExecutor { .saved(db) } - success((updatedJob ?? job), shouldFinishCurrentJob) + success((updatedJob ?? job), shouldFinishCurrentJob, dependencies) } ) } @@ -192,49 +192,32 @@ public enum ConfigurationSyncJob: JobExecutor { // MARK: - Convenience public extension ConfigurationSyncJob { - static func enqueue(_ db: Database, publicKey: String) { - // FIXME: Remove this once `useSharedUtilForUserConfig` is permanent - guard SessionUtil.userConfigsEnabled(db) else { - // If we don't have a userKeyPair (or name) yet then there is no need to sync the - // configuration as the user doesn't fully exist yet (this will get triggered on - // the first launch of a fresh install due to the migrations getting run and a few - // times during onboarding) - guard - Identity.userCompletedRequiredOnboarding(db), - let legacyConfigMessage: Message = try? ConfigurationMessage.getCurrent(db) - else { return } - - let publicKey: String = getUserHexEncodedPublicKey(db) - - JobRunner.add( - db, - job: Job( - variant: .messageSend, - threadId: publicKey, - details: MessageSendJob.Details( - destination: Message.Destination.contact(publicKey: publicKey), - message: legacyConfigMessage - ) - ) - ) - return - } - + static func enqueue( + _ db: Database, + publicKey: String, + dependencies: Dependencies = Dependencies() + ) { // Upsert a config sync job if needed - JobRunner.upsert( + dependencies.jobRunner.upsert( db, - job: ConfigurationSyncJob.createIfNeeded(db, publicKey: publicKey) + job: ConfigurationSyncJob.createIfNeeded(db, publicKey: publicKey, using: dependencies), + canStartJob: true, + using: dependencies ) } - @discardableResult static func createIfNeeded(_ db: Database, publicKey: String) -> Job? { + @discardableResult static func createIfNeeded( + _ db: Database, + publicKey: String, + 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 /// /// **Note:** Jobs with different `threadId` values can run concurrently guard - JobRunner - .infoForCurrentlyRunningJobs(of: .configurationSync) + dependencies.jobRunner + .jobInfoFor(state: .running, variant: .configurationSync) .filter({ _, info in info.threadId == publicKey }) .isEmpty, (try? Job @@ -252,39 +235,17 @@ public extension ConfigurationSyncJob { ) } - static func run() -> AnyPublisher { - // FIXME: Remove this once `useSharedUtilForUserConfig` is permanent - guard SessionUtil.userConfigsEnabled else { - return Storage.shared - .writePublisher { db -> MessageSender.PreparedSendData in - // If we don't have a userKeyPair yet then there is no need to sync the configuration - // as the user doesn't exist yet (this will get triggered on the first launch of a - // fresh install due to the migrations getting run) - guard Identity.userCompletedRequiredOnboarding(db) else { throw StorageError.generic } - - let publicKey: String = getUserHexEncodedPublicKey(db) - - return try MessageSender.preparedSendData( - db, - message: try ConfigurationMessage.getCurrent(db), - to: Message.Destination.contact(publicKey: publicKey), - namespace: .default, - interactionId: nil - ) - } - .flatMap { MessageSender.sendImmediate(preparedSendData: $0) } - .eraseToAnyPublisher() - } - + static func run(using dependencies: Dependencies = Dependencies()) -> AnyPublisher { // Trigger the job emitting the result when completed return Deferred { Future { resolver in ConfigurationSyncJob.run( Job(variant: .configurationSync), queue: .global(qos: .userInitiated), - success: { _, _ in resolver(Result.success(())) }, - failure: { _, error, _ in resolver(Result.failure(error ?? HTTPError.generic)) }, - deferred: { _ in } + success: { _, _, _ in resolver(Result.success(())) }, + failure: { _, error, _, _ in resolver(Result.failure(error ?? HTTPError.generic)) }, + deferred: { _, _ in }, + using: dependencies ) } } diff --git a/SessionMessagingKit/Jobs/Types/DisappearingMessagesJob.swift b/SessionMessagingKit/Jobs/Types/DisappearingMessagesJob.swift index 777c7fad8..ac0c55a12 100644 --- a/SessionMessagingKit/Jobs/Types/DisappearingMessagesJob.swift +++ b/SessionMessagingKit/Jobs/Types/DisappearingMessagesJob.swift @@ -13,9 +13,10 @@ public enum DisappearingMessagesJob: JobExecutor { public static func run( _ job: Job, queue: DispatchQueue, - success: @escaping (Job, Bool) -> (), - failure: @escaping (Job, Error?, Bool) -> (), - deferred: @escaping (Job) -> () + success: @escaping (Job, Bool, Dependencies) -> (), + failure: @escaping (Job, Error?, Bool, Dependencies) -> (), + deferred: @escaping (Job, Dependencies) -> (), + using dependencies: Dependencies ) { // The 'backgroundTask' gets captured and cleared within the 'completion' block let timestampNowMs: TimeInterval = TimeInterval(SnodeAPI.currentOffsetTimestampMs()) @@ -37,7 +38,7 @@ public enum DisappearingMessagesJob: JobExecutor { } SNLog("[DisappearingMessagesJob] Deleted \(numDeleted) expired messages") - success(updatedJob ?? job, false) + success(updatedJob ?? job, false, dependencies) // The 'if' is only there to prevent the "variable never read" warning from showing if backgroundTask != nil { backgroundTask = nil } diff --git a/SessionMessagingKit/Jobs/Types/FailedAttachmentDownloadsJob.swift b/SessionMessagingKit/Jobs/Types/FailedAttachmentDownloadsJob.swift index 747664c9b..20eac0a93 100644 --- a/SessionMessagingKit/Jobs/Types/FailedAttachmentDownloadsJob.swift +++ b/SessionMessagingKit/Jobs/Types/FailedAttachmentDownloadsJob.swift @@ -13,20 +13,21 @@ public enum FailedAttachmentDownloadsJob: JobExecutor { public static func run( _ job: Job, queue: DispatchQueue, - success: @escaping (Job, Bool) -> (), - failure: @escaping (Job, Error?, Bool) -> (), - deferred: @escaping (Job) -> () + success: @escaping (Job, Bool, Dependencies) -> (), + failure: @escaping (Job, Error?, Bool, Dependencies) -> (), + deferred: @escaping (Job, Dependencies) -> (), + using dependencies: Dependencies ) { var changeCount: Int = -1 // Update all 'sending' message states to 'failed' - Storage.shared.write { db in + dependencies.storage.write { db in changeCount = try Attachment .filter(Attachment.Columns.state == Attachment.State.downloading) .updateAll(db, Attachment.Columns.state.set(to: Attachment.State.failedDownload)) } SNLog("[FailedAttachmentDownloadsJob] Marked \(changeCount) attachments as failed") - success(job, false) + success(job, false, dependencies) } } diff --git a/SessionMessagingKit/Jobs/Types/FailedMessageSendsJob.swift b/SessionMessagingKit/Jobs/Types/FailedMessageSendsJob.swift index 31d382cc7..e458de633 100644 --- a/SessionMessagingKit/Jobs/Types/FailedMessageSendsJob.swift +++ b/SessionMessagingKit/Jobs/Types/FailedMessageSendsJob.swift @@ -12,15 +12,16 @@ public enum FailedMessageSendsJob: JobExecutor { public static func run( _ job: Job, queue: DispatchQueue, - success: @escaping (Job, Bool) -> (), - failure: @escaping (Job, Error?, Bool) -> (), - deferred: @escaping (Job) -> () + success: @escaping (Job, Bool, Dependencies) -> (), + failure: @escaping (Job, Error?, Bool, Dependencies) -> (), + deferred: @escaping (Job, Dependencies) -> (), + using dependencies: Dependencies ) { var changeCount: Int = -1 var attachmentChangeCount: Int = -1 // Update all 'sending' message states to 'failed' - Storage.shared.write { db in + dependencies.storage.write { db in let sendChangeCount: Int = try RecipientState .filter(RecipientState.Columns.state == RecipientState.State.sending) .updateAll(db, RecipientState.Columns.state.set(to: RecipientState.State.failed)) @@ -34,6 +35,6 @@ public enum FailedMessageSendsJob: JobExecutor { } SNLog("[FailedMessageSendsJob] Marked \(changeCount) message\(changeCount == 1 ? "" : "s") as failed (\(attachmentChangeCount) upload\(attachmentChangeCount == 1 ? "" : "s") cancelled)") - success(job, false) + success(job, false, dependencies) } } diff --git a/SessionMessagingKit/Jobs/Types/GarbageCollectionJob.swift b/SessionMessagingKit/Jobs/Types/GarbageCollectionJob.swift index 5649183fe..633588a3d 100644 --- a/SessionMessagingKit/Jobs/Types/GarbageCollectionJob.swift +++ b/SessionMessagingKit/Jobs/Types/GarbageCollectionJob.swift @@ -20,9 +20,10 @@ public enum GarbageCollectionJob: JobExecutor { public static func run( _ job: Job, queue: DispatchQueue, - success: @escaping (Job, Bool) -> (), - failure: @escaping (Job, Error?, Bool) -> (), - deferred: @escaping (Job) -> () + success: @escaping (Job, Bool, Dependencies) -> (), + failure: @escaping (Job, Error?, Bool, Dependencies) -> (), + deferred: @escaping (Job, 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) /// @@ -32,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) @@ -56,7 +57,7 @@ public enum GarbageCollectionJob: JobExecutor { return typesToCollect.asSet() }() - Storage.shared.writeAsync( + dependencies.storage.writeAsync( updates: { db in /// Remove any typing indicators if finalTypesToCollect.contains(.threadTypingIndicators) { @@ -81,7 +82,7 @@ public enum GarbageCollectionJob: JobExecutor { try db.execute(literal: """ DELETE FROM \(Interaction.self) WHERE \(Column.rowID) IN ( - SELECT \(interaction.alias[Column.rowID]) + SELECT \(interaction[.rowId]) FROM \(Interaction.self) JOIN \(SessionThread.self) ON ( \(SQL("\(thread[.variant]) = \(SessionThread.Variant.community)")) AND @@ -89,7 +90,7 @@ public enum GarbageCollectionJob: JobExecutor { ) JOIN ( SELECT - COUNT(\(interaction.alias[Column.rowID])) AS interactionCount, + COUNT(\(interaction[.rowId])) AS interactionCount, \(interaction[.threadId]) FROM \(Interaction.self) GROUP BY \(interaction[.threadId]) @@ -111,7 +112,7 @@ public enum GarbageCollectionJob: JobExecutor { try db.execute(literal: """ DELETE FROM \(Job.self) WHERE \(Column.rowID) IN ( - SELECT \(job.alias[Column.rowID]) + SELECT \(job[.rowId]) FROM \(Job.self) LEFT JOIN \(SessionThread.self) ON \(thread[.id]) = \(job[.threadId]) LEFT JOIN \(Interaction.self) ON \(interaction[.id]) = \(job[.interactionId]) @@ -138,11 +139,11 @@ public enum GarbageCollectionJob: JobExecutor { try db.execute(literal: """ DELETE FROM \(LinkPreview.self) WHERE \(Column.rowID) IN ( - SELECT \(linkPreview.alias[Column.rowID]) + SELECT \(linkPreview[.rowId]) FROM \(LinkPreview.self) LEFT JOIN \(Interaction.self) ON ( \(interaction[.linkPreviewUrl]) = \(linkPreview[.url]) AND - \(Interaction.linkPreviewFilterLiteral) + \(Interaction.linkPreviewFilterLiteral()) ) WHERE \(interaction[.id]) IS NULL ) @@ -158,7 +159,7 @@ public enum GarbageCollectionJob: JobExecutor { try db.execute(literal: """ DELETE FROM \(OpenGroup.self) WHERE \(Column.rowID) IN ( - SELECT \(openGroup.alias[Column.rowID]) + SELECT \(openGroup[.rowId]) FROM \(OpenGroup.self) LEFT JOIN \(SessionThread.self) ON \(thread[.id]) = \(openGroup[.threadId]) WHERE ( @@ -177,7 +178,7 @@ public enum GarbageCollectionJob: JobExecutor { try db.execute(literal: """ DELETE FROM \(Capability.self) WHERE \(Column.rowID) IN ( - SELECT \(capability.alias[Column.rowID]) + SELECT \(capability[.rowId]) FROM \(Capability.self) LEFT JOIN \(OpenGroup.self) ON \(openGroup[.server]) = \(capability[.openGroupServer]) WHERE \(openGroup[.threadId]) IS NULL @@ -194,7 +195,7 @@ public enum GarbageCollectionJob: JobExecutor { try db.execute(literal: """ DELETE FROM \(BlindedIdLookup.self) WHERE \(Column.rowID) IN ( - SELECT \(blindedIdLookup.alias[Column.rowID]) + SELECT \(blindedIdLookup[.rowId]) FROM \(BlindedIdLookup.self) LEFT JOIN \(SessionThread.self) ON ( \(thread[.id]) = \(blindedIdLookup[.blindedId]) OR @@ -221,7 +222,7 @@ public enum GarbageCollectionJob: JobExecutor { try db.execute(literal: """ DELETE FROM \(Contact.self) WHERE \(Column.rowID) IN ( - SELECT \(contact.alias[Column.rowID]) + SELECT \(contact[.rowId]) FROM \(Contact.self) LEFT JOIN \(BlindedIdLookup.self) ON ( \(blindedIdLookup[.blindedId]) = \(contact[.id]) AND @@ -242,7 +243,7 @@ public enum GarbageCollectionJob: JobExecutor { try db.execute(literal: """ DELETE FROM \(Attachment.self) WHERE \(Column.rowID) IN ( - SELECT \(attachment.alias[Column.rowID]) + SELECT \(attachment[.rowId]) FROM \(Attachment.self) LEFT JOIN \(Quote.self) ON \(quote[.attachmentId]) = \(attachment[.id]) LEFT JOIN \(LinkPreview.self) ON \(linkPreview[.attachmentId]) = \(attachment[.id]) @@ -268,7 +269,7 @@ public enum GarbageCollectionJob: JobExecutor { try db.execute(literal: """ DELETE FROM \(Profile.self) WHERE \(Column.rowID) IN ( - SELECT \(profile.alias[Column.rowID]) + SELECT \(profile[.rowId]) FROM \(Profile.self) LEFT JOIN \(SessionThread.self) ON \(thread[.id]) = \(profile[.id]) LEFT JOIN \(Interaction.self) ON \(interaction[.authorId]) = \(profile[.id]) @@ -309,7 +310,7 @@ public enum GarbageCollectionJob: JobExecutor { try db.execute(literal: """ DELETE FROM \(SessionThread.self) WHERE \(Column.rowID) IN ( - SELECT \(thread.alias[Column.rowID]) + SELECT \(thread[.rowId]) FROM \(SessionThread.self) LEFT JOIN \(Contact.self) ON \(contact[.id]) = \(thread[.id]) LEFT JOIN \(OpenGroup.self) ON \(openGroup[.threadId]) = \(thread[.id]) @@ -368,7 +369,7 @@ public enum GarbageCollectionJob: JobExecutor { // If we couldn't get the file lists then fail (invalid state and don't want to delete all attachment/profile files) guard let fileInfo: FileInfo = maybeFileInfo else { - failure(job, StorageError.generic, false) + failure(job, StorageError.generic, false, dependencies) return } @@ -443,17 +444,17 @@ public enum GarbageCollectionJob: JobExecutor { // Report a single file deletion as a job failure (even if other content was successfully removed) guard deletionErrors.isEmpty else { - failure(job, (deletionErrors.first ?? StorageError.generic), false) + failure(job, (deletionErrors.first ?? StorageError.generic), false, dependencies) return } // 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) + success(job, false, dependencies) } } ) diff --git a/SessionMessagingKit/Jobs/Types/GroupLeavingJob.swift b/SessionMessagingKit/Jobs/Types/GroupLeavingJob.swift index 9b7a6e2bc..65d2601d6 100644 --- a/SessionMessagingKit/Jobs/Types/GroupLeavingJob.swift +++ b/SessionMessagingKit/Jobs/Types/GroupLeavingJob.swift @@ -13,12 +13,13 @@ public enum GroupLeavingJob: JobExecutor { public static var requiresInteractionId: Bool = true public static func run( - _ job: SessionUtilitiesKit.Job, + _ job: Job, queue: DispatchQueue, - success: @escaping (Job, Bool) -> (), - failure: @escaping (Job, Error?, Bool) -> (), - deferred: @escaping (Job) -> ()) - { + success: @escaping (Job, Bool, Dependencies) -> (), + failure: @escaping (Job, Error?, Bool, Dependencies) -> (), + deferred: @escaping (Job, Dependencies) -> (), + using dependencies: Dependencies = Dependencies() + ) { guard let detailsData: Data = job.details, let details: Details = try? JSONDecoder().decode(Details.self, from: detailsData), @@ -26,13 +27,12 @@ public enum GroupLeavingJob: JobExecutor { let interactionId: Int64 = job.interactionId else { SNLog("[GroupLeavingJob] Failed due to missing details") - failure(job, JobRunnerError.missingRequiredDetails, true) - return + return failure(job, JobRunnerError.missingRequiredDetails, true, dependencies) } let destination: Message.Destination = .closedGroup(groupPublicKey: threadId) - Storage.shared + dependencies.storage .writePublisher { db in guard (try? SessionThread.exists(db, id: threadId)) == true else { SNLog("[GroupLeavingJob] Failed due to non-existent group conversation") @@ -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( @@ -71,7 +72,7 @@ public enum GroupLeavingJob: JobExecutor { ] // Handle the appropriate response - Storage.shared.writeAsync { db in + dependencies.storage.writeAsync { db in // If it failed due to one of these errors then clear out any associated data (as somehow // the 'SessionThread' exists but not the data required to send the 'MEMBER_LEFT' message // which would leave the user in a state where they can't leave the group) @@ -127,7 +128,7 @@ public enum GroupLeavingJob: JobExecutor { ) } - success(job, false) + success(job, false, dependencies) } ) } diff --git a/SessionMessagingKit/Jobs/Types/MessageReceiveJob.swift b/SessionMessagingKit/Jobs/Types/MessageReceiveJob.swift index 10e3fb15d..fabae4e97 100644 --- a/SessionMessagingKit/Jobs/Types/MessageReceiveJob.swift +++ b/SessionMessagingKit/Jobs/Types/MessageReceiveJob.swift @@ -12,24 +12,23 @@ public enum MessageReceiveJob: JobExecutor { public static func run( _ job: Job, queue: DispatchQueue, - success: @escaping (Job, Bool) -> (), - failure: @escaping (Job, Error?, Bool) -> (), - deferred: @escaping (Job) -> () + success: @escaping (Job, Bool, Dependencies) -> (), + failure: @escaping (Job, Error?, Bool, Dependencies) -> (), + deferred: @escaping (Job, Dependencies) -> (), + using dependencies: Dependencies = Dependencies() ) { guard let threadId: String = job.threadId, let detailsData: Data = job.details, let details: Details = try? JSONDecoder().decode(Details.self, from: detailsData) else { - failure(job, JobRunnerError.missingRequiredDetails, true) - return + return failure(job, JobRunnerError.missingRequiredDetails, true, dependencies) } // Ensure no config messages are sent through this job guard !details.messages.contains(where: { $0.variant == .sharedConfigMessage }) else { SNLog("[MessageReceiveJob] Config messages incorrectly sent to the 'messageReceive' job") - failure(job, MessageReceiverError.invalidSharedConfigMessageHandling, true) - return + return failure(job, MessageReceiverError.invalidSharedConfigMessageHandling, true, dependencies) } var updatedJob: Job = job @@ -52,7 +51,7 @@ public enum MessageReceiveJob: JobExecutor { } } - Storage.shared.write { db in + dependencies.storage.write { db in for (messageInfo, protoContent) in messageData { do { try MessageReceiver.handle( @@ -111,13 +110,13 @@ public enum MessageReceiveJob: JobExecutor { // Handle the result switch lastError { case let error as MessageReceiverError where !error.isRetryable: - failure(updatedJob, error, true) + failure(updatedJob, error, true, dependencies) case .some(let error): - failure(updatedJob, error, false) + failure(updatedJob, error, false, dependencies) case .none: - success(updatedJob, false) + success(updatedJob, false, dependencies) } } } diff --git a/SessionMessagingKit/Jobs/Types/MessageSendJob.swift b/SessionMessagingKit/Jobs/Types/MessageSendJob.swift index a9c4ffb8d..df411c1e0 100644 --- a/SessionMessagingKit/Jobs/Types/MessageSendJob.swift +++ b/SessionMessagingKit/Jobs/Types/MessageSendJob.swift @@ -15,17 +15,17 @@ public enum MessageSendJob: JobExecutor { public static func run( _ job: Job, queue: DispatchQueue, - success: @escaping (Job, Bool) -> (), - failure: @escaping (Job, Error?, Bool) -> (), - deferred: @escaping (Job) -> () + success: @escaping (Job, Bool, Dependencies) -> (), + failure: @escaping (Job, Error?, Bool, Dependencies) -> (), + deferred: @escaping (Job, Dependencies) -> (), + using dependencies: Dependencies ) { guard let detailsData: Data = job.details, let details: Details = try? JSONDecoder().decode(Details.self, from: detailsData) else { SNLog("[MessageSendJob] Failing due to missing details") - failure(job, JobRunnerError.missingRequiredDetails, true) - return + return failure(job, JobRunnerError.missingRequiredDetails, true, dependencies) } // We need to include 'fileIds' when sending messages with attachments to Open Groups @@ -46,14 +46,13 @@ public enum MessageSendJob: JobExecutor { let interactionId: Int64 = job.interactionId else { SNLog("[MessageSendJob] Failing due to missing details") - failure(job, JobRunnerError.missingRequiredDetails, true) - return + return failure(job, JobRunnerError.missingRequiredDetails, true, dependencies) } // Retrieve the current attachment state typealias AttachmentState = (error: Error?, pendingUploadAttachmentIds: [String], preparedFileIds: [String]) - let attachmentState: AttachmentState = Storage.shared + let attachmentState: AttachmentState = dependencies.storage .read { db in // If the original interaction no longer exists then don't bother sending the message (ie. the // message was deleted before it even got sent) @@ -91,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 } @@ -104,26 +107,26 @@ public enum MessageSendJob: JobExecutor { /// If we got an error when trying to retrieve the attachment state then this job is actually invalid so it /// should permanently fail guard attachmentState.error == nil else { - return failure(job, (attachmentState.error ?? MessageSenderError.invalidMessage), true) + return failure(job, (attachmentState.error ?? MessageSenderError.invalidMessage), true, dependencies) } /// If we have any pending (or failed) attachment uploads then we should create jobs for them and insert them into the /// queue before the current job and defer it (this will mean the current job will re-run after these inserted jobs complete) guard attachmentState.pendingUploadAttachmentIds.isEmpty else { - Storage.shared.write { db in + dependencies.storage.write { db in try attachmentState.pendingUploadAttachmentIds .filter { attachmentId in // Don't add a new job if there is one already in the queue - !JobRunner.hasPendingOrRunningJob( - with: .attachmentUpload, - details: AttachmentUploadJob.Details( + !dependencies.jobRunner.hasJob( + of: .attachmentUpload, + with: AttachmentUploadJob.Details( messageSendJobId: jobId, attachmentId: attachmentId ) ) } .compactMap { attachmentId -> (jobId: Int64, job: Job)? in - JobRunner + dependencies.jobRunner .insert( db, job: Job( @@ -150,7 +153,7 @@ public enum MessageSendJob: JobExecutor { } SNLog("[MessageSendJob] Deferring due to pending attachment uploads") - return deferred(job) + return deferred(job, dependencies) } // Store the fileIds so they can be sent with the open group message content @@ -164,7 +167,7 @@ public enum MessageSendJob: JobExecutor { /// /// **Note:** No need to upload attachments as part of this process as the above logic splits that out into it's own job /// so we shouldn't get here until attachments have already been uploaded - Storage.shared + dependencies.storage .writePublisher { db in try MessageSender.preparedSendData( db, @@ -172,30 +175,31 @@ 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 { - case .finished: success(job, false) + case .finished: success(job, false, dependencies) case .failure(let error): SNLog("[MessageSendJob] Couldn't send message due to error: \(error).") switch error { case let senderError as MessageSenderError where !senderError.isRetryable: - failure(job, error, true) + failure(job, error, true, dependencies) case OnionRequestAPIError.httpRequestFailedAtDestination(let statusCode, _, _) where statusCode == 429: // Rate limited - failure(job, error, true) + failure(job, error, true, dependencies) case SnodeAPIError.clockOutOfSync: SNLog("[MessageSendJob] \(originalSentTimestamp != nil ? "Permanently Failing" : "Failing") to send \(type(of: details.message)) due to clock out of sync issue.") - failure(job, error, (originalSentTimestamp != nil)) + failure(job, error, (originalSentTimestamp != nil), dependencies) default: SNLog("[MessageSendJob] Failed to send \(type(of: details.message)).") @@ -203,15 +207,14 @@ public enum MessageSendJob: JobExecutor { if details.message is VisibleMessage { guard let interactionId: Int64 = job.interactionId, - Storage.shared.read({ db in try Interaction.exists(db, id: interactionId) }) == true + dependencies.storage.read({ db in try Interaction.exists(db, id: interactionId) }) == true else { // The message has been deleted so permanently fail the job - failure(job, error, true) - return + return failure(job, error, true, dependencies) } } - failure(job, error, false) + failure(job, error, false, dependencies) } } } diff --git a/SessionMessagingKit/Jobs/Types/NotifyPushServerJob.swift b/SessionMessagingKit/Jobs/Types/NotifyPushServerJob.swift index fa2e1fdb4..a97101bab 100644 --- a/SessionMessagingKit/Jobs/Types/NotifyPushServerJob.swift +++ b/SessionMessagingKit/Jobs/Types/NotifyPushServerJob.swift @@ -5,6 +5,7 @@ import Combine import SessionSnodeKit import SessionUtilitiesKit +// FIXME: Remove this once legacy notifications and legacy groups are deprecated public enum NotifyPushServerJob: JobExecutor { public static var maxFailureCount: Int = 20 public static var requiresThreadId: Bool = false @@ -13,21 +14,21 @@ public enum NotifyPushServerJob: JobExecutor { public static func run( _ job: Job, queue: DispatchQueue, - success: @escaping (Job, Bool) -> (), - failure: @escaping (Job, Error?, Bool) -> (), - deferred: @escaping (Job) -> () + success: @escaping (Job, Bool, Dependencies) -> (), + failure: @escaping (Job, Error?, Bool, Dependencies) -> (), + deferred: @escaping (Job, Dependencies) -> (), + using dependencies: Dependencies ) { guard let detailsData: Data = job.details, let details: Details = try? JSONDecoder().decode(Details.self, from: detailsData) else { SNLog("[NotifyPushServerJob] Failing due to missing details") - failure(job, JobRunnerError.missingRequiredDetails, true) - return + return failure(job, JobRunnerError.missingRequiredDetails, true, dependencies) } PushNotificationAPI - .notify( + .legacyNotify( recipient: details.message.recipient, with: details.message.data, maxRetryCount: 4 @@ -37,8 +38,8 @@ public enum NotifyPushServerJob: JobExecutor { .sinkUntilComplete( receiveCompletion: { result in switch result { - case .finished: success(job, false) - case .failure(let error): failure(job, error, false) + case .finished: success(job, false, dependencies) + case .failure(let error): failure(job, error, false, dependencies) } } ) diff --git a/SessionMessagingKit/Jobs/Types/RetrieveDefaultOpenGroupRoomsJob.swift b/SessionMessagingKit/Jobs/Types/RetrieveDefaultOpenGroupRoomsJob.swift index f1629925a..7310f120e 100644 --- a/SessionMessagingKit/Jobs/Types/RetrieveDefaultOpenGroupRoomsJob.swift +++ b/SessionMessagingKit/Jobs/Types/RetrieveDefaultOpenGroupRoomsJob.swift @@ -12,13 +12,14 @@ public enum RetrieveDefaultOpenGroupRoomsJob: JobExecutor { public static func run( _ job: Job, queue: DispatchQueue, - success: @escaping (Job, Bool) -> (), - failure: @escaping (Job, Error?, Bool) -> (), - deferred: @escaping (Job) -> () + success: @escaping (Job, Bool, Dependencies) -> (), + failure: @escaping (Job, Error?, Bool, Dependencies) -> (), + deferred: @escaping (Job, Dependencies) -> (), + using dependencies: Dependencies ) { // Don't run when inactive or not in main app guard (UserDefaults.sharedLokiProject?[.isMainAppActive]).defaulting(to: false) else { - deferred(job) // Don't need to do anything if it's not the main app + deferred(job, dependencies) // Don't need to do anything if it's not the main app return } @@ -26,7 +27,7 @@ public enum RetrieveDefaultOpenGroupRoomsJob: JobExecutor { // in the database so we need to create a dummy one to retrieve the default room data let defaultGroupId: String = OpenGroup.idFor(roomToken: "", server: OpenGroupAPI.defaultServer) - Storage.shared.write { db in + dependencies.storage.write { db in guard try OpenGroup.exists(db, id: defaultGroupId) == false else { return } _ = try OpenGroup( @@ -49,11 +50,11 @@ public enum RetrieveDefaultOpenGroupRoomsJob: JobExecutor { switch result { case .finished: SNLog("[RetrieveDefaultOpenGroupRoomsJob] Successfully retrieved default Community rooms") - success(job, false) + success(job, false, dependencies) case .failure(let error): SNLog("[RetrieveDefaultOpenGroupRoomsJob] Failed to get default Community rooms") - failure(job, error, false) + failure(job, error, false, dependencies) } } ) diff --git a/SessionMessagingKit/Jobs/Types/SendReadReceiptsJob.swift b/SessionMessagingKit/Jobs/Types/SendReadReceiptsJob.swift index 0e746218e..8b5090afb 100644 --- a/SessionMessagingKit/Jobs/Types/SendReadReceiptsJob.swift +++ b/SessionMessagingKit/Jobs/Types/SendReadReceiptsJob.swift @@ -14,28 +14,27 @@ public enum SendReadReceiptsJob: JobExecutor { public static func run( _ job: Job, queue: DispatchQueue, - success: @escaping (Job, Bool) -> (), - failure: @escaping (Job, Error?, Bool) -> (), - deferred: @escaping (Job) -> () + success: @escaping (Job, Bool, Dependencies) -> (), + failure: @escaping (Job, Error?, Bool, Dependencies) -> (), + deferred: @escaping (Job, Dependencies) -> (), + using dependencies: Dependencies ) { guard let threadId: String = job.threadId, let detailsData: Data = job.details, let details: Details = try? JSONDecoder().decode(Details.self, from: detailsData) else { - failure(job, JobRunnerError.missingRequiredDetails, true) - return + return failure(job, JobRunnerError.missingRequiredDetails, true, dependencies) } // If there are no timestampMs values then the job can just complete (next time // something is marked as read we want to try and run immediately so don't scuedule // another run in this case) guard !details.timestampMsValues.isEmpty else { - success(job, true) - return + return success(job, true, dependencies) } - Storage.shared + dependencies.storage .writePublisher { db in try MessageSender.preparedSendData( db, @@ -48,19 +47,19 @@ 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( receiveCompletion: { result in switch result { - case .failure(let error): failure(job, error, false) + case .failure(let error): failure(job, error, false, dependencies) case .finished: // When we complete the 'SendReadReceiptsJob' we want to immediately schedule // 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 @@ -87,7 +86,7 @@ public enum SendReadReceiptsJob: JobExecutor { .saved(db) } - success(updatedJob ?? job, shouldFinishCurrentJob) + success(updatedJob ?? job, shouldFinishCurrentJob, dependencies) } } ) diff --git a/SessionMessagingKit/Jobs/Types/UpdateProfilePictureJob.swift b/SessionMessagingKit/Jobs/Types/UpdateProfilePictureJob.swift index c238eef5f..21afcc6de 100644 --- a/SessionMessagingKit/Jobs/Types/UpdateProfilePictureJob.swift +++ b/SessionMessagingKit/Jobs/Types/UpdateProfilePictureJob.swift @@ -12,37 +12,37 @@ public enum UpdateProfilePictureJob: JobExecutor { public static func run( _ job: Job, queue: DispatchQueue, - success: @escaping (Job, Bool) -> (), - failure: @escaping (Job, Error?, Bool) -> (), - deferred: @escaping (Job) -> () + success: @escaping (Job, Bool, Dependencies) -> (), + failure: @escaping (Job, Error?, Bool, Dependencies) -> (), + deferred: @escaping (Job, Dependencies) -> (), + using dependencies: Dependencies ) { // Don't run when inactive or not in main app guard (UserDefaults.sharedLokiProject?[.isMainAppActive]).defaulting(to: false) else { - deferred(job) // Don't need to do anything if it's not the main app - return + return deferred(job, dependencies) // Don't need to do anything if it's not the main app } // 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 if let jobId: Int64 = job.id { - Storage.shared.write { db in + dependencies.storage.write { db in try Job .filter(id: jobId) .updateAll(db, Job.Columns.nextRunTimestamp.set(to: 0)) } } + SNLog("[UpdateProfilePictureJob] Deferred as not enough time has passed since the last update") - deferred(job) - return + return deferred(job, dependencies) } // Note: The user defaults flag is updated in ProfileManager - let profile: Profile = Profile.fetchOrCreateCurrentUser() + let profile: Profile = Profile.fetchOrCreateCurrentUser(using: dependencies) let profilePictureData: Data? = profile.profilePictureFileName .map { ProfileManager.loadProfileData(with: $0) } @@ -56,12 +56,12 @@ public enum UpdateProfilePictureJob: JobExecutor { // another database write queue.async { SNLog("[UpdateProfilePictureJob] Profile successfully updated") - success(job, false) + success(job, false, dependencies) } }, failure: { error in SNLog("[UpdateProfilePictureJob] Failed to update profile") - failure(job, error, false) + failure(job, error, false, dependencies) } ) } diff --git a/SessionMessagingKit/Messages/Control Messages/ConfigurationMessage+Convenience.swift b/SessionMessagingKit/Messages/Control Messages/ConfigurationMessage+Convenience.swift deleted file mode 100644 index 6c8c5977a..000000000 --- a/SessionMessagingKit/Messages/Control Messages/ConfigurationMessage+Convenience.swift +++ /dev/null @@ -1,85 +0,0 @@ -// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. - -import Foundation -import GRDB -import SessionUtilitiesKit - -extension ConfigurationMessage { - public static func getCurrent(_ db: Database) throws -> ConfigurationMessage { - let currentUserProfile: Profile = Profile.fetchOrCreateCurrentUser(db) - let displayName: String = currentUserProfile.name - let profilePictureUrl: String? = currentUserProfile.profilePictureUrl - let profileKey: Data? = currentUserProfile.profileEncryptionKey - let closedGroups: Set = try ClosedGroup.fetchAll(db) - .compactMap { closedGroup -> CMClosedGroup? in - guard let latestKeyPair: ClosedGroupKeyPair = try closedGroup.fetchLatestKeyPair(db) else { - return nil - } - - return CMClosedGroup( - publicKey: closedGroup.publicKey, - name: closedGroup.name, - encryptionKeyPublicKey: latestKeyPair.publicKey, - encryptionKeySecretKey: latestKeyPair.secretKey, - members: try closedGroup.members - .select(GroupMember.Columns.profileId) - .asRequest(of: String.self) - .fetchSet(db), - admins: try closedGroup.admins - .select(GroupMember.Columns.profileId) - .asRequest(of: String.self) - .fetchSet(db), - expirationTimer: (try? DisappearingMessagesConfiguration - .fetchOne(db, id: closedGroup.threadId) - .map { ($0.isEnabled ? UInt32($0.durationSeconds) : 0) }) - .defaulting(to: 0) - ) - } - .asSet() - // The default room promise creates an OpenGroup with an empty `roomToken` value, - // we don't want to start a poller for this as the user hasn't actually joined a room - let openGroups: Set = try OpenGroup - .filter(OpenGroup.Columns.roomToken != "") - .filter(OpenGroup.Columns.isActive) - .fetchAll(db) - .compactMap { openGroup in - SessionUtil.communityUrlFor( - server: openGroup.server, - roomToken: openGroup.roomToken, - publicKey: openGroup.publicKey - ) - } - .asSet() - let contacts: Set = try Contact - .filter(Contact.Columns.id != currentUserProfile.id) - .fetchAll(db) - .map { contact -> CMContact in - // Can just default the 'hasX' values to true as they will be set to this - // when converting to proto anyway - let profile: Profile? = try? Profile.fetchOne(db, id: contact.id) - - return CMContact( - publicKey: contact.id, - displayName: (profile?.name ?? contact.id), - profilePictureUrl: profile?.profilePictureUrl, - profileKey: profile?.profileEncryptionKey, - hasIsApproved: true, - isApproved: contact.isApproved, - hasIsBlocked: true, - isBlocked: contact.isBlocked, - hasDidApproveMe: true, - didApproveMe: contact.didApproveMe - ) - } - .asSet() - - return ConfigurationMessage( - displayName: displayName, - profilePictureUrl: profilePictureUrl, - profileKey: profileKey, - closedGroups: closedGroups, - openGroups: openGroups, - contacts: contacts - ) - } -} diff --git a/SessionMessagingKit/Messages/Control Messages/ConfigurationMessage.swift b/SessionMessagingKit/Messages/Control Messages/ConfigurationMessage.swift index 44977dd52..d161ef92e 100644 --- a/SessionMessagingKit/Messages/Control Messages/ConfigurationMessage.swift +++ b/SessionMessagingKit/Messages/Control Messages/ConfigurationMessage.swift @@ -6,361 +6,80 @@ import SessionUtilitiesKit public final class ConfigurationMessage: ControlMessage { private enum CodingKeys: String, CodingKey { - case closedGroups - case openGroups case displayName case profilePictureUrl case profileKey - case contacts } - public var closedGroups: Set = [] - public var openGroups: Set = [] public var displayName: String? public var profilePictureUrl: String? public var profileKey: Data? - public var contacts: Set = [] public override var isSelfSendValid: Bool { true } - + // MARK: - Initialization public init( displayName: String?, profilePictureUrl: String?, - profileKey: Data?, - closedGroups: Set, - openGroups: Set, - contacts: Set + profileKey: Data? ) { super.init() - + self.displayName = displayName self.profilePictureUrl = profilePictureUrl self.profileKey = profileKey - self.closedGroups = closedGroups - self.openGroups = openGroups - self.contacts = contacts } - + // MARK: - Codable - + required init(from decoder: Decoder) throws { try super.init(from: decoder) - + let container: KeyedDecodingContainer = try decoder.container(keyedBy: CodingKeys.self) - - closedGroups = ((try? container.decode(Set.self, forKey: .closedGroups)) ?? []) - openGroups = ((try? container.decode(Set.self, forKey: .openGroups)) ?? []) + displayName = try? container.decode(String.self, forKey: .displayName) profilePictureUrl = try? container.decode(String.self, forKey: .profilePictureUrl) profileKey = try? container.decode(Data.self, forKey: .profileKey) - contacts = ((try? container.decode(Set.self, forKey: .contacts)) ?? []) } - + public override func encode(to encoder: Encoder) throws { try super.encode(to: encoder) - + var container: KeyedEncodingContainer = encoder.container(keyedBy: CodingKeys.self) - - try container.encodeIfPresent(closedGroups, forKey: .closedGroups) - try container.encodeIfPresent(openGroups, forKey: .openGroups) + try container.encodeIfPresent(displayName, forKey: .displayName) try container.encodeIfPresent(profilePictureUrl, forKey: .profilePictureUrl) try container.encodeIfPresent(profileKey, forKey: .profileKey) - try container.encodeIfPresent(contacts, forKey: .contacts) } // MARK: - Proto Conversion public override class func fromProto(_ proto: SNProtoContent, sender: String) -> ConfigurationMessage? { guard let configurationProto = proto.configurationMessage else { return nil } + let displayName = configurationProto.displayName let profilePictureUrl = configurationProto.profilePicture let profileKey = configurationProto.profileKey - let closedGroups = Set(configurationProto.closedGroups.compactMap { CMClosedGroup.fromProto($0) }) - let openGroups = Set(configurationProto.openGroups) - let contacts = Set(configurationProto.contacts.compactMap { CMContact.fromProto($0) }) - + return ConfigurationMessage( displayName: displayName, profilePictureUrl: profilePictureUrl, - profileKey: profileKey, - closedGroups: closedGroups, - openGroups: openGroups, - contacts: contacts + profileKey: profileKey ) } - public override func toProto(_ db: Database) -> SNProtoContent? { - let configurationProto = SNProtoConfigurationMessage.builder() - if let displayName = displayName { configurationProto.setDisplayName(displayName) } - if let profilePictureUrl = profilePictureUrl { configurationProto.setProfilePicture(profilePictureUrl) } - if let profileKey = profileKey { configurationProto.setProfileKey(profileKey) } - configurationProto.setClosedGroups(closedGroups.compactMap { $0.toProto() }) - configurationProto.setOpenGroups([String](openGroups)) - configurationProto.setContacts(contacts.compactMap { $0.toProto() }) - let contentProto = SNProtoContent.builder() - do { - contentProto.setConfigurationMessage(try configurationProto.build()) - return try contentProto.build() - } catch { - SNLog("Couldn't construct configuration proto from: \(self).") - return nil - } - } + public override func toProto(_ db: Database) -> SNProtoContent? { return nil } // MARK: - Description public var description: String { """ - ConfigurationMessage( - closedGroups: \([CMClosedGroup](closedGroups).prettifiedDescription), - openGroups: \([String](openGroups).prettifiedDescription), + LegacyConfigurationMessage( displayName: \(displayName ?? "null"), profilePictureUrl: \(profilePictureUrl ?? "null"), - profileKey: \(profileKey?.toHexString() ?? "null"), - contacts: \([CMContact](contacts).prettifiedDescription) + profileKey: \(profileKey?.toHexString() ?? "null") ) """ } } - -// MARK: - Closed Group - -extension ConfigurationMessage { - public struct CMClosedGroup: Codable, Hashable, CustomStringConvertible { - private enum CodingKeys: String, CodingKey { - case publicKey - case name - case encryptionKeyPublicKey - case encryptionKeySecretKey - case members - case admins - case expirationTimer - } - - public let publicKey: String - public let name: String - public let encryptionKeyPublicKey: Data - public let encryptionKeySecretKey: Data - public let members: Set - public let admins: Set - public let expirationTimer: UInt32 - - public var isValid: Bool { !members.isEmpty && !admins.isEmpty } - - // MARK: - Initialization - - public init( - publicKey: String, - name: String, - encryptionKeyPublicKey: Data, - encryptionKeySecretKey: Data, - members: Set, - admins: Set, - expirationTimer: UInt32 - ) { - self.publicKey = publicKey - self.name = name - self.encryptionKeyPublicKey = encryptionKeyPublicKey - self.encryptionKeySecretKey = encryptionKeySecretKey - self.members = members - self.admins = admins - self.expirationTimer = expirationTimer - } - - // MARK: - Codable - - public init(from decoder: Decoder) throws { - let container: KeyedDecodingContainer = try decoder.container(keyedBy: CodingKeys.self) - - publicKey = try container.decode(String.self, forKey: .publicKey) - name = try container.decode(String.self, forKey: .name) - encryptionKeyPublicKey = try container.decode(Data.self, forKey: .encryptionKeyPublicKey) - encryptionKeySecretKey = try container.decode(Data.self, forKey: .encryptionKeySecretKey) - members = try container.decode(Set.self, forKey: .members) - admins = try container.decode(Set.self, forKey: .admins) - expirationTimer = try container.decode(UInt32.self, forKey: .expirationTimer) - } - - public func encode(to encoder: Encoder) throws { - var container: KeyedEncodingContainer = encoder.container(keyedBy: CodingKeys.self) - - try container.encode(publicKey, forKey: .publicKey) - try container.encode(name, forKey: .name) - try container.encode(encryptionKeyPublicKey, forKey: .encryptionKeyPublicKey) - try container.encode(encryptionKeySecretKey, forKey: .encryptionKeySecretKey) - try container.encode(members, forKey: .members) - try container.encode(admins, forKey: .admins) - try container.encode(expirationTimer, forKey: .expirationTimer) - } - - public static func fromProto(_ proto: SNProtoConfigurationMessageClosedGroup) -> CMClosedGroup? { - guard - let publicKey = proto.publicKey?.toHexString(), - let name = proto.name, - let encryptionKeyPairAsProto = proto.encryptionKeyPair - else { return nil } - - let members = Set(proto.members.map { $0.toHexString() }) - let admins = Set(proto.admins.map { $0.toHexString() }) - let expirationTimer = proto.expirationTimer - let result = CMClosedGroup( - publicKey: publicKey, - name: name, - encryptionKeyPublicKey: encryptionKeyPairAsProto.publicKey, - encryptionKeySecretKey: encryptionKeyPairAsProto.privateKey, - members: members, - admins: admins, - expirationTimer: expirationTimer - ) - - guard result.isValid else { return nil } - return result - } - - public func toProto() -> SNProtoConfigurationMessageClosedGroup? { - guard isValid else { return nil } - let result = SNProtoConfigurationMessageClosedGroup.builder() - result.setPublicKey(Data(hex: publicKey)) - result.setName(name) - do { - let encryptionKeyPairAsProto = try SNProtoKeyPair.builder( - publicKey: encryptionKeyPublicKey, - privateKey: encryptionKeySecretKey - ).build() - result.setEncryptionKeyPair(encryptionKeyPairAsProto) - } catch { - SNLog("Couldn't construct closed group proto from: \(self).") - return nil - } - result.setMembers(members.map { Data(hex: $0) }) - result.setAdmins(admins.map { Data(hex: $0) }) - result.setExpirationTimer(expirationTimer) - do { - return try result.build() - } catch { - SNLog("Couldn't construct closed group proto from: \(self).") - return nil - } - } - - public var description: String { name } - } -} - -// MARK: - Contact - -extension ConfigurationMessage { - public struct CMContact: Codable, Hashable, CustomStringConvertible { - private enum CodingKeys: String, CodingKey { - case publicKey - case displayName - case profilePictureUrl - case profileKey - - case hasIsApproved - case isApproved - case hasIsBlocked - case isBlocked - case hasDidApproveMe - case didApproveMe - } - - public var publicKey: String? - public var displayName: String? - public var profilePictureUrl: String? - public var profileKey: Data? - - public var hasIsApproved: Bool - public var isApproved: Bool - public var hasIsBlocked: Bool - public var isBlocked: Bool - public var hasDidApproveMe: Bool - public var didApproveMe: Bool - - public var isValid: Bool { publicKey != nil && displayName != nil } - - public init( - publicKey: String?, - displayName: String?, - profilePictureUrl: String?, - profileKey: Data?, - hasIsApproved: Bool, - isApproved: Bool, - hasIsBlocked: Bool, - isBlocked: Bool, - hasDidApproveMe: Bool, - didApproveMe: Bool - ) { - self.publicKey = publicKey - self.displayName = displayName - self.profilePictureUrl = profilePictureUrl - self.profileKey = profileKey - self.hasIsApproved = hasIsApproved - self.isApproved = isApproved - self.hasIsBlocked = hasIsBlocked - self.isBlocked = isBlocked - self.hasDidApproveMe = hasDidApproveMe - self.didApproveMe = didApproveMe - } - - // MARK: - Codable - - public init(from decoder: Decoder) throws { - let container: KeyedDecodingContainer = try decoder.container(keyedBy: CodingKeys.self) - - publicKey = try? container.decode(String.self, forKey: .publicKey) - displayName = try? container.decode(String.self, forKey: .displayName) - profilePictureUrl = try? container.decode(String.self, forKey: .profilePictureUrl) - profileKey = try? container.decode(Data.self, forKey: .profileKey) - - hasIsApproved = try container.decode(Bool.self, forKey: .hasIsApproved) - isApproved = try container.decode(Bool.self, forKey: .isApproved) - hasIsBlocked = try container.decode(Bool.self, forKey: .hasIsBlocked) - isBlocked = try container.decode(Bool.self, forKey: .isBlocked) - hasDidApproveMe = try container.decode(Bool.self, forKey: .hasDidApproveMe) - didApproveMe = try container.decode(Bool.self, forKey: .didApproveMe) - } - - public static func fromProto(_ proto: SNProtoConfigurationMessageContact) -> CMContact? { - let result: CMContact = CMContact( - publicKey: proto.publicKey.toHexString(), - displayName: proto.name, - profilePictureUrl: proto.profilePicture, - profileKey: proto.profileKey, - hasIsApproved: proto.hasIsApproved, - isApproved: proto.isApproved, - hasIsBlocked: proto.hasIsBlocked, - isBlocked: proto.isBlocked, - hasDidApproveMe: proto.hasDidApproveMe, - didApproveMe: proto.didApproveMe - ) - - guard result.isValid else { return nil } - return result - } - - public func toProto() -> SNProtoConfigurationMessageContact? { - guard isValid else { return nil } - guard let publicKey = publicKey, let displayName = displayName else { return nil } - let result = SNProtoConfigurationMessageContact.builder(publicKey: Data(hex: publicKey), name: displayName) - if let profilePictureUrl = profilePictureUrl { result.setProfilePicture(profilePictureUrl) } - if let profileKey = profileKey { result.setProfileKey(profileKey) } - - if hasIsApproved { result.setIsApproved(isApproved) } - if hasIsBlocked { result.setIsBlocked(isBlocked) } - if hasDidApproveMe { result.setDidApproveMe(didApproveMe) } - - do { - return try result.build() - } catch { - SNLog("Couldn't construct contact proto from: \(self).") - return nil - } - } - - public var description: String { displayName ?? "" } - } -} 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/Messages/Visible Messages/VisibleMessage+Profile.swift b/SessionMessagingKit/Messages/Visible Messages/VisibleMessage+Profile.swift index 8f63ed5a9..4ad3649ac 100644 --- a/SessionMessagingKit/Messages/Visible Messages/VisibleMessage+Profile.swift +++ b/SessionMessagingKit/Messages/Visible Messages/VisibleMessage+Profile.swift @@ -10,15 +10,22 @@ public extension VisibleMessage { public let displayName: String? public let profileKey: Data? public let profilePictureUrl: String? + public let blocksCommunityMessageRequests: Bool? // MARK: - Initialization - internal init(displayName: String, profileKey: Data? = nil, profilePictureUrl: String? = nil) { + internal init( + displayName: String, + profileKey: Data? = nil, + profilePictureUrl: String? = nil, + blocksCommunityMessageRequests: Bool? = nil + ) { let hasUrlAndKey: Bool = (profileKey != nil && profilePictureUrl != nil) self.displayName = displayName self.profileKey = (hasUrlAndKey ? profileKey : nil) self.profilePictureUrl = (hasUrlAndKey ? profilePictureUrl : nil) + self.blocksCommunityMessageRequests = blocksCommunityMessageRequests } // MARK: - Proto Conversion @@ -32,7 +39,8 @@ public extension VisibleMessage { return VMProfile( displayName: displayName, profileKey: proto.profileKey, - profilePictureUrl: profileProto.profilePicture + profilePictureUrl: profileProto.profilePicture, + blocksCommunityMessageRequests: (proto.hasBlocksCommunityMessageRequests ? proto.blocksCommunityMessageRequests : nil) ) } @@ -45,6 +53,10 @@ public extension VisibleMessage { let profileProto = SNProtoLokiProfile.builder() profileProto.setDisplayName(displayName) + if let blocksCommunityMessageRequests: Bool = self.blocksCommunityMessageRequests { + dataMessageProto.setBlocksCommunityMessageRequests(blocksCommunityMessageRequests) + } + if let profileKey = profileKey, let profilePictureUrl = profilePictureUrl { dataMessageProto.setProfileKey(profileKey) profileProto.setProfilePicture(profilePictureUrl) @@ -112,10 +124,14 @@ public extension VisibleMessage { // MARK: - Conversion extension VisibleMessage.VMProfile { - init(profile: Profile) { + init( + profile: Profile, + blocksCommunityMessageRequests: Bool? + ) { self.displayName = profile.name self.profileKey = profile.profileEncryptionKey self.profilePictureUrl = profile.profilePictureUrl + self.blocksCommunityMessageRequests = blocksCommunityMessageRequests } } 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..4e3b69091 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,18 +78,26 @@ 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 } - case .none: + case .none, .group: SNLog("Ignoring message with invalid sender.") throw HTTPError.parsingFailed } diff --git a/SessionMessagingKit/Open Groups/OpenGroupAPI.swift b/SessionMessagingKit/Open Groups/OpenGroupAPI.swift index 3b9f8a03f..5c5d622c0 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) @@ -109,10 +109,12 @@ public enum OpenGroupAPI { // The 'inbox' and 'outbox' only work with blinded keys so don't bother polling them if not blinded !capabilities.contains(.blind) ? [] : [ - // Inbox - (lastInboxMessageId == 0 ? - try preparedInbox(db, on: server, using: dependencies) : - try preparedInboxSince(db, id: lastInboxMessageId, on: server, using: dependencies) + // Inbox (only check the inbox if the user want's community message requests) + (!db[.checkForCommunityMessageRequests] ? nil : + (lastInboxMessageId == 0 ? + try preparedInbox(db, on: server, using: dependencies) : + try preparedInboxSince(db, id: lastInboxMessageId, on: server, using: dependencies) + ) ), // Outbox @@ -120,7 +122,7 @@ public enum OpenGroupAPI { try preparedOutbox(db, on: server, using: dependencies) : try preparedOutboxSince(db, id: lastOutboxMessageId, on: server, using: dependencies) ), - ] + ].compactMap { $0 } ) ) @@ -143,7 +145,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 +175,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 +204,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 +227,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 +246,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 +269,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 +294,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 +334,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 +358,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 +394,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 +426,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 +450,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 +480,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 +504,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 +533,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 +562,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 +598,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 +622,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 +653,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 +682,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 +712,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 +750,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 +773,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 +795,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 +824,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 +856,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 +879,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 +900,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 +922,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 +946,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 +967,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 +1020,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 +1069,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 +1147,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 +1180,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 +1215,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 +1235,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 +1253,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 +1266,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 +1287,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 +1295,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(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 +1308,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 +1353,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 +1381,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 54b5f42e0..b45dfcf2c 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 = Date().timeIntervalSince(lastOpen) - return 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 @@ -809,15 +786,15 @@ public final class OpenGroupManager { } } - if let messageInfo: MessageReceiveJob.Details.MessageInfo = processedMessage?.messageInfo { + if let messageInfo: MessageReceiveJob.Details.MessageInfo = processedMessage?.messageInfo, let proto: SNProtoContent = processedMessage?.proto { try MessageReceiver.handle( db, threadId: (lookup.sessionId ?? lookup.blindedId), threadVariant: .contact, // Technically not open group messages message: messageInfo.message, serverExpirationTimestamp: messageInfo.serverExpirationTimestamp, - associatedWithProto: try SNProtoContent.parseData(messageInfo.serializedProtoData), - dependencies: dependencies + associatedWithProto: proto, + 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 @@ -997,25 +976,24 @@ public final class OpenGroupManager { .filter(possibleKeys.contains(GroupMember.Columns.profileId)) .filter(targetRoles.contains(GroupMember.Columns.role)) .isNotEmpty(db) + + case .group: return false } } .defaulting(to: false) } @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 +1001,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 +1068,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 +1077,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 +1092,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 +1105,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 = 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 +1119,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 +1200,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 +1211,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 +1283,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/Protos/Generated/SNProto.swift b/SessionMessagingKit/Protos/Generated/SNProto.swift index ed766ca8d..72e4ca517 100644 --- a/SessionMessagingKit/Protos/Generated/SNProto.swift +++ b/SessionMessagingKit/Protos/Generated/SNProto.swift @@ -2497,6 +2497,9 @@ extension SNProtoDataMessageClosedGroupControlMessage.SNProtoDataMessageClosedGr if let _value = syncTarget { builder.setSyncTarget(_value) } + if hasBlocksCommunityMessageRequests { + builder.setBlocksCommunityMessageRequests(blocksCommunityMessageRequests) + } return builder } @@ -2570,6 +2573,10 @@ extension SNProtoDataMessageClosedGroupControlMessage.SNProtoDataMessageClosedGr proto.syncTarget = valueParam } + @objc public func setBlocksCommunityMessageRequests(_ valueParam: Bool) { + proto.blocksCommunityMessageRequests = valueParam + } + @objc public func build() throws -> SNProtoDataMessage { return try SNProtoDataMessage.parseProto(proto) } @@ -2646,6 +2653,13 @@ extension SNProtoDataMessageClosedGroupControlMessage.SNProtoDataMessageClosedGr return proto.hasSyncTarget } + @objc public var blocksCommunityMessageRequests: Bool { + return proto.blocksCommunityMessageRequests + } + @objc public var hasBlocksCommunityMessageRequests: Bool { + return proto.hasBlocksCommunityMessageRequests + } + private init(proto: SessionProtos_DataMessage, attachments: [SNProtoAttachmentPointer], quote: SNProtoDataMessageQuote?, diff --git a/SessionMessagingKit/Protos/Generated/SessionProtos.pb.swift b/SessionMessagingKit/Protos/Generated/SessionProtos.pb.swift index 6f209cb67..3fb72c9ad 100644 --- a/SessionMessagingKit/Protos/Generated/SessionProtos.pb.swift +++ b/SessionMessagingKit/Protos/Generated/SessionProtos.pb.swift @@ -600,7 +600,7 @@ struct SessionProtos_DataMessage { set {_uniqueStorage()._attachments = newValue} } - /// optional GroupContext group = 3; // No longer used + /// optional GroupContext group = 3; // No longer used var flags: UInt32 { get {return _storage._flags ?? 0} set {_uniqueStorage()._flags = newValue} @@ -696,6 +696,15 @@ struct SessionProtos_DataMessage { /// Clears the value of `syncTarget`. Subsequent reads from it will return its default value. mutating func clearSyncTarget() {_uniqueStorage()._syncTarget = nil} + var blocksCommunityMessageRequests: Bool { + get {return _storage._blocksCommunityMessageRequests ?? false} + set {_uniqueStorage()._blocksCommunityMessageRequests = newValue} + } + /// Returns true if `blocksCommunityMessageRequests` has been explicitly set. + var hasBlocksCommunityMessageRequests: Bool {return _storage._blocksCommunityMessageRequests != nil} + /// Clears the value of `blocksCommunityMessageRequests`. Subsequent reads from it will return its default value. + mutating func clearBlocksCommunityMessageRequests() {_uniqueStorage()._blocksCommunityMessageRequests = nil} + var unknownFields = SwiftProtobuf.UnknownStorage() enum Flags: SwiftProtobuf.Enum { @@ -1665,6 +1674,43 @@ extension SessionProtos_SharedConfigMessage.Kind: CaseIterable { #endif // swift(>=4.2) +#if swift(>=5.5) && canImport(_Concurrency) +extension SessionProtos_Envelope: @unchecked Sendable {} +extension SessionProtos_Envelope.TypeEnum: @unchecked Sendable {} +extension SessionProtos_TypingMessage: @unchecked Sendable {} +extension SessionProtos_TypingMessage.Action: @unchecked Sendable {} +extension SessionProtos_UnsendRequest: @unchecked Sendable {} +extension SessionProtos_MessageRequestResponse: @unchecked Sendable {} +extension SessionProtos_Content: @unchecked Sendable {} +extension SessionProtos_CallMessage: @unchecked Sendable {} +extension SessionProtos_CallMessage.TypeEnum: @unchecked Sendable {} +extension SessionProtos_KeyPair: @unchecked Sendable {} +extension SessionProtos_DataExtractionNotification: @unchecked Sendable {} +extension SessionProtos_DataExtractionNotification.TypeEnum: @unchecked Sendable {} +extension SessionProtos_LokiProfile: @unchecked Sendable {} +extension SessionProtos_DataMessage: @unchecked Sendable {} +extension SessionProtos_DataMessage.Flags: @unchecked Sendable {} +extension SessionProtos_DataMessage.Quote: @unchecked Sendable {} +extension SessionProtos_DataMessage.Quote.QuotedAttachment: @unchecked Sendable {} +extension SessionProtos_DataMessage.Quote.QuotedAttachment.Flags: @unchecked Sendable {} +extension SessionProtos_DataMessage.Preview: @unchecked Sendable {} +extension SessionProtos_DataMessage.Reaction: @unchecked Sendable {} +extension SessionProtos_DataMessage.Reaction.Action: @unchecked Sendable {} +extension SessionProtos_DataMessage.OpenGroupInvitation: @unchecked Sendable {} +extension SessionProtos_DataMessage.ClosedGroupControlMessage: @unchecked Sendable {} +extension SessionProtos_DataMessage.ClosedGroupControlMessage.TypeEnum: @unchecked Sendable {} +extension SessionProtos_DataMessage.ClosedGroupControlMessage.KeyPairWrapper: @unchecked Sendable {} +extension SessionProtos_ConfigurationMessage: @unchecked Sendable {} +extension SessionProtos_ConfigurationMessage.ClosedGroup: @unchecked Sendable {} +extension SessionProtos_ConfigurationMessage.Contact: @unchecked Sendable {} +extension SessionProtos_ReceiptMessage: @unchecked Sendable {} +extension SessionProtos_ReceiptMessage.TypeEnum: @unchecked Sendable {} +extension SessionProtos_AttachmentPointer: @unchecked Sendable {} +extension SessionProtos_AttachmentPointer.Flags: @unchecked Sendable {} +extension SessionProtos_SharedConfigMessage: @unchecked Sendable {} +extension SessionProtos_SharedConfigMessage.Kind: @unchecked Sendable {} +#endif // swift(>=5.5) && canImport(_Concurrency) + // MARK: - Code below here is support for the SwiftProtobuf runtime. fileprivate let _protobuf_package = "SessionProtos" @@ -2288,6 +2334,7 @@ extension SessionProtos_DataMessage: SwiftProtobuf.Message, SwiftProtobuf._Messa 102: .same(proto: "openGroupInvitation"), 104: .same(proto: "closedGroupControlMessage"), 105: .same(proto: "syncTarget"), + 106: .same(proto: "blocksCommunityMessageRequests"), ] fileprivate class _StorageClass { @@ -2304,6 +2351,7 @@ extension SessionProtos_DataMessage: SwiftProtobuf.Message, SwiftProtobuf._Messa var _openGroupInvitation: SessionProtos_DataMessage.OpenGroupInvitation? = nil var _closedGroupControlMessage: SessionProtos_DataMessage.ClosedGroupControlMessage? = nil var _syncTarget: String? = nil + var _blocksCommunityMessageRequests: Bool? = nil static let defaultInstance = _StorageClass() @@ -2323,6 +2371,7 @@ extension SessionProtos_DataMessage: SwiftProtobuf.Message, SwiftProtobuf._Messa _openGroupInvitation = source._openGroupInvitation _closedGroupControlMessage = source._closedGroupControlMessage _syncTarget = source._syncTarget + _blocksCommunityMessageRequests = source._blocksCommunityMessageRequests } } @@ -2366,6 +2415,7 @@ extension SessionProtos_DataMessage: SwiftProtobuf.Message, SwiftProtobuf._Messa case 102: try { try decoder.decodeSingularMessageField(value: &_storage._openGroupInvitation) }() case 104: try { try decoder.decodeSingularMessageField(value: &_storage._closedGroupControlMessage) }() case 105: try { try decoder.decodeSingularStringField(value: &_storage._syncTarget) }() + case 106: try { try decoder.decodeSingularBoolField(value: &_storage._blocksCommunityMessageRequests) }() default: break } } @@ -2417,6 +2467,9 @@ extension SessionProtos_DataMessage: SwiftProtobuf.Message, SwiftProtobuf._Messa try { if let v = _storage._syncTarget { try visitor.visitSingularStringField(value: v, fieldNumber: 105) } }() + try { if let v = _storage._blocksCommunityMessageRequests { + try visitor.visitSingularBoolField(value: v, fieldNumber: 106) + } }() } try unknownFields.traverse(visitor: &visitor) } @@ -2439,6 +2492,7 @@ extension SessionProtos_DataMessage: SwiftProtobuf.Message, SwiftProtobuf._Messa if _storage._openGroupInvitation != rhs_storage._openGroupInvitation {return false} if _storage._closedGroupControlMessage != rhs_storage._closedGroupControlMessage {return false} if _storage._syncTarget != rhs_storage._syncTarget {return false} + if _storage._blocksCommunityMessageRequests != rhs_storage._blocksCommunityMessageRequests {return false} return true } if !storagesAreEqual {return false} diff --git a/SessionMessagingKit/Protos/Generated/WebSocketResources.pb.swift b/SessionMessagingKit/Protos/Generated/WebSocketResources.pb.swift index 737e40ce6..2fe165044 100644 --- a/SessionMessagingKit/Protos/Generated/WebSocketResources.pb.swift +++ b/SessionMessagingKit/Protos/Generated/WebSocketResources.pb.swift @@ -218,6 +218,13 @@ extension WebSocketProtos_WebSocketMessage.TypeEnum: CaseIterable { #endif // swift(>=4.2) +#if swift(>=5.5) && canImport(_Concurrency) +extension WebSocketProtos_WebSocketRequestMessage: @unchecked Sendable {} +extension WebSocketProtos_WebSocketResponseMessage: @unchecked Sendable {} +extension WebSocketProtos_WebSocketMessage: @unchecked Sendable {} +extension WebSocketProtos_WebSocketMessage.TypeEnum: @unchecked Sendable {} +#endif // swift(>=5.5) && canImport(_Concurrency) + // MARK: - Code below here is support for the SwiftProtobuf runtime. fileprivate let _protobuf_package = "WebSocketProtos" diff --git a/SessionMessagingKit/Protos/SessionProtos.proto b/SessionMessagingKit/Protos/SessionProtos.proto index 429c10b14..55d77b18f 100644 --- a/SessionMessagingKit/Protos/SessionProtos.proto +++ b/SessionMessagingKit/Protos/SessionProtos.proto @@ -192,20 +192,21 @@ message DataMessage { optional uint32 expirationTimer = 8; } - optional string body = 1; - repeated AttachmentPointer attachments = 2; - // optional GroupContext group = 3; // No longer used - optional uint32 flags = 4; - optional uint32 expireTimer = 5; - optional bytes profileKey = 6; - optional uint64 timestamp = 7; - optional Quote quote = 8; - repeated Preview preview = 10; - optional Reaction reaction = 11; - optional LokiProfile profile = 101; - optional OpenGroupInvitation openGroupInvitation = 102; - optional ClosedGroupControlMessage closedGroupControlMessage = 104; - optional string syncTarget = 105; + optional string body = 1; + repeated AttachmentPointer attachments = 2; + // optional GroupContext group = 3; // No longer used + optional uint32 flags = 4; + optional uint32 expireTimer = 5; + optional bytes profileKey = 6; + optional uint64 timestamp = 7; + optional Quote quote = 8; + repeated Preview preview = 10; + optional Reaction reaction = 11; + optional LokiProfile profile = 101; + optional OpenGroupInvitation openGroupInvitation = 102; + optional ClosedGroupControlMessage closedGroupControlMessage = 104; + optional string syncTarget = 105; + optional bool blocksCommunityMessageRequests = 106; } message ConfigurationMessage { 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..409fd76cf 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,10 +229,26 @@ 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)) + // Resubscribe for group push notifications + let currentUserPublicKey: String = getUserHexEncodedPublicKey(db) + + PushNotificationAPI + .subscribeToLegacyGroups( + currentUserPublicKey: currentUserPublicKey, + legacyGroupIds: try ClosedGroup + .select(.threadId) + .filter(!ClosedGroup.Columns.threadId.like("\(SessionId.Prefix.group.rawValue)%")) + .joining( + required: ClosedGroup.members + .filter(GroupMember.Columns.profileId == currentUserPublicKey) + ) + .asRequest(of: String.self) + .fetchSet(db) + .inserting(groupPublicKey) // Insert the new key just to be sure + ) + .sinkUntilComplete() } /// Extracts and adds the new encryption key pair to our list of key pairs if there is one for our public key, AND the message was diff --git a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+ConfigurationMessages.swift b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+ConfigurationMessages.swift deleted file mode 100644 index 1935d7619..000000000 --- a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+ConfigurationMessages.swift +++ /dev/null @@ -1,229 +0,0 @@ -// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. - -import Foundation -import GRDB -import Sodium -import SessionUIKit -import SessionUtilitiesKit - -extension MessageReceiver { - internal static func handleLegacyConfigurationMessage(_ db: Database, message: ConfigurationMessage) throws { - // FIXME: Remove this once `useSharedUtilForUserConfig` is permanent - guard !SessionUtil.userConfigsEnabled(db) else { - TopBannerController.show(warning: .outdatedUserConfig) - return - } - - let userPublicKey = getUserHexEncodedPublicKey(db) - - guard message.sender == userPublicKey else { return } - - SNLog("Configuration message received.") - - // Note: `message.sentTimestamp` is in ms (convert to TimeInterval before converting to - // seconds to maintain the accuracy) - let isInitialSync: Bool = (!UserDefaults.standard[.hasSyncedInitialConfiguration]) - let messageSentTimestamp: TimeInterval = TimeInterval((message.sentTimestamp ?? 0) / 1000) - let lastConfigTimestamp: TimeInterval = UserDefaults.standard[.lastConfigurationSync] - .defaulting(to: Date(timeIntervalSince1970: 0)) - .timeIntervalSince1970 - - // Handle user profile changes - try ProfileManager.updateProfileIfNeeded( - db, - publicKey: userPublicKey, - name: message.displayName, - avatarUpdate: { - guard - let profilePictureUrl: String = message.profilePictureUrl, - let profileKey: Data = message.profileKey - else { return .none } - - return .updateTo( - url: profilePictureUrl, - key: profileKey, - fileName: nil - ) - }(), - sentTimestamp: messageSentTimestamp, - calledFromConfigHandling: true - ) - - // Create a contact for the current user if needed (also force-approve the current user - // in case the account got into a weird state or restored directly from a migration) - let userContact: Contact = Contact.fetchOrCreate(db, id: userPublicKey) - - if !userContact.isTrusted || !userContact.isApproved || !userContact.didApproveMe { - try userContact.save(db) - try Contact - .filter(id: userPublicKey) - .updateAll( // Handling a config update so don't use `updateAllAndConfig` - db, - Contact.Columns.isTrusted.set(to: true), - Contact.Columns.isApproved.set(to: true), - Contact.Columns.didApproveMe.set(to: true) - ) - } - - if isInitialSync || messageSentTimestamp > lastConfigTimestamp { - if isInitialSync { - UserDefaults.standard[.hasSyncedInitialConfiguration] = true - NotificationCenter.default.post(name: .initialConfigurationMessageReceived, object: nil) - } - - UserDefaults.standard[.lastConfigurationSync] = Date(timeIntervalSince1970: messageSentTimestamp) - - // Contacts - try message.contacts.forEach { contactInfo in - guard let sessionId: String = contactInfo.publicKey else { return } - - // If the contact is a blinded contact then only add them if they haven't already been - // unblinded - if SessionId.Prefix(from: sessionId) == .blinded15 || SessionId.Prefix(from: sessionId) == .blinded25 { - let hasUnblindedContact: Bool = BlindedIdLookup - .filter(BlindedIdLookup.Columns.blindedId == sessionId) - .filter(BlindedIdLookup.Columns.sessionId != nil) - .isNotEmpty(db) - - if hasUnblindedContact { - return - } - } - - // Note: We only update the contact and profile records if the data has actually changed - // in order to avoid triggering UI updates for every thread on the home screen - let contact: Contact = Contact.fetchOrCreate(db, id: sessionId) - let profile: Profile = Profile.fetchOrCreate(db, id: sessionId) - - if - profile.name != contactInfo.displayName || - profile.profilePictureUrl != contactInfo.profilePictureUrl || - profile.profileEncryptionKey != contactInfo.profileKey - { - try profile.save(db) - try Profile - .filter(id: sessionId) - .updateAll( // Handling a config update so don't use `updateAllAndConfig` - db, - [ - Profile.Columns.name.set(to: contactInfo.displayName), - (contactInfo.profilePictureUrl == nil ? nil : - Profile.Columns.profilePictureUrl.set(to: contactInfo.profilePictureUrl) - ), - (contactInfo.profileKey == nil ? nil : - Profile.Columns.profileEncryptionKey.set(to: contactInfo.profileKey) - ) - ].compactMap { $0 } - ) - } - - /// We only update these values if the proto actually has values for them (this is to prevent an - /// edge case where an old client could override the values with default values since they aren't included) - /// - /// **Note:** Since message requests have no reverse, we should only handle setting `isApproved` - /// and `didApproveMe` to `true`. This may prevent some weird edge cases where a config message - /// swapping `isApproved` and `didApproveMe` to `false` - if - (contactInfo.hasIsApproved && (contact.isApproved != contactInfo.isApproved)) || - (contactInfo.hasIsBlocked && (contact.isBlocked != contactInfo.isBlocked)) || - (contactInfo.hasDidApproveMe && (contact.didApproveMe != contactInfo.didApproveMe)) - { - try contact.save(db) - try Contact - .filter(id: sessionId) - .updateAll( // Handling a config update so don't use `updateAllAndConfig` - db, - [ - (!contactInfo.hasIsApproved || !contactInfo.isApproved ? nil : - Contact.Columns.isApproved.set(to: true) - ), - (!contactInfo.hasIsBlocked ? nil : - Contact.Columns.isBlocked.set(to: contactInfo.isBlocked) - ), - (!contactInfo.hasDidApproveMe || !contactInfo.didApproveMe ? nil : - Contact.Columns.didApproveMe.set(to: contactInfo.didApproveMe) - ) - ].compactMap { $0 } - ) - } - - // If the contact is blocked - if contactInfo.hasIsBlocked && contactInfo.isBlocked { - // If this message changed them to the blocked state and there is an existing thread - // associated with them that is a message request thread then delete it (assume - // that the current user had deleted that message request) - if - contactInfo.isBlocked != contact.isBlocked, // 'contact.isBlocked' will be the old value - let thread: SessionThread = try? SessionThread.fetchOne(db, id: sessionId), - thread.isMessageRequest(db) - { - _ = try thread.delete(db) - } - } - } - - // Closed groups - // - // Note: Only want to add these for initial sync to avoid re-adding closed groups the user - // intentionally left (any closed groups joined since the first processed sync message should - // get added via the 'handleNewClosedGroup' method anyway as they will have come through in the - // past two weeks) - if isInitialSync { - let existingClosedGroupsIds: [String] = (try? SessionThread - .filter(SessionThread.Columns.variant == SessionThread.Variant.legacyGroup) - .fetchAll(db)) - .defaulting(to: []) - .map { $0.id } - - try message.closedGroups.forEach { closedGroup in - guard !existingClosedGroupsIds.contains(closedGroup.publicKey) else { return } - - let keyPair: KeyPair = KeyPair( - publicKey: closedGroup.encryptionKeyPublicKey.bytes, - secretKey: closedGroup.encryptionKeySecretKey.bytes - ) - - try MessageReceiver.handleNewClosedGroup( - db, - groupPublicKey: closedGroup.publicKey, - name: closedGroup.name, - encryptionKeyPair: keyPair, - members: [String](closedGroup.members), - admins: [String](closedGroup.admins), - expirationTimer: closedGroup.expirationTimer, - formationTimestampMs: message.sentTimestamp!, - calledFromConfigHandling: false // Legacy config isn't an issue - ) - } - } - - // Open groups - for openGroupURL in message.openGroups { - if let (room, server, publicKey) = SessionUtil.parseCommunity(url: openGroupURL) { - let successfullyAddedGroup: Bool = OpenGroupManager.shared - .add( - db, - roomToken: room, - server: server, - publicKey: publicKey, - calledFromConfigHandling: true - ) - - if successfullyAddedGroup { - db.afterNextTransactionNested { _ in - OpenGroupManager.shared.performInitialRequestsAfterAdd( - successfullyAddedGroup: successfullyAddedGroup, - roomToken: room, - server: server, - publicKey: publicKey, - calledFromConfigHandling: false - ) - .subscribe(on: OpenGroupAPI.workQueue) - .sinkUntilComplete() - } - } - } - } - } - } -} 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..520002125 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 @@ -31,6 +31,7 @@ extension MessageReceiver { db, publicKey: sender, name: profile.displayName, + blocksCommunityMessageRequests: profile.blocksCommunityMessageRequests, avatarUpdate: { guard let profilePictureUrl: String = profile.profilePictureUrl, @@ -43,7 +44,8 @@ extension MessageReceiver { fileName: nil ) }(), - sentTimestamp: messageSentTimestamp + sentTimestamp: messageSentTimestamp, + using: dependencies ) } @@ -64,7 +66,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? = { @@ -72,7 +74,7 @@ extension MessageReceiver { return try? OpenGroup.fetchOne(db, id: threadId) }() - let variant: Interaction.Variant = { + let variant: Interaction.Variant = try { guard let senderSessionId: SessionId = SessionId(from: sender), let openGroup: OpenGroup = maybeOpenGroup @@ -90,10 +92,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 } @@ -112,6 +116,10 @@ extension MessageReceiver { .standardOutgoing : .standardIncoming ) + + case .group: + SNLog("Ignoring message with invalid sender.") + throw HTTPError.parsingFailed } }() @@ -163,7 +171,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 +303,7 @@ extension MessageReceiver { .appending(quote?.attachmentId) .appending(linkPreview?.attachmentId) .forEach { attachmentId in - JobRunner.add( + dependencies.jobRunner.add( db, job: Job( variant: .attachmentDownload, @@ -304,7 +313,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..c582fe56d 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 - .writePublisher { db -> (String, SessionThread, [MessageSender.PreparedSendData]) in - let userPublicKey: String = getUserHexEncodedPublicKey(db) - var members: Set = members + dependencies.storage + .writePublisher { db -> (String, SessionThread, [MessageSender.PreparedSendData], Set) in + // 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,24 +104,34 @@ extension MessageSender { ), to: .contact(publicKey: memberId), namespace: Message.Destination.contact(publicKey: memberId).defaultNamespace, - interactionId: nil + interactionId: nil, + using: dependencies ) } + let allActiveLegacyGroupIds: Set = try ClosedGroup + .select(.threadId) + .filter(!ClosedGroup.Columns.threadId.like("\(SessionId.Prefix.group.rawValue)%")) + .joining( + required: ClosedGroup.members + .filter(GroupMember.Columns.profileId == userPublicKey) + ) + .asRequest(of: String.self) + .fetchSet(db) + .inserting(groupPublicKey) // Insert the new key just to be sure - return (userPublicKey, thread, memberSendData) + return (userPublicKey, thread, memberSendData, allActiveLegacyGroupIds) } - .flatMap { userPublicKey, thread, memberSendData in + .flatMap { userPublicKey, thread, memberSendData, allActiveLegacyGroupIds in Publishers .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( - .subscribe, - for: thread.id, - publicKey: userPublicKey + // Resubscribe to all legacy groups + PushNotificationAPI.subscribeToLegacyGroups( + currentUserPublicKey: userPublicKey, + legacyGroupIds: allActiveLegacyGroupIds ) ) ) @@ -135,7 +141,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 +157,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 +203,8 @@ extension MessageSender { encryptedKeyPair: try MessageSender.encryptWithSessionProtocol( db, plaintext: plaintext, - for: memberPublicKey + for: memberPublicKey, + using: dependencies ) ) } @@ -204,20 +215,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 +263,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 +305,8 @@ extension MessageSender { message: ClosedGroupControlMessage(kind: .nameChange(name: name)), interactionId: interactionId, threadId: groupPublicKey, - threadVariant: .legacyGroup + threadVariant: .legacyGroup, + using: dependencies ) // Update libSession @@ -321,7 +335,8 @@ extension MessageSender { addedMembers: addedMembers, userPublicKey: userPublicKey, allGroupMembers: allGroupMembers, - closedGroup: closedGroup + closedGroup: closedGroup, + using: dependencies ) } catch { @@ -348,7 +363,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 +380,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 +436,8 @@ extension MessageSender { ), interactionId: interactionId, threadId: closedGroup.threadId, - threadVariant: .legacyGroup + threadVariant: .legacyGroup, + using: dependencies ) try addedMembers.forEach { member in @@ -446,7 +464,8 @@ extension MessageSender { ), interactionId: nil, threadId: member, - threadVariant: .contact + threadVariant: .contact, + using: dependencies ) // Add the users to the group @@ -469,7 +488,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 +510,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 +555,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 +583,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 +597,7 @@ extension MessageSender { timestampMs: SnodeAPI.currentOffsetTimestampMs() ).inserted(db) - JobRunner.upsert( + dependencies.jobRunner.upsert( db, job: Job( variant: .groupLeaving, @@ -583,14 +606,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 +652,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 +672,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..e6a4d84a5 100644 --- a/SessionMessagingKit/Sending & Receiving/MessageReceiver.swift +++ b/SessionMessagingKit/Sending & Receiving/MessageReceiver.swift @@ -3,6 +3,7 @@ import Foundation import GRDB import Sodium +import SessionUIKit import SessionUtilitiesKit import SessionSnodeKit @@ -18,9 +19,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 @@ -64,6 +65,11 @@ public enum MessageReceiver { userEd25519KeyPair: userEd25519KeyPair, using: dependencies ) + + case .group: + // TODO: Need to decide how we will handle updated group messages + SNLog("Ignoring message with invalid sender.") + throw HTTPError.parsingFailed } case .closedGroupMessage: @@ -183,7 +189,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 +204,7 @@ public enum MessageReceiver { message: message, threadId: threadId, threadVariant: threadVariant, - dependencies: dependencies + using: dependencies ) switch message { @@ -222,7 +228,8 @@ public enum MessageReceiver { db, threadId: threadId, threadVariant: threadVariant, - message: message + message: message, + using: dependencies ) case let message as DataExtractionNotification: @@ -241,9 +248,6 @@ public enum MessageReceiver { message: message ) - case let message as ConfigurationMessage: - try MessageReceiver.handleLegacyConfigurationMessage(db, message: message) - case let message as UnsendRequest: try MessageReceiver.handleUnsendRequest( db, @@ -264,7 +268,7 @@ public enum MessageReceiver { try MessageReceiver.handleMessageRequestResponse( db, message: message, - dependencies: dependencies + using: dependencies ) case let message as VisibleMessage: @@ -277,6 +281,7 @@ public enum MessageReceiver { ) // SharedConfigMessages should be handled by the 'SharedUtil' instead of this + case is ConfigurationMessage: TopBannerController.show(warning: .outdatedUserConfig) case is SharedConfigMessage: throw MessageReceiverError.invalidSharedConfigMessageHandling default: fatalError() @@ -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 76681d309..747841f4e 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 }() @@ -433,7 +436,8 @@ public final class MessageSender { // Attach the user's profile message.profile = VisibleMessage.VMProfile( - profile: Profile.fetchOrCreateCurrentUser() + profile: Profile.fetchOrCreateCurrentUser(db), + blocksCommunityMessageRequests: !db[.checkForCommunityMessageRequests] ) if (message.profile?.displayName ?? "").isEmpty { @@ -492,7 +496,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 +586,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 +601,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 +609,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 +634,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 +645,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 +657,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 +696,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 @@ -718,15 +719,16 @@ public final class MessageSender { NotifyPushServerJob.run( job, queue: .global(qos: .default), - success: { _, _ in resolver(Result.success(())) }, - failure: { _, _, _ in + success: { _, _, _ in resolver(Result.success(())) }, + failure: { _, _, _, _ in // Always fulfill because the notify PN server job isn't critical. resolver(Result.success(())) }, - deferred: { _ in + deferred: { _, _ in // Always fulfill because the notify PN server job isn't critical. resolver(Result.success(())) - } + }, + using: dependencies ) } } @@ -761,7 +763,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 +831,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 +928,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 +966,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 +999,8 @@ public final class MessageSender { destination: destination, threadId: threadId, interactionId: interactionId, - isAlreadySyncMessage: isSyncMessage + isAlreadySyncMessage: isSyncMessage, + using: dependencies ) } @@ -1005,7 +1010,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 +1077,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 +1093,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 +1104,9 @@ public final class MessageSender { message: message, isSyncMessage: true ) - ) + ), + canStartJob: true, + using: dependencies ) } } diff --git a/SessionMessagingKit/Sending & Receiving/Notification+MessageReceiver.swift b/SessionMessagingKit/Sending & Receiving/Notification+MessageReceiver.swift index d5c5b48fa..df890e31d 100644 --- a/SessionMessagingKit/Sending & Receiving/Notification+MessageReceiver.swift +++ b/SessionMessagingKit/Sending & Receiving/Notification+MessageReceiver.swift @@ -3,18 +3,9 @@ import Foundation public extension Notification.Name { - - // FIXME: Remove once `useSharedUtilForUserConfig` is permanent - static let initialConfigurationMessageReceived = Notification.Name("initialConfigurationMessageReceived") static let missedCall = Notification.Name("missedCall") } public extension Notification.Key { static let senderId = Notification.Key("senderId") } - -@objc public extension NSNotification { - - // FIXME: Remove once `useSharedUtilForUserConfig` is permanent - @objc static let initialConfigurationMessageReceived = Notification.Name.initialConfigurationMessageReceived.rawValue as NSString -} diff --git a/SessionMessagingKit/Sending & Receiving/Notifications/Models/LegacyGroupOnlyRequest.swift b/SessionMessagingKit/Sending & Receiving/Notifications/Models/LegacyGroupOnlyRequest.swift new file mode 100644 index 000000000..1a87dcf8e --- /dev/null +++ b/SessionMessagingKit/Sending & Receiving/Notifications/Models/LegacyGroupOnlyRequest.swift @@ -0,0 +1,12 @@ +// Copyright © 2023 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +extension PushNotificationAPI { + struct LegacyGroupOnlyRequest: Codable { + let token: String + let pubKey: String + let device: String + let legacyGroupPublicKeys: Set + } +} diff --git a/SessionMessagingKit/Sending & Receiving/Notifications/Models/LegacyGroupRequest.swift b/SessionMessagingKit/Sending & Receiving/Notifications/Models/LegacyGroupRequest.swift new file mode 100644 index 000000000..962011dfd --- /dev/null +++ b/SessionMessagingKit/Sending & Receiving/Notifications/Models/LegacyGroupRequest.swift @@ -0,0 +1,10 @@ +// Copyright © 2023 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +extension PushNotificationAPI { + struct LegacyGroupRequest: Codable { + let pubKey: String + let closedGroupPublicKey: String + } +} diff --git a/SessionMessagingKit/Sending & Receiving/Notifications/Models/LegacyNotifyRequest.swift b/SessionMessagingKit/Sending & Receiving/Notifications/Models/LegacyNotifyRequest.swift new file mode 100644 index 000000000..491fa7757 --- /dev/null +++ b/SessionMessagingKit/Sending & Receiving/Notifications/Models/LegacyNotifyRequest.swift @@ -0,0 +1,15 @@ +// Copyright © 2023 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +extension PushNotificationAPI { + struct LegacyNotifyRequest: Codable { + enum CodingKeys: String, CodingKey { + case data + case sendTo = "send_to" + } + + let data: String + let sendTo: String + } +} diff --git a/SessionMessagingKit/Sending & Receiving/Notifications/Models/PushServerResponse.swift b/SessionMessagingKit/Sending & Receiving/Notifications/Models/LegacyPushServerResponse.swift similarity index 78% rename from SessionMessagingKit/Sending & Receiving/Notifications/Models/PushServerResponse.swift rename to SessionMessagingKit/Sending & Receiving/Notifications/Models/LegacyPushServerResponse.swift index eee22e266..dc8c77ff7 100644 --- a/SessionMessagingKit/Sending & Receiving/Notifications/Models/PushServerResponse.swift +++ b/SessionMessagingKit/Sending & Receiving/Notifications/Models/LegacyPushServerResponse.swift @@ -3,7 +3,7 @@ import Foundation extension PushNotificationAPI { - struct PushServerResponse: Codable { + struct LegacyPushServerResponse: Codable { let code: Int let message: String? } diff --git a/SessionMessagingKit/Sending & Receiving/Notifications/Models/LegacyUnsubscribeRequest.swift b/SessionMessagingKit/Sending & Receiving/Notifications/Models/LegacyUnsubscribeRequest.swift new file mode 100644 index 000000000..663bafb17 --- /dev/null +++ b/SessionMessagingKit/Sending & Receiving/Notifications/Models/LegacyUnsubscribeRequest.swift @@ -0,0 +1,14 @@ +// Copyright © 2023 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import SessionSnodeKit + +extension PushNotificationAPI { + struct LegacyUnsubscribeRequest: Codable { + private let token: String + + init(token: String) { + self.token = token + } + } +} diff --git a/SessionMessagingKit/Sending & Receiving/Notifications/Models/NotificationMetadata.swift b/SessionMessagingKit/Sending & Receiving/Notifications/Models/NotificationMetadata.swift new file mode 100644 index 000000000..9a3633d85 --- /dev/null +++ b/SessionMessagingKit/Sending & Receiving/Notifications/Models/NotificationMetadata.swift @@ -0,0 +1,47 @@ +// Copyright © 2023 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +extension PushNotificationAPI { + struct NotificationMetadata: Codable { + private enum CodingKeys: String, CodingKey { + case accountId = "@" + case hash = "#" + case namespace = "n" + case dataLength = "l" + case dataTooLong = "B" + } + + /// Account ID (such as Session ID or closed group ID) where the message arrived. + let accountId: String + + /// The hash of the message in the swarm. + let hash: String + + /// The swarm namespace in which this message arrived. + let namespace: Int + + /// The length of the message data. This is always included, even if the message content + /// itself was too large to fit into the push notification. + let dataLength: Int + + /// This will be `true` if the data was omitted because it was too long to fit in a push + /// notification (around 2.5kB of raw data), in which case the push notification includes + /// only this metadata but not the message content itself. + let dataTooLong: Bool + } +} + +extension PushNotificationAPI.NotificationMetadata { + init(from decoder: Decoder) throws { + let container: KeyedDecodingContainer = try decoder.container(keyedBy: CodingKeys.self) + + self = PushNotificationAPI.NotificationMetadata( + accountId: try container.decode(String.self, forKey: .accountId), + hash: try container.decode(String.self, forKey: .hash), + namespace: try container.decode(Int.self, forKey: .namespace), + dataLength: try container.decode(Int.self, forKey: .dataLength), + dataTooLong: ((try? container.decode(Bool.self, forKey: .dataTooLong)) ?? false) + ) + } +} diff --git a/SessionMessagingKit/Sending & Receiving/Notifications/Models/PushNotificationAPIRequest.swift b/SessionMessagingKit/Sending & Receiving/Notifications/Models/PushNotificationAPIRequest.swift new file mode 100644 index 000000000..671b0b7a5 --- /dev/null +++ b/SessionMessagingKit/Sending & Receiving/Notifications/Models/PushNotificationAPIRequest.swift @@ -0,0 +1,33 @@ +// Copyright © 2023 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import SessionUtilitiesKit + +public struct PushNotificationAPIRequest: Encodable { + private enum CodingKeys: String, CodingKey { + case method + case body = "params" + } + + internal let endpoint: PushNotificationAPI.Endpoint + internal let body: T + + // MARK: - Initialization + + public init( + endpoint: PushNotificationAPI.Endpoint, + body: T + ) { + self.endpoint = endpoint + self.body = body + } + + // MARK: - Codable + + public func encode(to encoder: Encoder) throws { + var container: KeyedEncodingContainer = encoder.container(keyedBy: CodingKeys.self) + + try container.encode(endpoint.rawValue, forKey: .method) + try container.encode(body, forKey: .body) + } +} diff --git a/SessionMessagingKit/Sending & Receiving/Notifications/Models/SubscribeRequest.swift b/SessionMessagingKit/Sending & Receiving/Notifications/Models/SubscribeRequest.swift new file mode 100644 index 000000000..9417d232d --- /dev/null +++ b/SessionMessagingKit/Sending & Receiving/Notifications/Models/SubscribeRequest.swift @@ -0,0 +1,153 @@ +// Copyright © 2023 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import SessionSnodeKit + +extension PushNotificationAPI { + struct SubscribeRequest: Encodable { + struct ServiceInfo: Codable { + private enum CodingKeys: String, CodingKey { + case token + } + + private let token: String + + // MARK: - Initialization + + init(token: String) { + self.token = token + } + } + + private enum CodingKeys: String, CodingKey { + case pubkey + case ed25519PublicKey = "session_ed25519" + case subkey = "subkey_tag" + case namespaces + case includeMessageData = "data" + case timestamp = "sig_ts" + case signatureBase64 = "signature" + case service + case serviceInfo = "service_info" + case notificationsEncryptionKey = "enc_key" + } + + /// The 33-byte account being subscribed to; typically a session ID. + private let pubkey: String + + /// List of integer namespace (-32768 through 32767). These must be sorted in ascending order. + private let namespaces: [SnodeAPI.Namespace] + + /// If provided and true then notifications will include the body of the message (as long as it isn't too large); if false then the body will + /// not be included in notifications. + private let includeMessageData: Bool + + /// Dict of service-specific data; typically this includes just a "token" field with a device-specific token, but different services in the + /// future may have different input requirements. + private let serviceInfo: ServiceInfo + + /// 32-byte encryption key; notification payloads sent to the device will be encrypted with XChaCha20-Poly1305 using this key. Though + /// it is permitted for this to change, it is recommended that the device generate this once and persist it. + private let notificationsEncryptionKey: Data + + /// 32-byte swarm authentication subkey; omitted (or null) when not using subkey auth + private let subkey: String? + + /// The signature unix timestamp (seconds, not ms) + private let timestamp: Int64 + + /// When the pubkey value starts with 05 (i.e. a session ID) this is the underlying ed25519 32-byte pubkey associated with the session + /// ID. When not 05, this field should not be provided. + private let ed25519PublicKey: [UInt8] + + /// Secret key used to generate the signature (**Not** sent with the request) + private let ed25519SecretKey: [UInt8] + + // MARK: - Initialization + + init( + pubkey: String, + namespaces: [SnodeAPI.Namespace], + includeMessageData: Bool, + serviceInfo: ServiceInfo, + notificationsEncryptionKey: Data, + subkey: String?, + timestamp: TimeInterval, + ed25519PublicKey: [UInt8], + ed25519SecretKey: [UInt8] + ) { + self.pubkey = pubkey + self.namespaces = namespaces + self.includeMessageData = includeMessageData + self.serviceInfo = serviceInfo + self.notificationsEncryptionKey = notificationsEncryptionKey + self.subkey = subkey + self.timestamp = Int64(timestamp) // Server expects rounded seconds + self.ed25519PublicKey = ed25519PublicKey + self.ed25519SecretKey = ed25519SecretKey + } + + // MARK: - Coding + + public func encode(to encoder: Encoder) throws { + var container: KeyedEncodingContainer = encoder.container(keyedBy: CodingKeys.self) + + // Generate the signature for the request for encoding + let signatureBase64: String = try generateSignature().toBase64() + try container.encode(pubkey, forKey: .pubkey) + try container.encode(ed25519PublicKey.toHexString(), forKey: .ed25519PublicKey) + try container.encodeIfPresent(subkey, forKey: .subkey) + try container.encode(namespaces.map { $0.rawValue}.sorted(), forKey: .namespaces) + try container.encode(includeMessageData, forKey: .includeMessageData) + try container.encode(timestamp, forKey: .timestamp) + try container.encode(signatureBase64, forKey: .signatureBase64) + try container.encode(Service.apns, forKey: .service) + try container.encode(serviceInfo, forKey: .serviceInfo) + try container.encode(notificationsEncryptionKey.toHexString(), forKey: .notificationsEncryptionKey) + } + + // MARK: - Abstract Methods + + func generateSignature() throws -> [UInt8] { + /// The signature data collected and stored here is used by the PN server to subscribe to the swarms + /// for the given account; the specific rules are governed by the storage server, but in general: + /// + /// A signature must have been produced (via the timestamp) within the past 14 days. It is + /// recommended that clients generate a new signature whenever they re-subscribe, and that + /// re-subscriptions happen more frequently than once every 14 days. + /// + /// A signature is signed using the account's Ed25519 private key (or Ed25519 subkey, if using + /// subkey authentication with a `subkey_tag`, for future closed group subscriptions), and signs the value: + /// `"MONITOR" || HEX(ACCOUNT) || SIG_TS || DATA01 || NS[0] || "," || ... || "," || NS[n]` + /// + /// Where `SIG_TS` is the `sig_ts` value as a base-10 string; `DATA01` is either "0" or "1" depending + /// on whether the subscription wants message data included; and the trailing `NS[i]` values are a + /// comma-delimited list of namespaces that should be subscribed to, in the same sorted order as + /// the `namespaces` parameter. + let verificationBytes: [UInt8] = "MONITOR".bytes + .appending(contentsOf: pubkey.bytes) + .appending(contentsOf: "\(timestamp)".bytes) + .appending(contentsOf: (includeMessageData ? "1" : "0").bytes) + .appending( + contentsOf: namespaces + .map { $0.rawValue } // Intentionally not using `verificationString` here + .sorted() + .map { "\($0)" } + .joined(separator: ",") + .bytes + ) + + // TODO: Need to add handling for subkey auth + guard + let signatureBytes: [UInt8] = sodium.wrappedValue.sign.signature( + message: verificationBytes, + secretKey: ed25519SecretKey + ) + else { + throw SnodeAPIError.signingFailed + } + + return signatureBytes + } + } +} diff --git a/SessionMessagingKit/Sending & Receiving/Notifications/Models/SubscribeResponse.swift b/SessionMessagingKit/Sending & Receiving/Notifications/Models/SubscribeResponse.swift new file mode 100644 index 000000000..c2298e1c2 --- /dev/null +++ b/SessionMessagingKit/Sending & Receiving/Notifications/Models/SubscribeResponse.swift @@ -0,0 +1,31 @@ +// Copyright © 2023 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +extension PushNotificationAPI { + struct SubscribeResponse: Codable { + /// Flag indicating the success of the registration + let success: Bool? + + /// Value is `true` upon an initial registration + let added: Bool? + + /// Value is `true` upon a renewal/update registration + let updated: Bool? + + /// This will be one of the errors found here: + /// https://github.com/jagerman/session-push-notification-server/blob/spns-v2/spns/hive/subscription.hpp#L21 + /// + /// Values at the time of writing are: + /// OK = 0 // Great Success! + /// BAD_INPUT = 1 // Unparseable, invalid values, missing required arguments, etc. (details in the string) + /// SERVICE_NOT_AVAILABLE = 2 // The requested service name isn't currently available + /// SERVICE_TIMEOUT = 3 // The backend service did not response + /// ERROR = 4 // There was some other error processing the subscription (details in the string) + /// INTERNAL_ERROR = 5 // An internal program error occured processing the request + let error: Int? + + /// Includes additional information about the error + let message: String? + } +} diff --git a/SessionMessagingKit/Sending & Receiving/Notifications/Models/UnsubscribeRequest.swift b/SessionMessagingKit/Sending & Receiving/Notifications/Models/UnsubscribeRequest.swift new file mode 100644 index 000000000..3d76f76ab --- /dev/null +++ b/SessionMessagingKit/Sending & Receiving/Notifications/Models/UnsubscribeRequest.swift @@ -0,0 +1,111 @@ +// Copyright © 2023 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import SessionSnodeKit + +extension PushNotificationAPI { + struct UnsubscribeRequest: Encodable { + struct ServiceInfo: Codable { + private enum CodingKeys: String, CodingKey { + case token + } + + private let token: String + + // MARK: - Initialization + + init(token: String) { + self.token = token + } + } + + private enum CodingKeys: String, CodingKey { + case pubkey + case ed25519PublicKey = "session_ed25519" + case subkey = "subkey_tag" + case timestamp = "sig_ts" + case signatureBase64 = "signature" + case service + case serviceInfo = "service_info" + } + + /// The 33-byte account being subscribed to; typically a session ID. + private let pubkey: String + + /// Dict of service-specific data; typically this includes just a "token" field with a device-specific token, but different services in the + /// future may have different input requirements. + private let serviceInfo: ServiceInfo + + /// 32-byte swarm authentication subkey; omitted (or null) when not using subkey auth + private let subkey: String? + + /// The signature unix timestamp (seconds, not ms) + private let timestamp: Int64 + + /// When the pubkey value starts with 05 (i.e. a session ID) this is the underlying ed25519 32-byte pubkey associated with the session + /// ID. When not 05, this field should not be provided. + private let ed25519PublicKey: [UInt8] + + /// Secret key used to generate the signature (**Not** sent with the request) + private let ed25519SecretKey: [UInt8] + + // MARK: - Initialization + + init( + pubkey: String, + serviceInfo: ServiceInfo, + subkey: String?, + timestamp: TimeInterval, + ed25519PublicKey: [UInt8], + ed25519SecretKey: [UInt8] + ) { + self.pubkey = pubkey + self.serviceInfo = serviceInfo + self.subkey = subkey + self.timestamp = Int64(timestamp) // Server expects rounded seconds + self.ed25519PublicKey = ed25519PublicKey + self.ed25519SecretKey = ed25519SecretKey + } + + // MARK: - Coding + + public func encode(to encoder: Encoder) throws { + var container: KeyedEncodingContainer = encoder.container(keyedBy: CodingKeys.self) + + // Generate the signature for the request for encoding + let signatureBase64: String = try generateSignature().toBase64() + try container.encode(pubkey, forKey: .pubkey) + try container.encode(ed25519PublicKey.toHexString(), forKey: .ed25519PublicKey) + try container.encodeIfPresent(subkey, forKey: .subkey) + try container.encode(timestamp, forKey: .timestamp) + try container.encode(signatureBase64, forKey: .signatureBase64) + try container.encode(Service.apns, forKey: .service) + try container.encode(serviceInfo, forKey: .serviceInfo) + } + + // MARK: - Abstract Methods + + func generateSignature() throws -> [UInt8] { + /// A signature is signed using the account's Ed25519 private key (or Ed25519 subkey, if using + /// subkey authentication with a `subkey_tag`, for future closed group subscriptions), and signs the value: + /// `"UNSUBSCRIBE" || HEX(ACCOUNT) || SIG_TS` + /// + /// Where `SIG_TS` is the `sig_ts` value as a base-10 string and must be within 24 hours of the current time. + let verificationBytes: [UInt8] = "UNSUBSCRIBE".bytes + .appending(contentsOf: pubkey.bytes) + .appending(contentsOf: "\(timestamp)".data(using: .ascii)?.bytes) + + // TODO: Need to add handling for subkey auth + guard + let signatureBytes: [UInt8] = sodium.wrappedValue.sign.signature( + message: verificationBytes, + secretKey: ed25519SecretKey + ) + else { + throw SnodeAPIError.signingFailed + } + + return signatureBytes + } + } +} diff --git a/SessionMessagingKit/Sending & Receiving/Notifications/Models/UnsubscribeResponse.swift b/SessionMessagingKit/Sending & Receiving/Notifications/Models/UnsubscribeResponse.swift new file mode 100644 index 000000000..03b38c524 --- /dev/null +++ b/SessionMessagingKit/Sending & Receiving/Notifications/Models/UnsubscribeResponse.swift @@ -0,0 +1,31 @@ +// Copyright © 2023 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +extension PushNotificationAPI { + struct UnsubscribeResponse: Codable { + /// Flag indicating the success of the registration + let success: Bool? + + /// Value is `true` upon an initial registration + let added: Bool? + + /// Value is `true` upon a renewal/update registration + let updated: Bool? + + /// This will be one of the errors found here: + /// https://github.com/jagerman/session-push-notification-server/blob/spns-v2/spns/hive/subscription.hpp#L21 + /// + /// Values at the time of writing are: + /// OK = 0 // Great Success! + /// BAD_INPUT = 1 // Unparseable, invalid values, missing required arguments, etc. (details in the string) + /// SERVICE_NOT_AVAILABLE = 2 // The requested service name isn't currently available + /// SERVICE_TIMEOUT = 3 // The backend service did not response + /// ERROR = 4 // There was some other error processing the subscription (details in the string) + /// INTERNAL_ERROR = 5 // An internal program error occured processing the request + let error: Int? + + /// Includes additional information about the error + let message: String? + } +} diff --git a/SessionMessagingKit/Sending & Receiving/Notifications/PushNotificationAPI.swift b/SessionMessagingKit/Sending & Receiving/Notifications/PushNotificationAPI.swift index 78886b8c8..6e32886ac 100644 --- a/SessionMessagingKit/Sending & Receiving/Notifications/PushNotificationAPI.swift +++ b/SessionMessagingKit/Sending & Receiving/Notifications/PushNotificationAPI.swift @@ -3,136 +3,33 @@ import Foundation import Combine import GRDB +import Sodium import SessionSnodeKit import SessionUtilitiesKit public enum PushNotificationAPI { - struct RegistrationRequestBody: Codable { - let token: String - let pubKey: String? - } - - struct NotifyRequestBody: Codable { - enum CodingKeys: String, CodingKey { - case data - case sendTo = "send_to" - } - - let data: String - let sendTo: String - } - - struct ClosedGroupRequestBody: Codable { - let closedGroupPublicKey: String - let pubKey: String - } - - // MARK: - Settings - - public static let server = "https://live.apns.getsession.org" - public static let serverPublicKey = "642a6585919742e5a2d4dc51244964fbcd8bcab2b75612407de58b810740d049" - + internal static let sodium: Atomic = Atomic(Sodium()) + private static let keychainService: String = "PNKeyChainService" + private static let encryptionKeyKey: String = "PNEncryptionKeyKey" + private static let encryptionKeyLength: Int = 32 private static let maxRetryCount: Int = 4 - private static let tokenExpirationInterval: TimeInterval = 12 * 60 * 60 - - public enum ClosedGroupOperation: Int { - case subscribe, unsubscribe - - public var endpoint: String { - switch self { - case .subscribe: return "subscribe_closed_group" - case .unsubscribe: return "unsubscribe_closed_group" - } - } - } + private static let tokenExpirationInterval: TimeInterval = (12 * 60 * 60) - // MARK: - Registration + public static let server = "https://push.getsession.org" + public static let serverPublicKey = "d7557fe563e2610de876c0ac7341b62f3c82d5eea4b62c702392ea4368f51b3b" + public static let legacyServer = "https://live.apns.getsession.org" + public static let legacyServerPublicKey = "642a6585919742e5a2d4dc51244964fbcd8bcab2b75612407de58b810740d049" + + // MARK: - Requests - public static func unregister(_ token: Data) -> AnyPublisher { - let requestBody: RegistrationRequestBody = RegistrationRequestBody(token: token.toHexString(), pubKey: nil) - - guard let body: Data = try? JSONEncoder().encode(requestBody) else { - return Fail(error: HTTPError.invalidJSON) - .eraseToAnyPublisher() - } - - // Unsubscribe from all closed groups (including ones the user is no longer a member of, - // just in case) - Storage.shared - .readPublisher { db -> (String, Set) in - ( - getUserHexEncodedPublicKey(db), - try ClosedGroup - .select(.threadId) - .asRequest(of: String.self) - .fetchSet(db) - ) - } - .flatMap { userPublicKey, closedGroupPublicKeys in - Publishers - .MergeMany( - closedGroupPublicKeys - .map { closedGroupPublicKey -> AnyPublisher in - PushNotificationAPI - .performOperation( - .unsubscribe, - for: closedGroupPublicKey, - publicKey: userPublicKey - ) - } - ) - .collect() - .eraseToAnyPublisher() - } - .subscribe(on: DispatchQueue.global(qos: .userInitiated)) - .sinkUntilComplete() - - // Unregister for normal push notifications - let url = URL(string: "\(server)/unregister")! - var request: URLRequest = URLRequest(url: url) - request.httpMethod = "POST" - request.allHTTPHeaderFields = [ HTTPHeader.contentType: "application/json" ] - request.httpBody = body - - return OnionRequestAPI - .sendOnionRequest(request, to: server, with: serverPublicKey) - .map { _, data -> Void in - guard let response: PushServerResponse = try? data?.decoded(as: PushServerResponse.self) else { - return SNLog("Couldn't unregister from push notifications.") - } - guard response.code != 0 else { - return SNLog("Couldn't unregister from push notifications due to error: \(response.message ?? "nil").") - } - - return () - } - .retry(maxRetryCount) - .handleEvents( - receiveCompletion: { result in - switch result { - case .finished: break - case .failure: SNLog("Couldn't unregister from push notifications.") - } - } - ) - .eraseToAnyPublisher() - } - - public static func register( - with token: Data, - publicKey: String, - isForcedUpdate: Bool + public static func subscribe( + token: Data, + isForcedUpdate: Bool, + using dependencies: Dependencies = Dependencies() ) -> AnyPublisher { let hexEncodedToken: String = token.toHexString() - let requestBody: RegistrationRequestBody = RegistrationRequestBody(token: hexEncodedToken, pubKey: publicKey) - - guard let body: Data = try? JSONEncoder().encode(requestBody) else { - return Fail(error: HTTPError.invalidJSON) - .eraseToAnyPublisher() - } - - let oldToken: String? = UserDefaults.standard[.deviceToken] - let lastUploadTime: Double = UserDefaults.standard[.lastDeviceTokenUpload] + let oldToken: String? = dependencies.standardUserDefaults[.deviceToken] + let lastUploadTime: Double = dependencies.standardUserDefaults[.lastDeviceTokenUpload] let now: TimeInterval = Date().timeIntervalSince1970 guard isForcedUpdate || hexEncodedToken != oldToken || now - lastUploadTime > tokenExpirationInterval else { @@ -142,153 +39,470 @@ public enum PushNotificationAPI { .eraseToAnyPublisher() } - let url = URL(string: "\(server)/register")! - var request: URLRequest = URLRequest(url: url) - request.httpMethod = "POST" - request.allHTTPHeaderFields = [ HTTPHeader.contentType: "application/json" ] - request.httpBody = body - - return Publishers - .MergeMany( - [ - OnionRequestAPI - .sendOnionRequest(request, to: server, with: serverPublicKey) - .map { _, data -> Void in - guard let response: PushServerResponse = try? data?.decoded(as: PushServerResponse.self) else { - return SNLog("Couldn't register device token.") - } - guard response.code != 0 else { - return SNLog("Couldn't register device token due to error: \(response.message ?? "nil").") - } - - UserDefaults.standard[.deviceToken] = hexEncodedToken - UserDefaults.standard[.lastDeviceTokenUpload] = now - UserDefaults.standard[.isUsingFullAPNs] = true - return () - } - .retry(maxRetryCount) - .handleEvents( - receiveCompletion: { result in - switch result { - case .finished: break - case .failure: SNLog("Couldn't register device token.") - } - } - ) - .eraseToAnyPublisher() - ].appending( - contentsOf: Storage.shared - .read { db -> [String] in - try ClosedGroup - .select(.threadId) - .joining( - required: ClosedGroup.members - .filter(GroupMember.Columns.profileId == getUserHexEncodedPublicKey(db)) - ) - .asRequest(of: String.self) - .fetchAll(db) - } - .defaulting(to: []) - .map { closedGroupPublicKey -> AnyPublisher in - PushNotificationAPI - .performOperation( - .subscribe, - for: closedGroupPublicKey, - publicKey: publicKey - ) - } - ) - ) - .collect() - .map { _ in () } - .eraseToAnyPublisher() - } - - public static func performOperation( - _ operation: ClosedGroupOperation, - for closedGroupPublicKey: String, - publicKey: String - ) -> AnyPublisher { - let isUsingFullAPNs = UserDefaults.standard[.isUsingFullAPNs] - let requestBody: ClosedGroupRequestBody = ClosedGroupRequestBody( - closedGroupPublicKey: closedGroupPublicKey, - pubKey: publicKey - ) - - guard isUsingFullAPNs else { + guard let notificationsEncryptionKey: Data = try? getOrGenerateEncryptionKey(using: dependencies) else { + SNLog("Unable to retrieve PN encryption key.") return Just(()) .setFailureType(to: Error.self) .eraseToAnyPublisher() } - guard let body: Data = try? JSONEncoder().encode(requestBody) else { - return Fail(error: HTTPError.invalidJSON) - .eraseToAnyPublisher() - } - let url = URL(string: "\(server)/\(operation.endpoint)")! - var request: URLRequest = URLRequest(url: url) - request.httpMethod = "POST" - request.allHTTPHeaderFields = [ HTTPHeader.contentType: "application/json" ] - request.httpBody = body - - return OnionRequestAPI - .sendOnionRequest(request, to: server, with: serverPublicKey) - .map { _, data in - guard let response: PushServerResponse = try? data?.decoded(as: PushServerResponse.self) else { - return SNLog("Couldn't subscribe/unsubscribe for closed group: \(closedGroupPublicKey).") - } - guard response.code != 0 else { - return SNLog("Couldn't subscribe/unsubscribe for closed group: \(closedGroupPublicKey) due to error: \(response.message ?? "nil").") + // TODO: Need to generate requests for each updated group as well + return dependencies.storage + .readPublisher(using: dependencies) { db -> (SubscribeRequest, String, Set) in + guard let userED25519KeyPair: KeyPair = Identity.fetchUserEd25519KeyPair(db) else { + throw SnodeAPIError.noKeyPair } - return () + let currentUserPublicKey: String = getUserHexEncodedPublicKey(db, using: dependencies) + let request: SubscribeRequest = SubscribeRequest( + pubkey: currentUserPublicKey, + namespaces: [.default], + // Note: Unfortunately we always need the message content because without the content + // control messages can't be distinguished from visible messages which results in the + // 'generic' notification being shown when receiving things like typing indicator updates + includeMessageData: true, + serviceInfo: SubscribeRequest.ServiceInfo( + token: hexEncodedToken + ), + notificationsEncryptionKey: notificationsEncryptionKey, + subkey: nil, + timestamp: (TimeInterval(SnodeAPI.currentOffsetTimestampMs()) / 1000), // Seconds + ed25519PublicKey: userED25519KeyPair.publicKey, + ed25519SecretKey: userED25519KeyPair.secretKey + ) + + return ( + request, + currentUserPublicKey, + try ClosedGroup + .select(.threadId) + .filter(!ClosedGroup.Columns.threadId.like("\(SessionId.Prefix.group.rawValue)%")) + .joining( + required: ClosedGroup.members + .filter(GroupMember.Columns.profileId == currentUserPublicKey) + ) + .asRequest(of: String.self) + .fetchSet(db) + ) } - .retry(maxRetryCount) + .flatMap { request, currentUserPublicKey, legacyGroupIds -> AnyPublisher in + Publishers + .MergeMany( + [ + PushNotificationAPI + .send( + request: PushNotificationAPIRequest( + endpoint: .subscribe, + body: request + ), + using: dependencies + ) + .decoded(as: SubscribeResponse.self, using: dependencies) + .retry(maxRetryCount, using: dependencies) + .handleEvents( + receiveOutput: { _, response in + guard response.success == true else { + return SNLog("Couldn't subscribe for push notifications due to error (\(response.error ?? -1)): \(response.message ?? "nil").") + } + + dependencies.standardUserDefaults[.deviceToken] = hexEncodedToken + dependencies.standardUserDefaults[.lastDeviceTokenUpload] = now + dependencies.standardUserDefaults[.isUsingFullAPNs] = true + }, + receiveCompletion: { result in + switch result { + case .finished: break + case .failure: SNLog("Couldn't subscribe for push notifications.") + } + } + ) + .map { _ in () } + .eraseToAnyPublisher(), + // FIXME: Remove this once legacy groups are deprecated + PushNotificationAPI.subscribeToLegacyGroups( + forced: true, + token: hexEncodedToken, + currentUserPublicKey: currentUserPublicKey, + legacyGroupIds: legacyGroupIds, + using: dependencies + ) + ] + ) + .collect() + .map { _ in () } + .eraseToAnyPublisher() + } + .eraseToAnyPublisher() + } + + public static func unsubscribe( + token: Data, + using dependencies: Dependencies = Dependencies() + ) -> AnyPublisher { + let hexEncodedToken: String = token.toHexString() + + // FIXME: Remove this once legacy groups are deprecated + /// Unsubscribe from all legacy groups (including ones the user is no longer a member of, just in case) + dependencies.storage + .readPublisher(using: dependencies) { db -> (String, Set) in + ( + getUserHexEncodedPublicKey(db, using: dependencies), + try ClosedGroup + .select(.threadId) + .filter(!ClosedGroup.Columns.threadId.like("\(SessionId.Prefix.group.rawValue)%")) + .asRequest(of: String.self) + .fetchSet(db) + ) + } + .flatMap { currentUserPublicKey, legacyGroupIds in + Publishers + .MergeMany( + legacyGroupIds + .map { legacyGroupId -> AnyPublisher in + PushNotificationAPI + .unsubscribeFromLegacyGroup( + legacyGroupId: legacyGroupId, + currentUserPublicKey: currentUserPublicKey, + using: dependencies + ) + } + ) + .collect() + .eraseToAnyPublisher() + } + .subscribe(on: DispatchQueue.global(qos: .userInitiated), using: dependencies) + .sinkUntilComplete() + + // TODO: Need to generate requests for each updated group as well + return dependencies.storage + .readPublisher(using: dependencies) { db -> UnsubscribeRequest in + guard let userED25519KeyPair: KeyPair = Identity.fetchUserEd25519KeyPair(db) else { + throw SnodeAPIError.noKeyPair + } + + return UnsubscribeRequest( + pubkey: getUserHexEncodedPublicKey(db, using: dependencies), + serviceInfo: UnsubscribeRequest.ServiceInfo( + token: hexEncodedToken + ), + subkey: nil, + timestamp: (TimeInterval(SnodeAPI.currentOffsetTimestampMs()) / 1000), // Seconds + ed25519PublicKey: userED25519KeyPair.publicKey, + ed25519SecretKey: userED25519KeyPair.secretKey + ) + } + .flatMap { request -> AnyPublisher in + PushNotificationAPI + .send( + request: PushNotificationAPIRequest( + endpoint: .unsubscribe, + body: request + ), + using: dependencies + ) + .decoded(as: UnsubscribeResponse.self, using: dependencies) + .retry(maxRetryCount, using: dependencies) + .handleEvents( + receiveOutput: { _, response in + guard response.success == true else { + return SNLog("Couldn't unsubscribe for push notifications due to error (\(response.error ?? -1)): \(response.message ?? "nil").") + } + + dependencies.standardUserDefaults[.deviceToken] = nil + }, + receiveCompletion: { result in + switch result { + case .finished: break + case .failure: SNLog("Couldn't unsubscribe for push notifications.") + } + } + ) + .map { _ in () } + .eraseToAnyPublisher() + } + .eraseToAnyPublisher() + } + + // MARK: - Legacy Notifications + + // FIXME: Remove this once legacy notifications and legacy groups are deprecated + public static func legacyNotify( + recipient: String, + with message: String, + maxRetryCount: Int? = nil, + using dependencies: Dependencies = Dependencies() + ) -> AnyPublisher { + return PushNotificationAPI + .send( + request: PushNotificationAPIRequest( + endpoint: .legacyNotify, + body: LegacyNotifyRequest( + data: message, + sendTo: recipient + ) + ), + using: dependencies + ) + .decoded(as: LegacyPushServerResponse.self, using: dependencies) + .retry(maxRetryCount ?? PushNotificationAPI.maxRetryCount, using: dependencies) .handleEvents( + receiveOutput: { _, response in + guard response.code != 0 else { + return SNLog("Couldn't send push notification due to error: \(response.message ?? "nil").") + } + }, receiveCompletion: { result in switch result { case .finished: break - case .failure: - SNLog("Couldn't subscribe/unsubscribe for closed group: \(closedGroupPublicKey).") + case .failure: SNLog("Couldn't send push notification.") } } ) + .map { _ in () } .eraseToAnyPublisher() } - // MARK: - Notify + // MARK: - Legacy Groups - public static func notify( - recipient: String, - with message: String, - maxRetryCount: Int? = nil + // FIXME: Remove this once legacy groups are deprecated + public static func subscribeToLegacyGroups( + forced: Bool = false, + token: String? = nil, + currentUserPublicKey: String, + legacyGroupIds: Set, + using dependencies: Dependencies = Dependencies() ) -> AnyPublisher { - let requestBody: NotifyRequestBody = NotifyRequestBody(data: message, sendTo: recipient) + let isUsingFullAPNs = dependencies.standardUserDefaults[.isUsingFullAPNs] - guard let body: Data = try? JSONEncoder().encode(requestBody) else { + // Only continue if PNs are enabled and we have a device token + guard + (forced || isUsingFullAPNs), + let deviceToken: String = (token ?? dependencies.standardUserDefaults[.deviceToken]) + else { + return Just(()) + .setFailureType(to: Error.self) + .eraseToAnyPublisher() + } + + return PushNotificationAPI + .send( + request: PushNotificationAPIRequest( + endpoint: .legacyGroupsOnlySubscribe, + body: LegacyGroupOnlyRequest( + token: deviceToken, + pubKey: currentUserPublicKey, + device: "ios", + legacyGroupPublicKeys: legacyGroupIds + ) + ), + using: dependencies + ) + .decoded(as: LegacyPushServerResponse.self, using: dependencies) + .retry(maxRetryCount, using: dependencies) + .handleEvents( + receiveOutput: { _, response in + guard response.code != 0 else { + return SNLog("Couldn't subscribe for legacy groups due to error: \(response.message ?? "nil").") + } + }, + receiveCompletion: { result in + switch result { + case .finished: break + case .failure: SNLog("Couldn't subscribe for legacy groups.") + } + } + ) + .map { _ in () } + .eraseToAnyPublisher() + } + + // FIXME: Remove this once legacy groups are deprecated + public static func unsubscribeFromLegacyGroup( + legacyGroupId: String, + currentUserPublicKey: String, + using dependencies: Dependencies = Dependencies() + ) -> AnyPublisher { + return PushNotificationAPI + .send( + request: PushNotificationAPIRequest( + endpoint: .legacyGroupUnsubscribe, + body: LegacyGroupRequest( + pubKey: currentUserPublicKey, + closedGroupPublicKey: legacyGroupId + ) + ), + using: dependencies + ) + .decoded(as: LegacyPushServerResponse.self, using: dependencies) + .retry(maxRetryCount, using: dependencies) + .handleEvents( + receiveOutput: { _, response in + guard response.code != 0 else { + return SNLog("Couldn't unsubscribe for legacy group: \(legacyGroupId) due to error: \(response.message ?? "nil").") + } + }, + receiveCompletion: { result in + switch result { + case .finished: break + case .failure: SNLog("Couldn't unsubscribe for legacy group: \(legacyGroupId).") + } + } + ) + .map { _ in () } + .eraseToAnyPublisher() + } + + // MARK: - Notification Handling + + public static func processNotification( + notificationContent: UNNotificationContent, + dependencies: Dependencies = Dependencies() + ) -> (envelope: SNProtoEnvelope?, result: ProcessResult) { + // Make sure the notification is from the updated push server + guard notificationContent.userInfo["spns"] != nil else { + guard + let base64EncodedData: String = notificationContent.userInfo["ENCRYPTED_DATA"] as? String, + let data: Data = Data(base64Encoded: base64EncodedData), + let envelope: SNProtoEnvelope = try? MessageWrapper.unwrap(data: data) + else { return (nil, .legacyFailure) } + + // We only support legacy notifications for legacy group conversations + guard envelope.type == .closedGroupMessage else { return (envelope, .legacyForceSilent) } + + return (envelope, .legacySuccess) + } + + guard + let base64EncodedEncString: String = notificationContent.userInfo["enc_payload"] as? String, + let encData: Data = Data(base64Encoded: base64EncodedEncString), + let notificationsEncryptionKey: Data = try? getOrGenerateEncryptionKey(using: dependencies), + encData.count > dependencies.crypto.size(.aeadXChaCha20NonceBytes) + else { return (nil, .failure) } + + let nonce: Data = encData[0.. = try? Bencode.decodeResponse(from: decryptedData) else { + return (nil, .failure) + } + + // If the metadata says that the message was too large then we should show the generic + // notification (this is a valid case) + guard !notification.info.dataTooLong else { return (nil, .success) } + + // Check that the body we were given is valid + guard + let notificationData: Data = notification.data, + notification.info.dataLength == notificationData.count, + let envelope = try? MessageWrapper.unwrap(data: notificationData) + else { return (nil, .failure) } + + // Success, we have the notification content + return (envelope, .success) + } + + // MARK: - Security + + @discardableResult private static func getOrGenerateEncryptionKey(using dependencies: Dependencies) throws -> Data { + do { + var encryptionKey: Data = try SSKDefaultKeychainStorage.shared.data( + forService: keychainService, + key: encryptionKeyKey + ) + defer { encryptionKey.resetBytes(in: 0..( + request: PushNotificationAPIRequest, + using dependencies: Dependencies + ) -> AnyPublisher<(ResponseInfoType, Data?), Error> { + guard + let url: URL = URL(string: "\(request.endpoint.server)/\(request.endpoint.rawValue)"), + let payload: Data = try? JSONEncoder().encode(request.body) + else { return Fail(error: HTTPError.invalidJSON) .eraseToAnyPublisher() } - let url = URL(string: "\(server)/notify")! - var request: URLRequest = URLRequest(url: url) - request.httpMethod = "POST" - request.allHTTPHeaderFields = [ HTTPHeader.contentType: "application/json" ] - request.httpBody = body + guard Features.useOnionRequests else { + return HTTP + .execute( + .post, + "\(request.endpoint.server)/\(request.endpoint.rawValue)", + body: payload + ) + .map { response in (HTTP.ResponseInfo(code: -1, headers: [:]), response) } + .eraseToAnyPublisher() + } - return OnionRequestAPI - .sendOnionRequest(request, to: server, with: serverPublicKey) - .map { _, data -> Void in - guard let response: PushServerResponse = try? data?.decoded(as: PushServerResponse.self) else { - return SNLog("Couldn't send push notification.") - } - guard response.code != 0 else { - return SNLog("Couldn't send push notification due to error: \(response.message ?? "nil").") - } - - return () - } - .retry(maxRetryCount ?? PushNotificationAPI.maxRetryCount) + var urlRequest: URLRequest = URLRequest(url: url) + urlRequest.httpMethod = "POST" + urlRequest.allHTTPHeaderFields = [ HTTPHeader.contentType: "application/json" ] + urlRequest.httpBody = payload + + return dependencies.network + .send( + .onionRequest( + urlRequest, + to: request.endpoint.server, + with: request.endpoint.serverPublicKey + ) + ) .eraseToAnyPublisher() } } diff --git a/SessionMessagingKit/Sending & Receiving/Notifications/Types/ProcessResult.swift b/SessionMessagingKit/Sending & Receiving/Notifications/Types/ProcessResult.swift new file mode 100644 index 000000000..1c72b1629 --- /dev/null +++ b/SessionMessagingKit/Sending & Receiving/Notifications/Types/ProcessResult.swift @@ -0,0 +1,13 @@ +// Copyright © 2023 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +public extension PushNotificationAPI { + enum ProcessResult { + case success + case failure + case legacySuccess + case legacyFailure + case legacyForceSilent + } +} diff --git a/SessionMessagingKit/Sending & Receiving/Notifications/Types/PushNotificationAPIEndpoint.swift b/SessionMessagingKit/Sending & Receiving/Notifications/Types/PushNotificationAPIEndpoint.swift new file mode 100644 index 000000000..072abcc60 --- /dev/null +++ b/SessionMessagingKit/Sending & Receiving/Notifications/Types/PushNotificationAPIEndpoint.swift @@ -0,0 +1,41 @@ +// Copyright © 2023 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +public extension PushNotificationAPI { + enum Endpoint: String { + case subscribe = "subscribe" + case unsubscribe = "unsubscribe" + + // MARK: - Legacy Endpoints + + case legacyNotify = "notify" + case legacyRegister = "register" + case legacyUnregister = "unregister" + case legacyGroupsOnlySubscribe = "register_legacy_groups_only" + case legacyGroupSubscribe = "subscribe_closed_group" + case legacyGroupUnsubscribe = "unsubscribe_closed_group" + + // MARK: - Convenience + + var server: String { + switch self { + case .legacyNotify, .legacyRegister, .legacyUnregister, + .legacyGroupsOnlySubscribe, .legacyGroupSubscribe, .legacyGroupUnsubscribe: + return PushNotificationAPI.legacyServer + + default: return PushNotificationAPI.server + } + } + + var serverPublicKey: String { + switch self { + case .legacyNotify, .legacyRegister, .legacyUnregister, + .legacyGroupsOnlySubscribe, .legacyGroupSubscribe, .legacyGroupUnsubscribe: + return PushNotificationAPI.legacyServerPublicKey + + default: return PushNotificationAPI.serverPublicKey + } + } + } +} diff --git a/SessionMessagingKit/Sending & Receiving/Notifications/Types/Service.swift b/SessionMessagingKit/Sending & Receiving/Notifications/Types/Service.swift new file mode 100644 index 000000000..b9aeb904b --- /dev/null +++ b/SessionMessagingKit/Sending & Receiving/Notifications/Types/Service.swift @@ -0,0 +1,10 @@ +// Copyright © 2023 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +extension PushNotificationAPI { + enum Service: String, Codable { + case apns + case sandbox = "apns-sandbox" // Use for push notifications in Testnet + } +} diff --git a/SessionMessagingKit/Sending & Receiving/Pollers/ClosedGroupPoller.swift b/SessionMessagingKit/Sending & Receiving/Pollers/ClosedGroupPoller.swift index 35174c7fa..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) @@ -77,12 +77,8 @@ 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..bf69dd878 100644 --- a/SessionMessagingKit/Sending & Receiving/Pollers/CurrentUserPoller.swift +++ b/SessionMessagingKit/Sending & Receiving/Pollers/CurrentUserPoller.swift @@ -14,12 +14,7 @@ public final class CurrentUserPoller: Poller { // MARK: - Settings - override var namespaces: [SnodeAPI.Namespace] { - // FIXME: Remove this once `useSharedUtilForUserConfig` is permanent - guard SessionUtil.userConfigsEnabled else { return [.default] } - - return CurrentUserPoller.namespaces - } + override var namespaces: [SnodeAPI.Namespace] { CurrentUserPoller.namespaces } /// After polling a given snode this many times we always switch to a new one. /// @@ -33,13 +28,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 +48,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 +60,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 60ae24320..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 { @@ -333,8 +330,13 @@ public class Poller { // If we are force-polling then add to the JobRunner so they are // persistent and will retry on the next app run if they fail but // don't let them auto-start - let updatedJob: Job? = JobRunner - .add(db, job: jobToRun, canStartJob: !calledFromBackgroundPoller) + let updatedJob: Job? = dependencies.jobRunner + .add( + db, + job: jobToRun, + canStartJob: !calledFromBackgroundPoller, + using: dependencies + ) return updatedJob?.id } @@ -362,8 +364,13 @@ public class Poller { // If we are force-polling then add to the JobRunner so they are // persistent and will retry on the next app run if they fail but // don't let them auto-start - let updatedJob: Job? = JobRunner - .add(db, job: jobToRun, canStartJob: !calledFromBackgroundPoller) + let updatedJob: Job? = dependencies.jobRunner + .add( + db, + job: jobToRun, + canStartJob: !calledFromBackgroundPoller, + using: dependencies + ) // Create the dependency between the jobs if let updatedJobId: Int64 = updatedJob?.id { @@ -419,10 +426,11 @@ public class Poller { // Note: In the background we just want jobs to fail silently ConfigMessageReceiveJob.run( job, - queue: dependencies.receiveQueue, - success: { _, _ in resolver(Result.success(())) }, - failure: { _, _, _ in resolver(Result.success(())) }, - deferred: { _ in resolver(Result.success(())) } + queue: Threading.pollerQueue, + success: { _, _, _ in resolver(Result.success(())) }, + failure: { _, _, _, _ in resolver(Result.success(())) }, + deferred: { _, _ in resolver(Result.success(())) }, + using: dependencies ) } } @@ -439,10 +447,11 @@ public class Poller { // Note: In the background we just want jobs to fail silently MessageReceiveJob.run( job, - queue: dependencies.receiveQueue, - success: { _, _ in resolver(Result.success(())) }, - failure: { _, _, _ in resolver(Result.success(())) }, - deferred: { _ in resolver(Result.success(())) } + queue: Threading.pollerQueue, + success: { _, _, _ in resolver(Result.success(())) }, + failure: { _, _, _, _ 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+Contacts.swift b/SessionMessagingKit/SessionUtil/Config Handling/SessionUtil+Contacts.swift index 019b19829..745373060 100644 --- a/SessionMessagingKit/SessionUtil/Config Handling/SessionUtil+Contacts.swift +++ b/SessionMessagingKit/SessionUtil/Config Handling/SessionUtil+Contacts.swift @@ -138,46 +138,37 @@ internal extension SessionUtil { let threadExists: Bool = (threadInfo != nil) let updatedShouldBeVisible: Bool = SessionUtil.shouldBeVisible(priority: data.priority) - switch (updatedShouldBeVisible, threadExists) { - case (false, true): - SessionUtil.kickFromConversationUIIfNeeded(removedThreadIds: [sessionId]) - - try SessionThread - .deleteOrLeave( - db, - threadId: sessionId, - threadVariant: .contact, - groupLeaveType: .forced, - calledFromConfigHandling: true - ) - - case (true, false): - try SessionThread( - id: sessionId, - variant: .contact, - creationDateTimestamp: data.created, - shouldBeVisible: true, - pinnedPriority: data.priority - ).save(db) - - case (true, true): - let changes: [ConfigColumnAssignment] = [ - (threadInfo?.shouldBeVisible == updatedShouldBeVisible ? nil : - SessionThread.Columns.shouldBeVisible.set(to: updatedShouldBeVisible) - ), - (threadInfo?.pinnedPriority == data.priority ? nil : - SessionThread.Columns.pinnedPriority.set(to: data.priority) - ) - ].compactMap { $0 } - - try SessionThread - .filter(id: sessionId) - .updateAll( // Handling a config update so don't use `updateAllAndConfig` - db, - changes - ) - - case (false, false): break + /// If we are hiding the conversation then kick the user from it if it's currently open + if !updatedShouldBeVisible { + SessionUtil.kickFromConversationUIIfNeeded(removedThreadIds: [sessionId]) + } + + /// Create the thread if it doesn't exist, otherwise just update it's state + if !threadExists { + try SessionThread( + id: sessionId, + variant: .contact, + creationDateTimestamp: data.created, + shouldBeVisible: updatedShouldBeVisible, + pinnedPriority: data.priority + ).save(db) + } + else { + let changes: [ConfigColumnAssignment] = [ + (threadInfo?.shouldBeVisible == updatedShouldBeVisible ? nil : + SessionThread.Columns.shouldBeVisible.set(to: updatedShouldBeVisible) + ), + (threadInfo?.pinnedPriority == data.priority ? nil : + SessionThread.Columns.pinnedPriority.set(to: data.priority) + ) + ].compactMap { $0 } + + try SessionThread + .filter(id: sessionId) + .updateAll( // Handling a config update so don't use `updateAllAndConfig` + db, + changes + ) } } @@ -573,7 +564,8 @@ private extension SessionUtil { count: ProfileManager.avatarAES256KeyByteLength ) ), - lastProfilePictureUpdate: (TimeInterval(latestConfigSentTimestampMs) / 1000) + lastProfilePictureUpdate: (TimeInterval(latestConfigSentTimestampMs) / 1000), + lastBlocksCommunityMessageRequests: 0 ) result[contactId] = ContactData( diff --git a/SessionMessagingKit/SessionUtil/Config Handling/SessionUtil+Shared.swift b/SessionMessagingKit/SessionUtil/Config Handling/SessionUtil+Shared.swift index 909ea9ce7..4a9c8d286 100644 --- a/SessionMessagingKit/SessionUtil/Config Handling/SessionUtil+Shared.swift +++ b/SessionMessagingKit/SessionUtil/Config Handling/SessionUtil+Shared.swift @@ -50,9 +50,6 @@ internal extension SessionUtil { publicKey: String, change: (UnsafeMutablePointer?) throws -> () ) throws { - // FIXME: Remove this once `useSharedUtilForUserConfig` is permanent - guard SessionUtil.userConfigsEnabled(db, ignoreRequirementsForRunningMigrations: true) else { return } - // Since we are doing direct memory manipulation we are using an `Atomic` // type which has blocking access in it's `mutate` closure let needsPush: Bool @@ -213,6 +210,46 @@ internal extension SessionUtil { return updated } + static func hasSetting(_ db: Database, forKey key: String) throws -> Bool { + let userPublicKey: String = getUserHexEncodedPublicKey(db) + + // Currently the only synced setting is 'checkForCommunityMessageRequests' + switch key { + case Setting.BoolKey.checkForCommunityMessageRequests.rawValue: + return try SessionUtil + .config(for: .userProfile, publicKey: userPublicKey) + .wrappedValue + .map { conf -> Bool in (try SessionUtil.rawBlindedMessageRequestValue(in: conf) >= 0) } + .defaulting(to: false) + + default: return false + } + } + + static func updatingSetting(_ db: Database, _ updated: Setting?) throws { + // Don't current support any nullable settings + guard let updatedSetting: Setting = updated else { return } + + let userPublicKey: String = getUserHexEncodedPublicKey(db) + + // Currently the only synced setting is 'checkForCommunityMessageRequests' + switch updatedSetting.id { + case Setting.BoolKey.checkForCommunityMessageRequests.rawValue: + try SessionUtil.performAndPushChange( + db, + for: .userProfile, + publicKey: userPublicKey + ) { conf in + try SessionUtil.updateSettings( + checkForCommunityMessageRequests: updatedSetting.unsafeValue(as: Bool.self), + in: conf + ) + } + + default: break + } + } + static func kickFromConversationUIIfNeeded(removedThreadIds: [String]) { guard !removedThreadIds.isEmpty else { return } @@ -307,9 +344,6 @@ internal extension SessionUtil { targetConfig: ConfigDump.Variant, changeTimestampMs: Int64 ) -> Bool { - // FIXME: Remove this once `useSharedUtilForUserConfig` is permanent - guard SessionUtil.userConfigsEnabled(db) else { return true } - let targetPublicKey: String = { switch targetConfig { default: return getUserHexEncodedPublicKey(db) @@ -349,10 +383,7 @@ public extension SessionUtil { threadVariant: SessionThread.Variant, visibleOnly: Bool ) -> Bool { - // FIXME: Remove this once `useSharedUtilForUserConfig` is permanent - guard SessionUtil.userConfigsEnabled(db) else { return true } - - let userPublicKey: String = getUserHexEncodedPublicKey() + let userPublicKey: String = getUserHexEncodedPublicKey(db) let configVariant: ConfigDump.Variant = { switch threadVariant { case .contact: return (threadId == userPublicKey ? .userProfile : .contacts) 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..f4017cec0 100644 --- a/SessionMessagingKit/SessionUtil/Config Handling/SessionUtil+UserProfile.swift +++ b/SessionMessagingKit/SessionUtil/Config Handling/SessionUtil+UserProfile.swift @@ -12,13 +12,18 @@ internal extension SessionUtil { Profile.Columns.profileEncryptionKey ] + static let syncedSettings: [String] = [ + Setting.BoolKey.checkForCommunityMessageRequests.rawValue + ] + // MARK: - Incoming Changes static func handleUserProfileUpdate( _ 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 +56,8 @@ internal extension SessionUtil { ) }(), sentTimestamp: (TimeInterval(latestConfigSentTimestampMs) / 1000), - calledFromConfigHandling: true + calledFromConfigHandling: true, + using: dependencies ) // Update the 'Note to Self' visibility and priority @@ -107,12 +113,23 @@ internal extension SessionUtil { db, threadId: userPublicKey, threadVariant: .contact, - groupLeaveType: .forced, + groupLeaveType: .silent, calledFromConfigHandling: true ) } } + // Update settings if needed + let updatedAllowBlindedMessageRequests: Int32 = user_profile_get_blinded_msgreqs(conf) + let updatedAllowBlindedMessageRequestsBoolValue: Bool = (updatedAllowBlindedMessageRequests >= 1) + + if + updatedAllowBlindedMessageRequests >= 0 && + updatedAllowBlindedMessageRequestsBoolValue != db[.checkForCommunityMessageRequests] + { + db[.checkForCommunityMessageRequests] = updatedAllowBlindedMessageRequestsBoolValue + } + // Create a contact for the current user if needed (also force-approve the current user // in case the account got into a weird state or restored directly from a migration) let userContact: Contact = Contact.fetchOrCreate(db, id: userPublicKey) @@ -157,4 +174,25 @@ internal extension SessionUtil { user_profile_set_nts_priority(conf, priority) } + + static func updateSettings( + checkForCommunityMessageRequests: Bool? = nil, + in conf: UnsafeMutablePointer? + ) throws { + guard conf != nil else { throw SessionUtilError.nilConfigObject } + + if let blindedMessageRequests: Bool = checkForCommunityMessageRequests { + user_profile_set_blinded_msgreqs(conf, (blindedMessageRequests ? 1 : 0)) + } + } +} + +// MARK: - Direct Values + +extension SessionUtil { + static func rawBlindedMessageRequestValue(in conf: UnsafeMutablePointer?) throws -> Int32 { + guard conf != nil else { throw SessionUtilError.nilConfigObject } + + return user_profile_get_blinded_msgreqs(conf) + } } diff --git a/SessionMessagingKit/SessionUtil/Database/QueryInterfaceRequest+Utilities.swift b/SessionMessagingKit/SessionUtil/Database/QueryInterfaceRequest+Utilities.swift index a8be039fe..8e3482a31 100644 --- a/SessionMessagingKit/SessionUtil/Database/QueryInterfaceRequest+Utilities.swift +++ b/SessionMessagingKit/SessionUtil/Database/QueryInterfaceRequest+Utilities.swift @@ -52,14 +52,16 @@ public extension QueryInterfaceRequest where RowDecoder: FetchableRecord & Table @discardableResult func updateAllAndConfig( _ db: Database, + calledFromConfig: Bool = false, _ assignments: ConfigColumnAssignment... ) throws -> Int { - return try updateAllAndConfig(db, assignments) + return try updateAllAndConfig(db, calledFromConfig: calledFromConfig, assignments) } @discardableResult func updateAllAndConfig( _ db: Database, + calledFromConfig: Bool = false, _ assignments: [ConfigColumnAssignment] ) throws -> Int { let targetAssignments: [ColumnAssignment] = assignments.map { $0.assignment } @@ -69,7 +71,7 @@ public extension QueryInterfaceRequest where RowDecoder: FetchableRecord & Table return try self.updateAll(db, targetAssignments) } - return try self.updateAndFetchAllAndUpdateConfig(db, assignments).count + return try self.updateAndFetchAllAndUpdateConfig(db, calledFromConfig: calledFromConfig, assignments).count } // MARK: -- updateAndFetchAll @@ -77,14 +79,16 @@ public extension QueryInterfaceRequest where RowDecoder: FetchableRecord & Table @discardableResult func updateAndFetchAllAndUpdateConfig( _ db: Database, + calledFromConfig: Bool = false, _ assignments: ConfigColumnAssignment... ) throws -> [RowDecoder] { - return try updateAndFetchAllAndUpdateConfig(db, assignments) + return try updateAndFetchAllAndUpdateConfig(db, calledFromConfig: calledFromConfig, assignments) } @discardableResult func updateAndFetchAllAndUpdateConfig( _ db: Database, + calledFromConfig: Bool = false, _ assignments: [ConfigColumnAssignment] ) throws -> [RowDecoder] { // First perform the actual updates @@ -92,8 +96,7 @@ public extension QueryInterfaceRequest where RowDecoder: FetchableRecord & Table // Then check if any of the changes could affect the config guard - // FIXME: Remove this once `useSharedUtilForUserConfig` is permanent - SessionUtil.userConfigsEnabled(db, ignoreRequirementsForRunningMigrations: true), + !calledFromConfig && SessionUtil.assignmentsRequireConfigUpdate(assignments) else { return updatedData } diff --git a/SessionMessagingKit/SessionUtil/Database/Setting+Utilities.swift b/SessionMessagingKit/SessionUtil/Database/Setting+Utilities.swift new file mode 100644 index 000000000..2e178c5b1 --- /dev/null +++ b/SessionMessagingKit/SessionUtil/Database/Setting+Utilities.swift @@ -0,0 +1,58 @@ +// Copyright © 2023 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import GRDB +import SessionUtilitiesKit + +public extension Database { + func setAndUpdateConfig(_ key: Setting.BoolKey, to newValue: Bool) throws { + try updateConfigIfNeeded(self, key: key.rawValue, updatedSetting: self.setting(key: key, to: newValue)) + } + + func setAndUpdateConfig(_ key: Setting.DoubleKey, to newValue: Double?) throws { + try updateConfigIfNeeded(self, key: key.rawValue, updatedSetting: self.setting(key: key, to: newValue)) + } + + func setAndUpdateConfig(_ key: Setting.IntKey, to newValue: Int?) throws { + try updateConfigIfNeeded(self, key: key.rawValue, updatedSetting: self.setting(key: key, to: newValue)) + } + + func setAndUpdateConfig(_ key: Setting.StringKey, to newValue: String?) throws { + try updateConfigIfNeeded(self, key: key.rawValue, updatedSetting: self.setting(key: key, to: newValue)) + } + + func setAndUpdateConfig(_ key: Setting.EnumKey, to newValue: T?) throws { + try updateConfigIfNeeded(self, key: key.rawValue, updatedSetting: self.setting(key: key, to: newValue)) + } + + func setAndUpdateConfig(_ key: Setting.EnumKey, to newValue: T?) throws { + try updateConfigIfNeeded(self, key: key.rawValue, updatedSetting: self.setting(key: key, to: newValue)) + } + + /// Value will be stored as a timestamp in seconds since 1970 + func setAndUpdateConfig(_ key: Setting.DateKey, to newValue: Date?) throws { + try updateConfigIfNeeded(self, key: key.rawValue, updatedSetting: self.setting(key: key, to: newValue)) + } + + private func updateConfigIfNeeded( + _ db: Database, + key: String, + updatedSetting: Setting? + ) throws { + // Before we do anything custom make sure the setting should trigger a change + guard SessionUtil.syncedSettings.contains(key) else { return } + + defer { + // If we changed a column that requires a config update then we may as well automatically + // enqueue a new config sync job once the transaction completes (but only enqueue it once + // per transaction - doing it more than once is pointless) + let userPublicKey: String = getUserHexEncodedPublicKey(db) + + db.afterNextTransactionNestedOnce(dedupeId: SessionUtil.syncDedupeId(userPublicKey)) { db in + ConfigurationSyncJob.enqueue(db, publicKey: userPublicKey) + } + } + + try SessionUtil.updatingSetting(db, updatedSetting) + } +} diff --git a/SessionMessagingKit/SessionUtil/SessionUtil.swift b/SessionMessagingKit/SessionUtil/SessionUtil.swift index d933238a5..363951df4 100644 --- a/SessionMessagingKit/SessionUtil/SessionUtil.swift +++ b/SessionMessagingKit/SessionUtil/SessionUtil.swift @@ -6,25 +6,6 @@ import SessionSnodeKit import SessionUtil import SessionUtilitiesKit -// MARK: - Features - -public extension Features { - static func useSharedUtilForUserConfig(_ db: Database? = nil) -> Bool { - guard Date().timeIntervalSince1970 < 1690761600 else { return true } - guard !SessionUtil.hasCheckedMigrationsCompleted.wrappedValue else { - return SessionUtil.userConfigsEnabledIgnoringFeatureFlag - } - - if let db: Database = db { - return SessionUtil.refreshingUserConfigsEnabled(db) - } - - return Storage.shared - .read { db in SessionUtil.refreshingUserConfigsEnabled(db) } - .defaulting(to: false) - } -} - // MARK: - SessionUtil public enum SessionUtil { @@ -70,10 +51,7 @@ public enum SessionUtil { /// Returns `true` if there is a config which needs to be pushed, but returns `false` if the configs are all up to date or haven't been /// loaded yet (eg. fresh install) public static var needsSync: Bool { - // FIXME: Remove this once `useSharedUtilForUserConfig` is permanent - guard SessionUtil.userConfigsEnabled else { return false } - - return configStore + configStore .wrappedValue .contains { _, atomicConf in guard atomicConf.wrappedValue != nil else { return false } @@ -84,56 +62,6 @@ public enum SessionUtil { public static var libSessionVersion: String { String(cString: LIBSESSION_UTIL_VERSION_STR) } - fileprivate static let hasCheckedMigrationsCompleted: Atomic = Atomic(false) - private static let requiredMigrationsCompleted: Atomic = Atomic(false) - private static let requiredMigrationIdentifiers: Set = [ - TargetMigrations.Identifier.messagingKit.key(with: _013_SessionUtilChanges.self), - TargetMigrations.Identifier.messagingKit.key(with: _014_GenerateInitialUserConfigDumps.self) - ] - - public static var userConfigsEnabled: Bool { - return userConfigsEnabled(nil) - } - - public static func userConfigsEnabled(_ db: Database?) -> Bool { - Features.useSharedUtilForUserConfig(db) && - SessionUtil.userConfigsEnabledIgnoringFeatureFlag - } - - public static var userConfigsEnabledIgnoringFeatureFlag: Bool { - SessionUtil.requiredMigrationsCompleted.wrappedValue - } - - internal static func userConfigsEnabled( - _ db: Database, - ignoreRequirementsForRunningMigrations: Bool - ) -> Bool { - // First check if we are enabled regardless of what we want to ignore - guard - Features.useSharedUtilForUserConfig(db), - !SessionUtil.requiredMigrationsCompleted.wrappedValue, - !SessionUtil.refreshingUserConfigsEnabled(db), - ignoreRequirementsForRunningMigrations, - let currentlyRunningMigration: (identifier: TargetMigrations.Identifier, migration: Migration.Type) = Storage.shared.currentlyRunningMigration - else { return true } - - let nonIgnoredMigrationIdentifiers: Set = SessionUtil.requiredMigrationIdentifiers - .removing(currentlyRunningMigration.identifier.key(with: currentlyRunningMigration.migration)) - - return Storage.appliedMigrationIdentifiers(db) - .isSuperset(of: nonIgnoredMigrationIdentifiers) - } - - @discardableResult public static func refreshingUserConfigsEnabled(_ db: Database) -> Bool { - let result: Bool = Storage.appliedMigrationIdentifiers(db) - .isSuperset(of: SessionUtil.requiredMigrationIdentifiers) - - requiredMigrationsCompleted.mutate { $0 = result } - hasCheckedMigrationsCompleted.mutate { $0 = true } - - return result - } - internal static func lastError(_ conf: UnsafeMutablePointer?) -> String { return (conf?.pointee.last_error.map { String(cString: $0) } ?? "Unknown") } @@ -141,9 +69,6 @@ public enum SessionUtil { // MARK: - Loading public static func clearMemoryState() { - // Ensure we have a loaded state before we continue - guard !SessionUtil.configStore.wrappedValue.isEmpty else { return } - SessionUtil.configStore.mutate { confStore in confStore.removeAll() } @@ -169,9 +94,6 @@ public enum SessionUtil { return } - // FIXME: Remove this once `useSharedUtilForUserConfig` is permanent - guard SessionUtil.userConfigsEnabled(db, ignoreRequirementsForRunningMigrations: true) else { return } - // Retrieve the existing dumps from the database let existingDumps: Set = ((try? ConfigDump.fetchSet(db)) ?? []) let existingDumpVariants: Set = existingDumps @@ -395,9 +317,6 @@ public enum SessionUtil { } public static func configHashes(for publicKey: String) -> [String] { - // FIXME: Remove this once `useSharedUtilForUserConfig` is permanent - guard SessionUtil.userConfigsEnabled else { return [] } - return Storage.shared .read { db -> Set in guard Identity.userExists(db) else { return [] } @@ -437,12 +356,11 @@ public enum SessionUtil { messages: [SharedConfigMessage], publicKey: String ) throws { - // FIXME: Remove this once `useSharedUtilForUserConfig` is permanent - guard SessionUtil.userConfigsEnabled(db) else { return } guard !messages.isEmpty else { return } guard !publicKey.isEmpty else { throw MessageReceiverError.noThread } let groupedMessages: [ConfigDump.Variant: [SharedConfigMessage]] = messages + .sorted { lhs, rhs in lhs.seqNo < rhs.seqNo } .grouped(by: \.kind.configDumpVariant) let needsPush: Bool = try groupedMessages diff --git a/SessionMessagingKit/Shared Models/MentionInfo.swift b/SessionMessagingKit/Shared Models/MentionInfo.swift index 984ddf63d..f4bb049cb 100644 --- a/SessionMessagingKit/Shared Models/MentionInfo.swift +++ b/SessionMessagingKit/Shared Models/MentionInfo.swift @@ -3,12 +3,14 @@ import GRDB import SessionUtilitiesKit -public struct MentionInfo: FetchableRecord, Decodable { - fileprivate static let threadVariantKey: SQL = SQL(stringLiteral: CodingKeys.threadVariant.stringValue) - fileprivate static let openGroupServerKey: SQL = SQL(stringLiteral: CodingKeys.openGroupServer.stringValue) - fileprivate static let openGroupRoomTokenKey: SQL = SQL(stringLiteral: CodingKeys.openGroupRoomToken.stringValue) - - fileprivate static let profileString: String = CodingKeys.profile.stringValue +public struct MentionInfo: FetchableRecord, Decodable, ColumnExpressible { + public typealias Columns = CodingKeys + public enum CodingKeys: String, CodingKey, ColumnExpression, CaseIterable { + case profile + case threadVariant + case openGroupServer + case openGroupRoomToken + } public let profile: Profile public let threadVariant: SessionThread.Variant @@ -79,7 +81,7 @@ public extension MentionInfo { return SQLRequest(""" SELECT \(Profile.self).*, - \(SQL("\(threadVariant) AS \(MentionInfo.threadVariantKey)")) + \(SQL("\(threadVariant) AS \(MentionInfo.Columns.threadVariant)")) \(targetJoin) \(targetWhere) AND \(SQL("\(profile[.id]) = \(threadId)")) @@ -89,7 +91,7 @@ public extension MentionInfo { return SQLRequest(""" SELECT \(Profile.self).*, - \(SQL("\(threadVariant) AS \(MentionInfo.threadVariantKey)")) + \(SQL("\(threadVariant) AS \(MentionInfo.Columns.threadVariant)")) \(targetJoin) JOIN \(GroupMember.self) ON ( @@ -107,9 +109,9 @@ public extension MentionInfo { SELECT \(Profile.self).*, MAX(\(interaction[.timestampMs])), -- Want the newest interaction (for sorting) - \(SQL("\(threadVariant) AS \(MentionInfo.threadVariantKey)")), - \(openGroup[.server]) AS \(MentionInfo.openGroupServerKey), - \(openGroup[.roomToken]) AS \(MentionInfo.openGroupRoomTokenKey) + \(SQL("\(threadVariant) AS \(MentionInfo.Columns.threadVariant)")), + \(openGroup[.server]) AS \(MentionInfo.Columns.openGroupServer), + \(openGroup[.roomToken]) AS \(MentionInfo.Columns.openGroupRoomToken) \(targetJoin) JOIN \(Interaction.self) ON ( @@ -130,8 +132,8 @@ public extension MentionInfo { Profile.numberOfSelectedColumns(db) ]) - return ScopeAdapter([ - MentionInfo.profileString: adapters[0] + return ScopeAdapter.with(MentionInfo.self, [ + .profile: adapters[0] ]) } } diff --git a/SessionMessagingKit/Shared Models/MessageViewModel.swift b/SessionMessagingKit/Shared Models/MessageViewModel.swift index 2a796cbd1..3eaada1b1 100644 --- a/SessionMessagingKit/Shared Models/MessageViewModel.swift +++ b/SessionMessagingKit/Shared Models/MessageViewModel.swift @@ -11,42 +11,66 @@ fileprivate typealias AttachmentInteractionInfo = MessageViewModel.AttachmentInt fileprivate typealias ReactionInfo = MessageViewModel.ReactionInfo fileprivate typealias TypingIndicatorInfo = MessageViewModel.TypingIndicatorInfo -public struct MessageViewModel: FetchableRecordWithRowId, Decodable, Equatable, Hashable, Identifiable, Differentiable { - public static let threadIdKey: SQL = SQL(stringLiteral: CodingKeys.threadId.stringValue) - public static let threadVariantKey: SQL = SQL(stringLiteral: CodingKeys.threadVariant.stringValue) - public static let threadIsTrustedKey: SQL = SQL(stringLiteral: CodingKeys.threadIsTrusted.stringValue) - public static let threadHasDisappearingMessagesEnabledKey: SQL = SQL(stringLiteral: CodingKeys.threadHasDisappearingMessagesEnabled.stringValue) - public static let threadOpenGroupServerKey: SQL = SQL(stringLiteral: CodingKeys.threadOpenGroupServer.stringValue) - public static let threadOpenGroupPublicKeyKey: SQL = SQL(stringLiteral: CodingKeys.threadOpenGroupPublicKey.stringValue) - public static let threadContactNameInternalKey: SQL = SQL(stringLiteral: CodingKeys.threadContactNameInternal.stringValue) - public static let rowIdKey: SQL = SQL(stringLiteral: CodingKeys.rowId.stringValue) - public static let authorNameInternalKey: SQL = SQL(stringLiteral: CodingKeys.authorNameInternal.stringValue) - public static let stateKey: SQL = SQL(stringLiteral: CodingKeys.state.stringValue) - public static let hasAtLeastOneReadReceiptKey: SQL = SQL(stringLiteral: CodingKeys.hasAtLeastOneReadReceipt.stringValue) - public static let mostRecentFailureTextKey: SQL = SQL(stringLiteral: CodingKeys.mostRecentFailureText.stringValue) - public static let isTypingIndicatorKey: SQL = SQL(stringLiteral: CodingKeys.isTypingIndicator.stringValue) - public static let isSenderOpenGroupModeratorKey: SQL = SQL(stringLiteral: CodingKeys.isSenderOpenGroupModerator.stringValue) - public static let profileKey: SQL = SQL(stringLiteral: CodingKeys.profile.stringValue) - public static let quoteKey: SQL = SQL(stringLiteral: CodingKeys.quote.stringValue) - public static let quoteAttachmentKey: SQL = SQL(stringLiteral: CodingKeys.quoteAttachment.stringValue) - public static let linkPreviewKey: SQL = SQL(stringLiteral: CodingKeys.linkPreview.stringValue) - public static let linkPreviewAttachmentKey: SQL = SQL(stringLiteral: CodingKeys.linkPreviewAttachment.stringValue) - public static let currentUserPublicKeyKey: SQL = SQL(stringLiteral: CodingKeys.currentUserPublicKey.stringValue) - public static let cellTypeKey: SQL = SQL(stringLiteral: CodingKeys.cellType.stringValue) - public static let authorNameKey: SQL = SQL(stringLiteral: CodingKeys.authorName.stringValue) - public static let canHaveProfileKey: SQL = SQL(stringLiteral: CodingKeys.canHaveProfile.stringValue) - public static let shouldShowProfileKey: SQL = SQL(stringLiteral: CodingKeys.shouldShowProfile.stringValue) - public static let shouldShowDateHeaderKey: SQL = SQL(stringLiteral: CodingKeys.shouldShowDateHeader.stringValue) - public static let positionInClusterKey: SQL = SQL(stringLiteral: CodingKeys.positionInCluster.stringValue) - public static let isOnlyMessageInClusterKey: SQL = SQL(stringLiteral: CodingKeys.isOnlyMessageInCluster.stringValue) - public static let isLastKey: SQL = SQL(stringLiteral: CodingKeys.isLast.stringValue) - public static let isLastOutgoingKey: SQL = SQL(stringLiteral: CodingKeys.isLastOutgoing.stringValue) - - public static let profileString: String = CodingKeys.profile.stringValue - public static let quoteString: String = CodingKeys.quote.stringValue - public static let quoteAttachmentString: String = CodingKeys.quoteAttachment.stringValue - public static let linkPreviewString: String = CodingKeys.linkPreview.stringValue - public static let linkPreviewAttachmentString: String = CodingKeys.linkPreviewAttachment.stringValue +public struct MessageViewModel: FetchableRecordWithRowId, Decodable, Equatable, Hashable, Identifiable, Differentiable, ColumnExpressible { + public typealias Columns = CodingKeys + public enum CodingKeys: String, CodingKey, ColumnExpression, CaseIterable { + case threadId + case threadVariant + case threadIsTrusted + case threadHasDisappearingMessagesEnabled + case threadOpenGroupServer + case threadOpenGroupPublicKey + case threadContactNameInternal + + // Interaction Info + + case rowId + case id + case openGroupServerMessageId + case variant + case timestampMs + case receivedAtTimestampMs + case authorId + case authorNameInternal + case body + case rawBody + case expiresStartedAtMs + case expiresInSeconds + + case state + case hasAtLeastOneReadReceipt + case mostRecentFailureText + case isSenderOpenGroupModerator + case isTypingIndicator + case profile + case quote + case quoteAttachment + case linkPreview + case linkPreviewAttachment + + case currentUserPublicKey + + // Post-Query Processing Data + + case attachments + case reactionInfo + case cellType + case authorName + case senderName + case canHaveProfile + case shouldShowProfile + case shouldShowDateHeader + case containsOnlyEmoji + case glyphCount + case previousVariant + case positionInCluster + case isOnlyMessageInCluster + case isLast + case isLastOutgoing + case currentUserBlinded15PublicKey + case currentUserBlinded25PublicKey + case optimisticMessageId + } public enum CellType: Int, Decodable, Equatable, Hashable, DatabaseValueConvertible { case textOnlyMessage @@ -462,13 +486,13 @@ public struct MessageViewModel: FetchableRecordWithRowId, Decodable, Equatable, // MARK: - AttachmentInteractionInfo public extension MessageViewModel { - struct AttachmentInteractionInfo: FetchableRecordWithRowId, Decodable, Identifiable, Equatable, Comparable { - public static let rowIdKey: SQL = SQL(stringLiteral: CodingKeys.rowId.stringValue) - public static let attachmentKey: SQL = SQL(stringLiteral: CodingKeys.attachment.stringValue) - public static let interactionAttachmentKey: SQL = SQL(stringLiteral: CodingKeys.interactionAttachment.stringValue) - - public static let attachmentString: String = CodingKeys.attachment.stringValue - public static let interactionAttachmentString: String = CodingKeys.interactionAttachment.stringValue + struct AttachmentInteractionInfo: FetchableRecordWithRowId, Decodable, Identifiable, Equatable, Comparable, ColumnExpressible { + public typealias Columns = CodingKeys + public enum CodingKeys: String, CodingKey, ColumnExpression, CaseIterable { + case rowId + case attachment + case interactionAttachment + } public let rowId: Int64 public let attachment: Attachment @@ -491,13 +515,13 @@ public extension MessageViewModel { // MARK: - ReactionInfo public extension MessageViewModel { - struct ReactionInfo: FetchableRecordWithRowId, Decodable, Identifiable, Equatable, Comparable, Hashable, Differentiable { - public static let rowIdKey: SQL = SQL(stringLiteral: CodingKeys.rowId.stringValue) - public static let reactionKey: SQL = SQL(stringLiteral: CodingKeys.reaction.stringValue) - public static let profileKey: SQL = SQL(stringLiteral: CodingKeys.profile.stringValue) - - public static let reactionString: String = CodingKeys.reaction.stringValue - public static let profileString: String = CodingKeys.profile.stringValue + struct ReactionInfo: FetchableRecordWithRowId, Decodable, Identifiable, Equatable, Comparable, Hashable, Differentiable, ColumnExpressible { + public typealias Columns = CodingKeys + public enum CodingKeys: String, CodingKey, ColumnExpression, CaseIterable { + case rowId + case reaction + case profile + } public let rowId: Int64 public let reaction: Reaction @@ -522,9 +546,12 @@ public extension MessageViewModel { // MARK: - TypingIndicatorInfo public extension MessageViewModel { - struct TypingIndicatorInfo: FetchableRecordWithRowId, Decodable, Identifiable, Equatable { - public static let rowIdKey: SQL = SQL(stringLiteral: CodingKeys.rowId.stringValue) - public static let threadIdKey: SQL = SQL(stringLiteral: CodingKeys.threadId.stringValue) + struct TypingIndicatorInfo: FetchableRecordWithRowId, Decodable, Identifiable, Equatable, ColumnExpressible { + public typealias Columns = CodingKeys + public enum CodingKeys: String, CodingKey, ColumnExpression, CaseIterable { + case rowId + case threadId + } public let rowId: Int64 public let threadId: String @@ -776,59 +803,48 @@ public extension MessageViewModel { let contact: TypedTableAlias = TypedTableAlias() let disappearingMessagesConfig: TypedTableAlias = TypedTableAlias() let profile: TypedTableAlias = TypedTableAlias() + let threadProfile: TypedTableAlias = TypedTableAlias(name: "threadProfile") let quote: TypedTableAlias = TypedTableAlias() + let quoteInteraction: TypedTableAlias = TypedTableAlias(name: "quoteInteraction") + let quoteInteractionAttachment: TypedTableAlias = TypedTableAlias( + name: "quoteInteractionAttachment" + ) + let quoteLinkPreview: TypedTableAlias = TypedTableAlias(name: "quoteLinkPreview") + let quoteAttachment: TypedTableAlias = TypedTableAlias(name: ViewModel.CodingKeys.quoteAttachment.stringValue) let linkPreview: TypedTableAlias = TypedTableAlias() - - let threadProfile: SQL = SQL(stringLiteral: "threadProfile") - let quoteInteraction: SQL = SQL(stringLiteral: "quoteInteraction") - let quoteInteractionAttachment: SQL = SQL(stringLiteral: "quoteInteractionAttachment") - let readReceipt: SQL = SQL(stringLiteral: "readReceipt") - let idColumn: SQL = SQL(stringLiteral: Interaction.Columns.id.name) - let interactionBodyColumn: SQL = SQL(stringLiteral: Interaction.Columns.body.name) - let profileIdColumn: SQL = SQL(stringLiteral: Profile.Columns.id.name) - let nicknameColumn: SQL = SQL(stringLiteral: Profile.Columns.nickname.name) - let nameColumn: SQL = SQL(stringLiteral: Profile.Columns.name.name) - let quoteBodyColumn: SQL = SQL(stringLiteral: Quote.Columns.body.name) - let quoteAttachmentIdColumn: SQL = SQL(stringLiteral: Quote.Columns.attachmentId.name) - let readReceiptInteractionIdColumn: SQL = SQL(stringLiteral: RecipientState.Columns.interactionId.name) - let readTimestampMsColumn: SQL = SQL(stringLiteral: RecipientState.Columns.readTimestampMs.name) - let timestampMsColumn: SQL = SQL(stringLiteral: Interaction.Columns.timestampMs.name) - let authorIdColumn: SQL = SQL(stringLiteral: Interaction.Columns.authorId.name) - let attachmentIdColumn: SQL = SQL(stringLiteral: Attachment.Columns.id.name) - let interactionAttachmentInteractionIdColumn: SQL = SQL(stringLiteral: InteractionAttachment.Columns.interactionId.name) - let interactionAttachmentAttachmentIdColumn: SQL = SQL(stringLiteral: InteractionAttachment.Columns.attachmentId.name) - let interactionAttachmentAlbumIndexColumn: SQL = SQL(stringLiteral: InteractionAttachment.Columns.albumIndex.name) + let linkPreviewAttachment: TypedTableAlias = TypedTableAlias(ViewModel.self, column: .linkPreviewAttachment) + let readReceipt: TypedTableAlias = TypedTableAlias(name: "readReceipt") let numColumnsBeforeLinkedRecords: Int = 22 let finalGroupSQL: SQL = (groupSQL ?? "") let request: SQLRequest = """ SELECT - \(thread[.id]) AS \(ViewModel.threadIdKey), - \(thread[.variant]) AS \(ViewModel.threadVariantKey), + \(thread[.id]) AS \(ViewModel.Columns.threadId), + \(thread[.variant]) AS \(ViewModel.Columns.threadVariant), -- Default to 'true' for non-contact threads - IFNULL(\(contact[.isTrusted]), true) AS \(ViewModel.threadIsTrustedKey), + IFNULL(\(contact[.isTrusted]), true) AS \(ViewModel.Columns.threadIsTrusted), -- Default to 'false' when no contact exists - IFNULL(\(disappearingMessagesConfig[.isEnabled]), false) AS \(ViewModel.threadHasDisappearingMessagesEnabledKey), - \(openGroup[.server]) AS \(ViewModel.threadOpenGroupServerKey), - \(openGroup[.publicKey]) AS \(ViewModel.threadOpenGroupPublicKeyKey), - IFNULL(\(threadProfile).\(nicknameColumn), \(threadProfile).\(nameColumn)) AS \(ViewModel.threadContactNameInternalKey), + IFNULL(\(disappearingMessagesConfig[.isEnabled]), false) AS \(ViewModel.Columns.threadHasDisappearingMessagesEnabled), + \(openGroup[.server]) AS \(ViewModel.Columns.threadOpenGroupServer), + \(openGroup[.publicKey]) AS \(ViewModel.Columns.threadOpenGroupPublicKey), + IFNULL(\(threadProfile[.nickname]), \(threadProfile[.name])) AS \(ViewModel.Columns.threadContactNameInternal), - \(interaction.alias[Column.rowID]) AS \(ViewModel.rowIdKey), + \(interaction[.rowId]) AS \(ViewModel.Columns.rowId), \(interaction[.id]), \(interaction[.openGroupServerMessageId]), \(interaction[.variant]), \(interaction[.timestampMs]), \(interaction[.receivedAtTimestampMs]), \(interaction[.authorId]), - IFNULL(\(profile[.nickname]), \(profile[.name])) AS \(ViewModel.authorNameInternalKey), + IFNULL(\(profile[.nickname]), \(profile[.name])) AS \(ViewModel.Columns.authorNameInternal), \(interaction[.body]), \(interaction[.expiresStartedAtMs]), \(interaction[.expiresInSeconds]), -- Default to 'sending' assuming non-processed interaction when null - IFNULL(MIN(\(recipientState[.state])), \(SQL("\(RecipientState.State.sending)"))) AS \(ViewModel.stateKey), - (\(readReceipt).\(readTimestampMsColumn) IS NOT NULL) AS \(ViewModel.hasAtLeastOneReadReceiptKey), - \(recipientState[.mostRecentFailureText]) AS \(ViewModel.mostRecentFailureTextKey), + IFNULL(MIN(\(recipientState[.state])), \(SQL("\(RecipientState.State.sending)"))) AS \(ViewModel.Columns.state), + (\(readReceipt[.readTimestampMs]) IS NOT NULL) AS \(ViewModel.Columns.hasAtLeastOneReadReceipt), + \(recipientState[.mostRecentFailureText]) AS \(ViewModel.Columns.mostRecentFailureText), EXISTS ( SELECT 1 @@ -839,46 +855,46 @@ public extension MessageViewModel { \(SQL("\(thread[.variant]) = \(SessionThread.Variant.community)")) AND \(SQL("\(groupMember[.role]) IN \([GroupMember.Role.moderator, GroupMember.Role.admin])")) ) - ) AS \(ViewModel.isSenderOpenGroupModeratorKey), + ) AS \(ViewModel.Columns.isSenderOpenGroupModerator), - \(ViewModel.profileKey).*, + \(profile.allColumns), \(quote[.interactionId]), \(quote[.authorId]), \(quote[.timestampMs]), - \(quoteInteraction).\(interactionBodyColumn) AS \(quoteBodyColumn), - \(quoteInteractionAttachment).\(interactionAttachmentAttachmentIdColumn) AS \(quoteAttachmentIdColumn), - \(ViewModel.quoteAttachmentKey).*, - \(ViewModel.linkPreviewKey).*, - \(ViewModel.linkPreviewAttachmentKey).*, + \(quoteInteraction[.body]), + \(quoteInteractionAttachment[.attachmentId]), + \(quoteAttachment.allColumns), + \(linkPreview.allColumns), + \(linkPreviewAttachment.allColumns), - \(SQL("\(userPublicKey)")) AS \(ViewModel.currentUserPublicKeyKey), + \(SQL("\(userPublicKey)")) AS \(ViewModel.Columns.currentUserPublicKey), -- All of the below properties are set in post-query processing but to prevent the -- query from crashing when decoding we need to provide default values - \(CellType.textOnlyMessage) AS \(ViewModel.cellTypeKey), - '' AS \(ViewModel.authorNameKey), - false AS \(ViewModel.canHaveProfileKey), - false AS \(ViewModel.shouldShowProfileKey), - false AS \(ViewModel.shouldShowDateHeaderKey), - \(Position.middle) AS \(ViewModel.positionInClusterKey), - false AS \(ViewModel.isOnlyMessageInClusterKey), - false AS \(ViewModel.isLastKey), - false AS \(ViewModel.isLastOutgoingKey) + \(CellType.textOnlyMessage) AS \(ViewModel.Columns.cellType), + '' AS \(ViewModel.Columns.authorName), + false AS \(ViewModel.Columns.canHaveProfile), + false AS \(ViewModel.Columns.shouldShowProfile), + false AS \(ViewModel.Columns.shouldShowDateHeader), + \(Position.middle) AS \(ViewModel.Columns.positionInCluster), + false AS \(ViewModel.Columns.isOnlyMessageInCluster), + false AS \(ViewModel.Columns.isLast), + false AS \(ViewModel.Columns.isLastOutgoing) FROM \(Interaction.self) JOIN \(SessionThread.self) ON \(thread[.id]) = \(interaction[.threadId]) LEFT JOIN \(Contact.self) ON \(contact[.id]) = \(interaction[.threadId]) - LEFT JOIN \(Profile.self) AS \(threadProfile) ON \(threadProfile).\(profileIdColumn) = \(interaction[.threadId]) + LEFT JOIN \(threadProfile) ON \(threadProfile[.id]) = \(interaction[.threadId]) LEFT JOIN \(DisappearingMessagesConfiguration.self) ON \(disappearingMessagesConfig[.threadId]) = \(interaction[.threadId]) LEFT JOIN \(OpenGroup.self) ON \(openGroup[.threadId]) = \(interaction[.threadId]) LEFT JOIN \(Profile.self) ON \(profile[.id]) = \(interaction[.authorId]) LEFT JOIN \(Quote.self) ON \(quote[.interactionId]) = \(interaction[.id]) - LEFT JOIN \(Interaction.self) AS \(quoteInteraction) ON ( - \(quoteInteraction).\(timestampMsColumn) = \(quote[.timestampMs]) AND ( - \(quoteInteraction).\(authorIdColumn) = \(quote[.authorId]) OR ( + LEFT JOIN \(quoteInteraction) ON ( + \(quoteInteraction[.timestampMs]) = \(quote[.timestampMs]) AND ( + \(quoteInteraction[.authorId]) = \(quote[.authorId]) OR ( -- A users outgoing message is stored in some cases using their standard id -- but the quote will use their blinded id so handle that case - \(quoteInteraction).\(authorIdColumn) = \(userPublicKey) AND + \(quoteInteraction[.authorId]) = \(userPublicKey) AND ( \(quote[.authorId]) = \(blinded15PublicKey ?? "''") OR \(quote[.authorId]) = \(blinded25PublicKey ?? "''") @@ -886,27 +902,38 @@ public extension MessageViewModel { ) ) ) - LEFT JOIN \(InteractionAttachment.self) AS \(quoteInteractionAttachment) ON ( - \(quoteInteractionAttachment).\(interactionAttachmentInteractionIdColumn) = \(quoteInteraction).\(idColumn) AND - \(quoteInteractionAttachment).\(interactionAttachmentAlbumIndexColumn) = 0 + LEFT JOIN \(quoteInteractionAttachment) ON ( + \(quoteInteractionAttachment[.interactionId]) = \(quoteInteraction[.id]) AND + \(quoteInteractionAttachment[.albumIndex]) = 0 + ) + LEFT JOIN \(quoteLinkPreview) ON ( + \(quoteLinkPreview[.url]) = \(quoteInteraction[.linkPreviewUrl]) AND + \(Interaction.linkPreviewFilterLiteral( + interaction: quoteInteraction, + linkPreview: quoteLinkPreview + )) + ) + LEFT JOIN \(quoteAttachment) ON ( + \(quoteAttachment[.id]) = \(quoteInteractionAttachment[.attachmentId]) OR + \(quoteAttachment[.id]) = \(quoteLinkPreview[.attachmentId]) OR + \(quoteAttachment[.id]) = \(quote[.attachmentId]) ) - LEFT JOIN \(Attachment.self) AS \(ViewModel.quoteAttachmentKey) ON \(ViewModel.quoteAttachmentKey).\(attachmentIdColumn) = \(quoteInteractionAttachment).\(interactionAttachmentAttachmentIdColumn) LEFT JOIN \(LinkPreview.self) ON ( \(linkPreview[.url]) = \(interaction[.linkPreviewUrl]) AND - \(Interaction.linkPreviewFilterLiteral) + \(Interaction.linkPreviewFilterLiteral()) ) - LEFT JOIN \(Attachment.self) AS \(ViewModel.linkPreviewAttachmentKey) ON \(ViewModel.linkPreviewAttachmentKey).\(attachmentIdColumn) = \(linkPreview[.attachmentId]) + LEFT JOIN \(linkPreviewAttachment) ON \(linkPreviewAttachment[.id]) = \(linkPreview[.attachmentId]) LEFT JOIN \(RecipientState.self) ON ( -- Ignore 'skipped' states \(SQL("\(recipientState[.state]) != \(RecipientState.State.skipped)")) AND \(recipientState[.interactionId]) = \(interaction[.id]) ) - LEFT JOIN \(RecipientState.self) AS \(readReceipt) ON ( - \(readReceipt).\(readTimestampMsColumn) IS NOT NULL AND - \(readReceipt).\(readReceiptInteractionIdColumn) = \(interaction[.id]) + LEFT JOIN \(readReceipt) ON ( + \(readReceipt[.readTimestampMs]) IS NOT NULL AND + \(readReceipt[.interactionId]) = \(interaction[.id]) ) - WHERE \(interaction.alias[Column.rowID]) IN \(rowIds) + WHERE \(interaction[.rowId]) IN \(rowIds) \(finalGroupSQL) ORDER BY \(orderSQL) """ @@ -921,12 +948,12 @@ public extension MessageViewModel { Attachment.numberOfSelectedColumns(db) ]) - return ScopeAdapter([ - ViewModel.profileString: adapters[1], - ViewModel.quoteString: adapters[2], - ViewModel.quoteAttachmentString: adapters[3], - ViewModel.linkPreviewString: adapters[4], - ViewModel.linkPreviewAttachmentString: adapters[5] + return ScopeAdapter.with(ViewModel.self, [ + .profile: adapters[1], + .quote: adapters[2], + .quoteAttachment: adapters[3], + .linkPreview: adapters[4], + .linkPreviewAttachment: adapters[5] ]) } } @@ -953,9 +980,9 @@ public extension MessageViewModel.AttachmentInteractionInfo { let numColumnsBeforeLinkedRecords: Int = 1 let request: SQLRequest = """ SELECT - \(attachment.alias[Column.rowID]) AS \(AttachmentInteractionInfo.rowIdKey), - \(AttachmentInteractionInfo.attachmentKey).*, - \(AttachmentInteractionInfo.interactionAttachmentKey).* + \(attachment[.rowId]) AS \(AttachmentInteractionInfo.Columns.rowId), + \(attachment.allColumns), + \(interactionAttachment.allColumns) FROM \(Attachment.self) JOIN \(InteractionAttachment.self) ON \(interactionAttachment[.attachmentId]) = \(attachment[.id]) \(finalFilterSQL) @@ -968,9 +995,9 @@ public extension MessageViewModel.AttachmentInteractionInfo { InteractionAttachment.numberOfSelectedColumns(db) ]) - return ScopeAdapter([ - AttachmentInteractionInfo.attachmentString: adapters[1], - AttachmentInteractionInfo.interactionAttachmentString: adapters[2] + return ScopeAdapter.with(AttachmentInteractionInfo.self, [ + .attachment: adapters[1], + .interactionAttachment: adapters[2] ]) } } @@ -1034,9 +1061,9 @@ public extension MessageViewModel.ReactionInfo { let numColumnsBeforeLinkedRecords: Int = 1 let request: SQLRequest = """ SELECT - \(reaction.alias[Column.rowID]) AS \(ReactionInfo.rowIdKey), - \(ReactionInfo.reactionKey).*, - \(ReactionInfo.profileKey).* + \(reaction[.rowId]) AS \(ReactionInfo.Columns.rowId), + \(reaction.allColumns), + \(profile.allColumns) FROM \(Reaction.self) LEFT JOIN \(Profile.self) ON \(profile[.id]) = \(reaction[.authorId]) \(finalFilterSQL) @@ -1049,9 +1076,9 @@ public extension MessageViewModel.ReactionInfo { Profile.numberOfSelectedColumns(db) ]) - return ScopeAdapter([ - ReactionInfo.reactionString: adapters[1], - ReactionInfo.profileString: adapters[2] + return ScopeAdapter.with(ReactionInfo.self, [ + .reaction: adapters[1], + .profile: adapters[2] ]) } } @@ -1117,8 +1144,8 @@ public extension MessageViewModel.TypingIndicatorInfo { }() let request: SQLRequest = """ SELECT - \(threadTypingIndicator.alias[Column.rowID]) AS \(MessageViewModel.TypingIndicatorInfo.rowIdKey), - \(threadTypingIndicator[.threadId]) AS \(MessageViewModel.TypingIndicatorInfo.threadIdKey) + \(threadTypingIndicator[.rowId]), + \(threadTypingIndicator[.threadId]) FROM \(ThreadTypingIndicator.self) \(finalFilterSQL) """ diff --git a/SessionMessagingKit/Shared Models/SessionThreadViewModel.swift b/SessionMessagingKit/Shared Models/SessionThreadViewModel.swift index 0ee0f5f5a..841f844e9 100644 --- a/SessionMessagingKit/Shared Models/SessionThreadViewModel.swift +++ b/SessionMessagingKit/Shared Models/SessionThreadViewModel.swift @@ -14,65 +14,70 @@ fileprivate typealias ViewModel = SessionThreadViewModel /// /// **Note:** When updating the UI make sure to check the actual queries being run as some fields will have incorrect default values /// in order to optimise their queries to only include the required data -public struct SessionThreadViewModel: FetchableRecordWithRowId, Decodable, Equatable, Hashable, Identifiable, Differentiable { - public static let rowIdKey: SQL = SQL(stringLiteral: CodingKeys.rowId.stringValue) - public static let threadIdKey: SQL = SQL(stringLiteral: CodingKeys.threadId.stringValue) - public static let threadVariantKey: SQL = SQL(stringLiteral: CodingKeys.threadVariant.stringValue) - public static let threadCreationDateTimestampKey: SQL = SQL(stringLiteral: CodingKeys.threadCreationDateTimestamp.stringValue) - public static let threadMemberNamesKey: SQL = SQL(stringLiteral: CodingKeys.threadMemberNames.stringValue) - public static let threadIsNoteToSelfKey: SQL = SQL(stringLiteral: CodingKeys.threadIsNoteToSelf.stringValue) - public static let threadIsMessageRequestKey: SQL = SQL(stringLiteral: CodingKeys.threadIsMessageRequest.stringValue) - public static let threadRequiresApprovalKey: SQL = SQL(stringLiteral: CodingKeys.threadRequiresApproval.stringValue) - public static let threadShouldBeVisibleKey: SQL = SQL(stringLiteral: CodingKeys.threadShouldBeVisible.stringValue) - public static let threadPinnedPriorityKey: SQL = SQL(stringLiteral: CodingKeys.threadPinnedPriority.stringValue) - public static let threadIsBlockedKey: SQL = SQL(stringLiteral: CodingKeys.threadIsBlocked.stringValue) - public static let threadMutedUntilTimestampKey: SQL = SQL(stringLiteral: CodingKeys.threadMutedUntilTimestamp.stringValue) - public static let threadOnlyNotifyForMentionsKey: SQL = SQL(stringLiteral: CodingKeys.threadOnlyNotifyForMentions.stringValue) - public static let threadMessageDraftKey: SQL = SQL(stringLiteral: CodingKeys.threadMessageDraft.stringValue) - public static let threadContactIsTypingKey: SQL = SQL(stringLiteral: CodingKeys.threadContactIsTyping.stringValue) - public static let threadWasMarkedUnreadKey: SQL = SQL(stringLiteral: CodingKeys.threadWasMarkedUnread.stringValue) - public static let threadUnreadCountKey: SQL = SQL(stringLiteral: CodingKeys.threadUnreadCount.stringValue) - public static let threadUnreadMentionCountKey: SQL = SQL(stringLiteral: CodingKeys.threadUnreadMentionCount.stringValue) - public static let disappearingMessagesConfigurationKey: SQL = SQL(stringLiteral: CodingKeys.disappearingMessagesConfiguration.stringValue) - public static let contactProfileKey: SQL = SQL(stringLiteral: CodingKeys.contactProfile.stringValue) - public static let closedGroupNameKey: SQL = SQL(stringLiteral: CodingKeys.closedGroupName.stringValue) - public static let closedGroupUserCountKey: SQL = SQL(stringLiteral: CodingKeys.closedGroupUserCount.stringValue) - public static let currentUserIsClosedGroupMemberKey: SQL = SQL(stringLiteral: CodingKeys.currentUserIsClosedGroupMember.stringValue) - public static let currentUserIsClosedGroupAdminKey: SQL = SQL(stringLiteral: CodingKeys.currentUserIsClosedGroupAdmin.stringValue) - public static let closedGroupProfileFrontKey: SQL = SQL(stringLiteral: CodingKeys.closedGroupProfileFront.stringValue) - public static let closedGroupProfileBackKey: SQL = SQL(stringLiteral: CodingKeys.closedGroupProfileBack.stringValue) - public static let closedGroupProfileBackFallbackKey: SQL = SQL(stringLiteral: CodingKeys.closedGroupProfileBackFallback.stringValue) - public static let openGroupNameKey: SQL = SQL(stringLiteral: CodingKeys.openGroupName.stringValue) - public static let openGroupServerKey: SQL = SQL(stringLiteral: CodingKeys.openGroupServer.stringValue) - public static let openGroupRoomTokenKey: SQL = SQL(stringLiteral: CodingKeys.openGroupRoomToken.stringValue) - public static let openGroupPublicKeyKey: SQL = SQL(stringLiteral: CodingKeys.openGroupPublicKey.stringValue) - public static let openGroupProfilePictureDataKey: SQL = SQL(stringLiteral: CodingKeys.openGroupProfilePictureData.stringValue) - public static let openGroupUserCountKey: SQL = SQL(stringLiteral: CodingKeys.openGroupUserCount.stringValue) - public static let openGroupPermissionsKey: SQL = SQL(stringLiteral: CodingKeys.openGroupPermissions.stringValue) - public static let interactionIdKey: SQL = SQL(stringLiteral: CodingKeys.interactionId.stringValue) - public static let interactionVariantKey: SQL = SQL(stringLiteral: CodingKeys.interactionVariant.stringValue) - public static let interactionTimestampMsKey: SQL = SQL(stringLiteral: CodingKeys.interactionTimestampMs.stringValue) - public static let interactionBodyKey: SQL = SQL(stringLiteral: CodingKeys.interactionBody.stringValue) - public static let interactionStateKey: SQL = SQL(stringLiteral: CodingKeys.interactionState.stringValue) - public static let interactionHasAtLeastOneReadReceiptKey: SQL = SQL(stringLiteral: CodingKeys.interactionHasAtLeastOneReadReceipt.stringValue) - public static let interactionIsOpenGroupInvitationKey: SQL = SQL(stringLiteral: CodingKeys.interactionIsOpenGroupInvitation.stringValue) - public static let interactionAttachmentDescriptionInfoKey: SQL = SQL(stringLiteral: CodingKeys.interactionAttachmentDescriptionInfo.stringValue) - public static let interactionAttachmentCountKey: SQL = SQL(stringLiteral: CodingKeys.interactionAttachmentCount.stringValue) - public static let threadContactNameInternalKey: SQL = SQL(stringLiteral: CodingKeys.threadContactNameInternal.stringValue) - public static let authorNameInternalKey: SQL = SQL(stringLiteral: CodingKeys.authorNameInternal.stringValue) - public static let currentUserPublicKeyKey: SQL = SQL(stringLiteral: CodingKeys.currentUserPublicKey.stringValue) - - public static let threadWasMarkedUnreadString: String = CodingKeys.threadWasMarkedUnread.stringValue - public static let threadUnreadCountString: String = CodingKeys.threadUnreadCount.stringValue - public static let threadUnreadMentionCountString: String = CodingKeys.threadUnreadMentionCount.stringValue - public static let closedGroupUserCountString: String = CodingKeys.closedGroupUserCount.stringValue - public static let openGroupUserCountString: String = CodingKeys.openGroupUserCount.stringValue - public static let disappearingMessagesConfigurationString: String = CodingKeys.disappearingMessagesConfiguration.stringValue - public static let contactProfileString: String = CodingKeys.contactProfile.stringValue - public static let closedGroupProfileFrontString: String = CodingKeys.closedGroupProfileFront.stringValue - public static let closedGroupProfileBackString: String = CodingKeys.closedGroupProfileBack.stringValue - public static let closedGroupProfileBackFallbackString: String = CodingKeys.closedGroupProfileBackFallback.stringValue - public static let interactionAttachmentDescriptionInfoString: String = CodingKeys.interactionAttachmentDescriptionInfo.stringValue +public struct SessionThreadViewModel: FetchableRecordWithRowId, Decodable, Equatable, Hashable, Identifiable, Differentiable, ColumnExpressible { + public typealias Columns = CodingKeys + public enum CodingKeys: String, CodingKey, ColumnExpression, CaseIterable { + case rowId + case threadId + case threadVariant + case threadCreationDateTimestamp + case threadMemberNames + + case threadIsNoteToSelf + case threadIsMessageRequest + case threadRequiresApproval + case threadShouldBeVisible + case threadPinnedPriority + case threadIsBlocked + case threadMutedUntilTimestamp + case threadOnlyNotifyForMentions + case threadMessageDraft + + case threadContactIsTyping + case threadWasMarkedUnread + case threadUnreadCount + case threadUnreadMentionCount + + // Thread display info + + case disappearingMessagesConfiguration + + case contactProfile + case closedGroupProfileFront + case closedGroupProfileBack + case closedGroupProfileBackFallback + case closedGroupName + case closedGroupUserCount + case currentUserIsClosedGroupMember + case currentUserIsClosedGroupAdmin + case openGroupName + case openGroupServer + case openGroupRoomToken + case openGroupPublicKey + case openGroupProfilePictureData + case openGroupUserCount + case openGroupPermissions + + // Interaction display info + + case interactionId + case interactionVariant + case interactionTimestampMs + case interactionBody + case interactionState + case interactionHasAtLeastOneReadReceipt + case interactionIsOpenGroupInvitation + case interactionAttachmentDescriptionInfo + case interactionAttachmentCount + + case authorId + case threadContactNameInternal + case authorNameInternal + case currentUserPublicKey + case currentUserBlinded15PublicKey + case currentUserBlinded25PublicKey + case recentReactionEmoji + } public var differenceIdentifier: String { threadId } public var id: String { threadId } @@ -104,7 +109,11 @@ public struct SessionThreadViewModel: FetchableRecordWithRowId, Decodable, Equat public var canWrite: Bool { switch threadVariant { - case .contact: return true + case .contact: + guard threadIsMessageRequest == true else { return true } + + return (profile?.blocksCommunityMessageRequests != true) + case .legacyGroup, .group: return ( currentUserIsClosedGroupMember == true && @@ -550,6 +559,51 @@ public extension SessionThreadViewModel { } } +// MARK: - AggregateInteraction + +private struct AggregateInteraction: Decodable, ColumnExpressible { + public typealias Columns = CodingKeys + public enum CodingKeys: String, CodingKey, ColumnExpression, CaseIterable { + case interactionId + case threadId + case interactionTimestampMs + case threadUnreadCount + case threadUnreadMentionCount + } + + let interactionId: Int64 + let threadId: String + let interactionTimestampMs: Int64 + let threadUnreadCount: UInt? + let threadUnreadMentionCount: UInt? +} + +// MARK: - ClosedGroupUserCount + +private struct ClosedGroupUserCount: Decodable, ColumnExpressible { + public typealias Columns = CodingKeys + public enum CodingKeys: String, CodingKey, ColumnExpression, CaseIterable { + case groupId + case closedGroupUserCount + } + + let groupId: String + let closedGroupUserCount: Int +} + +// MARK: - GroupMemberInfo + +private struct GroupMemberInfo: Decodable, ColumnExpressible { + public typealias Columns = CodingKeys + public enum CodingKeys: String, CodingKey, ColumnExpression, CaseIterable { + case groupId + case threadMemberNames + } + + let groupId: String + let threadMemberNames: String +} + // MARK: - HomeVC & MessageRequestsViewController // MARK: --SessionThreadViewModel @@ -566,65 +620,57 @@ public extension SessionThreadViewModel { let thread: TypedTableAlias = TypedTableAlias() let contact: TypedTableAlias = TypedTableAlias() let typingIndicator: TypedTableAlias = TypedTableAlias() - let closedGroup: TypedTableAlias = TypedTableAlias() - let groupMember: TypedTableAlias = TypedTableAlias() - let openGroup: TypedTableAlias = TypedTableAlias() + let aggregateInteraction: TypedTableAlias = TypedTableAlias(name: "aggregateInteraction") let interaction: TypedTableAlias = TypedTableAlias() let recipientState: TypedTableAlias = TypedTableAlias() + let readReceipt: TypedTableAlias = TypedTableAlias(name: "readReceipt") let linkPreview: TypedTableAlias = TypedTableAlias() + let firstInteractionAttachment: TypedTableAlias = TypedTableAlias(name: "firstInteractionAttachment") let attachment: TypedTableAlias = TypedTableAlias() let interactionAttachment: TypedTableAlias = TypedTableAlias() let profile: TypedTableAlias = TypedTableAlias() - - let aggregateInteractionLiteral: SQL = SQL(stringLiteral: "aggregateInteraction") - let timestampMsColumnLiteral: SQL = SQL(stringLiteral: Interaction.Columns.timestampMs.name) - let interactionStateInteractionIdColumnLiteral: SQL = SQL(stringLiteral: RecipientState.Columns.interactionId.name) - let readReceiptTableLiteral: SQL = SQL(stringLiteral: "readReceipt") - let readReceiptReadTimestampMsColumnLiteral: SQL = SQL(stringLiteral: RecipientState.Columns.readTimestampMs.name) - let profileIdColumnLiteral: SQL = SQL(stringLiteral: Profile.Columns.id.name) - let profileNicknameColumnLiteral: SQL = SQL(stringLiteral: Profile.Columns.nickname.name) - let profileNameColumnLiteral: SQL = SQL(stringLiteral: Profile.Columns.name.name) - let firstInteractionAttachmentLiteral: SQL = SQL(stringLiteral: "firstInteractionAttachment") - let interactionAttachmentAttachmentIdColumnLiteral: SQL = SQL(stringLiteral: InteractionAttachment.Columns.attachmentId.name) - let interactionAttachmentInteractionIdColumnLiteral: SQL = SQL(stringLiteral: InteractionAttachment.Columns.interactionId.name) - let interactionAttachmentAlbumIndexColumnLiteral: SQL = SQL(stringLiteral: InteractionAttachment.Columns.albumIndex.name) - + let contactProfile: TypedTableAlias = TypedTableAlias(ViewModel.self, column: .contactProfile) + let closedGroup: TypedTableAlias = TypedTableAlias() + let closedGroupProfileFront: TypedTableAlias = TypedTableAlias(ViewModel.self, column: .closedGroupProfileFront) + let closedGroupProfileBack: TypedTableAlias = TypedTableAlias(ViewModel.self, column: .closedGroupProfileBack) + let closedGroupProfileBackFallback: TypedTableAlias = TypedTableAlias(ViewModel.self, column: .closedGroupProfileBackFallback) + let groupMember: TypedTableAlias = TypedTableAlias() + let openGroup: TypedTableAlias = TypedTableAlias() /// **Note:** The `numColumnsBeforeProfiles` value **MUST** match the number of fields before - /// the `ViewModel.contactProfileKey` entry below otherwise the query will fail to - /// parse and might throw + /// the `contactProfile` entry below otherwise the query will fail to parse and might throw /// /// Explicitly set default values for the fields ignored for search results let numColumnsBeforeProfiles: Int = 14 let numColumnsBetweenProfilesAndAttachmentInfo: Int = 12 // The attachment info columns will be combined let request: SQLRequest = """ SELECT - \(thread.alias[Column.rowID]) AS \(ViewModel.rowIdKey), - \(thread[.id]) AS \(ViewModel.threadIdKey), - \(thread[.variant]) AS \(ViewModel.threadVariantKey), - \(thread[.creationDateTimestamp]) AS \(ViewModel.threadCreationDateTimestampKey), + \(thread[.rowId]) AS \(ViewModel.Columns.rowId), + \(thread[.id]) AS \(ViewModel.Columns.threadId), + \(thread[.variant]) AS \(ViewModel.Columns.threadVariant), + \(thread[.creationDateTimestamp]) AS \(ViewModel.Columns.threadCreationDateTimestamp), - (\(SQL("\(thread[.id]) = \(userPublicKey)"))) AS \(ViewModel.threadIsNoteToSelfKey), - IFNULL(\(thread[.pinnedPriority]), 0) AS \(ViewModel.threadPinnedPriorityKey), - \(contact[.isBlocked]) AS \(ViewModel.threadIsBlockedKey), - \(thread[.mutedUntilTimestamp]) AS \(ViewModel.threadMutedUntilTimestampKey), - \(thread[.onlyNotifyForMentions]) AS \(ViewModel.threadOnlyNotifyForMentionsKey), + (\(SQL("\(thread[.id]) = \(userPublicKey)"))) AS \(ViewModel.Columns.threadIsNoteToSelf), + IFNULL(\(thread[.pinnedPriority]), 0) AS \(ViewModel.Columns.threadPinnedPriority), + \(contact[.isBlocked]) AS \(ViewModel.Columns.threadIsBlocked), + \(thread[.mutedUntilTimestamp]) AS \(ViewModel.Columns.threadMutedUntilTimestamp), + \(thread[.onlyNotifyForMentions]) AS \(ViewModel.Columns.threadOnlyNotifyForMentions), ( \(SQL("\(thread[.variant]) = \(SessionThread.Variant.contact)")) AND \(SQL("\(thread[.id]) != \(userPublicKey)")) AND IFNULL(\(contact[.isApproved]), false) = false - ) AS \(ViewModel.threadIsMessageRequestKey), + ) AS \(ViewModel.Columns.threadIsMessageRequest), - (\(typingIndicator[.threadId]) IS NOT NULL) AS \(ViewModel.threadContactIsTypingKey), - \(thread[.markedAsUnread]) AS \(ViewModel.threadWasMarkedUnreadKey), - \(aggregateInteractionLiteral).\(ViewModel.threadUnreadCountKey), - \(aggregateInteractionLiteral).\(ViewModel.threadUnreadMentionCountKey), + (\(typingIndicator[.threadId]) IS NOT NULL) AS \(ViewModel.Columns.threadContactIsTyping), + \(thread[.markedAsUnread]) AS \(ViewModel.Columns.threadWasMarkedUnread), + \(aggregateInteraction[.threadUnreadCount]), + \(aggregateInteraction[.threadUnreadMentionCount]), - \(ViewModel.contactProfileKey).*, - \(ViewModel.closedGroupProfileFrontKey).*, - \(ViewModel.closedGroupProfileBackKey).*, - \(ViewModel.closedGroupProfileBackFallbackKey).*, - \(closedGroup[.name]) AS \(ViewModel.closedGroupNameKey), + \(contactProfile.allColumns), + \(closedGroupProfileFront.allColumns), + \(closedGroupProfileBack.allColumns), + \(closedGroupProfileBackFallback.allColumns), + \(closedGroup[.name]) AS \(ViewModel.Columns.closedGroupName), EXISTS ( SELECT 1 @@ -634,7 +680,7 @@ public extension SessionThreadViewModel { \(SQL("\(groupMember[.role]) != \(GroupMember.Role.zombie)")) AND \(SQL("\(groupMember[.profileId]) = \(userPublicKey)")) ) - ) AS \(ViewModel.currentUserIsClosedGroupMemberKey), + ) AS \(ViewModel.Columns.currentUserIsClosedGroupMember), EXISTS ( SELECT 1 @@ -644,15 +690,15 @@ public extension SessionThreadViewModel { \(SQL("\(groupMember[.role]) = \(GroupMember.Role.admin)")) AND \(SQL("\(groupMember[.profileId]) = \(userPublicKey)")) ) - ) AS \(ViewModel.currentUserIsClosedGroupAdminKey), + ) AS \(ViewModel.Columns.currentUserIsClosedGroupAdmin), - \(openGroup[.name]) AS \(ViewModel.openGroupNameKey), - \(openGroup[.imageData]) AS \(ViewModel.openGroupProfilePictureDataKey), + \(openGroup[.name]) AS \(ViewModel.Columns.openGroupName), + \(openGroup[.imageData]) AS \(ViewModel.Columns.openGroupProfilePictureData), - \(interaction[.id]) AS \(ViewModel.interactionIdKey), - \(interaction[.variant]) AS \(ViewModel.interactionVariantKey), - \(interaction[.timestampMs]) AS \(ViewModel.interactionTimestampMsKey), - \(interaction[.body]) AS \(ViewModel.interactionBodyKey), + \(interaction[.id]) AS \(ViewModel.Columns.interactionId), + \(interaction[.variant]) AS \(ViewModel.Columns.interactionVariant), + \(interaction[.timestampMs]) AS \(ViewModel.Columns.interactionTimestampMs), + \(interaction[.body]) AS \(ViewModel.Columns.interactionBody), -- Default to 'sending' assuming non-processed interaction when null IFNULL(( @@ -664,22 +710,22 @@ public extension SessionThreadViewModel { \(SQL("\(recipientState[.state]) != \(RecipientState.State.skipped)")) ) LIMIT 1 - ), \(SQL("\(RecipientState.State.sending)"))) AS \(ViewModel.interactionStateKey), + ), \(SQL("\(RecipientState.State.sending)"))) AS \(ViewModel.Columns.interactionState), - (\(readReceiptTableLiteral).\(readReceiptReadTimestampMsColumnLiteral) IS NOT NULL) AS \(ViewModel.interactionHasAtLeastOneReadReceiptKey), - (\(linkPreview[.url]) IS NOT NULL) AS \(ViewModel.interactionIsOpenGroupInvitationKey), + (\(readReceipt[.readTimestampMs]) IS NOT NULL) AS \(ViewModel.Columns.interactionHasAtLeastOneReadReceipt), + (\(linkPreview[.url]) IS NOT NULL) AS \(ViewModel.Columns.interactionIsOpenGroupInvitation), -- These 4 properties will be combined into 'Attachment.DescriptionInfo' \(attachment[.id]), \(attachment[.variant]), \(attachment[.contentType]), \(attachment[.sourceFilename]), - COUNT(\(interactionAttachment[.interactionId])) AS \(ViewModel.interactionAttachmentCountKey), + COUNT(\(interactionAttachment[.interactionId])) AS \(ViewModel.Columns.interactionAttachmentCount), \(interaction[.authorId]), - IFNULL(\(ViewModel.contactProfileKey).\(profileNicknameColumnLiteral), \(ViewModel.contactProfileKey).\(profileNameColumnLiteral)) AS \(ViewModel.threadContactNameInternalKey), - IFNULL(\(profile[.nickname]), \(profile[.name])) AS \(ViewModel.authorNameInternalKey), - \(SQL("\(userPublicKey)")) AS \(ViewModel.currentUserPublicKeyKey) + IFNULL(\(contactProfile[.nickname]), \(contactProfile[.name])) AS \(ViewModel.Columns.threadContactNameInternal), + IFNULL(\(profile[.nickname]), \(profile[.name])) AS \(ViewModel.Columns.authorNameInternal), + \(SQL("\(userPublicKey)")) AS \(ViewModel.Columns.currentUserPublicKey) FROM \(SessionThread.self) LEFT JOIN \(Contact.self) ON \(contact[.id]) = \(thread[.id]) @@ -687,46 +733,46 @@ public extension SessionThreadViewModel { LEFT JOIN ( SELECT - \(interaction[.id]) AS \(ViewModel.interactionIdKey), - \(interaction[.threadId]) AS \(ViewModel.threadIdKey), - MAX(\(interaction[.timestampMs])) AS \(timestampMsColumnLiteral), - SUM(\(interaction[.wasRead]) = false) AS \(ViewModel.threadUnreadCountKey), - SUM(\(interaction[.wasRead]) = false AND \(interaction[.hasMention]) = true) AS \(ViewModel.threadUnreadMentionCountKey) + \(interaction[.id]) AS \(AggregateInteraction.Columns.interactionId), + \(interaction[.threadId]) AS \(AggregateInteraction.Columns.threadId), + MAX(\(interaction[.timestampMs])) AS \(AggregateInteraction.Columns.interactionTimestampMs), + SUM(\(interaction[.wasRead]) = false) AS \(AggregateInteraction.Columns.threadUnreadCount), + SUM(\(interaction[.wasRead]) = false AND \(interaction[.hasMention]) = true) AS \(AggregateInteraction.Columns.threadUnreadMentionCount) FROM \(Interaction.self) WHERE \(SQL("\(interaction[.variant]) != \(Interaction.Variant.standardIncomingDeleted)")) GROUP BY \(interaction[.threadId]) - ) AS \(aggregateInteractionLiteral) ON \(aggregateInteractionLiteral).\(ViewModel.threadIdKey) = \(thread[.id]) + ) AS \(aggregateInteraction) ON \(aggregateInteraction[.threadId]) = \(thread[.id]) LEFT JOIN \(Interaction.self) ON ( \(interaction[.threadId]) = \(thread[.id]) AND - \(interaction[.id]) = \(aggregateInteractionLiteral).\(ViewModel.interactionIdKey) + \(interaction[.id]) = \(aggregateInteraction[.interactionId]) ) - LEFT JOIN \(RecipientState.self) AS \(readReceiptTableLiteral) ON ( - \(interaction[.id]) = \(readReceiptTableLiteral).\(interactionStateInteractionIdColumnLiteral) AND - \(readReceiptTableLiteral).\(readReceiptReadTimestampMsColumnLiteral) IS NOT NULL + LEFT JOIN \(readReceipt) ON ( + \(interaction[.id]) = \(readReceipt[.interactionId]) AND + \(readReceipt[.readTimestampMs]) IS NOT NULL ) LEFT JOIN \(LinkPreview.self) ON ( \(linkPreview[.url]) = \(interaction[.linkPreviewUrl]) AND - \(Interaction.linkPreviewFilterLiteral) AND + \(Interaction.linkPreviewFilterLiteral()) AND \(SQL("\(linkPreview[.variant]) = \(LinkPreview.Variant.openGroupInvitation)")) ) - LEFT JOIN \(InteractionAttachment.self) AS \(firstInteractionAttachmentLiteral) ON ( - \(firstInteractionAttachmentLiteral).\(interactionAttachmentInteractionIdColumnLiteral) = \(interaction[.id]) AND - \(firstInteractionAttachmentLiteral).\(interactionAttachmentAlbumIndexColumnLiteral) = 0 + LEFT JOIN \(firstInteractionAttachment) ON ( + \(firstInteractionAttachment[.interactionId]) = \(interaction[.id]) AND + \(firstInteractionAttachment[.albumIndex]) = 0 ) - LEFT JOIN \(Attachment.self) ON \(attachment[.id]) = \(firstInteractionAttachmentLiteral).\(interactionAttachmentAttachmentIdColumnLiteral) + LEFT JOIN \(Attachment.self) ON \(attachment[.id]) = \(firstInteractionAttachment[.attachmentId]) LEFT JOIN \(InteractionAttachment.self) ON \(interactionAttachment[.interactionId]) = \(interaction[.id]) LEFT JOIN \(Profile.self) ON \(profile[.id]) = \(interaction[.authorId]) -- Thread naming & avatar content - LEFT JOIN \(Profile.self) AS \(ViewModel.contactProfileKey) ON \(ViewModel.contactProfileKey).\(profileIdColumnLiteral) = \(thread[.id]) + LEFT JOIN \(contactProfile) ON \(contactProfile[.id]) = \(thread[.id]) LEFT JOIN \(OpenGroup.self) ON \(openGroup[.threadId]) = \(thread[.id]) LEFT JOIN \(ClosedGroup.self) ON \(closedGroup[.threadId]) = \(thread[.id]) - LEFT JOIN \(Profile.self) AS \(ViewModel.closedGroupProfileFrontKey) ON ( - \(ViewModel.closedGroupProfileFrontKey).\(profileIdColumnLiteral) = ( + LEFT JOIN \(closedGroupProfileFront) ON ( + \(closedGroupProfileFront[.id]) = ( SELECT MIN(\(groupMember[.profileId])) FROM \(GroupMember.self) JOIN \(Profile.self) ON \(profile[.id]) = \(groupMember[.profileId]) @@ -737,9 +783,9 @@ public extension SessionThreadViewModel { ) ) ) - LEFT JOIN \(Profile.self) AS \(ViewModel.closedGroupProfileBackKey) ON ( - \(ViewModel.closedGroupProfileBackKey).\(profileIdColumnLiteral) != \(ViewModel.closedGroupProfileFrontKey).\(profileIdColumnLiteral) AND - \(ViewModel.closedGroupProfileBackKey).\(profileIdColumnLiteral) = ( + LEFT JOIN \(closedGroupProfileBack) ON ( + \(closedGroupProfileBack[.id]) != \(closedGroupProfileFront[.id]) AND + \(closedGroupProfileBack[.id]) = ( SELECT MAX(\(groupMember[.profileId])) FROM \(GroupMember.self) JOIN \(Profile.self) ON \(profile[.id]) = \(groupMember[.profileId]) @@ -750,13 +796,13 @@ public extension SessionThreadViewModel { ) ) ) - LEFT JOIN \(Profile.self) AS \(ViewModel.closedGroupProfileBackFallbackKey) ON ( + LEFT JOIN \(closedGroupProfileBackFallback) ON ( \(closedGroup[.threadId]) IS NOT NULL AND - \(ViewModel.closedGroupProfileBackKey).\(profileIdColumnLiteral) IS NULL AND - \(ViewModel.closedGroupProfileBackFallbackKey).\(profileIdColumnLiteral) = \(SQL("\(userPublicKey)")) + \(closedGroupProfileBack[.id]) IS NULL AND + \(closedGroupProfileBackFallback[.id]) = \(SQL("\(userPublicKey)")) ) - WHERE \(thread.alias[Column.rowID]) IN \(rowIds) + WHERE \(thread[.rowId]) IN \(rowIds) \(groupSQL) ORDER BY \(orderSQL) """ @@ -772,12 +818,12 @@ public extension SessionThreadViewModel { Attachment.DescriptionInfo.numberOfSelectedColumns() ]) - return ScopeAdapter([ - ViewModel.contactProfileString: adapters[1], - ViewModel.closedGroupProfileFrontString: adapters[2], - ViewModel.closedGroupProfileBackString: adapters[3], - ViewModel.closedGroupProfileBackFallbackString: adapters[4], - ViewModel.interactionAttachmentDescriptionInfoString: adapters[6] + return ScopeAdapter.with(ViewModel.self, [ + .contactProfile: adapters[1], + .closedGroupProfileFront: adapters[2], + .closedGroupProfileBack: adapters[3], + .closedGroupProfileBackFallback: adapters[4], + .interactionAttachmentDescriptionInfo: adapters[6] ]) } } @@ -864,55 +910,52 @@ public extension SessionThreadViewModel { let thread: TypedTableAlias = TypedTableAlias() let disappearingMessagesConfiguration: TypedTableAlias = TypedTableAlias() let contact: TypedTableAlias = TypedTableAlias() + let contactProfile: TypedTableAlias = TypedTableAlias(ViewModel.self, column: .contactProfile) let closedGroup: TypedTableAlias = TypedTableAlias() let groupMember: TypedTableAlias = TypedTableAlias() let openGroup: TypedTableAlias = TypedTableAlias() + let aggregateInteraction: TypedTableAlias = TypedTableAlias(name: "aggregateInteraction") let interaction: TypedTableAlias = TypedTableAlias() - - let aggregateInteractionLiteral: SQL = SQL(stringLiteral: "aggregateInteraction") - let closedGroupUserCountTableLiteral: SQL = SQL(stringLiteral: "\(ViewModel.closedGroupUserCountString)_table") - let groupMemberGroupIdColumnLiteral: SQL = SQL(stringLiteral: GroupMember.Columns.groupId.name) - let profileIdColumnLiteral: SQL = SQL(stringLiteral: Profile.Columns.id.name) + let closedGroupUserCount: TypedTableAlias = TypedTableAlias(name: "closedGroupUserCount") /// **Note:** The `numColumnsBeforeProfiles` value **MUST** match the number of fields before - /// the `ViewModel.contactProfileKey` entry below otherwise the query will fail to - /// parse and might throw + /// the `disappearingMessageSConfiguration` entry below otherwise the query will fail to parse and might throw /// /// Explicitly set default values for the fields ignored for search results let numColumnsBeforeProfiles: Int = 15 let request: SQLRequest = """ SELECT - \(thread.alias[Column.rowID]) AS \(ViewModel.rowIdKey), - \(thread[.id]) AS \(ViewModel.threadIdKey), - \(thread[.variant]) AS \(ViewModel.threadVariantKey), - \(thread[.creationDateTimestamp]) AS \(ViewModel.threadCreationDateTimestampKey), + \(thread[.rowId]) AS \(ViewModel.Columns.rowId), + \(thread[.id]) AS \(ViewModel.Columns.threadId), + \(thread[.variant]) AS \(ViewModel.Columns.threadVariant), + \(thread[.creationDateTimestamp]) AS \(ViewModel.Columns.threadCreationDateTimestamp), - (\(SQL("\(thread[.id]) = \(userPublicKey)"))) AS \(ViewModel.threadIsNoteToSelfKey), + (\(SQL("\(thread[.id]) = \(userPublicKey)"))) AS \(ViewModel.Columns.threadIsNoteToSelf), ( \(SQL("\(thread[.variant]) = \(SessionThread.Variant.contact)")) AND \(SQL("\(thread[.id]) != \(userPublicKey)")) AND IFNULL(\(contact[.isApproved]), false) = false - ) AS \(ViewModel.threadIsMessageRequestKey), + ) AS \(ViewModel.Columns.threadIsMessageRequest), ( \(SQL("\(thread[.variant]) = \(SessionThread.Variant.contact)")) AND IFNULL(\(contact[.didApproveMe]), false) = false - ) AS \(ViewModel.threadRequiresApprovalKey), - \(thread[.shouldBeVisible]) AS \(ViewModel.threadShouldBeVisibleKey), + ) AS \(ViewModel.Columns.threadRequiresApproval), + \(thread[.shouldBeVisible]) AS \(ViewModel.Columns.threadShouldBeVisible), - IFNULL(\(thread[.pinnedPriority]), 0) AS \(ViewModel.threadPinnedPriorityKey), - \(contact[.isBlocked]) AS \(ViewModel.threadIsBlockedKey), - \(thread[.mutedUntilTimestamp]) AS \(ViewModel.threadMutedUntilTimestampKey), - \(thread[.onlyNotifyForMentions]) AS \(ViewModel.threadOnlyNotifyForMentionsKey), - \(thread[.messageDraft]) AS \(ViewModel.threadMessageDraftKey), + IFNULL(\(thread[.pinnedPriority]), 0) AS \(ViewModel.Columns.threadPinnedPriority), + \(contact[.isBlocked]) AS \(ViewModel.Columns.threadIsBlocked), + \(thread[.mutedUntilTimestamp]) AS \(ViewModel.Columns.threadMutedUntilTimestamp), + \(thread[.onlyNotifyForMentions]) AS \(ViewModel.Columns.threadOnlyNotifyForMentions), + \(thread[.messageDraft]) AS \(ViewModel.Columns.threadMessageDraft), - \(thread[.markedAsUnread]) AS \(ViewModel.threadWasMarkedUnreadKey), - \(aggregateInteractionLiteral).\(ViewModel.threadUnreadCountKey), + \(thread[.markedAsUnread]) AS \(ViewModel.Columns.threadWasMarkedUnread), + \(aggregateInteraction[.threadUnreadCount]), - \(ViewModel.disappearingMessagesConfigurationKey).*, + \(disappearingMessagesConfiguration.allColumns), - \(ViewModel.contactProfileKey).*, - \(closedGroup[.name]) AS \(ViewModel.closedGroupNameKey), - \(closedGroupUserCountTableLiteral).\(ViewModel.closedGroupUserCountKey) AS \(ViewModel.closedGroupUserCountKey), + \(contactProfile.allColumns), + \(closedGroup[.name]) AS \(ViewModel.Columns.closedGroupName), + \(closedGroupUserCount[.closedGroupUserCount]), EXISTS ( SELECT 1 @@ -922,49 +965,50 @@ public extension SessionThreadViewModel { \(SQL("\(groupMember[.role]) != \(GroupMember.Role.zombie)")) AND \(SQL("\(groupMember[.profileId]) = \(userPublicKey)")) ) - ) AS \(ViewModel.currentUserIsClosedGroupMemberKey), + ) AS \(ViewModel.Columns.currentUserIsClosedGroupMember), - \(openGroup[.name]) AS \(ViewModel.openGroupNameKey), - \(openGroup[.server]) AS \(ViewModel.openGroupServerKey), - \(openGroup[.roomToken]) AS \(ViewModel.openGroupRoomTokenKey), - \(openGroup[.publicKey]) AS \(ViewModel.openGroupPublicKeyKey), - \(openGroup[.userCount]) AS \(ViewModel.openGroupUserCountKey), - \(openGroup[.permissions]) AS \(ViewModel.openGroupPermissionsKey), + \(openGroup[.name]) AS \(ViewModel.Columns.openGroupName), + \(openGroup[.server]) AS \(ViewModel.Columns.openGroupServer), + \(openGroup[.roomToken]) AS \(ViewModel.Columns.openGroupRoomToken), + \(openGroup[.publicKey]) AS \(ViewModel.Columns.openGroupPublicKey), + \(openGroup[.userCount]) AS \(ViewModel.Columns.openGroupUserCount), + \(openGroup[.permissions]) AS \(ViewModel.Columns.openGroupPermissions), - \(aggregateInteractionLiteral).\(ViewModel.interactionIdKey), - \(aggregateInteractionLiteral).\(ViewModel.interactionTimestampMsKey), + \(aggregateInteraction[.interactionId]), + \(aggregateInteraction[.interactionTimestampMs]), - \(SQL("\(userPublicKey)")) AS \(ViewModel.currentUserPublicKeyKey) + \(SQL("\(userPublicKey)")) AS \(ViewModel.Columns.currentUserPublicKey) FROM \(SessionThread.self) LEFT JOIN \(DisappearingMessagesConfiguration.self) ON \(disappearingMessagesConfiguration[.threadId]) = \(thread[.id]) LEFT JOIN \(Contact.self) ON \(contact[.id]) = \(thread[.id]) LEFT JOIN ( SELECT - \(interaction[.id]) AS \(ViewModel.interactionIdKey), - \(interaction[.threadId]) AS \(ViewModel.threadIdKey), - MAX(\(interaction[.timestampMs])) AS \(ViewModel.interactionTimestampMsKey), - SUM(\(interaction[.wasRead]) = false) AS \(ViewModel.threadUnreadCountKey) + \(interaction[.id]) AS \(AggregateInteraction.Columns.interactionId), + \(interaction[.threadId]) AS \(AggregateInteraction.Columns.threadId), + MAX(\(interaction[.timestampMs])) AS \(AggregateInteraction.Columns.interactionTimestampMs), + SUM(\(interaction[.wasRead]) = false) AS \(AggregateInteraction.Columns.threadUnreadCount), + 0 AS \(AggregateInteraction.Columns.threadUnreadMentionCount) FROM \(Interaction.self) WHERE ( \(SQL("\(interaction[.threadId]) = \(threadId)")) AND \(SQL("\(interaction[.variant]) != \(Interaction.Variant.standardIncomingDeleted)")) ) - ) AS \(aggregateInteractionLiteral) ON \(aggregateInteractionLiteral).\(ViewModel.threadIdKey) = \(thread[.id]) + ) AS \(aggregateInteraction) ON \(aggregateInteraction[.threadId]) = \(thread[.id]) - LEFT JOIN \(Profile.self) AS \(ViewModel.contactProfileKey) ON \(ViewModel.contactProfileKey).\(profileIdColumnLiteral) = \(thread[.id]) + LEFT JOIN \(contactProfile) ON \(contactProfile[.id]) = \(thread[.id]) LEFT JOIN \(OpenGroup.self) ON \(openGroup[.threadId]) = \(thread[.id]) LEFT JOIN \(ClosedGroup.self) ON \(closedGroup[.threadId]) = \(thread[.id]) LEFT JOIN ( SELECT \(groupMember[.groupId]), - COUNT(\(groupMember.alias[Column.rowID])) AS \(ViewModel.closedGroupUserCountKey) + COUNT(\(groupMember[.rowId])) AS \(ClosedGroupUserCount.Columns.closedGroupUserCount) FROM \(GroupMember.self) WHERE ( \(SQL("\(groupMember[.groupId]) = \(threadId)")) AND \(SQL("\(groupMember[.role]) = \(GroupMember.Role.standard)")) ) - ) AS \(closedGroupUserCountTableLiteral) ON \(SQL("\(closedGroupUserCountTableLiteral).\(groupMemberGroupIdColumnLiteral) = \(threadId)")) + ) AS \(closedGroupUserCount) ON \(SQL("\(closedGroupUserCount[.groupId]) = \(threadId)")) WHERE \(SQL("\(thread[.id]) = \(threadId)")) """ @@ -976,9 +1020,9 @@ public extension SessionThreadViewModel { Profile.numberOfSelectedColumns(db) ]) - return ScopeAdapter([ - ViewModel.disappearingMessagesConfigurationString: adapters[1], - ViewModel.contactProfileString: adapters[2] + return ScopeAdapter.with(ViewModel.self, [ + .disappearingMessagesConfiguration: adapters[1], + .contactProfile: adapters[2] ]) } } @@ -986,39 +1030,40 @@ public extension SessionThreadViewModel { static func conversationSettingsQuery(threadId: String, userPublicKey: String) -> AdaptedFetchRequest> { let thread: TypedTableAlias = TypedTableAlias() let contact: TypedTableAlias = TypedTableAlias() + let contactProfile: TypedTableAlias = TypedTableAlias(ViewModel.self, column: .contactProfile) let closedGroup: TypedTableAlias = TypedTableAlias() + let closedGroupProfileFront: TypedTableAlias = TypedTableAlias(ViewModel.self, column: .closedGroupProfileFront) + let closedGroupProfileBack: TypedTableAlias = TypedTableAlias(ViewModel.self, column: .closedGroupProfileBack) + let closedGroupProfileBackFallback: TypedTableAlias = TypedTableAlias(ViewModel.self, column: .closedGroupProfileBackFallback) let groupMember: TypedTableAlias = TypedTableAlias() let openGroup: TypedTableAlias = TypedTableAlias() let profile: TypedTableAlias = TypedTableAlias() - let profileIdColumnLiteral: SQL = SQL(stringLiteral: Profile.Columns.id.name) - /// **Note:** The `numColumnsBeforeProfiles` value **MUST** match the number of fields before - /// the `ViewModel.contactProfileKey` entry below otherwise the query will fail to - /// parse and might throw + /// the `contactProfile` entry below otherwise the query will fail to parse and might throw /// /// Explicitly set default values for the fields ignored for search results let numColumnsBeforeProfiles: Int = 9 let request: SQLRequest = """ SELECT - \(thread.alias[Column.rowID]) AS \(ViewModel.rowIdKey), - \(thread[.id]) AS \(ViewModel.threadIdKey), - \(thread[.variant]) AS \(ViewModel.threadVariantKey), - \(thread[.creationDateTimestamp]) AS \(ViewModel.threadCreationDateTimestampKey), + \(thread[.rowId]) AS \(ViewModel.Columns.rowId), + \(thread[.id]) AS \(ViewModel.Columns.threadId), + \(thread[.variant]) AS \(ViewModel.Columns.threadVariant), + \(thread[.creationDateTimestamp]) AS \(ViewModel.Columns.threadCreationDateTimestamp), - (\(SQL("\(thread[.id]) = \(userPublicKey)"))) AS \(ViewModel.threadIsNoteToSelfKey), + (\(SQL("\(thread[.id]) = \(userPublicKey)"))) AS \(ViewModel.Columns.threadIsNoteToSelf), - IFNULL(\(thread[.pinnedPriority]), 0) AS \(ViewModel.threadPinnedPriorityKey), - \(contact[.isBlocked]) AS \(ViewModel.threadIsBlockedKey), - \(thread[.mutedUntilTimestamp]) AS \(ViewModel.threadMutedUntilTimestampKey), - \(thread[.onlyNotifyForMentions]) AS \(ViewModel.threadOnlyNotifyForMentionsKey), + IFNULL(\(thread[.pinnedPriority]), 0) AS \(ViewModel.Columns.threadPinnedPriority), + \(contact[.isBlocked]) AS \(ViewModel.Columns.threadIsBlocked), + \(thread[.mutedUntilTimestamp]) AS \(ViewModel.Columns.threadMutedUntilTimestamp), + \(thread[.onlyNotifyForMentions]) AS \(ViewModel.Columns.threadOnlyNotifyForMentions), - \(ViewModel.contactProfileKey).*, - \(ViewModel.closedGroupProfileFrontKey).*, - \(ViewModel.closedGroupProfileBackKey).*, - \(ViewModel.closedGroupProfileBackFallbackKey).*, + \(contactProfile.allColumns), + \(closedGroupProfileFront.allColumns), + \(closedGroupProfileBack.allColumns), + \(closedGroupProfileBackFallback.allColumns), - \(closedGroup[.name]) AS \(ViewModel.closedGroupNameKey), + \(closedGroup[.name]) AS \(ViewModel.Columns.closedGroupName), EXISTS ( SELECT 1 @@ -1028,7 +1073,7 @@ public extension SessionThreadViewModel { \(SQL("\(groupMember[.role]) != \(GroupMember.Role.zombie)")) AND \(SQL("\(groupMember[.profileId]) = \(userPublicKey)")) ) - ) AS \(ViewModel.currentUserIsClosedGroupMemberKey), + ) AS \(ViewModel.Columns.currentUserIsClosedGroupMember), EXISTS ( SELECT 1 @@ -1038,24 +1083,24 @@ public extension SessionThreadViewModel { \(SQL("\(groupMember[.role]) = \(GroupMember.Role.admin)")) AND \(SQL("\(groupMember[.profileId]) = \(userPublicKey)")) ) - ) AS \(ViewModel.currentUserIsClosedGroupAdminKey), + ) AS \(ViewModel.Columns.currentUserIsClosedGroupAdmin), - \(openGroup[.name]) AS \(ViewModel.openGroupNameKey), - \(openGroup[.server]) AS \(ViewModel.openGroupServerKey), - \(openGroup[.roomToken]) AS \(ViewModel.openGroupRoomTokenKey), - \(openGroup[.publicKey]) AS \(ViewModel.openGroupPublicKeyKey), - \(openGroup[.imageData]) AS \(ViewModel.openGroupProfilePictureDataKey), + \(openGroup[.name]) AS \(ViewModel.Columns.openGroupName), + \(openGroup[.server]) AS \(ViewModel.Columns.openGroupServer), + \(openGroup[.roomToken]) AS \(ViewModel.Columns.openGroupRoomToken), + \(openGroup[.publicKey]) AS \(ViewModel.Columns.openGroupPublicKey), + \(openGroup[.imageData]) AS \(ViewModel.Columns.openGroupProfilePictureData), - \(SQL("\(userPublicKey)")) AS \(ViewModel.currentUserPublicKeyKey) + \(SQL("\(userPublicKey)")) AS \(ViewModel.Columns.currentUserPublicKey) FROM \(SessionThread.self) LEFT JOIN \(Contact.self) ON \(contact[.id]) = \(thread[.id]) - LEFT JOIN \(Profile.self) AS \(ViewModel.contactProfileKey) ON \(ViewModel.contactProfileKey).\(profileIdColumnLiteral) = \(thread[.id]) + LEFT JOIN \(contactProfile) ON \(contactProfile[.id]) = \(thread[.id]) LEFT JOIN \(OpenGroup.self) ON \(openGroup[.threadId]) = \(thread[.id]) LEFT JOIN \(ClosedGroup.self) ON \(closedGroup[.threadId]) = \(thread[.id]) - LEFT JOIN \(Profile.self) AS \(ViewModel.closedGroupProfileFrontKey) ON ( - \(ViewModel.closedGroupProfileFrontKey).\(profileIdColumnLiteral) = ( + LEFT JOIN \(closedGroupProfileFront) ON ( + \(closedGroupProfileFront[.id]) = ( SELECT MIN(\(groupMember[.profileId])) FROM \(GroupMember.self) JOIN \(Profile.self) ON \(profile[.id]) = \(groupMember[.profileId]) @@ -1066,9 +1111,9 @@ public extension SessionThreadViewModel { ) ) ) - LEFT JOIN \(Profile.self) AS \(ViewModel.closedGroupProfileBackKey) ON ( - \(ViewModel.closedGroupProfileBackKey).\(profileIdColumnLiteral) != \(ViewModel.closedGroupProfileFrontKey).\(profileIdColumnLiteral) AND - \(ViewModel.closedGroupProfileBackKey).\(profileIdColumnLiteral) = ( + LEFT JOIN \(closedGroupProfileBack) ON ( + \(closedGroupProfileBack[.id]) != \(closedGroupProfileFront[.id]) AND + \(closedGroupProfileBack[.id]) = ( SELECT MAX(\(groupMember[.profileId])) FROM \(GroupMember.self) JOIN \(Profile.self) ON \(profile[.id]) = \(groupMember[.profileId]) @@ -1079,10 +1124,10 @@ public extension SessionThreadViewModel { ) ) ) - LEFT JOIN \(Profile.self) AS \(ViewModel.closedGroupProfileBackFallbackKey) ON ( + LEFT JOIN \(closedGroupProfileBackFallback) ON ( \(closedGroup[.threadId]) IS NOT NULL AND - \(ViewModel.closedGroupProfileBackKey).\(profileIdColumnLiteral) IS NULL AND - \(ViewModel.closedGroupProfileBackFallbackKey).\(profileIdColumnLiteral) = \(SQL("\(userPublicKey)")) + \(closedGroupProfileBack[.id]) IS NULL AND + \(closedGroupProfileBackFallback[.id]) = \(SQL("\(userPublicKey)")) ) WHERE \(SQL("\(thread[.id]) = \(threadId)")) @@ -1097,11 +1142,11 @@ public extension SessionThreadViewModel { Profile.numberOfSelectedColumns(db) ]) - return ScopeAdapter([ - ViewModel.contactProfileString: adapters[1], - ViewModel.closedGroupProfileFrontString: adapters[2], - ViewModel.closedGroupProfileBackString: adapters[3], - ViewModel.closedGroupProfileBackFallbackString: adapters[4] + return ScopeAdapter.with(ViewModel.self, [ + .contactProfile: adapters[1], + .closedGroupProfileFront: adapters[2], + .closedGroupProfileBack: adapters[3], + .closedGroupProfileBackFallback: adapters[4] ]) } } @@ -1188,13 +1233,15 @@ public extension SessionThreadViewModel { static func messagesQuery(userPublicKey: String, pattern: FTS5Pattern) -> AdaptedFetchRequest> { let interaction: TypedTableAlias = TypedTableAlias() let thread: TypedTableAlias = TypedTableAlias() + let profile: TypedTableAlias = TypedTableAlias() + let contactProfile: TypedTableAlias = TypedTableAlias(ViewModel.self, column: .contactProfile) let closedGroup: TypedTableAlias = TypedTableAlias() + let closedGroupProfileFront: TypedTableAlias = TypedTableAlias(ViewModel.self, column: .closedGroupProfileFront) + let closedGroupProfileBack: TypedTableAlias = TypedTableAlias(ViewModel.self, column: .closedGroupProfileBack) + let closedGroupProfileBackFallback: TypedTableAlias = TypedTableAlias(ViewModel.self, column: .closedGroupProfileBackFallback) let groupMember: TypedTableAlias = TypedTableAlias() let openGroup: TypedTableAlias = TypedTableAlias() - let profile: TypedTableAlias = TypedTableAlias() - let profileIdColumnLiteral: SQL = SQL(stringLiteral: Profile.Columns.id.name) - let interactionLiteral: SQL = SQL(stringLiteral: Interaction.databaseTableName) - let interactionFullTextSearch: SQL = SQL(stringLiteral: Interaction.fullTextSearchTableName) + let interactionFullTextSearch: TypedTableAlias = TypedTableAlias(name: Interaction.fullTextSearchTableName) /// **Note:** The `numColumnsBeforeProfiles` value **MUST** match the number of fields before /// the `ViewModel.contactProfileKey` entry below otherwise the query will fail to @@ -1204,44 +1251,44 @@ public extension SessionThreadViewModel { let numColumnsBeforeProfiles: Int = 6 let request: SQLRequest = """ SELECT - \(interaction.alias[Column.rowID]) AS \(ViewModel.rowIdKey), - \(thread[.id]) AS \(ViewModel.threadIdKey), - \(thread[.variant]) AS \(ViewModel.threadVariantKey), - \(thread[.creationDateTimestamp]) AS \(ViewModel.threadCreationDateTimestampKey), + \(interaction[.rowId]) AS \(ViewModel.Columns.rowId), + \(thread[.id]) AS \(ViewModel.Columns.threadId), + \(thread[.variant]) AS \(ViewModel.Columns.threadVariant), + \(thread[.creationDateTimestamp]) AS \(ViewModel.Columns.threadCreationDateTimestamp), - (\(SQL("\(thread[.id]) = \(userPublicKey)"))) AS \(ViewModel.threadIsNoteToSelfKey), - IFNULL(\(thread[.pinnedPriority]), 0) AS \(ViewModel.threadPinnedPriorityKey), + (\(SQL("\(thread[.id]) = \(userPublicKey)"))) AS \(ViewModel.Columns.threadIsNoteToSelf), + IFNULL(\(thread[.pinnedPriority]), 0) AS \(ViewModel.Columns.threadPinnedPriority), - \(ViewModel.contactProfileKey).*, - \(ViewModel.closedGroupProfileFrontKey).*, - \(ViewModel.closedGroupProfileBackKey).*, - \(ViewModel.closedGroupProfileBackFallbackKey).*, - \(closedGroup[.name]) AS \(ViewModel.closedGroupNameKey), - \(openGroup[.name]) AS \(ViewModel.openGroupNameKey), - \(openGroup[.imageData]) AS \(ViewModel.openGroupProfilePictureDataKey), + \(contactProfile.allColumns), + \(closedGroupProfileFront.allColumns), + \(closedGroupProfileBack.allColumns), + \(closedGroupProfileBackFallback.allColumns), + \(closedGroup[.name]) AS \(ViewModel.Columns.closedGroupName), + \(openGroup[.name]) AS \(ViewModel.Columns.openGroupName), + \(openGroup[.imageData]) AS \(ViewModel.Columns.openGroupProfilePictureData), - \(interaction[.id]) AS \(ViewModel.interactionIdKey), - \(interaction[.variant]) AS \(ViewModel.interactionVariantKey), - \(interaction[.timestampMs]) AS \(ViewModel.interactionTimestampMsKey), - snippet(\(interactionFullTextSearch), -1, '', '', '...', 6) AS \(ViewModel.interactionBodyKey), + \(interaction[.id]) AS \(ViewModel.Columns.interactionId), + \(interaction[.variant]) AS \(ViewModel.Columns.interactionVariant), + \(interaction[.timestampMs]) AS \(ViewModel.Columns.interactionTimestampMs), + snippet(\(interactionFullTextSearch), -1, '', '', '...', 6) AS \(ViewModel.Columns.interactionBody), \(interaction[.authorId]), - IFNULL(\(profile[.nickname]), \(profile[.name])) AS \(ViewModel.authorNameInternalKey), - \(SQL("\(userPublicKey)")) AS \(ViewModel.currentUserPublicKeyKey) + IFNULL(\(profile[.nickname]), \(profile[.name])) AS \(ViewModel.Columns.authorNameInternal), + \(SQL("\(userPublicKey)")) AS \(ViewModel.Columns.currentUserPublicKey) FROM \(Interaction.self) JOIN \(interactionFullTextSearch) ON ( - \(interactionFullTextSearch).rowid = \(interactionLiteral).rowid AND - \(interactionFullTextSearch).\(SQL(stringLiteral: Interaction.Columns.body.name)) MATCH \(pattern) + \(interactionFullTextSearch[.rowId]) = \(interaction[.rowId]) AND + \(interactionFullTextSearch[.body]) MATCH \(pattern) ) JOIN \(SessionThread.self) ON \(thread[.id]) = \(interaction[.threadId]) JOIN \(Profile.self) ON \(profile[.id]) = \(interaction[.authorId]) - LEFT JOIN \(Profile.self) AS \(ViewModel.contactProfileKey) ON \(ViewModel.contactProfileKey).\(profileIdColumnLiteral) = \(interaction[.threadId]) + LEFT JOIN \(contactProfile) ON \(contactProfile[.id]) = \(interaction[.threadId]) LEFT JOIN \(ClosedGroup.self) ON \(closedGroup[.threadId]) = \(interaction[.threadId]) LEFT JOIN \(OpenGroup.self) ON \(openGroup[.threadId]) = \(interaction[.threadId]) - LEFT JOIN \(Profile.self) AS \(ViewModel.closedGroupProfileFrontKey) ON ( - \(ViewModel.closedGroupProfileFrontKey).\(profileIdColumnLiteral) = ( + LEFT JOIN \(closedGroupProfileFront) ON ( + \(closedGroupProfileFront[.id]) = ( SELECT MIN(\(groupMember[.profileId])) FROM \(GroupMember.self) JOIN \(Profile.self) ON \(profile[.id]) = \(groupMember[.profileId]) @@ -1252,9 +1299,9 @@ public extension SessionThreadViewModel { ) ) ) - LEFT JOIN \(Profile.self) AS \(ViewModel.closedGroupProfileBackKey) ON ( - \(ViewModel.closedGroupProfileBackKey).\(profileIdColumnLiteral) != \(ViewModel.closedGroupProfileFrontKey).\(profileIdColumnLiteral) AND - \(ViewModel.closedGroupProfileBackKey).\(profileIdColumnLiteral) = ( + LEFT JOIN \(closedGroupProfileBack) ON ( + \(closedGroupProfileBack[.id]) != \(closedGroupProfileFront[.id]) AND + \(closedGroupProfileBack[.id]) = ( SELECT MAX(\(groupMember[.profileId])) FROM \(GroupMember.self) JOIN \(Profile.self) ON \(profile[.id]) = \(groupMember[.profileId]) @@ -1265,10 +1312,10 @@ public extension SessionThreadViewModel { ) ) ) - LEFT JOIN \(Profile.self) AS \(ViewModel.closedGroupProfileBackFallbackKey) ON ( + LEFT JOIN \(closedGroupProfileBackFallback) ON ( \(closedGroup[.threadId]) IS NOT NULL AND - \(ViewModel.closedGroupProfileBackKey).\(profileIdColumnLiteral) IS NULL AND - \(ViewModel.closedGroupProfileBackFallbackKey).\(profileIdColumnLiteral) = \(userPublicKey) + \(closedGroupProfileBack[.id]) IS NULL AND + \(closedGroupProfileBackFallback[.id]) = \(userPublicKey) ) ORDER BY \(Column.rank), \(interaction[.timestampMs].desc) @@ -1284,11 +1331,11 @@ public extension SessionThreadViewModel { Profile.numberOfSelectedColumns(db) ]) - return ScopeAdapter([ - ViewModel.contactProfileString: adapters[1], - ViewModel.closedGroupProfileFrontString: adapters[2], - ViewModel.closedGroupProfileBackString: adapters[3], - ViewModel.closedGroupProfileBackFallbackString: adapters[4] + return ScopeAdapter.with(ViewModel.self, [ + .contactProfile: adapters[1], + .closedGroupProfileFront: adapters[2], + .closedGroupProfileBack: adapters[3], + .closedGroupProfileBackFallback: adapters[4] ]) } } @@ -1311,31 +1358,26 @@ public extension SessionThreadViewModel { /// returned results will always be `-1` for those results static func contactsAndGroupsQuery(userPublicKey: String, pattern: FTS5Pattern, searchTerm: String) -> AdaptedFetchRequest> { let thread: TypedTableAlias = TypedTableAlias() + let contactProfile: TypedTableAlias = TypedTableAlias(ViewModel.self, column: .contactProfile) let closedGroup: TypedTableAlias = TypedTableAlias() + let closedGroupProfileFront: TypedTableAlias = TypedTableAlias(ViewModel.self, column: .closedGroupProfileFront) + let closedGroupProfileBack: TypedTableAlias = TypedTableAlias(ViewModel.self, column: .closedGroupProfileBack) + let closedGroupProfileBackFallback: TypedTableAlias = TypedTableAlias(ViewModel.self, column: .closedGroupProfileBackFallback) let groupMember: TypedTableAlias = TypedTableAlias() + let groupMemberProfile: TypedTableAlias = TypedTableAlias(name: "groupMemberProfile") let openGroup: TypedTableAlias = TypedTableAlias() + let groupMemberInfo: TypedTableAlias = TypedTableAlias(name: "groupMemberInfo") let profile: TypedTableAlias = TypedTableAlias() let contact: TypedTableAlias = TypedTableAlias() - let profileIdColumnLiteral: SQL = SQL(stringLiteral: Profile.Columns.id.name) - let profileNicknameColumnLiteral: SQL = SQL(stringLiteral: Profile.Columns.nickname.name) - let profileNameColumnLiteral: SQL = SQL(stringLiteral: Profile.Columns.name.name) + let profileFullTextSearch: TypedTableAlias = TypedTableAlias(name: Profile.fullTextSearchTableName) + let closedGroupFullTextSearch: TypedTableAlias = TypedTableAlias(name: ClosedGroup.fullTextSearchTableName) + let openGroupFullTextSearch: TypedTableAlias = TypedTableAlias(name: OpenGroup.fullTextSearchTableName) - let profileFullTextSearch: SQL = SQL(stringLiteral: Profile.fullTextSearchTableName) - let closedGroupNameColumnLiteral: SQL = SQL(stringLiteral: ClosedGroup.Columns.name.name) - let closedGroupLiteral: SQL = SQL(stringLiteral: ClosedGroup.databaseTableName) - let closedGroupFullTextSearch: SQL = SQL(stringLiteral: ClosedGroup.fullTextSearchTableName) - let openGroupNameColumnLiteral: SQL = SQL(stringLiteral: OpenGroup.Columns.name.name) - let openGroupLiteral: SQL = SQL(stringLiteral: OpenGroup.databaseTableName) - let openGroupFullTextSearch: SQL = SQL(stringLiteral: OpenGroup.fullTextSearchTableName) - let groupMemberInfoLiteral: SQL = SQL(stringLiteral: "groupMemberInfo") - let groupMemberGroupIdColumnLiteral: SQL = SQL(stringLiteral: GroupMember.Columns.groupId.name) - let groupMemberProfileLiteral: SQL = SQL(stringLiteral: "groupMemberProfile") let noteToSelfLiteral: SQL = SQL(stringLiteral: "NOTE_TO_SELF".localized().lowercased()) let searchTermLiteral: SQL = SQL(stringLiteral: searchTerm.lowercased()) /// **Note:** The `numColumnsBeforeProfiles` value **MUST** match the number of fields before - /// the `ViewModel.contactProfileKey` entry below otherwise the query will fail to - /// parse and might throw + /// the `contactProfile` entry below otherwise the query will fail to parse and might throw /// /// We use `IFNULL(rank, 100)` because the custom `Note to Self` like comparison will get a null /// `rank` value which ends up as the first result, by defaulting to `100` it will always be ranked last compared @@ -1346,24 +1388,24 @@ public extension SessionThreadViewModel { SELECT IFNULL(\(Column.rank), 100) AS \(Column.rank), - \(thread.alias[Column.rowID]) AS \(ViewModel.rowIdKey), - \(thread[.id]) AS \(ViewModel.threadIdKey), - \(thread[.variant]) AS \(ViewModel.threadVariantKey), - \(thread[.creationDateTimestamp]) AS \(ViewModel.threadCreationDateTimestampKey), - \(groupMemberInfoLiteral).\(ViewModel.threadMemberNamesKey), + \(thread[.rowId]) AS \(ViewModel.Columns.rowId), + \(thread[.id]) AS \(ViewModel.Columns.threadId), + \(thread[.variant]) AS \(ViewModel.Columns.threadVariant), + \(thread[.creationDateTimestamp]) AS \(ViewModel.Columns.threadCreationDateTimestamp), + \(groupMemberInfo[.threadMemberNames]), - (\(SQL("\(thread[.id]) = \(userPublicKey)"))) AS \(ViewModel.threadIsNoteToSelfKey), - IFNULL(\(thread[.pinnedPriority]), 0) AS \(ViewModel.threadPinnedPriorityKey), + (\(SQL("\(thread[.id]) = \(userPublicKey)"))) AS \(ViewModel.Columns.threadIsNoteToSelf), + IFNULL(\(thread[.pinnedPriority]), 0) AS \(ViewModel.Columns.threadPinnedPriority), - \(ViewModel.contactProfileKey).*, - \(ViewModel.closedGroupProfileFrontKey).*, - \(ViewModel.closedGroupProfileBackKey).*, - \(ViewModel.closedGroupProfileBackFallbackKey).*, - \(closedGroup[.name]) AS \(ViewModel.closedGroupNameKey), - \(openGroup[.name]) AS \(ViewModel.openGroupNameKey), - \(openGroup[.imageData]) AS \(ViewModel.openGroupProfilePictureDataKey), + \(contactProfile.allColumns), + \(closedGroupProfileFront.allColumns), + \(closedGroupProfileBack.allColumns), + \(closedGroupProfileBackFallback.allColumns), + \(closedGroup[.name]) AS \(ViewModel.Columns.closedGroupName), + \(openGroup[.name]) AS \(ViewModel.Columns.openGroupName), + \(openGroup[.imageData]) AS \(ViewModel.Columns.openGroupProfilePictureData), - \(SQL("\(userPublicKey)")) AS \(ViewModel.currentUserPublicKeyKey) + \(SQL("\(userPublicKey)")) AS \(ViewModel.Columns.currentUserPublicKey) FROM \(SessionThread.self) @@ -1371,18 +1413,13 @@ public extension SessionThreadViewModel { // MARK: --Contact Threads let contactQueryCommonJoinFilterGroup: SQL = """ - JOIN \(Profile.self) AS \(ViewModel.contactProfileKey) ON \(ViewModel.contactProfileKey).\(profileIdColumnLiteral) = \(thread[.id]) - LEFT JOIN \(Profile.self) AS \(ViewModel.closedGroupProfileFrontKey) ON false - LEFT JOIN \(Profile.self) AS \(ViewModel.closedGroupProfileBackKey) ON false - LEFT JOIN \(Profile.self) AS \(ViewModel.closedGroupProfileBackFallbackKey) ON false - LEFT JOIN \(ClosedGroup.self) ON false - LEFT JOIN \(OpenGroup.self) ON false - LEFT JOIN ( - SELECT - \(groupMember[.groupId]), - '' AS \(ViewModel.threadMemberNamesKey) - FROM \(GroupMember.self) - ) AS \(groupMemberInfoLiteral) ON false + JOIN \(contactProfile) ON \(contactProfile[.id]) = \(thread[.id]) + LEFT JOIN \(closedGroupProfileFront.never) + LEFT JOIN \(closedGroupProfileBack.never) + LEFT JOIN \(closedGroupProfileBackFallback.never) + LEFT JOIN \(closedGroup.never) + LEFT JOIN \(openGroup.never) + LEFT JOIN \(groupMemberInfo.never) WHERE \(SQL("\(thread[.variant]) = \(SessionThread.Variant.contact)")) AND @@ -1394,8 +1431,8 @@ public extension SessionThreadViewModel { sqlQuery += selectQuery sqlQuery += """ JOIN \(profileFullTextSearch) ON ( - \(profileFullTextSearch).rowid = \(ViewModel.contactProfileKey).rowid AND - \(profileFullTextSearch).\(profileNicknameColumnLiteral) MATCH \(pattern) + \(profileFullTextSearch[.rowId]) = \(contactProfile[.rowId]) AND + \(profileFullTextSearch[.nickname]) MATCH \(pattern) ) """ sqlQuery += contactQueryCommonJoinFilterGroup @@ -1409,8 +1446,8 @@ public extension SessionThreadViewModel { sqlQuery += selectQuery sqlQuery += """ JOIN \(profileFullTextSearch) ON ( - \(profileFullTextSearch).rowid = \(ViewModel.contactProfileKey).rowid AND - \(profileFullTextSearch).\(profileNameColumnLiteral) MATCH \(pattern) + \(profileFullTextSearch[.rowId]) = \(contactProfile[.rowId]) AND + \(profileFullTextSearch[.name]) MATCH \(pattern) ) """ sqlQuery += contactQueryCommonJoinFilterGroup @@ -1425,14 +1462,14 @@ public extension SessionThreadViewModel { LEFT JOIN ( SELECT \(groupMember[.groupId]), - GROUP_CONCAT(IFNULL(\(profile[.nickname]), \(profile[.name])), ', ') AS \(ViewModel.threadMemberNamesKey) + GROUP_CONCAT(IFNULL(\(profile[.nickname]), \(profile[.name])), ', ') AS \(GroupMemberInfo.Columns.threadMemberNames) FROM \(GroupMember.self) JOIN \(Profile.self) ON \(profile[.id]) = \(groupMember[.profileId]) WHERE \(SQL("\(groupMember[.role]) = \(GroupMember.Role.standard)")) GROUP BY \(groupMember[.groupId]) - ) AS \(groupMemberInfoLiteral) ON \(groupMemberInfoLiteral).\(groupMemberGroupIdColumnLiteral) = \(closedGroup[.threadId]) - LEFT JOIN \(Profile.self) AS \(ViewModel.closedGroupProfileFrontKey) ON ( - \(ViewModel.closedGroupProfileFrontKey).\(profileIdColumnLiteral) = ( + ) AS \(groupMemberInfo) ON \(groupMemberInfo[.groupId]) = \(closedGroup[.threadId]) + LEFT JOIN \(closedGroupProfileFront) ON ( + \(closedGroupProfileFront[.id]) = ( SELECT MIN(\(groupMember[.profileId])) FROM \(GroupMember.self) JOIN \(Profile.self) ON \(profile[.id]) = \(groupMember[.profileId]) @@ -1443,9 +1480,9 @@ public extension SessionThreadViewModel { ) ) ) - LEFT JOIN \(Profile.self) AS \(ViewModel.closedGroupProfileBackKey) ON ( - \(ViewModel.closedGroupProfileBackKey).\(profileIdColumnLiteral) != \(ViewModel.closedGroupProfileFrontKey).\(profileIdColumnLiteral) AND - \(ViewModel.closedGroupProfileBackKey).\(profileIdColumnLiteral) = ( + LEFT JOIN \(closedGroupProfileBack) ON ( + \(closedGroupProfileBack[.id]) != \(closedGroupProfileFront[.id]) AND + \(closedGroupProfileBack[.id]) = ( SELECT MAX(\(groupMember[.profileId])) FROM \(GroupMember.self) JOIN \(Profile.self) ON \(profile[.id]) = \(groupMember[.profileId]) @@ -1456,13 +1493,13 @@ public extension SessionThreadViewModel { ) ) ) - LEFT JOIN \(Profile.self) AS \(ViewModel.closedGroupProfileBackFallbackKey) ON ( - \(ViewModel.closedGroupProfileBackKey).\(profileIdColumnLiteral) IS NULL AND - \(ViewModel.closedGroupProfileBackFallbackKey).\(profileIdColumnLiteral) = \(userPublicKey) + LEFT JOIN \(closedGroupProfileBackFallback) ON ( + \(closedGroupProfileBack[.id]) IS NULL AND + \(closedGroupProfileBackFallback[.id]) = \(userPublicKey) ) - LEFT JOIN \(Profile.self) AS \(ViewModel.contactProfileKey) ON false - LEFT JOIN \(OpenGroup.self) ON false + LEFT JOIN \(contactProfile.never) + LEFT JOIN \(openGroup.never) WHERE ( \(SQL("\(thread[.variant]) = \(SessionThread.Variant.legacyGroup)")) OR @@ -1480,8 +1517,8 @@ public extension SessionThreadViewModel { sqlQuery += selectQuery sqlQuery += """ JOIN \(closedGroupFullTextSearch) ON ( - \(closedGroupFullTextSearch).rowid = \(closedGroupLiteral).rowid AND - \(closedGroupFullTextSearch).\(closedGroupNameColumnLiteral) MATCH \(pattern) + \(closedGroupFullTextSearch[.rowId]) = \(closedGroup[.rowId]) AND + \(closedGroupFullTextSearch[.name]) MATCH \(pattern) ) """ sqlQuery += closedGroupQueryCommonJoinFilterGroup @@ -1494,10 +1531,10 @@ public extension SessionThreadViewModel { """ sqlQuery += selectQuery sqlQuery += """ - JOIN \(Profile.self) AS \(groupMemberProfileLiteral) ON \(groupMemberProfileLiteral).\(profileIdColumnLiteral) = \(groupMember[.profileId]) + JOIN \(groupMemberProfile) ON \(groupMemberProfile[.id]) = \(groupMember[.profileId]) JOIN \(profileFullTextSearch) ON ( - \(profileFullTextSearch).rowid = \(groupMemberProfileLiteral).rowid AND - \(profileFullTextSearch).\(profileNicknameColumnLiteral) MATCH \(pattern) + \(profileFullTextSearch[.rowId]) = \(groupMemberProfile[.rowId]) AND + \(profileFullTextSearch[.nickname]) MATCH \(pattern) ) """ sqlQuery += closedGroupQueryCommonJoinFilterGroup @@ -1510,10 +1547,10 @@ public extension SessionThreadViewModel { """ sqlQuery += selectQuery sqlQuery += """ - JOIN \(Profile.self) AS \(groupMemberProfileLiteral) ON \(groupMemberProfileLiteral).\(profileIdColumnLiteral) = \(groupMember[.profileId]) + JOIN \(groupMemberProfile) ON \(groupMemberProfile[.id]) = \(groupMember[.profileId]) JOIN \(profileFullTextSearch) ON ( - \(profileFullTextSearch).rowid = \(groupMemberProfileLiteral).rowid AND - \(profileFullTextSearch).\(profileNameColumnLiteral) MATCH \(pattern) + \(profileFullTextSearch[.rowId]) = \(groupMemberProfile[.rowId]) AND + \(profileFullTextSearch[.name]) MATCH \(pattern) ) """ sqlQuery += closedGroupQueryCommonJoinFilterGroup @@ -1529,20 +1566,15 @@ public extension SessionThreadViewModel { sqlQuery += """ JOIN \(OpenGroup.self) ON \(openGroup[.threadId]) = \(thread[.id]) JOIN \(openGroupFullTextSearch) ON ( - \(openGroupFullTextSearch).rowid = \(openGroupLiteral).rowid AND - \(openGroupFullTextSearch).\(openGroupNameColumnLiteral) MATCH \(pattern) + \(openGroupFullTextSearch[.rowId]) = \(openGroup[.rowId]) AND + \(openGroupFullTextSearch[.name]) MATCH \(pattern) ) - LEFT JOIN \(Profile.self) AS \(ViewModel.contactProfileKey) ON false - LEFT JOIN \(Profile.self) AS \(ViewModel.closedGroupProfileFrontKey) ON false - LEFT JOIN \(Profile.self) AS \(ViewModel.closedGroupProfileBackKey) ON false - LEFT JOIN \(Profile.self) AS \(ViewModel.closedGroupProfileBackFallbackKey) ON false - LEFT JOIN \(ClosedGroup.self) ON false - LEFT JOIN ( - SELECT - \(groupMember[.groupId]), - '' AS \(ViewModel.threadMemberNamesKey) - FROM \(GroupMember.self) - ) AS \(groupMemberInfoLiteral) ON false + LEFT JOIN \(contactProfile.never) + LEFT JOIN \(closedGroupProfileFront.never) + LEFT JOIN \(closedGroupProfileBack.never) + LEFT JOIN \(closedGroupProfileBackFallback.never) + LEFT JOIN \(closedGroup.never) + LEFT JOIN \(groupMemberInfo.never) WHERE \(SQL("\(thread[.variant]) = \(SessionThread.Variant.community)")) AND @@ -1552,18 +1584,13 @@ public extension SessionThreadViewModel { // MARK: --Note to Self Thread let noteToSelfQueryCommonJoins: SQL = """ - JOIN \(Profile.self) AS \(ViewModel.contactProfileKey) ON \(ViewModel.contactProfileKey).\(profileIdColumnLiteral) = \(thread[.id]) - LEFT JOIN \(Profile.self) AS \(ViewModel.closedGroupProfileFrontKey) ON false - LEFT JOIN \(Profile.self) AS \(ViewModel.closedGroupProfileBackKey) ON false - LEFT JOIN \(Profile.self) AS \(ViewModel.closedGroupProfileBackFallbackKey) ON false - LEFT JOIN \(OpenGroup.self) ON false - LEFT JOIN \(ClosedGroup.self) ON false - LEFT JOIN ( - SELECT - \(groupMember[.groupId]), - '' AS \(ViewModel.threadMemberNamesKey) - FROM \(GroupMember.self) - ) AS \(groupMemberInfoLiteral) ON false + JOIN \(contactProfile) ON \(contactProfile[.id]) = \(thread[.id]) + LEFT JOIN \(closedGroupProfileFront.never) + LEFT JOIN \(closedGroupProfileBack.never) + LEFT JOIN \(closedGroupProfileBackFallback.never) + LEFT JOIN \(openGroup.never) + LEFT JOIN \(closedGroup.never) + LEFT JOIN \(groupMemberInfo.never) """ // Note to self thread searching for 'Note to Self' (need to join an FTS table to @@ -1596,8 +1623,8 @@ public extension SessionThreadViewModel { sqlQuery += """ JOIN \(profileFullTextSearch) ON ( - \(profileFullTextSearch).rowid = \(ViewModel.contactProfileKey).rowid AND - \(profileFullTextSearch).\(profileNicknameColumnLiteral) MATCH \(pattern) + \(profileFullTextSearch[.rowId]) = \(contactProfile[.rowId]) AND + \(profileFullTextSearch[.nickname]) MATCH \(pattern) ) """ sqlQuery += noteToSelfQueryCommonJoins @@ -1616,8 +1643,8 @@ public extension SessionThreadViewModel { sqlQuery += """ JOIN \(profileFullTextSearch) ON ( - \(profileFullTextSearch).rowid = \(ViewModel.contactProfileKey).rowid AND - \(profileFullTextSearch).\(profileNameColumnLiteral) MATCH \(pattern) + \(profileFullTextSearch[.rowId]) = \(contactProfile[.rowId]) AND + \(profileFullTextSearch[.name]) MATCH \(pattern) ) """ sqlQuery += noteToSelfQueryCommonJoins @@ -1631,41 +1658,36 @@ public extension SessionThreadViewModel { SELECT IFNULL(\(Column.rank), 100) AS \(Column.rank), - -1 AS \(ViewModel.rowIdKey), - \(contact[.id]) AS \(ViewModel.threadIdKey), - \(SQL("\(SessionThread.Variant.contact)")) AS \(ViewModel.threadVariantKey), - 0 AS \(ViewModel.threadCreationDateTimestampKey), - \(groupMemberInfoLiteral).\(ViewModel.threadMemberNamesKey), + -1 AS \(ViewModel.Columns.rowId), + \(contact[.id]) AS \(ViewModel.Columns.threadId), + \(SQL("\(SessionThread.Variant.contact)")) AS \(ViewModel.Columns.threadVariant), + 0 AS \(ViewModel.Columns.threadCreationDateTimestamp), + \(groupMemberInfo[.threadMemberNames]), - false AS \(ViewModel.threadIsNoteToSelfKey), - -1 AS \(ViewModel.threadPinnedPriorityKey), + false AS \(ViewModel.Columns.threadIsNoteToSelf), + -1 AS \(ViewModel.Columns.threadPinnedPriority), - \(ViewModel.contactProfileKey).*, - \(ViewModel.closedGroupProfileFrontKey).*, - \(ViewModel.closedGroupProfileBackKey).*, - \(ViewModel.closedGroupProfileBackFallbackKey).*, - \(closedGroup[.name]) AS \(ViewModel.closedGroupNameKey), - \(openGroup[.name]) AS \(ViewModel.openGroupNameKey), - \(openGroup[.imageData]) AS \(ViewModel.openGroupProfilePictureDataKey), + \(contactProfile.allColumns), + \(closedGroupProfileFront.allColumns), + \(closedGroupProfileBack.allColumns), + \(closedGroupProfileBackFallback.allColumns), + \(closedGroup[.name]) AS \(ViewModel.Columns.closedGroupName), + \(openGroup[.name]) AS \(ViewModel.Columns.openGroupName), + \(openGroup[.imageData]) AS \(ViewModel.Columns.openGroupProfilePictureData), - \(SQL("\(userPublicKey)")) AS \(ViewModel.currentUserPublicKeyKey) + \(SQL("\(userPublicKey)")) AS \(ViewModel.Columns.currentUserPublicKey) FROM \(Contact.self) """ let hiddenContactQueryCommonJoins: SQL = """ - JOIN \(Profile.self) AS \(ViewModel.contactProfileKey) ON \(ViewModel.contactProfileKey).\(profileIdColumnLiteral) = \(contact[.id]) + JOIN \(contactProfile) ON \(contactProfile[.id]) = \(contact[.id]) LEFT JOIN \(SessionThread.self) ON \(thread[.id]) = \(contact[.id]) - LEFT JOIN \(Profile.self) AS \(ViewModel.closedGroupProfileFrontKey) ON false - LEFT JOIN \(Profile.self) AS \(ViewModel.closedGroupProfileBackKey) ON false - LEFT JOIN \(Profile.self) AS \(ViewModel.closedGroupProfileBackFallbackKey) ON false - LEFT JOIN \(ClosedGroup.self) ON false - LEFT JOIN \(OpenGroup.self) ON false - LEFT JOIN ( - SELECT - \(groupMember[.groupId]), - '' AS \(ViewModel.threadMemberNamesKey) - FROM \(GroupMember.self) - ) AS \(groupMemberInfoLiteral) ON false + LEFT JOIN \(closedGroupProfileFront.never) + LEFT JOIN \(closedGroupProfileBack.never) + LEFT JOIN \(closedGroupProfileBackFallback.never) + LEFT JOIN \(closedGroup.never) + LEFT JOIN \(openGroup.never) + LEFT JOIN \(groupMemberInfo.never) WHERE \(thread[.id]) IS NULL GROUP BY \(contact[.id]) @@ -1681,8 +1703,8 @@ public extension SessionThreadViewModel { sqlQuery += """ JOIN \(profileFullTextSearch) ON ( - \(profileFullTextSearch).rowid = \(ViewModel.contactProfileKey).rowid AND - \(profileFullTextSearch).\(profileNicknameColumnLiteral) MATCH \(pattern) + \(profileFullTextSearch[.rowId]) = \(contactProfile[.rowId]) AND + \(profileFullTextSearch[.nickname]) MATCH \(pattern) ) """ sqlQuery += hiddenContactQueryCommonJoins @@ -1697,8 +1719,8 @@ public extension SessionThreadViewModel { sqlQuery += """ JOIN \(profileFullTextSearch) ON ( - \(profileFullTextSearch).rowid = \(ViewModel.contactProfileKey).rowid AND - \(profileFullTextSearch).\(profileNameColumnLiteral) MATCH \(pattern) + \(profileFullTextSearch[.rowId]) = \(contactProfile[.rowId]) AND + \(profileFullTextSearch[.name]) MATCH \(pattern) ) """ sqlQuery += hiddenContactQueryCommonJoins @@ -1713,13 +1735,13 @@ public extension SessionThreadViewModel { \(sqlQuery) ) - GROUP BY \(ViewModel.threadIdKey) + GROUP BY \(ViewModel.Columns.threadId) ORDER BY \(Column.rank), - \(ViewModel.threadIsNoteToSelfKey), - \(ViewModel.closedGroupNameKey), - \(ViewModel.openGroupNameKey), - \(ViewModel.threadIdKey) + \(ViewModel.Columns.threadIsNoteToSelf), + \(ViewModel.Columns.closedGroupName), + \(ViewModel.Columns.openGroupName), + \(ViewModel.Columns.threadId) LIMIT \(SQL("\(SessionThreadViewModel.searchResultsLimit)")) """ @@ -1748,11 +1770,11 @@ public extension SessionThreadViewModel { Profile.numberOfSelectedColumns(db) ]) - return ScopeAdapter([ - ViewModel.contactProfileString: adapters[1], - ViewModel.closedGroupProfileFrontString: adapters[2], - ViewModel.closedGroupProfileBackString: adapters[3], - ViewModel.closedGroupProfileBackFallbackString: adapters[4] + return ScopeAdapter.with(ViewModel.self, [ + .contactProfile: adapters[1], + .closedGroupProfileFront: adapters[2], + .closedGroupProfileBack: adapters[3], + .closedGroupProfileBackFallback: adapters[4] ]) } } @@ -1760,31 +1782,30 @@ public extension SessionThreadViewModel { /// This method returns only the 'Note to Self' thread in the structure of a search result conversation static func noteToSelfOnlyQuery(userPublicKey: String) -> AdaptedFetchRequest> { let thread: TypedTableAlias = TypedTableAlias() - let profileIdColumnLiteral: SQL = SQL(stringLiteral: Profile.Columns.id.name) + let contactProfile: TypedTableAlias = TypedTableAlias(ViewModel.self, column: .contactProfile) /// **Note:** The `numColumnsBeforeProfiles` value **MUST** match the number of fields before - /// the `ViewModel.contactProfileKey` entry below otherwise the query will fail to - /// parse and might throw + /// the `contactProfile` entry below otherwise the query will fail to parse and might throw let numColumnsBeforeProfiles: Int = 8 let request: SQLRequest = """ SELECT 100 AS \(Column.rank), - \(thread.alias[Column.rowID]) AS \(ViewModel.rowIdKey), - \(thread[.id]) AS \(ViewModel.threadIdKey), - \(thread[.variant]) AS \(ViewModel.threadVariantKey), - \(thread[.creationDateTimestamp]) AS \(ViewModel.threadCreationDateTimestampKey), - '' AS \(ViewModel.threadMemberNamesKey), + \(thread[.rowId]) AS \(ViewModel.Columns.rowId), + \(thread[.id]) AS \(ViewModel.Columns.threadId), + \(thread[.variant]) AS \(ViewModel.Columns.threadVariant), + \(thread[.creationDateTimestamp]) AS \(ViewModel.Columns.threadCreationDateTimestamp), + '' AS \(ViewModel.Columns.threadMemberNames), - true AS \(ViewModel.threadIsNoteToSelfKey), - IFNULL(\(thread[.pinnedPriority]), 0) AS \(ViewModel.threadPinnedPriorityKey), + true AS \(ViewModel.Columns.threadIsNoteToSelf), + IFNULL(\(thread[.pinnedPriority]), 0) AS \(ViewModel.Columns.threadPinnedPriority), - \(ViewModel.contactProfileKey).*, + \(contactProfile.allColumns), - \(SQL("\(userPublicKey)")) AS \(ViewModel.currentUserPublicKeyKey) + \(SQL("\(userPublicKey)")) AS \(ViewModel.Columns.currentUserPublicKey) FROM \(SessionThread.self) - JOIN \(Profile.self) AS \(ViewModel.contactProfileKey) ON \(ViewModel.contactProfileKey).\(profileIdColumnLiteral) = \(thread[.id]) + JOIN \(contactProfile) ON \(contactProfile[.id]) = \(thread[.id]) WHERE \(SQL("\(thread[.id]) = \(userPublicKey)")) """ @@ -1797,8 +1818,8 @@ public extension SessionThreadViewModel { Profile.numberOfSelectedColumns(db) ]) - return ScopeAdapter([ - ViewModel.contactProfileString: adapters[1] + return ScopeAdapter.with(ViewModel.self, [ + .contactProfile: adapters[1] ]) } } @@ -1810,67 +1831,90 @@ public extension SessionThreadViewModel { static func shareQuery(userPublicKey: String) -> AdaptedFetchRequest> { let thread: TypedTableAlias = TypedTableAlias() let contact: TypedTableAlias = TypedTableAlias() + let contactProfile: TypedTableAlias = TypedTableAlias(ViewModel.self, column: .contactProfile) let closedGroup: TypedTableAlias = TypedTableAlias() + let closedGroupProfileFront: TypedTableAlias = TypedTableAlias(ViewModel.self, column: .closedGroupProfileFront) + let closedGroupProfileBack: TypedTableAlias = TypedTableAlias(ViewModel.self, column: .closedGroupProfileBack) + let closedGroupProfileBackFallback: TypedTableAlias = TypedTableAlias(ViewModel.self, column: .closedGroupProfileBackFallback) let groupMember: TypedTableAlias = TypedTableAlias() let openGroup: TypedTableAlias = TypedTableAlias() let profile: TypedTableAlias = TypedTableAlias() + let aggregateInteraction: TypedTableAlias = TypedTableAlias(name: "aggregateInteraction") let interaction: TypedTableAlias = TypedTableAlias() - let aggregateInteractionLiteral: SQL = SQL(stringLiteral: "aggregateInteraction") - let profileIdColumnLiteral: SQL = SQL(stringLiteral: Profile.Columns.id.name) - /// **Note:** The `numColumnsBeforeProfiles` value **MUST** match the number of fields before - /// the `ViewModel.contactProfileKey` entry below otherwise the query will fail to - /// parse and might throw + /// the `contactProfile` entry below otherwise the query will fail to parse and might throw /// /// Explicitly set default values for the fields ignored for search results - let numColumnsBeforeProfiles: Int = 7 + let numColumnsBeforeProfiles: Int = 8 let request: SQLRequest = """ SELECT - \(thread.alias[Column.rowID]) AS \(ViewModel.rowIdKey), - \(thread[.id]) AS \(ViewModel.threadIdKey), - \(thread[.variant]) AS \(ViewModel.threadVariantKey), - \(thread[.creationDateTimestamp]) AS \(ViewModel.threadCreationDateTimestampKey), + \(thread[.rowId]) AS \(ViewModel.Columns.rowId), + \(thread[.id]) AS \(ViewModel.Columns.threadId), + \(thread[.variant]) AS \(ViewModel.Columns.threadVariant), + \(thread[.creationDateTimestamp]) AS \(ViewModel.Columns.threadCreationDateTimestamp), - (\(SQL("\(thread[.id]) = \(userPublicKey)"))) AS \(ViewModel.threadIsNoteToSelfKey), + (\(SQL("\(thread[.id]) = \(userPublicKey)"))) AS \(ViewModel.Columns.threadIsNoteToSelf), + ( + \(SQL("\(thread[.variant]) = \(SessionThread.Variant.contact)")) AND + \(SQL("\(thread[.id]) != \(userPublicKey)")) AND + IFNULL(\(contact[.isApproved]), false) = false + ) AS \(ViewModel.Columns.threadIsMessageRequest), - IFNULL(\(thread[.pinnedPriority]), 0) AS \(ViewModel.threadPinnedPriorityKey), - \(contact[.isBlocked]) AS \(ViewModel.threadIsBlockedKey), + IFNULL(\(thread[.pinnedPriority]), 0) AS \(ViewModel.Columns.threadPinnedPriority), + \(contact[.isBlocked]) AS \(ViewModel.Columns.threadIsBlocked), - \(ViewModel.contactProfileKey).*, - \(ViewModel.closedGroupProfileFrontKey).*, - \(ViewModel.closedGroupProfileBackKey).*, - \(ViewModel.closedGroupProfileBackFallbackKey).*, - \(closedGroup[.name]) AS \(ViewModel.closedGroupNameKey), - \(openGroup[.name]) AS \(ViewModel.openGroupNameKey), - \(openGroup[.imageData]) AS \(ViewModel.openGroupProfilePictureDataKey), + \(contactProfile.allColumns), + \(closedGroupProfileFront.allColumns), + \(closedGroupProfileBack.allColumns), + \(closedGroupProfileBackFallback.allColumns), + \(closedGroup[.name]) AS \(ViewModel.Columns.closedGroupName), - \(SQL("\(userPublicKey)")) AS \(ViewModel.currentUserPublicKeyKey) + EXISTS ( + SELECT 1 + FROM \(GroupMember.self) + WHERE ( + \(groupMember[.groupId]) = \(closedGroup[.threadId]) AND + \(SQL("\(groupMember[.role]) != \(GroupMember.Role.zombie)")) AND + \(SQL("\(groupMember[.profileId]) = \(userPublicKey)")) + ) + ) AS \(ViewModel.Columns.currentUserIsClosedGroupMember), + + \(openGroup[.name]) AS \(ViewModel.Columns.openGroupName), + \(openGroup[.imageData]) AS \(ViewModel.Columns.openGroupProfilePictureData), + \(openGroup[.permissions]) AS \(ViewModel.Columns.openGroupPermissions), + + \(interaction[.id]) AS \(ViewModel.Columns.interactionId), + \(interaction[.variant]) AS \(ViewModel.Columns.interactionVariant), + + \(SQL("\(userPublicKey)")) AS \(ViewModel.Columns.currentUserPublicKey) FROM \(SessionThread.self) LEFT JOIN \(Contact.self) ON \(contact[.id]) = \(thread[.id]) LEFT JOIN ( SELECT - \(interaction[.id]) AS \(ViewModel.interactionIdKey), - \(interaction[.threadId]) AS \(ViewModel.threadIdKey), - MAX(\(interaction[.timestampMs])) + \(interaction[.id]) AS \(AggregateInteraction.Columns.interactionId), + \(interaction[.threadId]) AS \(AggregateInteraction.Columns.threadId), + MAX(\(interaction[.timestampMs])) AS \(AggregateInteraction.Columns.interactionTimestampMs), + 0 AS \(AggregateInteraction.Columns.threadUnreadCount), + 0 AS \(AggregateInteraction.Columns.threadUnreadMentionCount) FROM \(Interaction.self) WHERE \(SQL("\(interaction[.variant]) != \(Interaction.Variant.standardIncomingDeleted)")) GROUP BY \(interaction[.threadId]) - ) AS \(aggregateInteractionLiteral) ON \(aggregateInteractionLiteral).\(ViewModel.threadIdKey) = \(thread[.id]) + ) AS \(aggregateInteraction) ON \(aggregateInteraction[.threadId]) = \(thread[.id]) LEFT JOIN \(Interaction.self) ON ( \(interaction[.threadId]) = \(thread[.id]) AND - \(interaction[.id]) = \(aggregateInteractionLiteral).\(ViewModel.interactionIdKey) + \(interaction[.id]) = \(aggregateInteraction[.interactionId]) ) - LEFT JOIN \(Profile.self) AS \(ViewModel.contactProfileKey) ON \(ViewModel.contactProfileKey).\(profileIdColumnLiteral) = \(thread[.id]) + LEFT JOIN \(contactProfile) ON \(contactProfile[.id]) = \(thread[.id]) LEFT JOIN \(ClosedGroup.self) ON \(closedGroup[.threadId]) = \(thread[.id]) LEFT JOIN \(OpenGroup.self) ON \(openGroup[.threadId]) = \(thread[.id]) - LEFT JOIN \(Profile.self) AS \(ViewModel.closedGroupProfileFrontKey) ON ( - \(ViewModel.closedGroupProfileFrontKey).\(profileIdColumnLiteral) = ( + LEFT JOIN \(closedGroupProfileFront) ON ( + \(closedGroupProfileFront[.id]) = ( SELECT MIN(\(groupMember[.profileId])) FROM \(GroupMember.self) JOIN \(Profile.self) ON \(profile[.id]) = \(groupMember[.profileId]) @@ -1881,9 +1925,9 @@ public extension SessionThreadViewModel { ) ) ) - LEFT JOIN \(Profile.self) AS \(ViewModel.closedGroupProfileBackKey) ON ( - \(ViewModel.closedGroupProfileBackKey).\(profileIdColumnLiteral) != \(ViewModel.closedGroupProfileFrontKey).\(profileIdColumnLiteral) AND - \(ViewModel.closedGroupProfileBackKey).\(profileIdColumnLiteral) = ( + LEFT JOIN \(closedGroupProfileBack) ON ( + \(closedGroupProfileBack[.id]) != \(closedGroupProfileFront[.id]) AND + \(closedGroupProfileBack[.id]) = ( SELECT MAX(\(groupMember[.profileId])) FROM \(GroupMember.self) JOIN \(Profile.self) ON \(profile[.id]) = \(groupMember[.profileId]) @@ -1894,10 +1938,10 @@ public extension SessionThreadViewModel { ) ) ) - LEFT JOIN \(Profile.self) AS \(ViewModel.closedGroupProfileBackFallbackKey) ON ( + LEFT JOIN \(closedGroupProfileBackFallback) ON ( \(closedGroup[.threadId]) IS NOT NULL AND - \(ViewModel.closedGroupProfileBackKey).\(profileIdColumnLiteral) IS NULL AND - \(ViewModel.closedGroupProfileBackFallbackKey).\(profileIdColumnLiteral) = \(SQL("\(userPublicKey)")) + \(closedGroupProfileBack[.id]) IS NULL AND + \(closedGroupProfileBackFallback[.id]) = \(SQL("\(userPublicKey)")) ) WHERE ( @@ -1925,11 +1969,11 @@ public extension SessionThreadViewModel { Profile.numberOfSelectedColumns(db) ]) - return ScopeAdapter([ - ViewModel.contactProfileString: adapters[1], - ViewModel.closedGroupProfileFrontString: adapters[2], - ViewModel.closedGroupProfileBackString: adapters[3], - ViewModel.closedGroupProfileBackFallbackString: adapters[4] + return ScopeAdapter.with(ViewModel.self, [ + .contactProfile: adapters[1], + .closedGroupProfileFront: adapters[2], + .closedGroupProfileBack: adapters[3], + .closedGroupProfileBackFallback: adapters[4] ]) } } diff --git a/SessionMessagingKit/Utilities/AppReadiness.m b/SessionMessagingKit/Utilities/AppReadiness.m index 6698a777c..0af06e673 100755 --- a/SessionMessagingKit/Utilities/AppReadiness.m +++ b/SessionMessagingKit/Utilities/AppReadiness.m @@ -4,6 +4,9 @@ #import "AppReadiness.h" #import "AppContext.h" +#import +#import +#import NS_ASSUME_NONNULL_BEGIN @@ -63,7 +66,7 @@ NS_ASSUME_NONNULL_BEGIN - (void)runNowOrWhenAppWillBecomeReady:(AppReadyBlock)block { - if (CurrentAppContext().isRunningTests) { + if ([SNUtilitiesKitConfiguration isRunningTests]) { // We don't need to do any "on app ready" work in the tests. return; } @@ -90,7 +93,7 @@ NS_ASSUME_NONNULL_BEGIN - (void)runNowOrWhenAppDidBecomeReady:(AppReadyBlock)block { - if (CurrentAppContext().isRunningTests) { + if ([SNUtilitiesKitConfiguration isRunningTests]) { // We don't need to do any "on app ready" work in the tests. return; } diff --git a/SessionMessagingKit/Utilities/Crypto+SessionMessagingKit.swift b/SessionMessagingKit/Utilities/Crypto+SessionMessagingKit.swift new file mode 100644 index 000000000..fa285dd36 --- /dev/null +++ b/SessionMessagingKit/Utilities/Crypto+SessionMessagingKit.swift @@ -0,0 +1,152 @@ +// 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: - AeadXChaCha20Poly1305Ietf + +public extension Crypto.Size { + static let aeadXChaCha20NonceBytes: Crypto.Size = Crypto.Size(id: "aeadXChaCha20NonceBytes") { + Sodium().aead.xchacha20poly1305ietf.NonceBytes + } +} + +// 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/Preferences.swift b/SessionMessagingKit/Utilities/Preferences.swift index 34a00860e..4713e8ce1 100644 --- a/SessionMessagingKit/Utilities/Preferences.swift +++ b/SessionMessagingKit/Utilities/Preferences.swift @@ -65,6 +65,9 @@ public extension Setting.BoolKey { /// Controls whether concurrent audio messages should automatically be played after the one the user starts /// playing finishes static let shouldAutoPlayConsecutiveAudioMessages: Setting.BoolKey = "shouldAutoPlayConsecutiveAudioMessages" + + /// Controls whether the device will poll for community message requests (SOGS `/inbox` endpoint) + static let checkForCommunityMessageRequests: Setting.BoolKey = "checkForCommunityMessageRequests" } public extension Setting.StringKey { diff --git a/SessionMessagingKit/Utilities/ProfileManager.swift b/SessionMessagingKit/Utilities/ProfileManager.swift index 5ffc3d937..1a1488984 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.") @@ -495,30 +498,35 @@ public struct ProfileManager { _ db: Database, publicKey: String, name: String?, + blocksCommunityMessageRequests: Bool? = nil, 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] = [] // Name if let name: String = name, !name.isEmpty, name != profile.name { - // FIXME: Remove the `userConfigsEnabled` check once `useSharedUtilForUserConfig` is permanent - if sentTimestamp > profile.lastNameUpdate || (isCurrentUser && (calledFromConfigHandling || !SessionUtil.userConfigsEnabled(db))) { + if sentTimestamp > profile.lastNameUpdate || (isCurrentUser && calledFromConfigHandling) { profileChanges.append(Profile.Columns.name.set(to: name)) profileChanges.append(Profile.Columns.lastNameUpdate.set(to: sentTimestamp)) } } + // Blocks community message requets flag + if let blocksCommunityMessageRequests: Bool = blocksCommunityMessageRequests, sentTimestamp > profile.lastBlocksCommunityMessageRequests { + profileChanges.append(Profile.Columns.blocksCommunityMessageRequests.set(to: blocksCommunityMessageRequests)) + profileChanges.append(Profile.Columns.lastBlocksCommunityMessageRequests.set(to: sentTimestamp)) + } + // Profile picture & profile key var avatarNeedsDownload: Bool = false var targetAvatarUrl: String? = nil - // FIXME: Remove the `userConfigsEnabled` check once `useSharedUtilForUserConfig` is permanent - if sentTimestamp > profile.lastProfilePictureUpdate || (isCurrentUser && (calledFromConfigHandling || !SessionUtil.userConfigsEnabled(db))) { + if sentTimestamp > profile.lastProfilePictureUpdate || (isCurrentUser && calledFromConfigHandling) { switch avatarUpdate { case .none: break case .uploadImageData: preconditionFailure("Invalid options for this function") @@ -568,25 +576,6 @@ public struct ProfileManager { profileChanges ) } - // FIXME: Remove this once `useSharedUtilForUserConfig` is permanent - else if !SessionUtil.userConfigsEnabled(db) { - // If we have a contact record for the profile (ie. it's a synced profile) then - // should should send an updated config message, otherwise we should just update - // the local state (the shared util has this logic build in to it's handling) - if (try? Contact.exists(db, id: publicKey)) == true { - try Profile - .filter(id: publicKey) - .updateAllAndConfig(db, profileChanges) - } - else { - try Profile - .filter(id: publicKey) - .updateAll( - db, - profileChanges - ) - } - } else { try Profile .filter(id: publicKey) @@ -604,7 +593,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/Sodium+Utilities.swift b/SessionMessagingKit/Utilities/Sodium+Utilities.swift deleted file mode 100644 index e785ef186..000000000 --- a/SessionMessagingKit/Utilities/Sodium+Utilities.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 new file mode 100644 index 000000000..3de0762cf --- /dev/null +++ b/SessionMessagingKitTests/Jobs/Types/MessageSendJobSpec.swift @@ -0,0 +1,419 @@ +// Copyright © 2023 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import GRDB + +import Quick +import Nimble + +@testable import SessionMessagingKit +@testable import SessionUtilitiesKit + +class MessageSendJobSpec: QuickSpec { + // MARK: - Spec + + override func spec() { + var job: Job! + var interaction: Interaction! + var attachment: Attachment! + var interactionAttachment: InteractionAttachment! + var mockStorage: Storage! + var mockJobRunner: MockJobRunner! + var dependencies: Dependencies! + + // MARK: - JobRunner + + describe("a MessageSendJob") { + // MARK: - Configuration + + beforeEach { + mockStorage = SynchronousStorage( + customWriter: try! DatabaseQueue(), + customMigrationTargets: [ + SNUtilitiesKit.self, + SNMessagingKit.self + ] + ) + mockJobRunner = MockJobRunner() + dependencies = Dependencies( + storage: mockStorage, + jobRunner: mockJobRunner, + dateNow: Date(timeIntervalSince1970: 1234567890) + ) + attachment = Attachment( + id: "200", + variant: .standard, + state: .failedDownload, + contentType: "text/plain", + byteCount: 200 + ) + + mockStorage.write { db in + try SessionThread.fetchOrCreate(db, id: "Test1", variant: .contact, shouldBeVisible: true) + } + + mockJobRunner + .when { + $0.jobInfoFor( + jobs: nil, + state: .running, + variant: .attachmentUpload + ) + } + .thenReturn([:]) + mockJobRunner + .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 + job.id = 1000 + + try! job.insert(db) + } + .thenReturn((1000, Job(variant: .messageSend))) + } + + afterEach { + job = nil + mockStorage = nil + dependencies = nil + } + + // MARK: - fails when not given any details + it("fails when not given any details") { + job = Job(variant: .messageSend) + + 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 }, + using: dependencies + ) + + expect(error).to(matchError(JobRunnerError.missingRequiredDetails)) + expect(permanentFailure).to(beTrue()) + } + + // MARK: - fails when given incorrect details + it("fails when given incorrect details") { + job = Job( + variant: .messageSend, + details: MessageReceiveJob.Details(messages: [], calledFromBackgroundPoller: false) + ) + + 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 }, + using: dependencies + ) + + expect(error).to(matchError(JobRunnerError.missingRequiredDetails)) + expect(permanentFailure).to(beTrue()) + } + + // MARK: - of VisibleMessage + context("of VisibleMessage") { + beforeEach { + interaction = Interaction( + id: 100, + serverHash: nil, + messageUuid: nil, + threadId: "Test1", + authorId: "Test", + variant: .standardOutgoing, + body: "Test", + timestampMs: 1234567890, + receivedAtTimestampMs: 1234567900, + wasRead: false, + hasMention: false, + expiresInSeconds: nil, + expiresStartedAtMs: nil, + linkPreviewUrl: nil, + openGroupServerMessageId: nil, + openGroupWhisperMods: false, + openGroupWhisperTo: nil + ) + job = Job( + variant: .messageSend, + interactionId: interaction.id!, + details: MessageSendJob.Details( + destination: .contact(publicKey: "Test"), + message: VisibleMessage( + text: "Test" + ) + ) + ) + + mockStorage.write { db in + try interaction.insert(db) + try job.insert(db) + } + } + + // MARK: -- fails when there is no job id + it("fails when there is no job id") { + job = Job( + variant: .messageSend, + interactionId: interaction.id!, + details: MessageSendJob.Details( + destination: .contact(publicKey: "Test"), + message: VisibleMessage( + text: "Test" + ) + ) + ) + + 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 }, + 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, + details: MessageSendJob.Details( + destination: .contact(publicKey: "Test"), + message: VisibleMessage( + text: "Test" + ) + ) + ) + + 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 }, + 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, + interactionId: 12345, + details: MessageSendJob.Details( + destination: .contact(publicKey: "Test"), + message: VisibleMessage( + text: "Test" + ) + ) + ) + mockStorage.write { db in try job.insert(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 }, + using: dependencies + ) + + expect(error).to(matchError(StorageError.objectNotFound)) + expect(permanentFailure).to(beTrue()) + } + + // MARK: -- with an attachment + context("with an attachment") { + beforeEach { + interactionAttachment = InteractionAttachment( + albumIndex: 0, + interactionId: interaction.id!, + attachmentId: attachment.id + ) + + mockStorage.write { db in + 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 attachment.with(state: .failedDownload).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 }, + 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 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 attachment.with(state: .uploading).save(db) + } + + MessageSendJob.run( + job, + queue: .main, + success: { _, _, _ in }, + failure: { _, _, _, _ in }, + deferred: { _, _ in didDefer = true }, + 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 { + $0.jobInfoFor( + jobs: nil, + state: .running, + variant: .attachmentUpload + ) + } + .thenReturn([:]) + + MessageSendJob.run( + job, + queue: .main, + success: { _, _, _ in }, + failure: { _, _, _, _ in }, + deferred: { _, _ in }, + using: dependencies + ) + + expect(mockJobRunner) + .to(call(.exactly(times: 1), matchingParameters: true) { + $0.insert( + any(), + job: Job( + variant: .attachmentUpload, + behaviour: .runOnce, + shouldBlock: false, + shouldSkipLaunchBecomeActive: false, + interactionId: 100, + details: AttachmentUploadJob.Details( + messageSendJobId: 1, + attachmentId: "200" + ) + ), + 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, + queue: .main, + success: { _, _, _ in }, + failure: { _, _, _, _ in }, + deferred: { _, _ in }, + using: dependencies + ) + + expect(mockStorage.read { db in try JobDependencies.fetchOne(db) }) + .to(equal(JobDependencies(jobId: 9, dependantId: 1000))) + } + } + } + } + } + } +} diff --git a/SessionMessagingKitTests/LibSessionUtil/Configs/ConfigUserProfileSpec.swift b/SessionMessagingKitTests/LibSessionUtil/Configs/ConfigUserProfileSpec.swift index ce0ba7a6e..849cdc85b 100644 --- a/SessionMessagingKitTests/LibSessionUtil/Configs/ConfigUserProfileSpec.swift +++ b/SessionMessagingKitTests/LibSessionUtil/Configs/ConfigUserProfileSpec.swift @@ -285,6 +285,17 @@ class ConfigUserProfileSpec { ) user_profile_set_pic(conf2, p2) + user_profile_set_nts_expiry(conf2, 86400) + expect(user_profile_get_nts_expiry(conf2)).to(equal(86400)) + + expect(user_profile_get_blinded_msgreqs(conf2)).to(equal(-1)) + user_profile_set_blinded_msgreqs(conf2, 0) + expect(user_profile_get_blinded_msgreqs(conf2)).to(equal(0)) + user_profile_set_blinded_msgreqs(conf2, -1) + expect(user_profile_get_blinded_msgreqs(conf2)).to(equal(-1)) + user_profile_set_blinded_msgreqs(conf2, 1) + expect(user_profile_get_blinded_msgreqs(conf2)).to(equal(1)) + // Both have changes, so push need a push expect(config_needs_push(conf)).to(beTrue()) expect(config_needs_push(conf2)).to(beTrue()) @@ -364,6 +375,10 @@ class ConfigUserProfileSpec { .to(equal("7177657274007975696f31323334353637383930313233343536373839303132")) expect(user_profile_get_nts_priority(conf)).to(equal(9)) expect(user_profile_get_nts_priority(conf2)).to(equal(9)) + expect(user_profile_get_nts_expiry(conf)).to(equal(86400)) + expect(user_profile_get_nts_expiry(conf2)).to(equal(86400)) + expect(user_profile_get_blinded_msgreqs(conf)).to(equal(1)) + expect(user_profile_get_blinded_msgreqs(conf2)).to(equal(1)) let fakeHash4: String = "fakehash4" var cFakeHash4: [CChar] = fakeHash4.cArray.nullTerminated() 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..8586c99d1 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,372 @@ 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 and checking for message requests + context("when blinded and checking for message requests") { + beforeEach { + mockStorage.write { db in + db[.checkForCommunityMessageRequests] = true + + _ = 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()) + expect(preparedRequest?.batchEndpoints).to(contain(.outboxSince(id: 125))) + } + } + + // MARK: -- when blinded and not checking for message requests + context("when blinded and not checking for message requests") { + beforeEach { + mockStorage.write { db in + db[.checkForCommunityMessageRequests] = false + + _ = 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("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) + // MARK: ---- includes the inbox and outbox endpoints + it("does not include the inbox endpoint") { + let preparedRequest: OpenGroupAPI.PreparedSendData? = mockStorage.read { db in + try OpenGroupAPI.preparedPoll( + db, + server: "testserver", + hasPerformedInitialPoll: false, + timeSinceLastPoll: 0, + using: dependencies ) + } - expect(pollResponse).to(beNil()) + expect(preparedRequest?.batchEndpoints).toNot(contain(.inbox)) + } + + // MARK: ---- does not retrieve recent inbox messages if there was no last message + it("does not retrieve 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(preparedRequest?.batchEndpoints).toNot(contain(.inbox)) + } + + // MARK: ---- does not retrieve inbox messages since the last message if there was one + it("does not retrieve inbox messages since the last message if there was one") { + mockStorage.write { db in + try OpenGroup + .updateAll(db, OpenGroup.Columns.inboxLatestMessageId.set(to: 124)) + } + + let preparedRequest: OpenGroupAPI.PreparedSendData? = mockStorage.read { db in + try OpenGroupAPI.preparedPoll( + db, + server: "testserver", + hasPerformedInitialPoll: true, + timeSinceLastPoll: 0, + using: dependencies + ) + } + + expect(preparedRequest?.batchEndpoints).toNot(contain(.inboxSince(id: 124))) } } } - // 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 +484,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 +1911,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/SessionNotificationServiceExtension/NSENotificationPresenter.swift b/SessionNotificationServiceExtension/NSENotificationPresenter.swift index acff494bb..2a0d12b80 100644 --- a/SessionNotificationServiceExtension/NSENotificationPresenter.swift +++ b/SessionNotificationServiceExtension/NSENotificationPresenter.swift @@ -5,6 +5,7 @@ import GRDB import UserNotifications import SignalUtilitiesKit import SessionMessagingKit +import SessionUtilitiesKit public class NSENotificationPresenter: NSObject, NotificationsProtocol { private var notifications: [String: UNNotificationRequest] = [:] @@ -44,8 +45,11 @@ public class NSENotificationPresenter: NSObject, NotificationsProtocol { .replacingMentions(for: thread.id)) .defaulting(to: "APN_Message".localized()) - var userInfo: [String: Any] = [ NotificationServiceExtension.isFromRemoteKey: true ] - userInfo[NotificationServiceExtension.threadIdKey] = thread.id + let userInfo: [String: Any] = [ + NotificationServiceExtension.isFromRemoteKey: true, + NotificationServiceExtension.threadIdKey: thread.id, + NotificationServiceExtension.threadVariantRaw: thread.variant.rawValue + ] let notificationContent = UNMutableNotificationContent() notificationContent.userInfo = userInfo @@ -144,8 +148,11 @@ public class NSENotificationPresenter: NSObject, NotificationsProtocol { // Only notify missed calls guard messageInfo.state == .missed || messageInfo.state == .permissionDenied else { return } - var userInfo: [String: Any] = [ NotificationServiceExtension.isFromRemoteKey: true ] - userInfo[NotificationServiceExtension.threadIdKey] = thread.id + let userInfo: [String: Any] = [ + NotificationServiceExtension.isFromRemoteKey: true, + NotificationServiceExtension.threadIdKey: thread.id, + NotificationServiceExtension.threadVariantRaw: thread.variant.rawValue + ] let notificationContent = UNMutableNotificationContent() notificationContent.userInfo = userInfo @@ -205,8 +212,11 @@ public class NSENotificationPresenter: NSObject, NotificationsProtocol { default: notificationBody = NotificationStrings.incomingMessageBody } - var userInfo: [String: Any] = [ NotificationServiceExtension.isFromRemoteKey: true ] - userInfo[NotificationServiceExtension.threadIdKey] = thread.id + let userInfo: [String: Any] = [ + NotificationServiceExtension.isFromRemoteKey: true, + NotificationServiceExtension.threadIdKey: thread.id, + NotificationServiceExtension.threadVariantRaw: thread.variant.rawValue + ] let notificationContent = UNMutableNotificationContent() notificationContent.userInfo = userInfo diff --git a/SessionNotificationServiceExtension/NotificationServiceExtension.swift b/SessionNotificationServiceExtension/NotificationServiceExtension.swift index 6237975be..a80b424f5 100644 --- a/SessionNotificationServiceExtension/NotificationServiceExtension.swift +++ b/SessionNotificationServiceExtension/NotificationServiceExtension.swift @@ -9,6 +9,7 @@ import BackgroundTasks import SessionMessagingKit import SignalUtilitiesKit import SignalCoreKit +import SessionUtilitiesKit public final class NotificationServiceExtension: UNNotificationServiceExtension { private var didPerformSetup = false @@ -17,6 +18,7 @@ public final class NotificationServiceExtension: UNNotificationServiceExtension public static let isFromRemoteKey = "remote" public static let threadIdKey = "Signal.AppNotificationsUserInfoKey.threadId" + public static let threadVariantRaw = "Signal.AppNotificationsUserInfoKey.threadVariantRaw" public static let threadNotificationCounter = "Session.AppNotificationsUserInfoKey.threadNotificationCounter" // MARK: Did receive a remote push notification request @@ -25,8 +27,6 @@ public final class NotificationServiceExtension: UNNotificationServiceExtension self.contentHandler = contentHandler self.request = request - Storage.resumeDatabaseAccess() - guard let notificationContent = request.content.mutableCopy() as? UNMutableNotificationContent else { return self.completeSilenty() } @@ -36,10 +36,16 @@ public final class NotificationServiceExtension: UNNotificationServiceExtension return self.completeSilenty() } + /// Create the context if we don't have it (needed before _any_ interaction with the database) + if !HasAppContext() { + SetCurrentAppContext(NotificationServiceExtensionContext()) + } + let isCallOngoing: Bool = (UserDefaults.sharedLokiProject?[.isCallOngoing]) .defaulting(to: false) // Perform main setup + Storage.resumeDatabaseAccess() DispatchQueue.main.sync { self.setUpIfNecessary() { } } // Handle the push notification @@ -57,12 +63,22 @@ public final class NotificationServiceExtension: UNNotificationServiceExtension ) } + let (maybeEnvelope, result) = PushNotificationAPI.processNotification( + notificationContent: notificationContent + ) + guard - let base64EncodedData: String = notificationContent.userInfo["ENCRYPTED_DATA"] as? String, - let data: Data = Data(base64Encoded: base64EncodedData), - let envelope = try? MessageWrapper.unwrap(data: data) + (result == .success || result == .legacySuccess), + let envelope: SNProtoEnvelope = maybeEnvelope else { - return self.handleFailure(for: notificationContent) + switch result { + // If we got an explicit failure, or we got a success but no content then show + // the fallback notification + case .success, .legacySuccess, .failure, .legacyFailure: + return self.handleFailure(for: notificationContent) + + case .legacyForceSilent: return + } } // HACK: It is important to use write synchronously here to avoid a race condition @@ -212,22 +228,13 @@ public final class NotificationServiceExtension: UNNotificationServiceExtension // to process new messages. guard !didPerformSetup else { return } + NSLog("[NotificationServiceExtension] Performing setup") didPerformSetup = true - // This should be the first thing we do. - SetCurrentAppContext(NotificationServiceExtensionContext()) - _ = AppVersion.sharedInstance() Cryptography.seedRandom() - // We should never receive a non-voip notification on an app that doesn't support - // app extensions since we have to inform the service we wanted these, so in theory - // this path should never occur. However, the service does have our push token - // so it is possible that could change in the future. If it does, do nothing - // and don't disturb the user. Messages will be processed when they open the app. - guard Storage.shared[.isReadyForAppExtensions] else { return completeSilenty() } - AppSetup.setupEnvironment( appSpecificBlock: { Environment.shared?.notificationsManager.mutate { @@ -237,8 +244,22 @@ public final class NotificationServiceExtension: UNNotificationServiceExtension migrationsCompletion: { [weak self] result, needsConfigSync in switch result { // Only 'NSLog' works in the extension - viewable via Console.app - case .failure: NSLog("[NotificationServiceExtension] Failed to complete migrations") + case .failure(let error): + NSLog("[NotificationServiceExtension] Failed to complete migrations: \(error)") + self?.completeSilenty() + case .success: + // We should never receive a non-voip notification on an app that doesn't support + // app extensions since we have to inform the service we wanted these, so in theory + // this path should never occur. However, the service does have our push token + // so it is possible that could change in the future. If it does, do nothing + // and don't disturb the user. Messages will be processed when they open the app. + guard Storage.shared[.isReadyForAppExtensions] else { + NSLog("[NotificationServiceExtension] Not ready for extensions") + self?.completeSilenty() + return + } + DispatchQueue.main.async { self?.versionMigrationsDidComplete(needsConfigSync: needsConfigSync) } @@ -269,7 +290,11 @@ public final class NotificationServiceExtension: UNNotificationServiceExtension guard !AppReadiness.isAppReady() else { return } // App isn't ready until storage is ready AND all version migrations are complete. - guard Storage.shared.isValid && migrationsCompleted else { return } + guard Storage.shared.isValid && migrationsCompleted else { + NSLog("[NotificationServiceExtension] Storage invalid") + self.completeSilenty() + return + } SignalUtilitiesKit.Configuration.performMainSetup() @@ -286,8 +311,7 @@ public final class NotificationServiceExtension: UNNotificationServiceExtension } private func completeSilenty() { - SNLog("Complete silenty") - + NSLog("[NotificationServiceExtension] Complete silently") Storage.suspendDatabaseAccess() self.contentHandler!(.init()) diff --git a/SessionNotificationServiceExtension/NotificationServiceExtensionContext.swift b/SessionNotificationServiceExtension/NotificationServiceExtensionContext.swift index c7e89d012..d642a984c 100644 --- a/SessionNotificationServiceExtension/NotificationServiceExtensionContext.swift +++ b/SessionNotificationServiceExtension/NotificationServiceExtensionContext.swift @@ -4,6 +4,7 @@ import Foundation import SignalUtilitiesKit +import SessionUtilitiesKit final class NotificationServiceExtensionContext : NSObject, AppContext { let appLaunchTime = Date() @@ -31,10 +32,6 @@ final class NotificationServiceExtensionContext : NSObject, AppContext { func isInBackground() -> Bool { true } func mainApplicationStateOnLaunch() -> UIApplication.State { .inactive } - func appDatabaseBaseDirectoryPath() -> String { - return appSharedDataDirectoryPath() - } - func appDocumentDirectoryPath() -> String { guard let documentDirectoryURL = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).last else { preconditionFailure("Couldn't get document directory.") @@ -43,14 +40,14 @@ final class NotificationServiceExtensionContext : NSObject, AppContext { } func appSharedDataDirectoryPath() -> String { - guard let groupContainerURL = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: SignalApplicationGroup) else { + guard let groupContainerURL = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: UserDefaults.applicationGroup) else { preconditionFailure("Couldn't get shared data directory.") } return groupContainerURL.path } func appUserDefaults() -> UserDefaults { - guard let userDefaults = UserDefaults(suiteName: SignalApplicationGroup) else { + guard let userDefaults = UserDefaults.sharedLokiProject else { preconditionFailure("Couldn't set up shared user defaults.") } return userDefaults @@ -61,7 +58,6 @@ final class NotificationServiceExtensionContext : NSObject, AppContext { let frame = CGRect.zero let interfaceOrientation = UIInterfaceOrientation.unknown let isRTL = false - let isRunningTests = false let reportedApplicationState = UIApplication.State.background let statusBarHeight = CGFloat.zero diff --git a/SessionShareExtension/ShareAppExtensionContext.swift b/SessionShareExtension/ShareAppExtensionContext.swift index 213d8178b..4c647fdc7 100644 --- a/SessionShareExtension/ShareAppExtensionContext.swift +++ b/SessionShareExtension/ShareAppExtensionContext.swift @@ -31,7 +31,6 @@ final class ShareAppExtensionContext: NSObject, AppContext { }() var isRTL: Bool { return ShareAppExtensionContext._isRTL } - var isRunningTests: Bool { return false } // We don't need to distinguish this in the SAE var statusBarHeight: CGFloat { return 20 } var openSystemSettingsAction: UIAlertAction? @@ -158,7 +157,7 @@ final class ShareAppExtensionContext: NSObject, AppContext { func appSharedDataDirectoryPath() -> String { let targetPath: String? = FileManager.default - .containerURL(forSecurityApplicationGroupIdentifier: SignalApplicationGroup)? + .containerURL(forSecurityApplicationGroupIdentifier: UserDefaults.applicationGroup)? .path owsAssertDebug(targetPath != nil) @@ -166,10 +165,9 @@ final class ShareAppExtensionContext: NSObject, AppContext { } func appUserDefaults() -> UserDefaults { - let targetUserDefaults: UserDefaults? = UserDefaults(suiteName: SignalApplicationGroup) - owsAssertDebug(targetUserDefaults != nil) + owsAssertDebug(UserDefaults.sharedLokiProject != nil) - return (targetUserDefaults ?? UserDefaults.standard) + return (UserDefaults.sharedLokiProject ?? UserDefaults.standard) } func setStatusBarHidden(_ isHidden: Bool, animated isAnimated: Bool) { diff --git a/SessionShareExtension/ShareNavController.swift b/SessionShareExtension/ShareNavController.swift index 496674eae..1789ab470 100644 --- a/SessionShareExtension/ShareNavController.swift +++ b/SessionShareExtension/ShareNavController.swift @@ -5,10 +5,12 @@ import Combine import CoreServices import SignalUtilitiesKit import SessionUIKit +import SessionUtilitiesKit import SignalCoreKit final class ShareNavController: UINavigationController, ShareViewDelegate { public static var attachmentPrepPublisher: AnyPublisher<[SignalAttachment], Error>? + private let versionMigrationsComplete: Atomic = Atomic(false) // MARK: - Error @@ -23,6 +25,8 @@ final class ShareNavController: UINavigationController, ShareViewDelegate { override func loadView() { super.loadView() + + view.themeBackgroundColor = .backgroundPrimary // This should be the first thing we do (Note: If you leave the share context and return to it // the context will already exist, trying to override it results in the share context crashing @@ -32,10 +36,6 @@ final class ShareNavController: UINavigationController, ShareViewDelegate { SetCurrentAppContext(appContext) } - // Need to manually trigger these since we don't have a "mainWindow" here and the current theme - // might have been changed since the share extension was last opened - ThemeManager.applySavedTheme() - Logger.info("") _ = AppVersion.sharedInstance() @@ -46,7 +46,7 @@ final class ShareNavController: UINavigationController, ShareViewDelegate { // We don't need to use applySignalAppearence in the SAE. - if CurrentAppContext().isRunningTests { + if SNUtilitiesKit.isRunningTests { // TODO: Do we need to implement isRunningTests in the SAE context? return } @@ -62,6 +62,11 @@ final class ShareNavController: UINavigationController, ShareViewDelegate { case .failure: SNLog("[SessionShareExtension] Failed to complete migrations") case .success: DispatchQueue.main.async { + // Need to manually trigger these since we don't have a "mainWindow" here + // and the current theme might have been changed since the share extension + // was last opened + ThemeManager.applySavedTheme() + // performUpdateCheck must be invoked after Environment has been initialized because // upgrade process may depend on Environment. self?.versionMigrationsDidComplete(needsConfigSync: needsConfigSync) @@ -77,6 +82,12 @@ final class ShareNavController: UINavigationController, ShareViewDelegate { name: .OWSApplicationDidEnterBackground, object: nil ) + + /// **Note:** If the user opens, dismisses and re-opens the share extension it'll actually use the same instance which + /// results in the `AppSetup` not actually running (and the UI not actually being loaded correctly) - in order to avoid this + /// we call `checkIsAppReady` explicitly here assuming that either the `AppSetup` _hasn't_ complete or won't ever + /// get run + checkIsAppReady(migrationsCompleted: versionMigrationsComplete.wrappedValue) } override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { @@ -99,6 +110,7 @@ final class ShareNavController: UINavigationController, ShareViewDelegate { } } + versionMigrationsComplete.mutate { $0 = true } checkIsAppReady(migrationsCompleted: true) } @@ -107,9 +119,14 @@ final class ShareNavController: UINavigationController, ShareViewDelegate { // App isn't ready until storage is ready AND all version migrations are complete. guard migrationsCompleted else { return } - guard Storage.shared.isValid else { return } + guard Storage.shared.isValid else { + // If the database is invalid then the UI will handle it + showLockScreenOrMainContent() + return + } guard !AppReadiness.isAppReady() else { // Only mark the app as ready once. + showLockScreenOrMainContent() return } @@ -210,11 +227,11 @@ final class ShareNavController: UINavigationController, ShareViewDelegate { } func shareViewWasCompleted() { - extensionContext!.completeRequest(returningItems: [], completionHandler: nil) + extensionContext?.completeRequest(returningItems: [], completionHandler: nil) } func shareViewWasCancelled() { - extensionContext!.completeRequest(returningItems: [], completionHandler: nil) + extensionContext?.completeRequest(returningItems: [], completionHandler: nil) } func shareViewFailed(error: Error) { diff --git a/SessionShareExtension/ThreadPickerVC.swift b/SessionShareExtension/ThreadPickerVC.swift index 09a58d228..ced7e707c 100644 --- a/SessionShareExtension/ThreadPickerVC.swift +++ b/SessionShareExtension/ThreadPickerVC.swift @@ -7,6 +7,7 @@ import DifferenceKit import SessionUIKit import SignalUtilitiesKit import SessionMessagingKit +import SessionSnodeKit final class ThreadPickerVC: UIViewController, UITableViewDataSource, UITableViewDelegate, AttachmentApprovalViewControllerDelegate { private let viewModel: ThreadPickerViewModel = ThreadPickerViewModel() @@ -33,6 +34,18 @@ final class ThreadPickerVC: UIViewController, UITableViewDataSource, UITableView return titleLabel }() + + private lazy var databaseErrorLabel: UILabel = { + let result: UILabel = UILabel() + result.font = .systemFont(ofSize: Values.mediumFontSize) + result.text = "database_inaccessible_error".localized() + result.textAlignment = .center + result.themeTextColor = .textPrimary + result.numberOfLines = 0 + result.isHidden = true + + return result + }() private lazy var tableView: UITableView = { let tableView: UITableView = UITableView() @@ -55,6 +68,7 @@ final class ThreadPickerVC: UIViewController, UITableViewDataSource, UITableView view.themeBackgroundColor = .backgroundPrimary view.addSubview(tableView) + view.addSubview(databaseErrorLabel) setupLayout() @@ -99,6 +113,10 @@ final class ThreadPickerVC: UIViewController, UITableViewDataSource, UITableView private func setupLayout() { tableView.pin(to: view) + + databaseErrorLabel.pin(.top, to: .top, of: view, withInset: Values.massiveSpacing) + databaseErrorLabel.pin(.leading, to: .leading, of: view, withInset: Values.veryLargeSpacing) + databaseErrorLabel.pin(.trailing, to: .trailing, of: view, withInset: -Values.veryLargeSpacing) } // MARK: - Updating @@ -109,7 +127,7 @@ final class ThreadPickerVC: UIViewController, UITableViewDataSource, UITableView // Start observing for data changes dataChangeObservable = Storage.shared.start( viewModel.observableViewData, - onError: { _ in }, + onError: { [weak self] _ in self?.databaseErrorLabel.isHidden = Storage.shared.isValid }, onChange: { [weak self] viewData in // The defaul scheduler emits changes on the main thread self?.handleUpdates(viewData) @@ -167,6 +185,7 @@ final class ThreadPickerVC: UIViewController, UITableViewDataSource, UITableView let approvalVC: UINavigationController = AttachmentApprovalViewController.wrappedInNavController( threadId: strongSelf.viewModel.viewData[indexPath.row].threadId, + threadVariant: strongSelf.viewModel.viewData[indexPath.row].threadVariant, attachments: attachments, approvalDelegate: strongSelf ) @@ -175,7 +194,14 @@ 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, + threadVariant: SessionThread.Variant, + 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) @@ -197,77 +223,111 @@ final class ThreadPickerVC: UIViewController, UITableViewDataSource, UITableView ModalActivityIndicatorViewController.present(fromViewController: shareNavController!, canCancel: false, message: "vc_share_sending_message".localized()) { activityIndicator in Storage.resumeDatabaseAccess() - Storage.shared - .writePublisher { db -> MessageSender.PreparedSendData in - guard - let threadVariant: SessionThread.Variant = try SessionThread - .filter(id: threadId) - .select(.variant) - .asRequest(of: SessionThread.Variant.self) - .fetchOne(db) - else { throw MessageSenderError.noThread } - - // Create the interaction - let interaction: Interaction = try Interaction( - threadId: threadId, - authorId: getUserHexEncodedPublicKey(db), - variant: .standardOutgoing, - body: body, - timestampMs: SnodeAPI.currentOffsetTimestampMs(), - hasMention: Interaction.isUserMentioned(db, threadId: threadId, body: body), - expiresInSeconds: try? DisappearingMessagesConfiguration - .select(.durationSeconds) - .filter(id: threadId) - .filter(DisappearingMessagesConfiguration.Columns.isEnabled == true) - .asRequest(of: TimeInterval.self) - .fetchOne(db), - linkPreviewUrl: (isSharingUrl ? attachments.first?.linkPreviewDraft?.urlString : nil) - ).inserted(db) - - guard let interactionId: Int64 = interaction.id else { - throw StorageError.failedToSave + /// When we prepare the message we set the timestamp to be the `SnodeAPI.currentOffsetTimestampMs()` + /// but won't actually have a value because the share extension won't have talked to a service node yet which can cause + /// issues with Disappearing Messages, as a result we need to explicitly `getNetworkTime` in order to ensure it's accurate + Just(()) + .setFailureType(to: Error.self) + .flatMap { _ in + // We may not have sufficient snodes, so rather than failing we try to load/fetch + // them if needed + guard !SnodeAPI.hasCachedSnodesIncludingExpired() else { + return Just(()) + .setFailureType(to: Error.self) + .eraseToAnyPublisher() } - // If the user is sharing a Url, there is a LinkPreview and it doesn't match an existing - // one then add it now - if - isSharingUrl, - let linkPreviewDraft: LinkPreviewDraft = attachments.first?.linkPreviewDraft, - (try? interaction.linkPreview.isEmpty(db)) == true - { - try LinkPreview( - url: linkPreviewDraft.urlString, - title: linkPreviewDraft.title, - attachmentId: LinkPreview - .generateAttachmentIfPossible( - imageData: linkPreviewDraft.jpegImageData, - mimeType: OWSMimeTypeImageJpeg - )? - .inserted(db) - .id - ).insert(db) - } - - // Prepare any attachments - try Attachment.process( - db, - data: Attachment.prepare(attachments: finalAttachments), - for: interactionId - ) - - // Prepare the message send data - return try MessageSender - .preparedSendData( - db, - interaction: interaction, - threadId: threadId, - threadVariant: threadVariant - ) + return SnodeAPI.getSnodePool() + .map { _ in () } + .eraseToAnyPublisher() } .subscribe(on: DispatchQueue.global(qos: .userInitiated)) - .flatMap { MessageSender.performUploadsIfNeeded(preparedSendData: $0) } - .flatMap { MessageSender.sendImmediate(preparedSendData: $0) } - .subscribe(on: DispatchQueue.global(qos: .userInitiated)) + .flatMap { _ in + SnodeAPI + .getSwarm( + for: { + switch threadVariant { + case .contact, .legacyGroup, .group: return threadId + case .community: return getUserHexEncodedPublicKey(using: dependencies) + } + }(), + using: dependencies + ) + .tryFlatMapWithRandomSnode { SnodeAPI.getNetworkTime(from: $0, using: dependencies) } + .map { _ in () } + .eraseToAnyPublisher() + } + .flatMap { _ in + dependencies.storage.writePublisher { db -> MessageSender.PreparedSendData in + guard + let threadVariant: SessionThread.Variant = try SessionThread + .filter(id: threadId) + .select(.variant) + .asRequest(of: SessionThread.Variant.self) + .fetchOne(db) + else { throw MessageSenderError.noThread } + + // Create the interaction + let interaction: Interaction = try Interaction( + threadId: threadId, + authorId: getUserHexEncodedPublicKey(db), + variant: .standardOutgoing, + body: body, + timestampMs: SnodeAPI.currentOffsetTimestampMs(), + hasMention: Interaction.isUserMentioned(db, threadId: threadId, body: body), + expiresInSeconds: try? DisappearingMessagesConfiguration + .select(.durationSeconds) + .filter(id: threadId) + .filter(DisappearingMessagesConfiguration.Columns.isEnabled == true) + .asRequest(of: TimeInterval.self) + .fetchOne(db), + linkPreviewUrl: (isSharingUrl ? attachments.first?.linkPreviewDraft?.urlString : nil) + ).inserted(db) + + guard let interactionId: Int64 = interaction.id else { + throw StorageError.failedToSave + } + + // If the user is sharing a Url, there is a LinkPreview and it doesn't match an existing + // one then add it now + if + isSharingUrl, + let linkPreviewDraft: LinkPreviewDraft = attachments.first?.linkPreviewDraft, + (try? interaction.linkPreview.isEmpty(db)) == true + { + try LinkPreview( + url: linkPreviewDraft.urlString, + title: linkPreviewDraft.title, + attachmentId: LinkPreview + .generateAttachmentIfPossible( + imageData: linkPreviewDraft.jpegImageData, + mimeType: OWSMimeTypeImageJpeg + )? + .inserted(db) + .id + ).insert(db) + } + + // Prepare any attachments + try Attachment.process( + db, + data: Attachment.prepare(attachments: finalAttachments), + for: interactionId + ) + + // Prepare the message send data + return try MessageSender + .preparedSendData( + db, + interaction: interaction, + threadId: threadId, + threadVariant: threadVariant, + using: dependencies + ) + } + } + .flatMap { MessageSender.performUploadsIfNeeded(preparedSendData: $0, using: dependencies) } + .flatMap { MessageSender.sendImmediate(data: $0, using: dependencies) } .receive(on: DispatchQueue.main) .sinkUntilComplete( receiveCompletion: { [weak self] result in diff --git a/SessionShareExtension/ThreadPickerViewModel.swift b/SessionShareExtension/ThreadPickerViewModel.swift index 2d07a43cd..e2bae7488 100644 --- a/SessionShareExtension/ThreadPickerViewModel.swift +++ b/SessionShareExtension/ThreadPickerViewModel.swift @@ -28,6 +28,7 @@ public class ThreadPickerViewModel { .shareQuery(userPublicKey: userPublicKey) .fetchAll(db) } + .map { threads -> [SessionThreadViewModel] in threads.filter { $0.canWrite } } // Exclude unwritable threads .removeDuplicates() .handleEvents(didFail: { SNLog("[ThreadPickerViewModel] Observation failed with error: \($0)") }) diff --git a/SessionSnodeKit/Configuration.swift b/SessionSnodeKit/Configuration.swift index 7f8a597d4..301a8e3af 100644 --- a/SessionSnodeKit/Configuration.swift +++ b/SessionSnodeKit/Configuration.swift @@ -26,6 +26,6 @@ public enum SNSnodeKit: MigratableTarget { // Just to make the external API nice public static func configure() { // Configure the job executors - JobRunner.add(executor: GetSnodePoolJob.self, for: .getSnodePool) + JobRunner.setExecutor(GetSnodePoolJob.self, for: .getSnodePool) } } 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 79041941a..72844ff82 100644 --- a/SessionSnodeKit/Jobs/GetSnodePoolJob.swift +++ b/SessionSnodeKit/Jobs/GetSnodePoolJob.swift @@ -14,28 +14,28 @@ public enum GetSnodePoolJob: JobExecutor { public static func run( _ job: Job, queue: DispatchQueue, - success: @escaping (Job, Bool) -> (), - failure: @escaping (Job, Error?, Bool) -> (), - deferred: @escaping (Job) -> () + success: @escaping (Job, Bool, Dependencies) -> (), + failure: @escaping (Job, Error?, Bool, Dependencies) -> (), + deferred: @escaping (Job, 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 // to block if we have no Snode pool and prevent other jobs from failing but avoids having to // wait if we already have a potentially valid snode pool - guard !SnodeAPI.hasCachedSnodesInclusingExpired() else { + guard !SnodeAPI.hasCachedSnodesIncludingExpired() else { SNLog("[GetSnodePoolJob] Has valid cached pool, running async instead") SnodeAPI .getSnodePool() .subscribe(on: DispatchQueue.global(qos: .default)) .sinkUntilComplete() - success(job, false) - return + return success(job, false, dependencies) } // 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( @@ -43,23 +43,24 @@ public enum GetSnodePoolJob: JobExecutor { switch result { case .finished: SNLog("[GetSnodePoolJob] Completed") - success(job, false) + success(job, false, dependencies) case .failure(let error): SNLog("[GetSnodePoolJob] Failed due to error: \(error)") - failure(job, error, false) + failure(job, error, false, dependencies) } } ) } - 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 } + success: { _, _, _ in }, + failure: { _, _, _, _ in }, + deferred: { _, _ in }, + using: dependencies ) } } diff --git a/SessionSnodeKit/Networking/OnionRequestAPI.swift b/SessionSnodeKit/Networking/OnionRequestAPI.swift index 926c49008..4d0e13b3d 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,8 +274,8 @@ 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 - .subscribe(on: DispatchQueue.global(qos: .background)) + buildPaths(reusing: paths, using: dependencies) // Re-build paths in the background + .subscribe(on: DispatchQueue.global(qos: .background), using: dependencies) .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" @@ -798,50 +817,15 @@ public enum OnionRequestAPI: OnionRequestAPIType { } public static func process(bencodedData data: Data) -> (info: ResponseInfoType, body: Data?)? { - // The data will be in the form of `l123:jsone` or `l123:json456:bodye` so we need to break - // the data into parts to properly process it - guard let responseString: String = String(data: data, encoding: .ascii), responseString.starts(with: "l") else { + guard let response: BencodeResponse = try? Bencode.decodeResponse(from: data) else { return nil } - let stringParts: [String.SubSequence] = responseString.split(separator: ":") - - guard stringParts.count > 1, let infoLength: Int = Int(stringParts[0].suffix(from: stringParts[0].index(stringParts[0].startIndex, offsetBy: 1))) else { - return nil - } - - let infoStringStartIndex: String.Index = responseString.index(responseString.startIndex, offsetBy: "l\(infoLength):".count) - let infoStringEndIndex: String.Index = responseString.index(infoStringStartIndex, offsetBy: infoLength) - let infoString: String = String(responseString[infoStringStartIndex.. "l\(infoLength)\(infoString)e".count else { - return (responseInfo, nil) - } - - // Extract the response data as well - let dataString: String = String(responseString.suffix(from: infoStringEndIndex)) - let dataStringParts: [String.SubSequence] = dataString.split(separator: ":") - - guard dataStringParts.count > 1, let finalDataLength: Int = Int(dataStringParts[0]), let suffixData: Data = "e".data(using: .utf8) else { - return nil - } - - let dataBytes: Array = Array(data) - let dataEndIndex: Int = (dataBytes.count - suffixData.count) - let dataStartIndex: Int = (dataEndIndex - finalDataLength) - let finalDataBytes: ArraySlice = dataBytes[dataStartIndex.. 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()) @@ -129,17 +141,19 @@ public final class SnodeAPI { // MARK: - Public API - public static func hasCachedSnodesInclusingExpired() -> Bool { + public static func hasCachedSnodesIncludingExpired() -> Bool { loadSnodePoolIfNeeded() 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) @@ -987,9 +1009,9 @@ public final class SnodeAPI { // MARK: - Internal API - private static func getNetworkTime( + public static func getNetworkTime( from snode: Snode, - using dependencies: SSKDependencies = SSKDependencies() + using dependencies: Dependencies = Dependencies() ) -> AnyPublisher { return SnodeAPI .send( @@ -998,10 +1020,18 @@ public final class SnodeAPI { body: [:] ), to: snode, - associatedWith: nil + associatedWith: nil, + using: dependencies ) .decoded(as: GetNetworkTimestampResponse.self, using: dependencies) - .map { _, response in response.timestamp } + .map { _, response in + // Assume we've fetched the networkTime in order to send a message to the specified snode, in + // which case we want to update the 'clockOffsetMs' value for subsequent requests + let offset = (Int64(response.timestamp) - Int64(floor(dependencies.dateNow.timeIntervalSince1970 * 1000))) + SnodeAPI.clockOffsetMs.mutate { $0 = offset } + + return response.timestamp + } .eraseToAnyPublisher() } @@ -1013,7 +1043,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 +1103,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 +1140,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 +1180,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 +1206,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..6f2e5fbc3 100644 --- a/SessionTests/Conversations/Settings/ThreadDisappearingMessagesViewModelSpec.swift +++ b/SessionTests/Conversations/Settings/ThreadDisappearingMessagesViewModelSpec.swift @@ -6,6 +6,7 @@ import Quick import Nimble import SessionUIKit import SessionSnodeKit +import SessionUtilitiesKit @testable import Session @@ -24,7 +25,7 @@ class ThreadDisappearingMessagesSettingsViewModelSpec: QuickSpec { // MARK: - Configuration beforeEach { - mockStorage = Storage( + mockStorage = SynchronousStorage( customWriter: try! DatabaseQueue(), customMigrationTargets: [ SNUtilitiesKit.self, @@ -44,10 +45,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 +128,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 +233,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..8ba49032b 100644 --- a/SessionTests/Conversations/Settings/ThreadSettingsViewModelSpec.swift +++ b/SessionTests/Conversations/Settings/ThreadSettingsViewModelSpec.swift @@ -6,6 +6,7 @@ import Quick import Nimble import SessionUIKit import SessionSnodeKit +import SessionUtilitiesKit @testable import Session @@ -16,6 +17,7 @@ class ThreadSettingsViewModelSpec: QuickSpec { override func spec() { var mockStorage: Storage! + var mockCaches: MockCaches! var mockGeneralCache: MockGeneralCache! var disposables: [AnyCancellable] = [] var dependencies: Dependencies! @@ -35,12 +37,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( @@ -57,23 +61,25 @@ class ThreadSettingsViewModelSpec: QuickSpec { id: "05\(TestConstants.publicKey)", name: "TestMe", lastNameUpdate: 0, - lastProfilePictureUpdate: 0 + lastProfilePictureUpdate: 0, + lastBlocksCommunityMessageRequests: 0 ).insert(db) try Profile( id: "TestId", name: "TestUser", lastNameUpdate: 0, - lastProfilePictureUpdate: 0 + lastProfilePictureUpdate: 0, + lastBlocksCommunityMessageRequests: 0 ).insert(db) } viewModel = ThreadSettingsViewModel( - dependencies: dependencies, threadId: "TestId", threadVariant: .contact, didTriggerSearch: { didTriggerSearchCallbackTriggered = true - } + }, + using: dependencies ) disposables.append( viewModel.observableTableData @@ -166,12 +172,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 +446,12 @@ class ThreadSettingsViewModelSpec: QuickSpec { } viewModel = ThreadSettingsViewModel( - dependencies: dependencies, threadId: "TestId", threadVariant: .legacyGroup, didTriggerSearch: { didTriggerSearchCallbackTriggered = true - } + }, + using: dependencies ) disposables.append( viewModel.observableTableData @@ -482,12 +488,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..090ea3d61 100644 --- a/SessionTests/Settings/NotificationContentViewModelSpec.swift +++ b/SessionTests/Settings/NotificationContentViewModelSpec.swift @@ -4,9 +4,9 @@ import Combine import GRDB import Quick import Nimble - import SessionUIKit import SessionSnodeKit +import SessionUtilitiesKit @testable import Session @@ -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..c4a2dc0cb 100644 --- a/SessionUtilitiesKit/Combine/Publisher+Utilities.swift +++ b/SessionUtilitiesKit/Combine/Publisher+Utilities.swift @@ -43,6 +43,45 @@ 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() + } + + func manualRefreshFrom(_ refreshTrigger: some Publisher) -> AnyPublisher { + return Publishers + .CombineLatest(refreshTrigger.prepend(()).setFailureType(to: Failure.self), self) + .map { _, value in value } + .eraseToAnyPublisher() + } + + func withPrevious() -> AnyPublisher<(previous: Output?, current: Output), Failure> { + scan(Optional<(Output?, Output)>.none) { ($0?.1, $1) } + .compactMap { $0 } + .eraseToAnyPublisher() + } + + func withPrevious(_ initialPreviousValue: Output) -> AnyPublisher<(previous: Output, current: Output), Failure> { + scan((initialPreviousValue, initialPreviousValue)) { ($0.1, $1) }.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/Configuration.swift b/SessionUtilitiesKit/Configuration.swift index df5b5b366..363e3eec0 100644 --- a/SessionUtilitiesKit/Configuration.swift +++ b/SessionUtilitiesKit/Configuration.swift @@ -4,6 +4,10 @@ import Foundation import GRDB public enum SNUtilitiesKit: MigratableTarget { // Just to make the external API nice + public static var isRunningTests: Bool { + ProcessInfo.processInfo.environment["XCTestConfigurationFilePath"] != nil + } + public static func migrations(_ db: Database) -> TargetMigrations { return TargetMigrations( identifier: .utilitiesKit, @@ -32,4 +36,5 @@ public enum SNUtilitiesKit: MigratableTarget { // Just to make the external API @objc public final class SNUtilitiesKitConfiguration: NSObject { @objc public static var maxFileSize: UInt = 0 + @objc public static var isRunningTests: Bool { return SNUtilitiesKit.isRunningTests } } diff --git a/SessionUtilitiesKit/Database/Models/Job.swift b/SessionUtilitiesKit/Database/Models/Job.swift index d1c588c39..ee1190ade 100644 --- a/SessionUtilitiesKit/Database/Models/Job.swift +++ b/SessionUtilitiesKit/Database/Models/Job.swift @@ -207,7 +207,7 @@ public struct Job: Codable, Equatable, Hashable, Identifiable, FetchableRecord, // MARK: - Initialization - fileprivate init( + internal init( id: Int64?, priority: Int64 = 0, failureCount: UInt, @@ -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/Models/Setting.swift b/SessionUtilitiesKit/Database/Models/Setting.swift index 1f634dac5..163fd01b8 100644 --- a/SessionUtilitiesKit/Database/Models/Setting.swift +++ b/SessionUtilitiesKit/Database/Models/Setting.swift @@ -15,6 +15,7 @@ public struct Setting: Codable, Identifiable, FetchableRecord, PersistableRecord } public var id: String { key } + public var rawValue: Data { value } let key: String let value: Data @@ -53,7 +54,7 @@ extension Setting { self.value = Data(bytes: &targetValue, count: MemoryLayout.size(ofValue: targetValue)) } - fileprivate func value(as type: Bool.Type) -> Bool? { + public func unsafeValue(as type: Bool.Type) -> Bool? { // Note: The 'assumingMemoryBound' is essentially going to try to convert // the memory into the provided type so can result in invalid data being // returned if the type is incorrect. But it does seem safer than the 'load' @@ -189,7 +190,7 @@ public extension Database { subscript(key: Setting.BoolKey) -> Bool { get { // Default to false if it doesn't exist - (self[key.rawValue]?.value(as: Bool.self) ?? false) + (self[key.rawValue]?.unsafeValue(as: Bool.self) ?? false) } set { self[key.rawValue] = Setting(key: key.rawValue, value: newValue) } } @@ -245,4 +246,47 @@ public extension Database { ) } } + + func setting(key: Setting.BoolKey, to newValue: Bool) -> Setting? { + let result: Setting? = Setting(key: key.rawValue, value: newValue) + self[key.rawValue] = result + return result + } + + func setting(key: Setting.DoubleKey, to newValue: Double?) -> Setting? { + let result: Setting? = Setting(key: key.rawValue, value: newValue) + self[key.rawValue] = result + return result + } + + func setting(key: Setting.IntKey, to newValue: Int?) -> Setting? { + let result: Setting? = Setting(key: key.rawValue, value: newValue) + self[key.rawValue] = result + return result + } + + func setting(key: Setting.StringKey, to newValue: String?) -> Setting? { + let result: Setting? = Setting(key: key.rawValue, value: newValue) + self[key.rawValue] = result + return result + } + + func setting(key: Setting.EnumKey, to newValue: T?) -> Setting? { + let result: Setting? = Setting(key: key.rawValue, value: newValue?.rawValue) + self[key.rawValue] = result + return result + } + + func setting(key: Setting.EnumKey, to newValue: T?) -> Setting? { + let result: Setting? = Setting(key: key.rawValue, value: newValue?.rawValue) + self[key.rawValue] = result + return result + } + + /// Value will be stored as a timestamp in seconds since 1970 + func setting(key: Setting.DateKey, to newValue: Date?) -> Setting? { + let result: Setting? = Setting(key: key.rawValue, value: newValue.map { $0.timeIntervalSince1970 }) + self[key.rawValue] = result + return result + } } diff --git a/SessionUtilitiesKit/Database/Storage.swift b/SessionUtilitiesKit/Database/Storage.swift index a1efa8322..2278e4670 100644 --- a/SessionUtilitiesKit/Database/Storage.swift +++ b/SessionUtilitiesKit/Database/Storage.swift @@ -46,8 +46,11 @@ open class Storage { public static let defaultPublisherScheduler: ValueObservationScheduler = .async(onQueue: .main) fileprivate var dbWriter: DatabaseWriter? + internal var testDbWriter: DatabaseWriter? { dbWriter } + private var unprocessedMigrationRequirements: Atomic<[MigrationRequirement]> = Atomic(MigrationRequirement.allCases) private var migrator: DatabaseMigrator? private var migrationProgressUpdater: Atomic<((String, CGFloat) -> ())>? + private var migrationRequirementProcesser: Atomic<(Database?, MigrationRequirement) -> ()>? // MARK: - Initialization @@ -76,18 +79,24 @@ open class Storage { migrationTargets: (customMigrationTargets ?? []), async: false, onProgressUpdate: nil, + onMigrationRequirement: { _, _ in }, onComplete: { _, _ in } ) return } - // Generate the database KeySpec if needed (this MUST be done before we try to access the database - // as a different thread might attempt to access the database before the key is successfully created) - // - // Note: We reset the bytes immediately after generation to ensure the database key doesn't hang - // around in memory unintentionally - var tmpKeySpec: Data = Storage.getOrGenerateDatabaseKeySpec() - tmpKeySpec.resetBytes(in: 0.. ())?, + onMigrationRequirement: @escaping (Database?, MigrationRequirement) -> (), onComplete: @escaping (Swift.Result, Bool) -> () ) { guard isValid, let dbWriter: DatabaseWriter = dbWriter else { @@ -226,13 +236,24 @@ open class Storage { onProgressUpdate?(totalProgress, totalMinExpectedDuration) } }) + self.migrationRequirementProcesser = Atomic(onMigrationRequirement) // Store the logic to run when the migration completes let migrationCompleted: (Swift.Result) -> () = { [weak self] result in + // Process any unprocessed requirements which need to be processed before completion + // then clear out the state + self?.unprocessedMigrationRequirements.wrappedValue + .filter { $0.shouldProcessAtCompletionIfNotRequired } + .forEach { self?.migrationRequirementProcesser?.wrappedValue(nil, $0) } self?.migrationsCompleted.mutate { $0 = true } self?.migrationProgressUpdater = nil + self?.migrationRequirementProcesser = nil SUKLegacy.clearLegacyDatabaseInstance() + // Reset in case there is a requirement on a migration which runs when returning from + // the background + self?.unprocessedMigrationRequirements.mutate { $0 = MigrationRequirement.allCases } + // Don't log anything in the case of a 'success' or if the database is suspended (the // latter will happen if the user happens to return to the background too quickly on // launch so is unnecessarily alarming, it also gets caught and logged separately by @@ -282,16 +303,29 @@ open class Storage { } } + public func willStartMigration(_ db: Database, _ migration: Migration.Type) { + let unprocessedRequirements: Set = migration.requirements.asSet() + .intersection(unprocessedMigrationRequirements.wrappedValue.asSet()) + + // No need to do anything if there are no unprocessed requirements + guard !unprocessedRequirements.isEmpty else { return } + + // Process all of the requirements for this migration + unprocessedRequirements.forEach { migrationRequirementProcesser?.wrappedValue(db, $0) } + + // Remove any processed requirements from the list (don't want to process them multiple times) + unprocessedMigrationRequirements.mutate { + $0 = Array($0.asSet().subtracting(migration.requirements.asSet())) + } + } + public static func update( progress: CGFloat, for migration: Migration.Type, in target: TargetMigrations.Identifier ) { - // In test builds ignore any migration progress updates (we run in a custom database writer anyway), - // this code should be the same as 'CurrentAppContext().isRunningTests' but since the tests can run - // without being attached to a host application the `CurrentAppContext` might not have been set and - // would crash as it gets force-unwrapped - better to just do the check explicitly instead - guard ProcessInfo.processInfo.environment["XCTestConfigurationFilePath"] == nil else { return } + // In test builds ignore any migration progress updates (we run in a custom database writer anyway) + guard !SNUtilitiesKit.isRunningTests else { return } Storage.shared.migrationProgressUpdater?.wrappedValue(target.key(with: migration), progress) } @@ -302,7 +336,7 @@ open class Storage { return try SSKDefaultKeychainStorage.shared.data(forService: keychainService, key: dbCipherKeySpecKey) } - @discardableResult private static func getOrGenerateDatabaseKeySpec() -> Data { + @discardableResult private static func getOrGenerateDatabaseKeySpec() throws -> Data { do { var keySpec: Data = try getDatabaseCipherKeySpec() defer { keySpec.resetBytes(in: 0..( + @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 } @@ -481,12 +520,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 } ) @@ -496,6 +537,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 ) { @@ -520,6 +562,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 { @@ -548,6 +591,7 @@ open class Storage { } open func readPublisher( + using dependencies: Dependencies = Dependencies(), value: @escaping (Database) throws -> T ) -> AnyPublisher { guard isValid, let dbWriter: DatabaseWriter = dbWriter else { @@ -573,7 +617,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) } @@ -595,7 +642,10 @@ open class Storage { onError: @escaping (Error) -> Void, onChange: @escaping (Reducer.Value) -> Void ) -> DatabaseCancellable { - guard isValid, let dbWriter: DatabaseWriter = dbWriter else { return AnyDatabaseCancellable(cancel: {}) } + guard isValid, let dbWriter: DatabaseWriter = dbWriter else { + onError(StorageError.databaseInvalid) + return AnyDatabaseCancellable(cancel: {}) + } return observation.start( in: dbWriter, diff --git a/SessionUtilitiesKit/Database/StorageError.swift b/SessionUtilitiesKit/Database/StorageError.swift index b8fbfdf73..9932f4719 100644 --- a/SessionUtilitiesKit/Database/StorageError.swift +++ b/SessionUtilitiesKit/Database/StorageError.swift @@ -8,6 +8,8 @@ public enum StorageError: Error { case startupFailed case migrationFailed case invalidKeySpec + case keySpecCreationFailed + case keySpecInaccessible case decodingFailed case failedToSave diff --git a/SessionUtilitiesKit/Database/Types/Migration.swift b/SessionUtilitiesKit/Database/Types/Migration.swift index 6e4c909e5..aa8a815de 100644 --- a/SessionUtilitiesKit/Database/Types/Migration.swift +++ b/SessionUtilitiesKit/Database/Types/Migration.swift @@ -8,17 +8,21 @@ public protocol Migration { static var identifier: String { get } static var needsConfigSync: Bool { get } static var minExpectedRunDuration: TimeInterval { get } + static var requirements: [MigrationRequirement] { get } static func migrate(_ db: Database) throws } public extension Migration { + static var requirements: [MigrationRequirement] { [] } + static func loggedMigrate( _ storage: Storage?, targetIdentifier: TargetMigrations.Identifier ) -> ((_ db: Database) throws -> ()) { return { (db: Database) in SNLogNotTests("[Migration Info] Starting \(targetIdentifier.key(with: self))") + storage?.willStartMigration(db, self) storage?.internalCurrentlyRunningMigration.mutate { $0 = (targetIdentifier, self) } defer { storage?.internalCurrentlyRunningMigration.mutate { $0 = nil } } diff --git a/SessionUtilitiesKit/Database/Types/MigrationRequirement.swift b/SessionUtilitiesKit/Database/Types/MigrationRequirement.swift new file mode 100644 index 000000000..9e586b809 --- /dev/null +++ b/SessionUtilitiesKit/Database/Types/MigrationRequirement.swift @@ -0,0 +1,12 @@ +// Copyright © 2023 Rangeproof Pty Ltd. All rights reserved. +import Foundation + +public enum MigrationRequirement: CaseIterable { + case sessionUtilStateLoaded + + var shouldProcessAtCompletionIfNotRequired: Bool { + switch self { + case .sessionUtilStateLoaded: return true + } + } +} diff --git a/SessionUtilitiesKit/Database/Types/PagedDatabaseObserver.swift b/SessionUtilitiesKit/Database/Types/PagedDatabaseObserver.swift index ab6ae915f..2c41643c2 100644 --- a/SessionUtilitiesKit/Database/Types/PagedDatabaseObserver.swift +++ b/SessionUtilitiesKit/Database/Types/PagedDatabaseObserver.swift @@ -1351,18 +1351,24 @@ public class AssociatedRecord: ErasedAssociatedRecord where T: Fet // Fetch the inserted/updated rows let additionalFilters: SQL = SQL(rowIds.contains(Column.rowID)) - let updatedItems: [T] = (try? dataQuery(additionalFilters) - .fetchAll(db)) - .defaulting(to: []) - // If the inserted/updated rows we irrelevant (eg. associated to another thread, a quote or a link - // preview) then trigger the update callback (if there were deletions) and stop here - guard !updatedItems.isEmpty else { return hasOtherChanges } - - // Process the upserted data (assume at least one value changed) - dataCache.mutate { $0 = $0.upserting(items: updatedItems) } - - return true + do { + let updatedItems: [T] = try dataQuery(additionalFilters) + .fetchAll(db) + + // If the inserted/updated rows we irrelevant (eg. associated to another thread, a quote or a link + // preview) then trigger the update callback (if there were deletions) and stop here + guard !updatedItems.isEmpty else { return hasOtherChanges } + + // Process the upserted data (assume at least one value changed) + dataCache.mutate { $0 = $0.upserting(items: updatedItems) } + + return true + } + catch { + SNLog("[PagedDatabaseObserver] Error loading associated data: \(error)") + return hasOtherChanges + } } public func clearCache(_ db: Database) { diff --git a/SessionUtilitiesKit/Database/Types/TypedTableAlias.swift b/SessionUtilitiesKit/Database/Types/TypedTableAlias.swift index 14dd0aacd..ed42d63e0 100644 --- a/SessionUtilitiesKit/Database/Types/TypedTableAlias.swift +++ b/SessionUtilitiesKit/Database/Types/TypedTableAlias.swift @@ -3,22 +3,65 @@ import Foundation import GRDB -public class TypedTableAlias where T: TableRecord, T: ColumnExpressible { - public let alias: TableAlias = TableAlias(name: T.databaseTableName) +public struct TypedTableAlias { + public enum RowIdColumn { + case rowId + } - public init() {} + internal let name: String + internal let tableName: String? + internal let alias: TableAlias + + public var allColumns: SQLSelection { alias[AllColumns().sqlSelection] } + public var never: NeverJoiningTypedTableAlias { NeverJoiningTypedTableAlias(alias: self) } + + // MARK: - Initialization + + public init(name: String, tableName: String? = nil) { + self.name = name + self.tableName = tableName + self.alias = TableAlias(name: name) + } + + public init(name: String) where T: TableRecord { + self.name = name + self.tableName = T.databaseTableName + self.alias = TableAlias(name: name) + } + + public init() where T: TableRecord { + self = TypedTableAlias(name: T.databaseTableName) + } + + public init(_ viewModel: VM.Type, column: VM.Columns, tableName: String?) { + self.name = column.name + self.tableName = tableName + self.alias = TableAlias(name: name) + } + + public init(_ viewModel: VM.Type, column: VM.Columns) where T: TableRecord { + self = TypedTableAlias(viewModel, column: column, tableName: T.databaseTableName) + } + + // MARK: - Functions public subscript(_ column: T.Columns) -> SQLExpression { return alias[column.name] } - /// **Warning:** For this to work you **MUST** call the '.aliased()' method when joining or it will - /// throw when trying to decode - public func allColumns() -> SQLSelection { - return alias[AllColumns().sqlSelection] + public subscript(_ column: RowIdColumn) -> SQLSelection { + return alias[Column.rowID] } } +// MARK: - NeverJoiningTypedTableAlias + +public struct NeverJoiningTypedTableAlias { + internal let alias: TypedTableAlias +} + +// MARK: - Extensions + extension QueryInterfaceRequest { public func aliased(_ typedAlias: TypedTableAlias) -> Self { return aliased(typedAlias.alias) @@ -32,7 +75,5 @@ extension Association { } extension TableAlias { - public func allColumns() -> SQLSelection { - return self[AllColumns().sqlSelection] - } + public var allColumns: SQLSelection { self[AllColumns().sqlSelection] } } 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/Database/Utilities/SQLInterpolation+Utilities.swift b/SessionUtilitiesKit/Database/Utilities/SQLInterpolation+Utilities.swift new file mode 100644 index 000000000..01d0c64bb --- /dev/null +++ b/SessionUtilitiesKit/Database/Utilities/SQLInterpolation+Utilities.swift @@ -0,0 +1,69 @@ +// Copyright © 2023 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import GRDB + +public extension SQLInterpolation { + /// Appends the table name of the record type. + /// + /// // SELECT * FROM player + /// let player: TypedTableAlias = TypedTableAlias() + /// let request: SQLRequest = "SELECT * FROM \(player)" + @_disfavoredOverload + mutating func appendInterpolation(_ typedTableAlias: TypedTableAlias) { + let name: String = typedTableAlias.name + + guard let tableName: String = typedTableAlias.tableName else { return appendLiteral(name.quotedDatabaseIdentifier) } + guard name != tableName else { return appendLiteral(tableName.quotedDatabaseIdentifier) } + + appendLiteral("\(tableName.quotedDatabaseIdentifier) AS \(name.quotedDatabaseIdentifier)") + } + + /// Appends a simple SQL query for use when we want a `LEFT JOIN` that will always fail + /// + /// // SELECT * FROM player LEFT JOIN team AS testTeam ON false + /// let player: TypedTableAlias = TypedTableAlias() + /// let testTeam: TypedTableAlias = TypedTableAlias(name: "testTeam") + /// let request: SQLRequest = "SELECT * FROM \(player) LEFT JOIN \(testTeam.never) + @_disfavoredOverload + mutating func appendInterpolation(_ neverJoiningAlias: NeverJoiningTypedTableAlias) where T: TableRecord { + guard let tableName: String = neverJoiningAlias.alias.tableName else { + appendLiteral("(SELECT \(generateSelection(for: T.self))) AS \(neverJoiningAlias.alias.name.quotedDatabaseIdentifier) ON false") + return + } + + appendLiteral("\(tableName.quotedDatabaseIdentifier) AS \(neverJoiningAlias.alias.name.quotedDatabaseIdentifier) ON false") + } + + /// Appends a simple SQL query for use when we want a `LEFT JOIN` that will always fail + /// + /// // SELECT * FROM player LEFT JOIN (SELECT 0 AS teamInfo.Column.A, 0 AS teamInfo.Column.B) AS teamInfo ON false + /// let player: TypedTableAlias = TypedTableAlias() + /// let teamInfo: TypedTableAlias = TypedTableAlias(name: "teamInfo") + /// let request: SQLRequest = "SELECT * FROM \(player) LEFT JOIN \(teamInfo.never) + @_disfavoredOverload + mutating func appendInterpolation(_ neverJoiningAlias: NeverJoiningTypedTableAlias) where T.Columns: CaseIterable { + appendLiteral("(SELECT \(generateSelection(for: T.self))) AS \(neverJoiningAlias.alias.name.quotedDatabaseIdentifier) ON false") + } + + /// Appends a simple SQL query for use when we want a `LEFT JOIN` that will always fail + /// + /// // SELECT * FROM player LEFT JOIN (SELECT 0 AS teamInfo.Column.A, 0 AS teamInfo.Column.B) AS teamInfo ON false + /// let player: TypedTableAlias = TypedTableAlias() + /// let teamInfo: TypedTableAlias = TypedTableAlias(name: "teamInfo") + /// let request: SQLRequest = "SELECT * FROM \(player) LEFT JOIN \(teamInfo.never) + @_disfavoredOverload + mutating func appendInterpolation(_ neverJoiningAlias: NeverJoiningTypedTableAlias) { + appendLiteral("(SELECT \(generateSelection(for: T.self))) AS \(neverJoiningAlias.alias.name.quotedDatabaseIdentifier) ON false") + } + + private func generateSelection(for type: T.Type) -> String where T.Columns: CaseIterable { + return T.Columns.allCases + .map { "NULL AS \($0.name)" } + .joined(separator: ", ") + } + + private func generateSelection(for type: T.Type) -> String { + return "SELECT 1" + } +} diff --git a/SessionUtilitiesKit/Database/Utilities/ScopeAdapter+Utilities.swift b/SessionUtilitiesKit/Database/Utilities/ScopeAdapter+Utilities.swift new file mode 100644 index 000000000..500131972 --- /dev/null +++ b/SessionUtilitiesKit/Database/Utilities/ScopeAdapter+Utilities.swift @@ -0,0 +1,13 @@ +// Copyright © 2023 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import GRDB + +public extension ScopeAdapter { + static func with( + _ viewModel: VM.Type, + _ scopes: [VM.Columns: RowAdapter] + ) -> ScopeAdapter { + return ScopeAdapter(scopes.reduce(into: [:]) { result, next in result[next.key.name] = next.value }) + } +} diff --git a/SessionUtilitiesKit/General/AppContext.h b/SessionUtilitiesKit/General/AppContext.h index 626c93a2e..dff051bd0 100755 --- a/SessionUtilitiesKit/General/AppContext.h +++ b/SessionUtilitiesKit/General/AppContext.h @@ -2,15 +2,6 @@ NS_ASSUME_NONNULL_BEGIN -static inline BOOL OWSIsDebugBuild() -{ -#ifdef DEBUG - return YES; -#else - return NO; -#endif -} - // These are fired whenever the corresponding "main app" or "app extension" // notification is fired. // @@ -42,8 +33,6 @@ NSString *NSStringForUIApplicationState(UIApplicationState value); // Whether the user is using a right-to-left language like Arabic. @property (nonatomic, readonly) BOOL isRTL; -@property (nonatomic, readonly) BOOL isRunningTests; - @property (atomic, nullable) UIWindow *mainWindow; // Unlike UIApplication.applicationState, this is thread-safe. 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 61da1dc05..a6b99474e 100644 --- a/SessionUtilitiesKit/General/Dependencies.swift +++ b/SessionUtilitiesKit/General/Dependencies.swift @@ -3,91 +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 _scheduler: Atomic - public var scheduler: ValueObservationScheduler { - get { Dependencies.getValueSettingIfNull(&_scheduler) { Storage.defaultPublisherScheduler } } - set { _scheduler.mutate { $0 = newValue } } + private var _network: Atomic + public var network: NetworkType { + get { Dependencies.getValueSettingIfNull(&_network) { Network() } } + set { _network.mutate { $0 = newValue } } } - public var _standardUserDefaults: Atomic + private var _crypto: Atomic + public var crypto: CryptoType { + get { Dependencies.getValueSettingIfNull(&_crypto) { Crypto() } } + set { _crypto.mutate { $0 = newValue } } + } + + 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 } } + 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, - scheduler: ValueObservationScheduler? = nil, + network: NetworkType? = nil, + crypto: CryptoType? = nil, standardUserDefaults: UserDefaultsType? = nil, - date: Date? = nil + caches: CachesType = Caches(), + jobRunner: JobRunnerType? = nil, + scheduler: ValueObservationScheduler? = nil, + dateNow: Date? = nil, + fixedTime: Int? = nil, + forceSynchronous: Bool = false ) { - _subscribeQueue = Atomic(subscribeQueue) - _receiveQueue = Atomic(receiveQueue) - _mutableGeneralCache = Atomic(generalCache) _storage = Atomic(storage) - _scheduler = Atomic(scheduler) + _network = Atomic(network) + _crypto = Atomic(crypto) _standardUserDefaults = Atomic(standardUserDefaults) - _date = Atomic(date) + _caches = caches + _jobRunner = Atomic(jobRunner) + _scheduler = Atomic(scheduler) + _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 } @@ -97,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 } @@ -106,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/SNUserDefaults.swift b/SessionUtilitiesKit/General/SNUserDefaults.swift index bc55e8231..a1829aab0 100644 --- a/SessionUtilitiesKit/General/SNUserDefaults.swift +++ b/SessionUtilitiesKit/General/SNUserDefaults.swift @@ -37,7 +37,6 @@ public enum SNUserDefaults { } public enum Date: Swift.String { - case lastConfigurationSync case lastProfilePictureUpload case lastOpenGroupImageUpdate case lastOpen @@ -62,8 +61,10 @@ public enum SNUserDefaults { } public extension UserDefaults { + public static let applicationGroup: String = "group.com.loki-project.loki-messenger" + @objc static var sharedLokiProject: UserDefaults? { - UserDefaults(suiteName: "group.com.loki-project.loki-messenger") + UserDefaults(suiteName: UserDefaults.applicationGroup) } } diff --git a/SessionUtilitiesKit/General/SessionId.swift b/SessionUtilitiesKit/General/SessionId.swift index ecf4bc3a5..25cc06def 100644 --- a/SessionUtilitiesKit/General/SessionId.swift +++ b/SessionUtilitiesKit/General/SessionId.swift @@ -11,6 +11,7 @@ public struct SessionId { case blinded15 = "15" // Used for authentication and participants in open groups with blinding enabled case blinded25 = "25" // Used for authentication and participants in open groups with blinding enabled case unblinded = "00" // Used for authentication in open groups with blinding disabled + case group = "03" // Used for update group conversations public init?(from stringValue: String?) { guard let stringValue: String = stringValue else { return nil } diff --git a/SessionUtilitiesKit/General/Set+Utilities.swift b/SessionUtilitiesKit/General/Set+Utilities.swift index c15f96f6a..346d96e0e 100644 --- a/SessionUtilitiesKit/General/Set+Utilities.swift +++ b/SessionUtilitiesKit/General/Set+Utilities.swift @@ -3,6 +3,12 @@ import Foundation public extension Set { + mutating func insert(contentsOf value: Set?) { + guard let value: Set = value else { return } + + value.forEach { self.insert($0) } + } + func inserting(_ value: Element?) -> Set { guard let value: Element = value else { return self } 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 d87731118..7c46c2730 100644 --- a/SessionUtilitiesKit/JobRunner/JobRunner.swift +++ b/SessionUtilitiesKit/JobRunner/JobRunner.swift @@ -3,6 +3,83 @@ import Foundation import GRDB +public protocol JobRunnerType { + // MARK: - Configuration + + 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(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, 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 + +public extension JobRunnerType { + func allJobInfo() -> [Int64: JobRunner.JobInfo] { return jobInfoFor(jobs: nil, state: .any, variant: nil) } + + func jobInfoFor(jobs: [Job]) -> [Int64: JobRunner.JobInfo] { + return jobInfoFor(jobs: jobs, state: .any, variant: nil) + } + + func jobInfoFor(jobs: [Job], state: JobRunner.JobState) -> [Int64: JobRunner.JobInfo] { + return jobInfoFor(jobs: jobs, state: state, variant: nil) + } + + func jobInfoFor(state: JobRunner.JobState) -> [Int64: JobRunner.JobInfo] { + return jobInfoFor(jobs: nil, state: state, variant: nil) + } + + func jobInfoFor(state: JobRunner.JobState, variant: Job.Variant) -> [Int64: JobRunner.JobInfo] { + return jobInfoFor(jobs: nil, state: state, variant: variant) + } + + func jobInfoFor(variant: Job.Variant) -> [Int64: JobRunner.JobInfo] { + return jobInfoFor(jobs: nil, state: .any, variant: variant) + } + + func isCurrentlyRunning(_ job: Job?) -> Bool { + guard let job: Job = job else { return false } + + return !jobInfoFor(jobs: [job], state: .running).isEmpty + } + + func hasJob( + of variant: Job.Variant? = nil, + inState state: JobRunner.JobState = .any, + with jobDetails: T + ) -> Bool { + 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 + .contains(where: { $0.detailsData == detailsData }) + } + + func stopAndClearPendingJobs(exceptForVariant: Job.Variant? = nil, onComplete: (() -> ())? = nil) { + stopAndClearPendingJobs(exceptForVariant: exceptForVariant, onComplete: onComplete) + } +} + +// MARK: - JobExecutor + public protocol JobExecutor { /// The maximum number of times the job can fail before it fails permanently /// @@ -28,221 +105,297 @@ public protocol JobExecutor { static func run( _ job: Job, queue: DispatchQueue, - success: @escaping (Job, Bool) -> (), - failure: @escaping (Job, Error?, Bool) -> (), - deferred: @escaping (Job) -> () + success: @escaping (Job, Bool, Dependencies) -> (), + failure: @escaping (Job, Error?, Bool, Dependencies) -> (), + deferred: @escaping (Job, Dependencies) -> (), + using dependencies: Dependencies ) } -public final class JobRunner { +// MARK: - JobRunner + +public final class JobRunner: JobRunnerType { + public struct JobState: OptionSet, Hashable { + public let rawValue: UInt8 + + public init(rawValue: UInt8) { + self.rawValue = rawValue + } + + public static let pending: JobState = JobState(rawValue: 1 << 0) + public static let running: JobState = JobState(rawValue: 1 << 1) + + public static let any: JobState = [ .pending, .running ] + } + public enum JobResult { case succeeded case failed case deferred case notFound } - - public struct JobInfo { + + 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() + } } - private static let blockingQueue: Atomic = Atomic( - JobQueue( - type: .blocking, - qos: .default, - jobVariants: [], - onQueueDrained: { - // Once all blocking jobs have been completed we want to start running - // the remaining job queues - queues.wrappedValue.forEach { _, queue in queue.start() } - blockingQueueDrainCallback.mutate { - $0.forEach { $0() } - $0 = [] - } - } - ) - ) - private static let queues: Atomic<[Job.Variant: JobQueue]> = { - var jobVariants: Set = Job.Variant.allCases.asSet() + // MARK: - Variables + + private let allowToExecuteJobs: Bool + private let blockingQueue: Atomic + private let queues: Atomic<[Job.Variant: JobQueue]> + 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) + + 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] = [], + using dependencies: Dependencies = Dependencies() + ) { + var jobVariants: Set = Job.Variant.allCases + .filter { !variantsToExclude.contains($0) } + .asSet() - let messageSendQueue: JobQueue = JobQueue( - type: .messageSend, - executionType: .concurrent, // Allow as many jobs to run at once as supported by the device - qos: .default, - jobVariants: [ - jobVariants.remove(.attachmentUpload), - jobVariants.remove(.messageSend), - jobVariants.remove(.notifyPushServer), - jobVariants.remove(.sendReadReceipts), - jobVariants.remove(.groupLeaving), - jobVariants.remove(.configurationSync) - ].compactMap { $0 } + self.allowToExecuteJobs = ( + isTestingJobRunner || ( + HasAppContext() && + CurrentAppContext().isMainApp && + !SNUtilitiesKit.isRunningTests + ) ) - let messageReceiveQueue: JobQueue = JobQueue( - type: .messageReceive, - // Explicitly serial as executing concurrently means message receives getting processed at - // different speeds which can result in: - // • Small batches of messages appearing in the UI before larger batches - // • Closed group messages encrypted with updated keys could start parsing before it's key - // update message has been processed (ie. guaranteed to fail) - executionType: .serial, - qos: .default, - jobVariants: [ - jobVariants.remove(.messageReceive), - jobVariants.remove(.configMessageReceive) - ].compactMap { $0 } + self.blockingQueue = Atomic( + JobQueue( + type: .blocking, + executionType: .serial, + qos: .default, + isTestingJobRunner: isTestingJobRunner, + jobVariants: [] + ) ) - let attachmentDownloadQueue: JobQueue = JobQueue( - type: .attachmentDownload, - qos: .utility, - jobVariants: [ - jobVariants.remove(.attachmentDownload) - ].compactMap { $0 } - ) - let generalQueue: JobQueue = JobQueue( - type: .general(number: 0), - qos: .utility, - jobVariants: Array(jobVariants) - ) - - return Atomic([ - messageSendQueue, - messageReceiveQueue, - attachmentDownloadQueue, - generalQueue + self.queues = Atomic([ + // MARK: -- Message Send Queue + + JobQueue( + 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), + jobVariants.remove(.notifyPushServer), + jobVariants.remove(.sendReadReceipts), + jobVariants.remove(.groupLeaving), + jobVariants.remove(.configurationSync) + ].compactMap { $0 } + ), + + // MARK: -- Message Receive Queue + + JobQueue( + type: .messageReceive, + // Explicitly serial as executing concurrently means message receives getting processed at + // different speeds which can result in: + // • Small batches of messages appearing in the UI before larger batches + // • Closed group messages encrypted with updated keys could start parsing before it's key + // update message has been processed (ie. guaranteed to fail) + executionType: .serial, + qos: .default, + isTestingJobRunner: isTestingJobRunner, + jobVariants: [ + jobVariants.remove(.messageReceive), + jobVariants.remove(.configMessageReceive) + ].compactMap { $0 } + ), + + // MARK: -- Attachment Download Queue + + JobQueue( + type: .attachmentDownload, + executionType: .serial, + qos: .utility, + isTestingJobRunner: isTestingJobRunner, + jobVariants: [ + jobVariants.remove(.attachmentDownload) + ].compactMap { $0 } + ), + + // MARK: -- General Queue + + JobQueue( + type: .general(number: 0), + executionType: .serial, + qos: .utility, + isTestingJobRunner: isTestingJobRunner, + jobVariants: Array(jobVariants) + ) ].reduce(into: [:]) { prev, next in next.jobVariants.forEach { variant in prev[variant] = next } }) - }() - - 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 + + // Now that we've finished setting up the JobRunner, update the queue closures + self.blockingQueue.mutate { + $0?.canStart = { [weak self] queue -> Bool in (self?.canStart(queue: queue) == true) } + $0?.onQueueDrained = { [weak self] in + // Once all blocking jobs have been completed we want to start running + // the remaining job queues + self?.startNonBlockingQueues(using: dependencies) + + self?.blockingQueueDrainCallback.mutate { + $0.forEach { $0() } + $0 = [] + } + } + } + + self.queues.mutate { + $0.values.forEach { queue in + queue.canStart = { [weak self] targetQueue -> Bool in (self?.canStart(queue: targetQueue) == true) } + } + } } // MARK: - Configuration - public static func add(executor: JobExecutor.Type, for variant: Job.Variant) { - executorMap.mutate { $0[variant] = executor } + public func setExecutor(_ executor: JobExecutor.Type, for variant: Job.Variant) { + blockingQueue.wrappedValue?.setExecutor(executor, for: variant) // The blocking queue can run any job + queues.wrappedValue[variant]?.setExecutor(executor, for: variant) } - public static func afterBlockingQueue(callback: @escaping () -> ()) { + public func canStart(queue: JobQueue?) -> Bool { + return ( + allowToExecuteJobs && + appReadyToStartQueues.wrappedValue && ( + queue?.type == .blocking || + canStartNonBlockingQueue + ) + ) + } + + 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: - 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 + + // MARK: - State Management + + public func jobInfoFor( + jobs: [Job]?, + state: JobRunner.JobState, + variant: Job.Variant? + ) -> [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 infoFor(queue: JobQueue?, variants: [Job.Variant]) -> [(Int64, JobRunner.JobInfo)] { + return (queue?.pendingJobsQueue.wrappedValue + .filter { variants.isEmpty || variants.contains($0.variant) } + .compactMap { job -> (Int64, JobRunner.JobInfo)? in + guard let jobKey: JobQueue.JobKey = JobQueue.JobKey(job) else { return nil } + guard + targetKeys.isEmpty || + targetKeys.contains(jobKey) + else { return nil } + + return ( + jobKey.id, + JobRunner.JobInfo( + variant: job.variant, + threadId: job.threadId, + interactionId: job.interactionId, + detailsData: job.details + ) + ) + }) + .defaulting(to: []) + } + + result.append(contentsOf: infoFor(queue: blockingQueue.wrappedValue, variants: targetVariants)) + queues.wrappedValue + .filter { key, _ -> Bool in targetVariants.isEmpty || targetVariants.contains(key) } + .map { _, queue in queue } + .asSet() + .forEach { queue in result.append(contentsOf: infoFor(queue: queue, variants: targetVariants)) } } - // 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) + // Insert the state of any running jobs + if state.contains(.running) { + 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 (jobId, info) + }) + .defaulting(to: []) + } - // Don't start the queue if the job can't be started - guard canStartJob else { return } - - queues.wrappedValue[updatedJob.variant]?.start() + result.append(contentsOf: infoFor(queue: blockingQueue.wrappedValue, variants: targetVariants)) + queues.wrappedValue + .filter { key, _ -> Bool in targetVariants.isEmpty || targetVariants.contains(key) } + .map { _, queue in queue } + .asSet() + .forEach { queue in result.append(contentsOf: infoFor(queue: queue, variants: targetVariants)) } } - return updatedJob + return result + .reduce(into: [:]) { result, next in + result[next.0] = next.1 + } } - /// Upsert 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 - public static func upsert(_ db: Database, job: Job?, canStartJob: Bool = true) { - guard let job: Job = job else { return } // Ignore null jobs - guard job.id != nil else { - add(db, job: job, canStartJob: canStartJob) - return - } - - // 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 static func appDidFinishLaunching() { + public func appDidFinishLaunching(using dependencies: Dependencies) { // Flag that the JobRunner can start it's queues - JobRunner.canStartQueues.mutate { $0 = true } + appReadyToStartQueues.mutate { $0 = true } // Note: 'appDidBecomeActive' will run on first launch anyway so we can // leave those jobs out and can wait until then to start the JobRunner - let jobsToRun: (blocking: [Job], nonBlocking: [Job]) = Storage.shared + let jobsToRun: (blocking: [Job], nonBlocking: [Job]) = dependencies.storage .read { db in let blockingJobs: [Job] = try Job .filter( @@ -275,10 +428,12 @@ public final class JobRunner { } .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) + blockingQueue.wrappedValue?.appDidFinishLaunching( + with: jobsToRun.blocking, + canStart: true, + using: dependencies + ) // Add any non-blocking jobs (we don't start these incase there are blocking "on active" // jobs as well) @@ -286,13 +441,18 @@ public final class JobRunner { let jobQueues: [Job.Variant: JobQueue] = queues.wrappedValue jobsByVariant.forEach { variant, jobs in - jobQueues[variant]?.appDidFinishLaunching(with: jobs, canStart: false) + jobQueues[variant]?.appDidFinishLaunching( + with: jobs, + canStart: false, + using: dependencies + ) } } - public static func appDidBecomeActive() { - // Flag that the JobRunner can start it's queues - JobRunner.canStartQueues.mutate { $0 = true } + 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 @@ -302,8 +462,8 @@ public final class JobRunner { } // Retrieve any jobs which should run when becoming active - let hasCompletedInitialBecomeActive: Bool = JobRunner.hasCompletedInitialBecomeActive.wrappedValue - let jobsToRun: [Job] = Storage.shared + let hasCompletedInitialBecomeActive: Bool = self.hasCompletedInitialBecomeActive.wrappedValue + let jobsToRun: [Job] = dependencies.storage .read { db in return try Job .filter(Job.Columns.behaviour == Job.Behaviour.recurringOnActive) @@ -322,38 +482,54 @@ public final class JobRunner { guard !jobsToRun.isEmpty else { if !blockingQueueIsRunning { - jobQueues.forEach { _, queue in queue.start() } + 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 - ) - } - JobRunner.hasCompletedInitialBecomeActive.mutate { $0 = true } + 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 } } - /// 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 - /// failure - they _should_ be picked up again the next time the app is launched) - public static func stopAndClearPendingJobs( - exceptForVariant: Job.Variant? = nil, - onComplete: (() -> ())? = nil + public func startNonBlockingQueues(using dependencies: Dependencies) { + queues.wrappedValue.map { _, queue in queue }.asSet().forEach { queue in + queue.start(using: dependencies) + } + } + + public func stopAndClearPendingJobs( + exceptForVariant: Job.Variant?, + onComplete: (() -> ())? ) { // Inform the JobRunner that it can't start any queues (this is to prevent queues from // rescheduling themselves while in the background, when the app restarts or becomes active // the JobRunenr will update this flag) - JobRunner.canStartQueues.mutate { $0 = false } + 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 } @@ -391,27 +567,107 @@ public final class JobRunner { } // Add a callback to be triggered once the queue is drained - queue.onQueueDrained = { [weak queue] in + queue.onQueueDrained = { [weak self, weak queue] in oldQueueDrained?() queue?.onQueueDrained = oldQueueDrained onComplete?() - shutdownBackgroundTask.mutate { $0 = nil } + self?.shutdownBackgroundTask.mutate { $0 = nil } } } - public static func isCurrentlyRunning(_ job: Job?) -> Bool { - guard let job: Job = job, let jobId: Int64 = job.id else { return false } + // MARK: - Execution + + @discardableResult public func add( + _ db: Database, + job: Job?, + canStartJob: Bool, + 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 nil + } + guard !canStartJob || updatedJob.id != nil else { + SNLog("[JobRunner] Not starting \(job.map { "\($0.variant)" } ?? "unknown") job due to missing id") + return nil + } - return (queues.wrappedValue[job.variant]?.isCurrentlyRunning(jobId) == true) + // 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 updatedJob } + + // Start the job runner if needed + db.afterNextTransactionNestedOnce(dedupeId: "JobRunner-Start: \(updatedJob.variant)") { [weak self] _ in + self?.queues.wrappedValue[updatedJob.variant]?.start(using: dependencies) + } + + return updatedJob } - public static func infoForCurrentlyRunningJobs(of variant: Job.Variant) -> [Int64: JobInfo] { - return (queues.wrappedValue[variant]?.infoForAllCurrentlyRunningJobs()) - .defaulting(to: [:]) + public func upsert( + _ db: Database, + job: Job?, + canStartJob: Bool, + using dependencies: Dependencies + ) { + guard let job: Job = job else { return } // Ignore null jobs + guard job.id != nil else { + add(db, job: job, canStartJob: canStartJob, using: dependencies) + return + } + + // 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(using: dependencies) + } } - public static func afterCurrentlyRunningJob(_ job: Job?, callback: @escaping (JobResult) -> ()) { + @discardableResult public 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 + } + + queues.wrappedValue[updatedJob.variant]?.insert(updatedJob, before: otherJob) + + return (jobId, updatedJob) + } + + internal func afterCurrentlyRunningJob(_ job: Job?, callback: @escaping (JobResult) -> ()) { guard let job: Job = job, let jobId: Int64 = job.id, let queue: JobQueue = queues.wrappedValue[job.variant] else { callback(.notFound) return @@ -420,27 +676,7 @@ public final class JobRunner { queue.afterCurrentlyRunningJob(jobId, callback: callback) } - public static func hasPendingOrRunningJob( - with variant: Job.Variant, - threadId: String? = nil, - interactionId: Int64? = nil, - details: T? = nil - ) -> Bool { - guard let targetQueue: JobQueue = queues.wrappedValue[variant] else { return false } - - // Ensure we can encode the details (if provided) - let detailsData: Data? = details.map { try? JSONEncoder().encode($0) } - - guard details == nil || detailsData != nil else { return false } - - return targetQueue.hasPendingOrRunningJobWith( - threadId: threadId, - interactionId: interactionId, - detailsData: detailsData - ) - } - - public static func removePendingJob(_ job: Job?) { + internal func removePendingJob(_ job: Job?) { guard let job: Job = job, let jobId: Int64 = job.id else { return } queues.wrappedValue[job.variant]?.removePendingJob(jobId) @@ -459,12 +695,21 @@ public final class JobRunner { 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 enum QueueType: Hashable { +public final class JobQueue: Hashable { + fileprivate enum QueueType: Hashable { case blocking case general(number: Int) case messageSend @@ -496,22 +741,26 @@ public final class JobQueue { private var timer: Timer? fileprivate var fireTimestamp: TimeInterval = 0 - static func create(queue: JobQueue, timestamp: TimeInterval) -> Trigger? { + static func create( + queue: JobQueue, + timestamp: TimeInterval, + using dependencies: Dependencies + ) -> Trigger? { /// 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 - 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() + queue?.start(using: dependencies) } ) - return trigger } @@ -522,19 +771,33 @@ public final class JobQueue { } } + fileprivate struct JobKey: Equatable, Hashable { + 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 } + + self.id = id + self.variant = variant + } + } + 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, @@ -548,54 +811,84 @@ public final class JobQueue { return result }() - private var nextTrigger: Atomic = Atomic(nil) + private var executorMap: Atomic<[Job.Variant: JobExecutor.Type]> = Atomic([:]) + fileprivate var canStart: ((JobQueue?) -> Bool)? + fileprivate var onQueueDrained: (() -> ())? fileprivate var hasStartedAtLeastOnce: Atomic = Atomic(false) fileprivate var isRunning: Atomic = Atomic(false) - private var queue: Atomic<[Job]> = Atomic([]) + fileprivate var pendingJobsQueue: Atomic<[Job]> = 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 deferLoopTracker: Atomic<[Int64: (count: Int, times: [TimeInterval])]> = Atomic([:]) + private let maxDeferralsPerSecond: Int - fileprivate var hasPendingJobs: Bool { !queue.wrappedValue.isEmpty } + fileprivate var hasPendingJobs: Bool { !pendingJobsQueue.wrappedValue.isEmpty } // MARK: - Initialization fileprivate init( type: QueueType, - executionType: ExecutionType = .serial, + executionType: ExecutionType, 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 + + fileprivate func setExecutor(_ executor: JobExecutor.Type, for variant: Job.Variant) { + executorMap.mutate { $0[variant] = executor } } // MARK: - Execution - fileprivate func add(_ job: Job, canStartJob: Bool = true) { + 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 <= 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") return } - queue.mutate { $0.append(job) } + pendingJobsQueue.mutate { $0.append(job) } // If this is a concurrent queue then we should immediately start the next job guard executionType == .concurrent else { return } - runNextJob() + // 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(using: dependencies) + } } /// Upsert a job onto the queue, if the queue isn't currently running and 'canStartJob' is true then this will start @@ -603,30 +896,35 @@ 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(_ job: Job, canStartJob: Bool = true) { + 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 } - // Lock the queue while checking the index and inserting to ensure we don't run into + // Lock the pendingJobsQueue while checking the index and inserting to ensure we don't run into // any multi-threading shenanigans // - // Note: currently running jobs are removed from the queue so we don't need to check + // Note: currently running jobs are removed from the pendingJobsQueue so we don't need to check // the 'jobsCurrentlyRunning' set var didUpdateExistingJob: Bool = false - queue.mutate { queue in + pendingJobsQueue.mutate { queue in if let jobIndex: Array.Index = queue.firstIndex(where: { $0.id == jobId }) { queue[jobIndex] = job didUpdateExistingJob = true } } - // If we didn't update an existing job then we need to add it to the queue + // If we didn't update an existing job then we need to add it to the pendingJobsQueue guard !didUpdateExistingJob else { return } - add(job, canStartJob: canStartJob) + add(db, job: job, canStartJob: canStartJob, using: dependencies) } fileprivate func insert(_ job: Job, before otherJob: Job) { @@ -636,10 +934,10 @@ public final class JobQueue { } // Insert the job before the current job (re-adding the current job to - // the start of the queue if it's not in there) - this will mean the new + // the start of the pendingJobsQueue if it's not in there) - this will mean the new // job will run and then the otherJob will run (or run again) once it's // done - queue.mutate { + pendingJobsQueue.mutate { guard let otherJobIndex: Int = $0.firstIndex(of: otherJob) else { $0.insert(contentsOf: [job, otherJob], at: 0) return @@ -649,19 +947,27 @@ public final class JobQueue { } } - fileprivate func appDidFinishLaunching(with jobs: [Job], canStart: Bool) { - queue.mutate { $0.append(contentsOf: jobs) } + fileprivate func appDidFinishLaunching( + with jobs: [Job], + canStart: Bool, + using dependencies: Dependencies + ) { + pendingJobsQueue.mutate { $0.append(contentsOf: jobs) } // Start the job runner if needed if canStart && !isRunning.wrappedValue { - start() + start(using: dependencies) } } - fileprivate func appDidBecomeActive(with jobs: [Job], canStart: Bool) { + fileprivate func appDidBecomeActive( + with jobs: [Job], + canStart: Bool, + using dependencies: Dependencies + ) { let currentlyRunningJobIds: Set = currentlyRunningJobIds.wrappedValue - queue.mutate { queue in + pendingJobsQueue.mutate { queue in // Avoid re-adding jobs to the queue that are already in it (this can // happen if the user sends the app to the background before the 'onActive' // jobs and then brings it back to the foreground) @@ -676,23 +982,16 @@ public final class JobQueue { // Start the job runner if needed if canStart && !isRunning.wrappedValue { - start() + 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 afterCurrentlyRunningJob(_ jobId: Int64, callback: @escaping (JobRunner.JobResult) -> ()) { - guard isCurrentlyRunning(jobId) else { - callback(.notFound) - return - } + guard currentlyRunningJobIds.wrappedValue.contains(jobId) else { return callback(.notFound) } jobCallbacks.mutate { jobCallbacks in jobCallbacks[jobId] = (jobCallbacks[jobId] ?? []).appending(callback) @@ -704,7 +1003,7 @@ public final class JobQueue { 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()) @@ -761,32 +1060,26 @@ public final class JobQueue { } fileprivate func removePendingJob(_ jobId: Int64) { - queue.mutate { queue in + pendingJobsQueue.mutate { queue in queue = queue.filter { $0.id != jobId } } } // MARK: - Job Running - fileprivate func start(force: Bool = false) { - // 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 } - guard force || !isRunning.wrappedValue else { return } + fileprivate func start( + forceWhenAlreadyRunning: Bool = false, + using dependencies: Dependencies + ) { + // Only start if the JobRunner is allowed to start the queue + guard canStart?(self) == true else { return } + guard forceWhenAlreadyRunning || !isRunning.wrappedValue 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() + guard DispatchQueue.with(key: queueKey, matches: queueContext, using: dependencies) else { + internalQueue.async(using: dependencies) { [weak self] in + self?.start(using: dependencies) } return } @@ -802,8 +1095,8 @@ public final class JobQueue { // Get any pending jobs let jobIdsAlreadyRunning: Set = currentlyRunningJobIds.wrappedValue - let jobsAlreadyInQueue: Set = queue.wrappedValue.compactMap { $0.id }.asSet() - let jobsToRun: [Job] = Storage.shared.read { db in + let jobsAlreadyInQueue: Set = pendingJobsQueue.wrappedValue.compactMap { $0.id }.asSet() + let jobsToRun: [Job] = dependencies.storage.read(using: dependencies) { db in try Job .filterPendingJobs( variants: jobVariants, @@ -819,7 +1112,7 @@ public final class JobQueue { // Determine the number of jobs to run var jobCount: Int = 0 - queue.mutate { queue in + pendingJobsQueue.mutate { queue in queue.append(contentsOf: jobsToRun) jobCount = queue.count } @@ -829,36 +1122,36 @@ public final class JobQueue { guard jobCount > 0 else { if jobIdsAlreadyRunning.isEmpty { isRunning.mutate { $0 = false } - scheduleNextSoonestJob() + scheduleNextSoonestJob(using: dependencies) } return } - // Run the first job in the queue + // 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() + runNextJob(using: dependencies) } fileprivate func stopAndClearPendingJobs() { isRunning.mutate { $0 = false } - queue.mutate { $0 = [] } + pendingJobsQueue.mutate { $0 = [] } deferLoopTracker.mutate { $0 = [:] } } - private func runNextJob() { + 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() + guard DispatchQueue.with(key: queueKey, matches: queueContext, using: dependencies) else { + internalQueue.async(using: dependencies) { [weak self] in + self?.runNextJob(using: dependencies) } return } - guard let (nextJob, numJobsRemaining): (Job, Int) = queue.mutate({ queue in queue.popFirst().map { ($0, queue.count) } }) else { + guard let (nextJob, numJobsRemaining): (Job, Int) = pendingJobsQueue.mutate({ queue in queue.popFirst().map { ($0, queue.count) } }) else { // If it's a serial queue, or there are no more jobs running then update the 'isRunning' flag if executionType != .concurrent || currentlyRunningJobIds.wrappedValue.isEmpty { isRunning.mutate { $0 = false } @@ -866,38 +1159,58 @@ 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() + scheduleNextSoonestJob(using: dependencies) return } - guard let jobExecutor: JobExecutor.Type = JobRunner.executorMap.wrappedValue[nextJob.variant] else { + guard let jobExecutor: JobExecutor.Type = executorMap.wrappedValue[nextJob.variant] else { SNLog("[JobRunner] \(queueContext) Unable to run \(nextJob.variant) job due to missing executor") - handleJobFailed(nextJob, error: JobRunnerError.executorMissing, permanentFailure: true) + handleJobFailed( + nextJob, + error: JobRunnerError.executorMissing, + permanentFailure: true, + using: dependencies + ) return } guard !jobExecutor.requiresThreadId || nextJob.threadId != nil else { SNLog("[JobRunner] \(queueContext) Unable to run \(nextJob.variant) job due to missing required threadId") - handleJobFailed(nextJob, error: JobRunnerError.requiredThreadIdMissing, permanentFailure: true) + handleJobFailed( + nextJob, + error: JobRunnerError.requiredThreadIdMissing, + permanentFailure: true, + using: dependencies + ) return } guard !jobExecutor.requiresInteractionId || nextJob.interactionId != nil else { SNLog("[JobRunner] \(queueContext) Unable to run \(nextJob.variant) job due to missing required interactionId") - handleJobFailed(nextJob, error: JobRunnerError.requiredInteractionIdMissing, permanentFailure: true) + handleJobFailed( + nextJob, + error: JobRunnerError.requiredInteractionIdMissing, + permanentFailure: true, + using: dependencies + ) return } guard nextJob.id != nil else { SNLog("[JobRunner] \(queueContext) Unable to run \(nextJob.variant) job due to missing id") - handleJobFailed(nextJob, error: JobRunnerError.jobIdMissing, permanentFailure: false) + handleJobFailed( + nextJob, + error: JobRunnerError.jobIdMissing, + permanentFailure: false, + using: dependencies + ) return } // If the 'nextRunTimestamp' for the job is in the future then don't run it yet - guard nextJob.nextRunTimestamp <= Date().timeIntervalSince1970 else { - handleJobDeferred(nextJob) + 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) = Storage.shared.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) @@ -911,7 +1224,12 @@ public final class JobQueue { guard dependencyInfo.jobs.count == dependencyInfo.expectedCount else { SNLog("[JobRunner] \(queueContext) found job with missing dependencies, removing the job") - handleJobFailed(nextJob, error: JobRunnerError.missingDependencies, permanentFailure: true) + handleJobFailed( + nextJob, + error: JobRunnerError.missingDependencies, + permanentFailure: true, + using: dependencies + ) return } guard dependencyInfo.jobs.isEmpty else { @@ -927,12 +1245,12 @@ public final class JobQueue { .filter { job in !currentlyRunningJobIds.contains(job.id ?? -1) } .sorted { lhs, rhs in (lhs.id ?? -1) < (rhs.id ?? -1) } - queue.mutate { queue in + pendingJobsQueue.mutate { queue in queue = queue .filter { !dependencyJobsNotCurrentlyRunning.contains($0) } .inserting(contentsOf: dependencyJobsNotCurrentlyRunning, at: 0) } - handleJobDeferred(nextJob) + handleJobDeferred(nextJob, using: dependencies) return } @@ -954,6 +1272,7 @@ public final class JobQueue { currentlyRunningJobInfo = currentlyRunningJobInfo.setting( nextJob.id, JobRunner.JobInfo( + variant: nextJob.variant, threadId: nextJob.threadId, interactionId: nextJob.interactionId, detailsData: nextJob.details @@ -985,21 +1304,22 @@ public final class JobQueue { queue: targetQueue, success: handleJobSucceeded, failure: handleJobFailed, - deferred: handleJobDeferred + deferred: handleJobDeferred, + 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() + internalQueue.async(using: dependencies) { [weak self] in + self?.runNextJob(using: dependencies) } } } - private func scheduleNextSoonestJob() { + private func scheduleNextSoonestJob(using dependencies: Dependencies) { let jobIdsAlreadyRunning: Set = currentlyRunningJobIds.wrappedValue - let nextJobTimestamp: TimeInterval? = Storage.shared.read { db in + let nextJobTimestamp: TimeInterval? = dependencies.storage.read(using: dependencies) { db in try Job .filterPendingJobs( variants: jobVariants, @@ -1014,7 +1334,7 @@ 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, JobRunner.canStartQueues.wrappedValue else { + guard let nextJobTimestamp: TimeInterval = nextJobTimestamp, canStart?(self) == true else { if executionType != .concurrent || currentlyRunningJobIds.wrappedValue.isEmpty { self.onQueueDrained?() } @@ -1022,7 +1342,7 @@ public final class JobQueue { } // If the next job isn't scheduled in the future then just restart the JobRunner immediately - let secondsUntilNextJob: TimeInterval = (nextJobTimestamp - 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 @@ -1037,8 +1357,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(force: (self?.executionType == .concurrent)) + internalQueue.async(using: dependencies) { [weak self] in + self?.start(forceWhenAlreadyRunning: (self?.executionType == .concurrent), using: dependencies) } return } @@ -1050,25 +1370,29 @@ 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) + trigger = Trigger.create(queue: self, timestamp: nextJobTimestamp, using: dependencies) } } // MARK: - Handling Results /// This function is called when a job succeeds - private func handleJobSucceeded(_ job: Job, shouldStop: Bool) { + private func handleJobSucceeded( + _ job: Job, + shouldStop: Bool, + 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] = Storage.shared - .read { db in try job.dependantJobs.fetchAll(db) } + let dependantJobs: [Job] = dependencies.storage + .read(using: dependencies) { db in try job.dependantJobs.fetchAll(db) } .defaulting(to: []) switch job.behaviour { case .runOnce, .runOnceNextLaunch: - Storage.shared.write { db in - // First remove any JobDependencies requiring this job to be completed (if - // we don't then the dependant jobs will automatically be deleted) + 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 .filter(JobDependencies.Columns.dependantId == job.id) .deleteAll(db) @@ -1077,9 +1401,9 @@ public final class JobQueue { } case .recurring where shouldStop == true: - Storage.shared.write { db in - // First remove any JobDependencies requiring this job to be completed (if - // we don't then the dependant jobs will automatically be deleted) + 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 .filter(JobDependencies.Columns.dependantId == job.id) .deleteAll(db) @@ -1087,25 +1411,23 @@ public final class JobQueue { _ = try job.delete(db) } - // 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: + /// 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 <= dependencies.dateNow.timeIntervalSince1970: guard let jobId: Int64 = job.id else { break } - Storage.shared.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)) ) } - // For `recurringOnLaunch/Active` jobs which have already run but failed once, we need to - // clear their `failureCount` and `nextRunTimestamp` to prevent them from endlessly running - // over and over again + /// For `recurringOnLaunch/Active` jobs which have already run but failed once, we need to clear their + /// `failureCount` and `nextRunTimestamp` to prevent them from endlessly running over and over again case .recurringOnLaunch, .recurringOnActive: guard let jobId: Int64 = job.id, @@ -1113,7 +1435,7 @@ public final class JobQueue { job.nextRunTimestamp > TimeInterval.leastNonzeroMagnitude else { break } - Storage.shared.write { db in + dependencies.storage.write(using: dependencies) { db in _ = try Job .filter(id: jobId) .updateAll( @@ -1138,7 +1460,7 @@ public final class JobQueue { .filter { job in !currentlyRunningJobIds.contains(job.id ?? -1) } .sorted { lhs, rhs in (lhs.id ?? -1) < (rhs.id ?? -1) } - queue.mutate { queue in + pendingJobsQueue.mutate { queue in queue = queue .filter { !dependantJobsNotCurrentlyRunning.contains($0) } .inserting(contentsOf: dependantJobsNotCurrentlyRunning, at: 0) @@ -1146,21 +1468,26 @@ public final class JobQueue { } // Perform job cleanup and start the next job - performCleanUp(for: job, result: .succeeded) - internalQueue.async { [weak self] in - self?.runNextJob() + performCleanUp(for: job, result: .succeeded, using: dependencies) + internalQueue.async(using: dependencies) { [weak self] in + self?.runNextJob(using: dependencies) } } /// This function is called when a job fails, if it's wasn't a permanent failure then the 'failureCount' for the job will be incremented and it'll /// be re-run after a retry interval has passed - private func handleJobFailed(_ job: Job, error: Error?, permanentFailure: Bool) { - guard Storage.shared.read({ db in try Job.exists(db, id: job.id ?? -1) }) == true else { + private func handleJobFailed( + _ job: Job, + error: Error?, + permanentFailure: Bool, + using dependencies: Dependencies + ) { + 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() + internalQueue.async(using: dependencies) { [weak self] in + self?.runNextJob(using: dependencies) } return } @@ -1182,33 +1509,34 @@ 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 if !wasPossibleDeferralLoop { - queue.mutate { $0.insert(job, at: 0) } + pendingJobsQueue.mutate { $0.insert(job, at: 0) } } - internalQueue.async { [weak self] in - self?.runNextJob() + 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 = (JobRunner.executorMap.wrappedValue[job.variant]?.maxFailureCount ?? 0) - let nextRunTimestamp: TimeInterval = (Date().timeIntervalSince1970 + JobRunner.getRetryInterval(for: job)) + let maxFailureCount: Int = (executorMap.wrappedValue[job.variant]?.maxFailureCount ?? 0) + let nextRunTimestamp: TimeInterval = (dependencies.dateNow.timeIntervalSince1970 + JobRunner.getRetryInterval(for: job)) var dependantJobIds: [Int64] = [] var failureText: String = "failed" - Storage.shared.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) .asRequest(of: Int64.self) .fetchAll(db) - + /// Delete/update the failed jobs and any dependencies let updatedFailureCount: UInt = (job.failureCount + 1) @@ -1242,7 +1570,7 @@ public final class JobQueue { .saved(db) // Update the failureCount and nextRunTimestamp on dependant jobs as well (update the - // 'nextRunTimestamp' value to be 1ms later so when the queue gets regenerated it'll + // 'nextRunTimestamp' value to be 1ms later so when the queue gets regenerated they'll // come after the dependency) try job.dependantJobs .updateAll( @@ -1255,36 +1583,39 @@ public final class JobQueue { /// Remove any dependant jobs from the queue (shouldn't be in there but filter the queue just in case so we don't try /// to run a deleted job or get stuck in a loop of trying to run dependencies indefinitely) if !dependantJobIds.isEmpty { - queue.mutate { queue in + pendingJobsQueue.mutate { queue in queue = queue.filter { !dependantJobIds.contains($0.id ?? -1) } } } SNLog("[JobRunner] \(queueContext) \(job.variant) job \(failureText)") - performCleanUp(for: job, result: .failed) - internalQueue.async { [weak self] in - self?.runNextJob() + performCleanUp(for: job, result: .failed, using: dependencies) + internalQueue.async(using: dependencies) { [weak self] in + self?.runNextJob(using: dependencies) } } /// This function is called when a job neither succeeds or fails (this should only occur if the job has specific logic that makes it dependant /// on other jobs, and it should automatically manage those dependencies) - private func handleJobDeferred(_ job: Job) { + public func handleJobDeferred( + _ job: Job, + using dependencies: Dependencies + ) { var stuckInDeferLoop: Bool = false deferLoopTracker.mutate { guard let lastRecord: (count: Int, times: [TimeInterval]) = $0[job.id] else { $0 = $0.setting( job.id, - (1, [Date().timeIntervalSince1970]) + (1, [dependencies.dateNow.timeIntervalSince1970]) ) return } - let timeNow: TimeInterval = 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( @@ -1307,17 +1638,27 @@ public final class JobQueue { // more than 'deferralLoopThreshold' times within 'deferralLoopThreshold' seconds) guard !stuckInDeferLoop else { deferLoopTracker.mutate { $0 = $0.removingValue(forKey: job.id) } - handleJobFailed(job, error: JobRunnerError.possibleDeferralLoop, permanentFailure: false) + handleJobFailed( + job, + error: JobRunnerError.possibleDeferralLoop, + permanentFailure: false, + using: dependencies + ) return } - performCleanUp(for: job, result: .deferred) - internalQueue.async { [weak self] in - self?.runNextJob() + 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 currentlyRunningJobIds.mutate { $0 = $0.removing(job.id) } @@ -1332,8 +1673,82 @@ 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) } } } } + +// MARK: - JobRunner Singleton +// FIXME: Remove this once the jobRunner is dependency injected everywhere correctly +public extension JobRunner { + internal static let instance: JobRunner = JobRunner() + + // MARK: - Static Access + + static func setExecutor(_ executor: JobExecutor.Type, for variant: Job.Variant) { + instance.setExecutor(executor, for: variant) + } + + static func appDidFinishLaunching(using dependencies: Dependencies = Dependencies()) { + instance.appDidFinishLaunching(using: dependencies) + } + + static func appDidBecomeActive(using dependencies: Dependencies = Dependencies()) { + instance.appDidBecomeActive(using: dependencies) + } + + static func afterBlockingQueue(callback: @escaping () -> ()) { + instance.afterBlockingQueue(callback: callback) + } + + /// 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 + static func add( + _ db: Database, + job: Job?, + canStartJob: Bool = true, + 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 + /// + /// **Note:** If the job has a `behaviour` of `runOnceNextLaunch` or the `nextRunTimestamp` + /// is in the future then the job won't be started + static func upsert( + _ db: Database, + job: Job?, + canStartJob: Bool = true, + using dependencies: Dependencies = Dependencies() + ) { instance.upsert(db, job: job, canStartJob: canStartJob, using: dependencies) } + + @discardableResult static func insert( + _ db: Database, + job: Job?, + 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 + /// failure - they _should_ be picked up again the next time the app is launched) + static func stopAndClearPendingJobs( + exceptForVariant: Job.Variant? = nil, + onComplete: (() -> ())? = nil + ) { instance.stopAndClearPendingJobs(exceptForVariant: exceptForVariant, onComplete: onComplete) } + + static func isCurrentlyRunning(_ job: Job?) -> Bool { + return instance.isCurrentlyRunning(job) + } + + static func afterCurrentlyRunningJob(_ job: Job?, callback: @escaping (JobResult) -> ()) { + instance.afterCurrentlyRunningJob(job, callback: callback) + } + + static func removePendingJob(_ job: Job?) { + instance.removePendingJob(job) + } +} 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/Utilities/Bencode.swift b/SessionUtilitiesKit/Utilities/Bencode.swift new file mode 100644 index 000000000..1138208cc --- /dev/null +++ b/SessionUtilitiesKit/Utilities/Bencode.swift @@ -0,0 +1,263 @@ +// Copyright © 2023 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +public protocol BencodableType { + associatedtype ValueType: BencodableType + + static var isCollection: Bool { get } + static var isDictionary: Bool { get } +} + +public struct BencodeResponse { + public let info: T + public let data: Data? +} + +extension BencodeResponse: Equatable where T: Equatable {} + +public enum Bencode { + private enum Element: Character { + case number0 = "0" + case number1 = "1" + case number2 = "2" + case number3 = "3" + case number4 = "4" + case number5 = "5" + case number6 = "6" + case number7 = "7" + case number8 = "8" + case number9 = "9" + case intIndicator = "i" + case listIndicator = "l" + case dictIndicator = "d" + case endIndicator = "e" + case separator = ":" + + init?(_ byte: UInt8?) { + guard + let byte: UInt8 = byte, + let byteString: String = String(data: Data([byte]), encoding: .utf8), + let character: Character = byteString.first, + let result: Element = Element(rawValue: character) + else { return nil } + + self = result + } + } + + private struct BencodeString { + let value: String? + let rawValue: Data + } + + // MARK: - Functions + + public static func decodeResponse( + from data: Data, + using dependencies: Dependencies = Dependencies() + ) throws -> BencodeResponse where T: Decodable { + guard + let result: [Data] = try? decode([Data].self, from: data), + let responseData: Data = result.first + else { throw HTTPError.parsingFailed } + + return BencodeResponse( + info: try responseData.decoded(as: T.self, using: dependencies), + data: (result.count > 1 ? result.last : nil) + ) + } + + public static func decode(_ type: T.Type, from data: Data) throws -> T { + guard + let decodedData: (value: Any, remainingData: Data) = decodeData(data), + decodedData.remainingData.isEmpty == true // Ensure there is no left over data + else { throw HTTPError.parsingFailed } + + return try recursiveCast(type, from: decodedData.value) + } + + // MARK: - Logic + + private static func decodeData(_ data: Data) -> (value: Any, remainingData: Data)? { + switch Element(data.first) { + case .number0, .number1, .number2, .number3, .number4, + .number5, .number6, .number7, .number8, .number9: + return decodeString(data) + + case .intIndicator: return decodeInt(data) + case .listIndicator: return decodeList(data) + case .dictIndicator: return decodeDict(data) + default: return nil + } + } + + /// Decode a string element from iterator assumed to have structure `{length}:{data}` + private static func decodeString(_ data: Data) -> (value: BencodeString, remainingData: Data)? { + var mutableData: Data = data + var lengthData: [UInt8] = [] + + // Remove bytes until we hit the separator + while let next: UInt8 = mutableData.popFirst(), Element(next) != .separator { + lengthData.append(next) + } + + // Need to reset the index of the data (it maintains the index after popping/slicing) + // See https://forums.swift.org/t/data-subscript/57195 for more info + mutableData = Data(mutableData) + + guard + let lengthString: String = String(data: Data(lengthData), encoding: .ascii), + let length: Int = Int(lengthString, radix: 10), + mutableData.count >= length + else { return nil } + + // Need to reset the index of the data (it maintains the index after popping/slicing) + // See https://forums.swift.org/t/data-subscript/57195 for more info + return ( + BencodeString( + value: String(data: mutableData[0.. (value: Int, remainingData: Data)? { + var mutableData: Data = data + var intData: [UInt8] = [] + _ = mutableData.popFirst() // drop `i` + + // Pop until after `e` + while let next: UInt8 = mutableData.popFirst(), Element(next) != .endIndicator { + intData.append(next) + } + + guard + let intString: String = String(data: Data(intData), encoding: .ascii), + let result: Int = Int(intString, radix: 10) + else { return nil } + + // Need to reset the index of the data (it maintains the index after popping/slicing) + // See https://forums.swift.org/t/data-subscript/57195 for more info + return (result, Data(mutableData)) + } + + /// Decode a list element from iterator assumed to have structure `l{data}e` + private static func decodeList(_ data: Data) -> ([Any], Data)? { + var mutableData: Data = data + var listElements: [Any] = [] + _ = mutableData.popFirst() // drop `l` + + while !mutableData.isEmpty, let next: UInt8 = mutableData.first, Element(next) != .endIndicator { + guard let result = decodeData(mutableData) else { break } + + listElements.append(result.value) + mutableData = result.remainingData + } + + _ = mutableData.popFirst() // drop `e` + + // Need to reset the index of the data (it maintains the index after popping/slicing) + // See https://forums.swift.org/t/data-subscript/57195 for more info + return (listElements, Data(mutableData)) + } + + /// Decode a dict element from iterator assumed to have structure `d{data}e` + private static func decodeDict(_ data: Data) -> ([String: Any], Data)? { + var mutableData: Data = data + var dictElements: [String: Any] = [:] + _ = mutableData.popFirst() // drop `d` + + while !mutableData.isEmpty, let next: UInt8 = mutableData.first, Element(next) != .endIndicator { + guard + let keyResult = decodeString(mutableData), + let key: String = keyResult.value.value, + let valueResult = decodeData(keyResult.remainingData) + else { return nil } + + dictElements[key] = valueResult.value + mutableData = valueResult.remainingData + } + + _ = mutableData.popFirst() // drop `e` + + // Need to reset the index of the data (it maintains the index after popping/slicing) + // See https://forums.swift.org/t/data-subscript/57195 for more info + return (dictElements, Data(mutableData)) + } + + // MARK: - Internal Functions + + private static func recursiveCast(_ type: T.Type, from value: Any) throws -> T { + switch (type.isCollection, type.isDictionary) { + case (_, true): + guard let dictValue: [String: Any] = value as? [String: Any] else { throw HTTPError.parsingFailed } + + return try ( + dictValue.mapValues { try recursiveCast(type.ValueType.self, from: $0) } as? T ?? + { throw HTTPError.parsingFailed }() + ) + + case (true, _): + guard let arrayValue: [Any] = value as? [Any] else { throw HTTPError.parsingFailed } + + return try ( + arrayValue.map { try recursiveCast(type.ValueType.self, from: $0) } as? T ?? + { throw HTTPError.parsingFailed }() + ) + + default: + switch (value, type) { + case (let bencodeString as BencodeString, is String.Type): + return try (bencodeString.value as? T ?? { throw HTTPError.parsingFailed }()) + + case (let bencodeString as BencodeString, is Optional.Type): + return try (bencodeString.value as? T ?? { throw HTTPError.parsingFailed }()) + + case (let bencodeString as BencodeString, _): + return try (bencodeString.rawValue as? T ?? { throw HTTPError.parsingFailed }()) + + default: return try (value as? T ?? { throw HTTPError.parsingFailed }()) + } + } + } +} + +// MARK: - BencodableType Extensions + +extension Data: BencodableType { + public typealias ValueType = Data + + public static var isCollection: Bool { false } + public static var isDictionary: Bool { false } +} + +extension Int: BencodableType { + public typealias ValueType = Int + + public static var isCollection: Bool { false } + public static var isDictionary: Bool { false } +} + +extension String: BencodableType { + public typealias ValueType = String + + public static var isCollection: Bool { false } + public static var isDictionary: Bool { false } +} + +extension Array: BencodableType where Element: BencodableType { + public typealias ValueType = Element + + public static var isCollection: Bool { true } + public static var isDictionary: Bool { false } +} + +extension Dictionary: BencodableType where Key == String, Value: BencodableType { + public typealias ValueType = Value + + public static var isCollection: Bool { false } + public static var isDictionary: Bool { true } +} 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/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 new file mode 100644 index 000000000..fff8e4c15 --- /dev/null +++ b/SessionUtilitiesKitTests/JobRunner/JobRunnerSpec.swift @@ -0,0 +1,1755 @@ +// Copyright © 2023 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import GRDB + +import Quick +import Nimble + +@testable import SessionUtilitiesKit + +class JobRunnerSpec: QuickSpec { + struct TestDetails: Codable { + enum ResultType: Codable { + case success + case failure + case permanentFailure + case deferred + } + + public let result: ResultType + public let completeTime: Int + public let intValue: Int64 + public let stringValue: String + + init( + result: ResultType = .success, + completeTime: Int = 0, + intValue: Int64 = 100, + stringValue: String = "200" + ) { + self.result = result + self.completeTime = completeTime + self.intValue = intValue + self.stringValue = stringValue + } + } + + struct InvalidDetails: Codable { + func encode(to encoder: Encoder) throws { throw HTTPError.parsingFailed } + } + + enum TestJob: JobExecutor { + static let maxFailureCount: Int = 1 + static let requiresThreadId: Bool = false + static let requiresInteractionId: Bool = false + + static func run( + _ job: Job, + queue: DispatchQueue, + success: @escaping (Job, Bool, Dependencies) -> (), + failure: @escaping (Job, Error?, Bool, Dependencies) -> (), + deferred: @escaping (Job, Dependencies) -> (), + using dependencies: Dependencies + ) { + guard + let detailsData: Data = job.details, + let details: TestDetails = try? JSONDecoder().decode(TestDetails.self, from: detailsData) + else { return success(job, true, dependencies) } + + let completeJob: () -> () = { + // Need to increase the 'completeTime' and 'nextRunTimestamp' to prevent the job + // from immediately being run again or immediately completing afterwards + let updatedJob: Job = job + .with(nextRunTimestamp: TimeInterval(details.completeTime + 1)) + .with( + details: TestDetails( + result: details.result, + completeTime: (details.completeTime + 2), + intValue: details.intValue, + stringValue: details.stringValue + ) + )! + dependencies.storage.write { db in try _ = updatedJob.saved(db) } + + switch details.result { + case .success: success(job, true, dependencies) + case .failure: failure(job, nil, false, dependencies) + case .permanentFailure: failure(job, nil, true, dependencies) + case .deferred: deferred(updatedJob, dependencies) + } + } + + guard dependencies.fixedTime < details.completeTime else { + return queue.async(using: dependencies) { + completeJob() + } + } + + dependencies.asyncExecutions.appendTo(details.completeTime) { + queue.async(using: dependencies) { + completeJob() + } + } + } + } + + // MARK: - Spec + + override func spec() { + var jobRunner: JobRunnerType! + var job1: Job! + var job2: Job! + var mockStorage: Storage! + var dependencies: Dependencies! + + describe("a JobRunner") { + // MARK: - Configuration + + beforeEach { + mockStorage = SynchronousStorage( + customWriter: try! DatabaseQueue(), + customMigrationTargets: [ + SNUtilitiesKit.self + ] + ) + dependencies = Dependencies( + storage: mockStorage, + dateNow: Date(timeIntervalSince1970: 0), + forceSynchronous: true + ) + + // Migrations add jobs which we don't want so delete them + mockStorage.write { db in try Job.deleteAll(db) } + + job1 = Job( + id: 100, + failureCount: 0, + variant: .messageSend, + behaviour: .runOnce, + shouldBlock: false, + shouldSkipLaunchBecomeActive: false, + nextRunTimestamp: 0, + threadId: nil, + interactionId: nil, + details: nil + ) + job2 = Job( + id: 101, + failureCount: 0, + variant: .attachmentUpload, + behaviour: .runOnce, + shouldBlock: false, + shouldSkipLaunchBecomeActive: false, + nextRunTimestamp: 0, + threadId: nil, + interactionId: nil, + details: nil + ) + + jobRunner = JobRunner(isTestingJobRunner: true, using: dependencies) + jobRunner.setExecutor(TestJob.self, for: .messageSend) + jobRunner.setExecutor(TestJob.self, for: .attachmentUpload) + jobRunner.setExecutor(TestJob.self, for: .messageReceive) + + // Need to assign this to ensure it's used by nested dependencies + dependencies.jobRunner = jobRunner + } + + afterEach { + /// We **must** set `fixedTime` to ensure we break any loops within the `TestJob` executor + dependencies.fixedTime = Int.max + jobRunner.stopAndClearPendingJobs() + jobRunner = nil + mockStorage = nil + dependencies = nil + } + + // MARK: - when configuring + context("when configuring") { + // MARK: -- adds an executor correctly + it("adds an executor correctly") { + job1 = Job( + id: 101, + failureCount: 0, + variant: .getSnodePool, + 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) + + // 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, + using: dependencies + ) + } + + // 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.add( + db, + job: job1, + canStartJob: true, + using: dependencies + ) + } + + // 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(using: dependencies) + jobRunner.appDidBecomeActive(using: dependencies) + + mockStorage.write { db in + jobRunner.add( + db, + job: job1, + canStartJob: true, + using: dependencies + ) + } + + 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, + 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: job2, + canStartJob: true, + using: dependencies + ) + } + + jobRunner.appDidFinishLaunching(using: dependencies) + + 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(using: dependencies) + jobRunner.appDidBecomeActive(using: dependencies) + + mockStorage.write { db in + jobRunner.add( + db, + job: job2, + canStartJob: true, + using: dependencies + ) + } + + expect(jobRunner.jobInfoFor(state: .running, variant: .messageSend)).to(equal([:])) + } + + // MARK: ------ can filter to specific jobs + it("can filter to specific 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: nil + ) + 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: 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, + using: dependencies + ) + } + + // Wait for there to be data and the validate the filtering works + expect(jobRunner.jobInfoFor(state: .running)) + .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: .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: true, + using: dependencies + ) + } + + // Wait for there to be data and the validate the filtering works + expect(jobRunner.jobInfoFor(state: .pending)) + .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(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: true, + using: dependencies + ) + } + + // Wait for there to be data and the validate the filtering works + expect(jobRunner.jobInfoFor(variant: .attachmentUpload)) + .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(using: dependencies) + jobRunner.appDidBecomeActive(using: dependencies) + + mockStorage.write { db in + jobRunner.add( + db, + job: job2, + canStartJob: true, + using: dependencies + ) + } + + expect(jobRunner.jobInfoFor(state: .running, variant: .attachmentUpload)) + .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, + failureCount: 0, + variant: .attachmentUpload, + 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: job2, + canStartJob: true, + using: dependencies + ) + } + + jobRunner.appDidFinishLaunching(using: dependencies) + + expect(jobRunner.jobInfoFor(state: .running, variant: .attachmentUpload)) + .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], + 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") { + 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.add( + db, + job: job1, + canStartJob: true, + using: dependencies + ) + + jobRunner.add( + db, + job: job2, + canStartJob: true, + using: dependencies + ) + } + + 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(using: dependencies) + jobRunner.appDidBecomeActive(using: dependencies) + + mockStorage.write { db in + jobRunner.add( + db, + job: job2, + canStartJob: true, + using: dependencies + ) + } + + expect(Array(jobRunner.jobInfoFor(state: .running, variant: .attachmentUpload).keys)) + .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, + failureCount: 0, + variant: .attachmentUpload, + 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: job2, + canStartJob: true, + using: 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)) + .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(using: dependencies) + jobRunner.appDidBecomeActive(using: dependencies) + + mockStorage.write { db in + jobRunner.add( + db, + job: job2, + canStartJob: true, + using: dependencies + ) + } + + expect(Array(jobRunner.jobInfoFor(state: .running, variant: .attachmentUpload).keys)) + .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.add( + db, + job: job1, + canStartJob: true, + using: dependencies + ) + } + + 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.add( + db, + job: job1, + canStartJob: true, + using: dependencies + ) + } + + 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.add( + db, + job: job1, + canStartJob: true, + using: dependencies + ) + } + + 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( + 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)).to(beFalse()) + + // Start the blocking job + jobRunner.appDidFinishLaunching(using: dependencies) + + // Make sure the other queues don't start + jobRunner.appDidBecomeActive(using: dependencies) + + 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, + failureCount: 0, + variant: .messageSend, + behaviour: .recurringOnActive, + shouldBlock: false, + shouldSkipLaunchBecomeActive: false, + nextRunTimestamp: 0, + threadId: nil, + interactionId: nil, + details: try? JSONEncoder() + .with(outputFormatting: .sortedKeys) + .encode(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)).to(beFalse()) + + // Start the blocking queue + jobRunner.appDidFinishLaunching(using: dependencies) + + // Make sure the other queues don't start + jobRunner.appDidBecomeActive(using: dependencies) + + 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.add( + db, + job: job1, + canStartJob: true, + using: dependencies + ) + } + + // Make sure it isn't already started + expect(jobRunner.isCurrentlyRunning(job1)).to(beFalse()) + + // 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, + failureCount: 0, + variant: .messageSend, + behaviour: .recurringOnActive, + 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: .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 + ) + 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 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)) + .to(beFalse()) + expect(jobRunner.isCurrentlyRunning(job2)).to(beFalse()) + + // Make sure it starts + jobRunner.appDidFinishLaunching(using: dependencies) + jobRunner.appDidBecomeActive(using: dependencies) + + // 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(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, using: dependencies) + + expect(Array(jobRunner.jobInfoFor(state: .running).keys)).to(beEmpty()) + } + + expect(Array(jobRunner.jobInfoFor(state: .running).keys)).to(equal([100])) + } + } + + // 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)) + + mockStorage.write { db in + try job1.insert(db) + try job2.insert(db) + try JobDependencies(jobId: job1.id!, dependantId: job2.id!).insert(db) + + jobRunner.upsert(db, job: job1, canStartJob: true, using: dependencies) + } + + // Make sure the dependency is run + expect(Array(jobRunner.jobInfoFor(state: .running, variant: .attachmentUpload).keys)) + .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)) + + mockStorage.write { db in + try job1.insert(db) + try job2.insert(db) + try JobDependencies(jobId: job1.id!, dependantId: job2.id!).insert(db) + + 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)) + .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)) + + mockStorage.write { db in + try job1.insert(db) + try job2.insert(db) + try JobDependencies(jobId: job1.id!, dependantId: job2.id!).insert(db) + + jobRunner.upsert(db, job: job1, canStartJob: true, using: dependencies) + } + + // Make sure the dependency is run + expect(Array(jobRunner.jobInfoFor(state: .running, variant: .attachmentUpload).keys)) + .to(equal([101])) + expect(jobRunner.jobInfoFor(state: .running, variant: .messageSend).keys).toNot(contain(100)) + + // Make sure the initial job starts + dependencies.stepForwardInTime() + expect(Array(jobRunner.jobInfoFor(state: .running, variant: .messageSend).keys)) + .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)) + + mockStorage.write { db in + try job1.insert(db) + try job2.insert(db) + try JobDependencies(jobId: job1.id!, dependantId: job2.id!).insert(db) + + jobRunner.upsert(db, job: job1, canStartJob: true, using: dependencies) + } + + // Make sure the dependency is run + expect(Array(jobRunner.jobInfoFor(state: .running, variant: .attachmentUpload).keys)) + .to(equal([101])) + expect(jobRunner.jobInfoFor(state: .running, variant: .messageSend).keys).toNot(contain(100)) + + // Make sure there are no running jobs + 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)) + + mockStorage.write { db in + try job1.insert(db) + try job2.insert(db) + try JobDependencies(jobId: job1.id!, dependantId: job2.id!).insert(db) + + jobRunner.upsert(db, job: job1, canStartJob: true, using: dependencies) + } + + // Make sure the dependency is run + expect(Array(jobRunner.jobInfoFor(state: .running, variant: .attachmentUpload).keys)) + .to(equal([101])) + expect(jobRunner.jobInfoFor(state: .running, variant: .messageSend).keys).toNot(contain(100)) + + // Make sure there are no running jobs + 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)) + + mockStorage.write { db in + try job1.insert(db) + try job2.insert(db) + try JobDependencies(jobId: job1.id!, dependantId: job2.id!).insert(db) + + jobRunner.upsert(db, job: job1, canStartJob: true, using: dependencies) + } + + // Make sure the dependency is run + expect(Array(jobRunner.jobInfoFor(state: .running, variant: .attachmentUpload).keys)) + .to(equal([101])) + expect(jobRunner.jobInfoFor(state: .running, variant: .messageSend).keys).toNot(contain(100)) + + // Make sure there are no running jobs + dependencies.stepForwardInTime() + expect(Array(jobRunner.jobInfoFor(state: .running, variant: .attachmentUpload).keys)) + .to(beEmpty()) + + // Stop the queues so it doesn't run out of retry attempts + jobRunner.stopAndClearPendingJobs(exceptForVariant: nil, onComplete: nil) + + // Make sure the jobs still exist + 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)) + + mockStorage.write { db in + try job1.insert(db) + try job2.insert(db) + try JobDependencies(jobId: job1.id!, dependantId: job2.id!).insert(db) + + jobRunner.upsert(db, job: job1, canStartJob: true, using: dependencies) + } + + // Make sure the dependency is run + expect(Array(jobRunner.jobInfoFor(state: .running, variant: .attachmentUpload).keys)) + .to(equal([101])) + expect(jobRunner.jobInfoFor(state: .running, variant: .messageSend).keys).toNot(contain(100)) + + // Make sure there are no running jobs + dependencies.stepForwardInTime() + expect(Array(jobRunner.jobInfoFor(state: .running, variant: .attachmentUpload).keys)) + .to(beEmpty()) + + // Make sure the jobs were deleted + expect(mockStorage.read { db in try Job.fetchCount(db) }).to(equal(0)) + } + } + } + + // MARK: -- when completing jobs + context("when completing jobs") { + beforeEach { + 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, using: dependencies) + } + + // Make sure the dependency is run + expect(Array(jobRunner.jobInfoFor(state: .running).keys)).to(equal([100])) + + // Make sure there are no running jobs + 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, using: dependencies) + } + + // Make sure the dependency is run + expect(Array(jobRunner.jobInfoFor(state: .running).keys)).to(equal([100])) + + // Make sure there are no running jobs + 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)) + } + } + + // 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, using: dependencies) + } + + // Make sure the dependency is run + expect(Array(jobRunner.jobInfoFor(state: .running).keys)).to(equal([100])) + + // Make sure there are no running jobs + 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, using: dependencies) + } + + // Make sure the dependency is run + expect(Array(jobRunner.jobInfoFor(state: .running).keys)).to(equal([100])) + + // Make sure there are no running jobs + 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, using: dependencies) + } + + // Make sure it runs + 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.stepForwardInTime() + + // Make sure it finishes once + expect(jobRunner.jobInfoFor(state: .running)) + .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.stepForwardInTime() + + // Make sure it finishes twice + expect(jobRunner.jobInfoFor(state: .running)) + .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.stepForwardInTime() + + // Make sure it's finishes the last time + expect(jobRunner.jobInfoFor(state: .running)) + .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)) + } + } + + // 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, using: dependencies) + } + + // Make sure the dependency is run + expect(Array(jobRunner.jobInfoFor(state: .running).keys)).to(equal([100])) + + // Make sure there are no running jobs + 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, using: dependencies) + } + + // Make sure the dependency is run + expect(Array(jobRunner.jobInfoFor(state: .running).keys)).to(equal([100])) + + // Make sure there are no running jobs + 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)) + } + } + + // 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, using: dependencies) + } + + // Make sure the dependency is run + expect(Array(jobRunner.jobInfoFor(state: .running).keys)).to(equal([100])) + + // Make sure there are no running jobs + 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, using: dependencies) + } + + // Make sure the dependency is run + expect(Array(jobRunner.jobInfoFor(state: .running).keys)).to(equal([100])) + + // Make sure there are no running jobs + 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/SessionUtilitiesKitTests/Utilities/BencodeSpec.swift b/SessionUtilitiesKitTests/Utilities/BencodeSpec.swift new file mode 100644 index 000000000..08f00df10 --- /dev/null +++ b/SessionUtilitiesKitTests/Utilities/BencodeSpec.swift @@ -0,0 +1,97 @@ +// Copyright © 2023 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +import Quick +import Nimble + +@testable import SessionUtilitiesKit + +class BencodeSpec: QuickSpec { + struct TestType: Codable, Equatable { + let intValue: Int + let stringValue: String + } + + // MARK: - Spec + + override func spec() { + describe("Bencode") { + context("when decoding") { + it("should decode a basic string") { + let basicStringData: Data = "5:howdy".data(using: .utf8)! + let result = try? Bencode.decode(String.self, from: basicStringData) + + expect(result).to(equal("howdy")) + } + + it("should decode a basic integer") { + let basicIntegerData: Data = "i3e".data(using: .utf8)! + let result = try? Bencode.decode(Int.self, from: basicIntegerData) + + expect(result).to(equal(3)) + } + + it("should decode a list of integers") { + let basicIntListData: Data = "li1ei2ee".data(using: .utf8)! + let result = try? Bencode.decode([Int].self, from: basicIntListData) + + expect(result).to(equal([1, 2])) + } + + it("should decode a basic dict") { + let basicDictData: Data = "d4:spaml1:a1:bee".data(using: .utf8)! + let result = try? Bencode.decode([String: [String]].self, from: basicDictData) + + expect(result).to(equal(["spam": ["a", "b"]])) + } + } + + context("when decoding a response") { + it("decodes successfully") { + let data: Data = "l37:{\"intValue\":100,\"stringValue\":\"Test\"}5:\u{01}\u{02}\u{03}\u{04}\u{05}e" + .data(using: .utf8)! + let result: BencodeResponse? = try? Bencode.decodeResponse(from: data) + + expect(result) + .to(equal( + BencodeResponse( + info: TestType( + intValue: 100, + stringValue: "Test" + ), + data: Data([1, 2, 3, 4, 5]) + ) + )) + } + + it("decodes successfully with no body") { + let data: Data = "l37:{\"intValue\":100,\"stringValue\":\"Test\"}e" + .data(using: .utf8)! + let result: BencodeResponse? = try? Bencode.decodeResponse(from: data) + + expect(result) + .to(equal( + BencodeResponse( + info: TestType( + intValue: 100, + stringValue: "Test" + ), + data: nil + ) + )) + } + + it("throws a parsing error when invalid") { + let data: Data = "l36:{\"INVALID\":100,\"stringValue\":\"Test\"}5:\u{01}\u{02}\u{03}\u{04}\u{05}e" + .data(using: .utf8)! + + expect { + let result: BencodeResponse = try Bencode.decodeResponse(from: data) + _ = result + }.to(throwError(HTTPError.parsingFailed)) + } + } + } + } +} diff --git a/SignalUtilitiesKit/Configuration.swift b/SignalUtilitiesKit/Configuration.swift index 4ab7e2d50..651bd49cd 100644 --- a/SignalUtilitiesKit/Configuration.swift +++ b/SignalUtilitiesKit/Configuration.swift @@ -4,6 +4,7 @@ import Foundation import SessionUIKit import SessionSnodeKit import SessionMessagingKit +import SessionUtilitiesKit public enum Configuration { public static func performMainSetup() { diff --git a/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentApprovalViewController.swift b/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentApprovalViewController.swift index a8c34aec9..77b13ab15 100644 --- a/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentApprovalViewController.swift +++ b/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentApprovalViewController.swift @@ -9,13 +9,16 @@ import CoreServices import SessionUIKit import SessionMessagingKit import SignalCoreKit +import SessionUtilitiesKit public protocol AttachmentApprovalViewControllerDelegate: AnyObject { func attachmentApproval( _ attachmentApproval: AttachmentApprovalViewController, didApproveAttachments attachments: [SignalAttachment], forThreadId threadId: String, - messageText: String? + threadVariant: SessionThread.Variant, + messageText: String?, + using dependencies: Dependencies ) func attachmentApprovalDidCancel(_ attachmentApproval: AttachmentApprovalViewController) @@ -57,6 +60,7 @@ public class AttachmentApprovalViewController: UIPageViewController, UIPageViewC private let mode: Mode private let threadId: String + private let threadVariant: SessionThread.Variant private let isAddMoreVisible: Bool public weak var approvalDelegate: AttachmentApprovalViewControllerDelegate? @@ -126,11 +130,13 @@ public class AttachmentApprovalViewController: UIPageViewController, UIPageViewC required public init( mode: Mode, threadId: String, + threadVariant: SessionThread.Variant, attachments: [SignalAttachment] ) { assert(attachments.count > 0) self.mode = mode self.threadId = threadId + self.threadVariant = threadVariant let attachmentItems = attachments.map { SignalAttachmentItem(attachment: $0 )} self.isAddMoreVisible = (mode == .sharedNavigation) @@ -160,10 +166,16 @@ public class AttachmentApprovalViewController: UIPageViewController, UIPageViewC public class func wrappedInNavController( threadId: String, + threadVariant: SessionThread.Variant, attachments: [SignalAttachment], approvalDelegate: AttachmentApprovalViewControllerDelegate ) -> UINavigationController { - let vc = AttachmentApprovalViewController(mode: .modal, threadId: threadId, attachments: attachments) + let vc = AttachmentApprovalViewController( + mode: .modal, + threadId: threadId, + threadVariant: threadVariant, + attachments: attachments + ) vc.approvalDelegate = approvalDelegate let navController = StyledNavigationController(rootViewController: vc) @@ -664,7 +676,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 +684,14 @@ 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, + threadVariant: threadVariant, + messageText: attachmentTextToolbar.messageText, + using: dependencies + ) } func attachmentTextToolbarDidChange(_ attachmentTextToolbar: AttachmentTextToolbar) { diff --git a/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentCaptionToolbar.swift b/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentCaptionToolbar.swift index 4c3ad9f57..dcd4f2044 100644 --- a/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentCaptionToolbar.swift +++ b/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentCaptionToolbar.swift @@ -4,6 +4,7 @@ import Foundation import UIKit import SessionUIKit import SignalCoreKit +import SessionUtilitiesKit protocol AttachmentCaptionToolbarDelegate: AnyObject { func attachmentCaptionToolbarDidEdit(_ attachmentCaptionToolbar: AttachmentCaptionToolbar) @@ -150,26 +151,7 @@ class AttachmentCaptionToolbar: UIView, UITextViewDelegate { public func textView(_ textView: UITextView, shouldChangeTextIn range: NSRange, replacementText text: String) -> Bool { let existingText: String = textView.text ?? "" let proposedText: String = (existingText as NSString).replacingCharacters(in: range, with: text) - - // Don't complicate things by mixing media attachments with oversize text attachments - guard proposedText.utf8.count < kOversizeTextMessageSizeThreshold else { - Logger.debug("long text was truncated") - self.lengthLimitLabel.isHidden = false - - // `range` represents the section of the existing text we will replace. We can re-use that space. - // Range is in units of NSStrings's standard UTF-16 characters. Since some of those chars could be - // represented as single bytes in utf-8, while others may be 8 or more, the only way to be sure is - // to just measure the utf8 encoded bytes of the replaced substring. - let bytesAfterDelete: Int = (existingText as NSString).replacingCharacters(in: range, with: "").utf8.count - - // Accept as much of the input as we can - let byteBudget: Int = Int(kOversizeTextMessageSizeThreshold) - bytesAfterDelete - if byteBudget >= 0, let acceptableNewText = text.truncated(toByteCount: UInt(byteBudget)) { - textView.text = (existingText as NSString).replacingCharacters(in: range, with: acceptableNewText) - } - - return false - } + self.lengthLimitLabel.isHidden = true // After verifying the byte-length is sufficiently small, verify the character count is within bounds. diff --git a/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentPrepViewController.swift b/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentPrepViewController.swift index db65cd7e2..4b45d2e1d 100644 --- a/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentPrepViewController.swift +++ b/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentPrepViewController.swift @@ -5,6 +5,8 @@ import UIKit import AVFoundation import SessionUIKit import SignalCoreKit +import SessionMessagingKit +import SessionUtilitiesKit protocol AttachmentPrepViewControllerDelegate: AnyObject { func prepViewControllerUpdateNavigationBar() diff --git a/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentTextToolbar.swift b/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentTextToolbar.swift index 53a29da81..736c9049b 100644 --- a/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentTextToolbar.swift +++ b/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentTextToolbar.swift @@ -2,15 +2,16 @@ import Foundation import UIKit -import SessionUIKit -import SignalCoreKit import PureLayout +import SignalCoreKit +import SessionUIKit +import SessionUtilitiesKit // Coincides with Android's max text message length 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 +211,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 @@ -226,25 +229,6 @@ class AttachmentTextToolbar: UIView, UITextViewDelegate { let existingText: String = textView.text ?? "" let proposedText: String = (existingText as NSString).replacingCharacters(in: range, with: text) - // Don't complicate things by mixing media attachments with oversize text attachments - guard proposedText.utf8.count < kOversizeTextMessageSizeThreshold else { - Logger.debug("long text was truncated") - self.lengthLimitLabel.isHidden = false - - // `range` represents the section of the existing text we will replace. We can re-use that space. - // Range is in units of NSStrings's standard UTF-16 characters. Since some of those chars could be - // represented as single bytes in utf-8, while others may be 8 or more, the only way to be sure is - // to just measure the utf8 encoded bytes of the replaced substring. - let bytesAfterDelete: Int = (existingText as NSString).replacingCharacters(in: range, with: "").utf8.count - - // Accept as much of the input as we can - let byteBudget: Int = Int(kOversizeTextMessageSizeThreshold) - bytesAfterDelete - if byteBudget >= 0, let acceptableNewText = text.truncated(toByteCount: UInt(byteBudget)) { - textView.text = (existingText as NSString).replacingCharacters(in: range, with: acceptableNewText) - } - - return false - } self.lengthLimitLabel.isHidden = true // After verifying the byte-length is sufficiently small, verify the character count is within bounds. diff --git a/SignalUtilitiesKit/Media Viewing & Editing/Image Editing/ImageEditorCanvasView.swift b/SignalUtilitiesKit/Media Viewing & Editing/Image Editing/ImageEditorCanvasView.swift index e0acf8bba..903c886cf 100644 --- a/SignalUtilitiesKit/Media Viewing & Editing/Image Editing/ImageEditorCanvasView.swift +++ b/SignalUtilitiesKit/Media Viewing & Editing/Image Editing/ImageEditorCanvasView.swift @@ -3,6 +3,7 @@ import UIKit import SessionUIKit import SignalCoreKit +import SessionUtilitiesKit public class EditorTextLayer: CATextLayer { let itemId: String diff --git a/SignalUtilitiesKit/Media Viewing & Editing/Image Editing/ImageEditorCropViewController.swift b/SignalUtilitiesKit/Media Viewing & Editing/Image Editing/ImageEditorCropViewController.swift index ad415e5e6..c2f3585fe 100644 --- a/SignalUtilitiesKit/Media Viewing & Editing/Image Editing/ImageEditorCropViewController.swift +++ b/SignalUtilitiesKit/Media Viewing & Editing/Image Editing/ImageEditorCropViewController.swift @@ -3,6 +3,7 @@ import UIKit import SessionUIKit import SignalCoreKit +import SessionUtilitiesKit public protocol ImageEditorCropViewControllerDelegate: AnyObject { func cropDidComplete(transform: ImageEditorTransform) @@ -200,7 +201,7 @@ class ImageEditorCropViewController: OWSViewController { case .topRight, .bottomRight: cropCornerView.autoPinEdge(toSuperviewEdge: .right) default: - owsFailDebug("Invalid crop region: \(cropRegion)") + owsFailDebug("Invalid crop region: \(String(describing: cropRegion))") } switch cropCornerView.cropRegion { case .topLeft, .topRight: @@ -208,7 +209,7 @@ class ImageEditorCropViewController: OWSViewController { case .bottomLeft, .bottomRight: cropCornerView.autoPinEdge(toSuperviewEdge: .bottom) default: - owsFailDebug("Invalid crop region: \(cropRegion)") + owsFailDebug("Invalid crop region: \(String(describing: cropRegion))") } } diff --git a/SignalUtilitiesKit/Media Viewing & Editing/Image Editing/ImageEditorModel.swift b/SignalUtilitiesKit/Media Viewing & Editing/Image Editing/ImageEditorModel.swift index d2c8b062b..9ac29b78b 100644 --- a/SignalUtilitiesKit/Media Viewing & Editing/Image Editing/ImageEditorModel.swift +++ b/SignalUtilitiesKit/Media Viewing & Editing/Image Editing/ImageEditorModel.swift @@ -2,6 +2,7 @@ import UIKit import SignalCoreKit +import SessionUtilitiesKit // Used to represent undo/redo operations. // diff --git a/SignalUtilitiesKit/Media Viewing & Editing/Image Editing/ImageEditorPinchGestureRecognizer.swift b/SignalUtilitiesKit/Media Viewing & Editing/Image Editing/ImageEditorPinchGestureRecognizer.swift index 01e580ecb..8d6551f4b 100644 --- a/SignalUtilitiesKit/Media Viewing & Editing/Image Editing/ImageEditorPinchGestureRecognizer.swift +++ b/SignalUtilitiesKit/Media Viewing & Editing/Image Editing/ImageEditorPinchGestureRecognizer.swift @@ -2,6 +2,7 @@ import UIKit import SignalCoreKit +import SessionUtilitiesKit public struct ImageEditorPinchState { public let centroid: CGPoint diff --git a/SignalUtilitiesKit/Media Viewing & Editing/Image Editing/ImageEditorStrokeItem.swift b/SignalUtilitiesKit/Media Viewing & Editing/Image Editing/ImageEditorStrokeItem.swift index f86940d22..50b627eea 100644 --- a/SignalUtilitiesKit/Media Viewing & Editing/Image Editing/ImageEditorStrokeItem.swift +++ b/SignalUtilitiesKit/Media Viewing & Editing/Image Editing/ImageEditorStrokeItem.swift @@ -3,6 +3,7 @@ // import UIKit +import SessionUtilitiesKit @objc public class ImageEditorStrokeItem: ImageEditorItem { diff --git a/SignalUtilitiesKit/Media Viewing & Editing/MediaMessageView.swift b/SignalUtilitiesKit/Media Viewing & Editing/MediaMessageView.swift index 6089077cc..f0be90aec 100644 --- a/SignalUtilitiesKit/Media Viewing & Editing/MediaMessageView.swift +++ b/SignalUtilitiesKit/Media Viewing & Editing/MediaMessageView.swift @@ -8,6 +8,7 @@ import NVActivityIndicatorView import SessionUIKit import SessionMessagingKit import SignalCoreKit +import SessionUtilitiesKit public protocol MediaMessageViewAudioDelegate: AnyObject { func progressChanged(_ progressSeconds: CGFloat, durationSeconds: CGFloat) diff --git a/SignalUtilitiesKit/Meta/SignalUtilitiesKit.h b/SignalUtilitiesKit/Meta/SignalUtilitiesKit.h index f90e1721d..73cd95cff 100644 --- a/SignalUtilitiesKit/Meta/SignalUtilitiesKit.h +++ b/SignalUtilitiesKit/Meta/SignalUtilitiesKit.h @@ -3,16 +3,5 @@ FOUNDATION_EXPORT double SignalUtilitiesKitVersionNumber; FOUNDATION_EXPORT const unsigned char SignalUtilitiesKitVersionString[]; -@import SessionMessagingKit; -@import SessionSnodeKit; -@import SessionUtilitiesKit; - #import -#import -#import -#import -#import -#import #import -#import -#import diff --git a/SignalUtilitiesKit/Screen Lock/ScreenLock.swift b/SignalUtilitiesKit/Screen Lock/ScreenLock.swift index c1ed4654a..c87fb370d 100644 --- a/SignalUtilitiesKit/Screen Lock/ScreenLock.swift +++ b/SignalUtilitiesKit/Screen Lock/ScreenLock.swift @@ -7,6 +7,10 @@ import SessionMessagingKit import SignalCoreKit public class ScreenLock { + public enum ScreenLockError: Error { + case general(description: String) + } + public enum Outcome { case success case cancel @@ -54,11 +58,11 @@ public class ScreenLock { switch outcome { case .failure(let error): Logger.error("local authentication failed with error: \(error)") - failure(self.authenticationError(errorDescription: error)) + failure(ScreenLockError.general(description: error)) case .unexpectedFailure(let error): Logger.error("local authentication failed with unexpected error: \(error)") - unexpectedFailure(self.authenticationError(errorDescription: error)) + unexpectedFailure(ScreenLockError.general(description: error)) case .success: Logger.verbose("local authentication succeeded.") @@ -203,11 +207,7 @@ public class ScreenLock { } } - return .failure(error:defaultErrorDescription) - } - - private func authenticationError(errorDescription: String) -> Error { - return OWSErrorWithCodeDescription(.localAuthenticationError, errorDescription) + return .failure(error: defaultErrorDescription) } // MARK: - Context diff --git a/SignalUtilitiesKit/Shared View Controllers/OWSViewController.m b/SignalUtilitiesKit/Shared View Controllers/OWSViewController.m index c35b43035..82b111a14 100644 --- a/SignalUtilitiesKit/Shared View Controllers/OWSViewController.m +++ b/SignalUtilitiesKit/Shared View Controllers/OWSViewController.m @@ -8,17 +8,18 @@ #import #import #import +#import NS_ASSUME_NONNULL_BEGIN BOOL IsLandscapeOrientationEnabled(void) { - return NO; + return UIDevice.currentDevice.isIPad; } UIInterfaceOrientationMask DefaultUIInterfaceOrientationMask(void) { - return (IsLandscapeOrientationEnabled() ? UIInterfaceOrientationMaskAllButUpsideDown + return (IsLandscapeOrientationEnabled() ? UIInterfaceOrientationMaskAll : UIInterfaceOrientationMaskPortrait); } diff --git a/SignalUtilitiesKit/Utilities/AppSetup.swift b/SignalUtilitiesKit/Utilities/AppSetup.swift index 58f0c6856..75b708cba 100644 --- a/SignalUtilitiesKit/Utilities/AppSetup.swift +++ b/SignalUtilitiesKit/Utilities/AppSetup.swift @@ -5,6 +5,7 @@ import GRDB import SessionMessagingKit import SessionUtilitiesKit import SessionUIKit +import SessionSnodeKit public enum AppSetup { private static let hasRun: Atomic = Atomic(false) @@ -63,6 +64,14 @@ public enum AppSetup { migrationProgressChanged: ((CGFloat, TimeInterval) -> ())? = nil, migrationsCompletion: @escaping (Result, Bool) -> () ) { + // If the database can't be initialised into a valid state then error + guard Storage.shared.isValid else { + DispatchQueue.main.async { + migrationsCompletion(Result.failure(StorageError.databaseInvalid), false) + } + return + } + var backgroundTask: OWSBackgroundTask? = (backgroundTask ?? OWSBackgroundTask(labelStr: #function)) Storage.shared.perform( @@ -73,6 +82,20 @@ public enum AppSetup { SNUIKit.self ], onProgressUpdate: migrationProgressChanged, + onMigrationRequirement: { db, requirement in + switch requirement { + case .sessionUtilStateLoaded: + guard Identity.userExists(db) else { return } + + // After the migrations have run but before the migration completion we load the + // SessionUtil state + SessionUtil.loadState( + db, + userPublicKey: getUserHexEncodedPublicKey(db), + ed25519SecretKey: Identity.fetchUserEd25519KeyPair(db)?.secretKey + ) + } + }, onComplete: { result, needsConfigSync in // After the migrations have run but before the migration completion we load the // SessionUtil state and update the 'needsConfigSync' flag based on whether the @@ -84,12 +107,8 @@ public enum AppSetup { ) } - // Refresh the migration state for 'SessionUtil' so it's logic can start running - // correctly when called (doing this here instead of automatically via the - // `SessionUtil.userConfigsEnabled` property to avoid having to use the correct - // method when calling within a database read/write closure) - Storage.shared.read { db in SessionUtil.refreshingUserConfigsEnabled(db) } - + // The 'needsConfigSync' flag should be based on whether either a migration or the + // configs need to be sync'ed migrationsCompletion(result, (needsConfigSync || SessionUtil.needsSync)) // The 'if' is only there to prevent the "variable never read" warning from showing diff --git a/SignalUtilitiesKit/Utilities/ByteParser.h b/SignalUtilitiesKit/Utilities/ByteParser.h deleted file mode 100644 index c30c8c86c..000000000 --- a/SignalUtilitiesKit/Utilities/ByteParser.h +++ /dev/null @@ -1,40 +0,0 @@ -// -// Copyright (c) 2018 Open Whisper Systems. All rights reserved. -// - -#import - -NS_ASSUME_NONNULL_BEGIN - -@interface ByteParser : NSObject - -@property (nonatomic, readonly) BOOL hasError; - -- (instancetype)init NS_UNAVAILABLE; - -- (instancetype)initWithData:(NSData *)data littleEndian:(BOOL)littleEndian; - -#pragma mark - Short - -- (uint16_t)shortAtIndex:(NSUInteger)index; -- (uint16_t)nextShort; - -#pragma mark - Int - -- (uint32_t)intAtIndex:(NSUInteger)index; -- (uint32_t)nextInt; - -#pragma mark - Long - -- (uint64_t)longAtIndex:(NSUInteger)index; -- (uint64_t)nextLong; - -#pragma mark - - -- (BOOL)readZero:(NSUInteger)length; - -- (nullable NSData *)readBytes:(NSUInteger)length; - -@end - -NS_ASSUME_NONNULL_END diff --git a/SignalUtilitiesKit/Utilities/ByteParser.m b/SignalUtilitiesKit/Utilities/ByteParser.m deleted file mode 100644 index 4dd7c38db..000000000 --- a/SignalUtilitiesKit/Utilities/ByteParser.m +++ /dev/null @@ -1,143 +0,0 @@ -// -// Copyright (c) 2018 Open Whisper Systems. All rights reserved. -// - -#import "ByteParser.h" -#import - -NS_ASSUME_NONNULL_BEGIN - -@interface ByteParser () - -@property (nonatomic, readonly) BOOL littleEndian; -@property (nonatomic, readonly) NSData *data; -@property (nonatomic) NSUInteger cursor; -@property (nonatomic) BOOL hasError; - -@end - -#pragma mark - - -@implementation ByteParser - -- (instancetype)initWithData:(NSData *)data littleEndian:(BOOL)littleEndian -{ - if (self = [super init]) { - _littleEndian = littleEndian; - _data = data; - } - - return self; -} - -#pragma mark - Short - -- (uint16_t)shortAtIndex:(NSUInteger)index -{ - uint16_t value; - const size_t valueSize = sizeof(value); - OWSAssertDebug(valueSize == 2); - if (index + valueSize > self.data.length) { - self.hasError = YES; - return 0; - } - [self.data getBytes:&value range:NSMakeRange(index, valueSize)]; - if (self.littleEndian) { - return CFSwapInt16LittleToHost(value); - } else { - return CFSwapInt16BigToHost(value); - } -} - -- (uint16_t)nextShort -{ - uint16_t value = [self shortAtIndex:self.cursor]; - self.cursor += sizeof(value); - return value; -} - -#pragma mark - Int - -- (uint32_t)intAtIndex:(NSUInteger)index -{ - uint32_t value; - const size_t valueSize = sizeof(value); - OWSAssertDebug(valueSize == 4); - if (index + valueSize > self.data.length) { - self.hasError = YES; - return 0; - } - [self.data getBytes:&value range:NSMakeRange(index, valueSize)]; - if (self.littleEndian) { - return CFSwapInt32LittleToHost(value); - } else { - return CFSwapInt32BigToHost(value); - } -} - -- (uint32_t)nextInt -{ - uint32_t value = [self intAtIndex:self.cursor]; - self.cursor += sizeof(value); - return value; -} - -#pragma mark - Long - -- (uint64_t)longAtIndex:(NSUInteger)index -{ - uint64_t value; - const size_t valueSize = sizeof(value); - OWSAssertDebug(valueSize == 8); - if (index + valueSize > self.data.length) { - self.hasError = YES; - return 0; - } - [self.data getBytes:&value range:NSMakeRange(index, valueSize)]; - if (self.littleEndian) { - return CFSwapInt64LittleToHost(value); - } else { - return CFSwapInt64BigToHost(value); - } -} - -- (uint64_t)nextLong -{ - uint64_t value = [self longAtIndex:self.cursor]; - self.cursor += sizeof(value); - return value; -} - -#pragma mark - - -- (BOOL)readZero:(NSUInteger)length -{ - NSData *_Nullable subdata = [self readBytes:length]; - if (!subdata) { - return NO; - } - uint8_t bytes[length]; - [subdata getBytes:bytes range:NSMakeRange(0, length)]; - for (int i = 0; i < length; i++) { - if (bytes[i] != 0) { - return NO; - } - } - return YES; -} - -- (nullable NSData *)readBytes:(NSUInteger)length -{ - NSUInteger index = self.cursor; - if (index + length > self.data.length) { - self.hasError = YES; - return nil; - } - NSData *_Nullable subdata = [self.data subdataWithRange:NSMakeRange(index, length)]; - self.cursor += length; - return subdata; -} - -@end - -NS_ASSUME_NONNULL_END diff --git a/SignalUtilitiesKit/Utilities/FunctionalUtil.h b/SignalUtilitiesKit/Utilities/FunctionalUtil.h deleted file mode 100644 index e86ed911a..000000000 --- a/SignalUtilitiesKit/Utilities/FunctionalUtil.h +++ /dev/null @@ -1,27 +0,0 @@ -// -// Copyright (c) 2019 Open Whisper Systems. All rights reserved. -// - -#import - -NS_ASSUME_NONNULL_BEGIN - -@interface NSArray (FunctionalUtil) - -/// Returns true when any of the items in this array match the given predicate. -- (bool)any:(int (^)(id item))predicate; - -/// Returns true when all of the items in this array match the given predicate. -- (bool)all:(int (^)(id item))predicate; - -/// Returns an array of all the results of passing items from this array through the given projection function. -- (NSArray *)map:(id (^)(id item))projection; - -/// Returns an array of all the results of passing items from this array through the given projection function. -- (NSArray *)filter:(int (^)(id item))predicate; - -- (NSDictionary *)groupBy:(id (^)(id value))keySelector; - -@end - -NS_ASSUME_NONNULL_END diff --git a/SignalUtilitiesKit/Utilities/FunctionalUtil.m b/SignalUtilitiesKit/Utilities/FunctionalUtil.m deleted file mode 100644 index 65fe6dc5c..000000000 --- a/SignalUtilitiesKit/Utilities/FunctionalUtil.m +++ /dev/null @@ -1,98 +0,0 @@ -// -// Copyright (c) 2019 Open Whisper Systems. All rights reserved. -// - -#import "FunctionalUtil.h" -#import - -NS_ASSUME_NONNULL_BEGIN - -@interface FUBadArgument : NSException - -+ (FUBadArgument *) new:(NSString *)reason; -+ (void)raise:(NSString *)message; - -@end - -@implementation FUBadArgument - -+ (FUBadArgument *) new:(NSString *)reason { - return [[FUBadArgument alloc] initWithName:@"Invalid Argument" reason:reason userInfo:nil]; -} -+ (void)raise:(NSString *)message { - [FUBadArgument raise:@"Invalid Argument" format:@"%@", message]; -} - -@end - -#define tskit_require(expr) \ - if (!(expr)) { \ - NSString *reason = \ - [NSString stringWithFormat:@"require %@ (in %s at line %d)", (@ #expr), __FILE__, __LINE__]; \ - OWSLogError(@"%@", reason); \ - [FUBadArgument raise:reason]; \ - }; - - -@implementation NSArray (FunctionalUtil) -- (bool)any:(int (^)(id item))predicate { - tskit_require(predicate != nil); - for (id e in self) { - if (predicate(e)) { - return true; - } - } - return false; -} -- (bool)all:(int (^)(id item))predicate { - tskit_require(predicate != nil); - for (id e in self) { - if (!predicate(e)) { - return false; - } - } - return true; -} -- (NSArray *)map:(id (^)(id item))projection { - tskit_require(projection != nil); - - NSMutableArray *r = [NSMutableArray arrayWithCapacity:self.count]; - for (id e in self) { - [r addObject:projection(e)]; - } - return r; -} -- (NSArray *)filter:(int (^)(id item))predicate { - tskit_require(predicate != nil); - - NSMutableArray *r = [NSMutableArray array]; - for (id e in self) { - if (predicate(e)) { - [r addObject:e]; - } - } - return r; -} - -- (NSDictionary *)groupBy:(id (^)(id value))keySelector { - tskit_require(keySelector != nil); - - NSMutableDictionary *result = [NSMutableDictionary dictionary]; - - for (id item in self) { - id key = keySelector(item); - - NSMutableArray *group = result[key]; - if (group == nil) { - group = [NSMutableArray array]; - result[key] = group; - } - [group addObject:item]; - } - - return result; -} - -@end - -NS_ASSUME_NONNULL_END diff --git a/SignalUtilitiesKit/Utilities/NSURLSessionDataTask+StatusCode.h b/SignalUtilitiesKit/Utilities/NSURLSessionDataTask+StatusCode.h deleted file mode 100644 index 62718ffe3..000000000 --- a/SignalUtilitiesKit/Utilities/NSURLSessionDataTask+StatusCode.h +++ /dev/null @@ -1,15 +0,0 @@ -// -// Copyright (c) 2019 Open Whisper Systems. All rights reserved. -// - -#import - -NS_ASSUME_NONNULL_BEGIN - -@interface NSURLSessionTask (StatusCode) - -- (long)statusCode; - -@end - -NS_ASSUME_NONNULL_END diff --git a/SignalUtilitiesKit/Utilities/NSURLSessionDataTask+StatusCode.m b/SignalUtilitiesKit/Utilities/NSURLSessionDataTask+StatusCode.m deleted file mode 100644 index 212eeac55..000000000 --- a/SignalUtilitiesKit/Utilities/NSURLSessionDataTask+StatusCode.m +++ /dev/null @@ -1,18 +0,0 @@ -// -// Copyright (c) 2019 Open Whisper Systems. All rights reserved. -// - -#import "NSURLSessionDataTask+StatusCode.h" - -NS_ASSUME_NONNULL_BEGIN - -@implementation NSURLSessionTask (StatusCode) - -- (long)statusCode { - NSHTTPURLResponse *response = (NSHTTPURLResponse *)self.response; - return response.statusCode; -} - -@end - -NS_ASSUME_NONNULL_END diff --git a/SignalUtilitiesKit/Utilities/OWSError.h b/SignalUtilitiesKit/Utilities/OWSError.h deleted file mode 100644 index e4772fc17..000000000 --- a/SignalUtilitiesKit/Utilities/OWSError.h +++ /dev/null @@ -1,69 +0,0 @@ -// -// Copyright (c) 2019 Open Whisper Systems. All rights reserved. -// - -#import - -NS_ASSUME_NONNULL_BEGIN - -extern NSString *const OWSSignalServiceKitErrorDomain; - -typedef NS_ENUM(NSInteger, OWSErrorCode) { - OWSErrorCodeInvalidMethodParameters = 11, - OWSErrorCodeUnableToProcessServerResponse = 12, - OWSErrorCodeFailedToDecodeJson = 13, - OWSErrorCodeFailedToEncodeJson = 14, - OWSErrorCodeFailedToDecodeQR = 15, - OWSErrorCodePrivacyVerificationFailure = 20, - OWSErrorCodeUntrustedIdentity = 25, - OWSErrorCodeFailedToSendOutgoingMessage = 30, - OWSErrorCodeAssertionFailure = 31, - OWSErrorCodeFailedToDecryptMessage = 100, - OWSErrorCodeFailedToDecryptUDMessage = 101, - OWSErrorCodeFailedToEncryptMessage = 110, - OWSErrorCodeFailedToEncryptUDMessage = 111, - OWSErrorCodeSignalServiceFailure = 1001, - OWSErrorCodeSignalServiceRateLimited = 1010, - OWSErrorCodeUserError = 2001, - OWSErrorCodeMessageSendDisabledDueToPreKeyUpdateFailures = 777405, - OWSErrorCodeMessageSendFailedToBlockList = 777406, - OWSErrorCodeMessageSendNoValidRecipients = 777407, - OWSErrorCodeContactsUpdaterRateLimit = 777408, - OWSErrorCodeCouldNotWriteAttachmentData = 777409, - OWSErrorCodeMessageDeletedBeforeSent = 777410, - OWSErrorCodeDatabaseConversionFatalError = 777411, - OWSErrorCodeMoveFileToSharedDataContainerError = 777412, - OWSErrorCodeRegistrationMissing2FAPIN = 777413, - OWSErrorCodeDebugLogUploadFailed = 777414, - // A non-recoverable error occured while exporting a backup. - OWSErrorCodeExportBackupFailed = 777415, - // A possibly recoverable error occured while exporting a backup. - OWSErrorCodeExportBackupError = 777416, - // A non-recoverable error occured while importing a backup. - OWSErrorCodeImportBackupFailed = 777417, - // A possibly recoverable error occured while importing a backup. - OWSErrorCodeImportBackupError = 777418, - // A non-recoverable while importing or exporting a backup. - OWSErrorCodeBackupFailure = 777419, - OWSErrorCodeLocalAuthenticationError = 777420, - OWSErrorCodeMessageRequestFailed = 777421, - OWSErrorCodeMessageResponseFailed = 777422, - OWSErrorCodeInvalidMessage = 777423, - OWSErrorCodeProfileUpdateFailed = 777424, - OWSErrorCodeAvatarWriteFailed = 777425, - OWSErrorCodeAvatarUploadFailed = 777426, - OWSErrorCodeNoSessionForTransientMessage, -}; - -extern NSString *const OWSErrorRecipientIdentifierKey; - -extern NSError *OWSErrorWithCodeDescription(OWSErrorCode code, NSString *description); -extern NSError *OWSErrorMakeUntrustedIdentityError(NSString *description, NSString *recipientId); -extern NSError *OWSErrorMakeUnableToProcessServerResponseError(void); -extern NSError *OWSErrorMakeFailedToSendOutgoingMessageError(void); -extern NSError *OWSErrorMakeAssertionError(NSString *description); -extern NSError *OWSErrorMakeMessageSendDisabledDueToPreKeyUpdateFailuresError(void); -extern NSError *OWSErrorMakeMessageSendFailedDueToBlockListError(void); -extern NSError *OWSErrorMakeWriteAttachmentDataError(void); - -NS_ASSUME_NONNULL_END diff --git a/SignalUtilitiesKit/Utilities/OWSError.m b/SignalUtilitiesKit/Utilities/OWSError.m deleted file mode 100644 index f8096d70e..000000000 --- a/SignalUtilitiesKit/Utilities/OWSError.m +++ /dev/null @@ -1,68 +0,0 @@ -// -// Copyright (c) 2018 Open Whisper Systems. All rights reserved. -// - -#import "OWSError.h" -#import - -NS_ASSUME_NONNULL_BEGIN - -NSString *const OWSSignalServiceKitErrorDomain = @"OWSSignalServiceKitErrorDomain"; -NSString *const OWSErrorRecipientIdentifierKey = @"OWSErrorKeyRecipientIdentifier"; - -NSError *OWSErrorWithCodeDescription(OWSErrorCode code, NSString *description) -{ - return [NSError errorWithDomain:OWSSignalServiceKitErrorDomain - code:code - userInfo:@{ NSLocalizedDescriptionKey: description }]; -} - -NSError *OWSErrorMakeUnableToProcessServerResponseError() -{ - return OWSErrorWithCodeDescription(OWSErrorCodeUnableToProcessServerResponse, - NSLocalizedString(@"ERROR_DESCRIPTION_SERVER_FAILURE", @"Generic server error")); -} - -NSError *OWSErrorMakeFailedToSendOutgoingMessageError() -{ - return OWSErrorWithCodeDescription(OWSErrorCodeFailedToSendOutgoingMessage, - NSLocalizedString(@"ERROR_DESCRIPTION_CLIENT_SENDING_FAILURE", @"Generic notice when message failed to send.")); -} - -NSError *OWSErrorMakeAssertionError(NSString *description) -{ - OWSCFailDebug(@"Assertion failed: %@", description); - return OWSErrorWithCodeDescription(OWSErrorCodeAssertionFailure, - NSLocalizedString(@"ERROR_DESCRIPTION_UNKNOWN_ERROR", @"Worst case generic error message")); -} - -NSError *OWSErrorMakeUntrustedIdentityError(NSString *description, NSString *recipientId) -{ - return [NSError - errorWithDomain:OWSSignalServiceKitErrorDomain - code:OWSErrorCodeUntrustedIdentity - userInfo:@{ NSLocalizedDescriptionKey : description, OWSErrorRecipientIdentifierKey : recipientId }]; -} - -NSError *OWSErrorMakeMessageSendDisabledDueToPreKeyUpdateFailuresError() -{ - return OWSErrorWithCodeDescription(OWSErrorCodeMessageSendDisabledDueToPreKeyUpdateFailures, - NSLocalizedString(@"ERROR_DESCRIPTION_MESSAGE_SEND_DISABLED_PREKEY_UPDATE_FAILURES", - @"Error message indicating that message send is disabled due to prekey update failures")); -} - -NSError *OWSErrorMakeMessageSendFailedDueToBlockListError() -{ - return OWSErrorWithCodeDescription(OWSErrorCodeMessageSendFailedToBlockList, - NSLocalizedString(@"ERROR_DESCRIPTION_MESSAGE_SEND_FAILED_DUE_TO_BLOCK_LIST", - @"Error message indicating that message send failed due to block list")); -} - -NSError *OWSErrorMakeWriteAttachmentDataError() -{ - return OWSErrorWithCodeDescription(OWSErrorCodeCouldNotWriteAttachmentData, - NSLocalizedString(@"ERROR_DESCRIPTION_MESSAGE_SEND_FAILED_DUE_TO_FAILED_ATTACHMENT_WRITE", - @"Error message indicating that message send failed due to failed attachment write")); -} - -NS_ASSUME_NONNULL_END diff --git a/SignalUtilitiesKit/Utilities/OWSOperation.h b/SignalUtilitiesKit/Utilities/OWSOperation.h deleted file mode 100644 index ebeeaa53f..000000000 --- a/SignalUtilitiesKit/Utilities/OWSOperation.h +++ /dev/null @@ -1,88 +0,0 @@ -// -// Copyright (c) 2018 Open Whisper Systems. All rights reserved. -// - -#import - -NS_ASSUME_NONNULL_BEGIN - -typedef NS_ENUM(NSInteger, OWSOperationState) { - OWSOperationStateNew, - OWSOperationStateExecuting, - OWSOperationStateFinished -}; - -// A base class for implementing retryable operations. -// To utilize the retryable behavior: -// Set remainingRetries to something greater than 0, and when you're reporting an error, -// set `error.isRetryable = YES`. -// If the failure is one that will not succeed upon retry, set `error.isFatal = YES`. -// -// isRetryable and isFatal are opposites but not redundant. -// -// If a group message send fails, the send will be retried if any of the errors were retryable UNLESS -// any of the errors were fatal. Fatal errors trump retryable errors. -@interface OWSOperation : NSOperation - -@property (readonly, nullable) NSError *failingError; - -// Defaults to 0, set to greater than 0 in init if you'd like the operation to be retryable. -@property NSUInteger remainingRetries; - -#pragma mark - Mandatory Subclass Overrides - -// Called every retry, this is where the bulk of the operation's work should go. -- (void)run; - -#pragma mark - Optional Subclass Overrides - -// Called one time only -- (nullable NSError *)checkForPreconditionError; - -// Called at most one time. -- (void)didSucceed; - -// Called at most one time. -- (void)didCancel; - -// Called zero or more times, retry may be possible -- (void)didReportError:(NSError *)error; - -// Called at most one time, once retry is no longer possible. -- (void)didFailWithError:(NSError *)error NS_SWIFT_NAME(didFail(error:)); - -// How long to wait before retry, if possible -- (NSTimeInterval)retryInterval; - -#pragma mark - Success/Error - Do Not Override - -// Runs now if a retry timer has been set by a previous failure, -// otherwise assumes we're currently running and does nothing. -- (void)runAnyQueuedRetry; - -// Report that the operation completed successfully. -// -// Each invocation of `run` must make exactly one call to one of: `reportSuccess`, `reportCancelled`, or `reportError:` -- (void)reportSuccess; - -// Call this when you abort before completion due to being cancelled. -// -// Each invocation of `run` must make exactly one call to one of: `reportSuccess`, `reportCancelled`, or `reportError:` -- (void)reportCancelled; - -// Report that the operation failed to complete due to an error. -// -// Each invocation of `run` must make exactly one call to one of: `reportSuccess`, `reportCancelled`, or `reportError:` -// You must ensure that `run` cannot succeed after calling `reportError`, e.g. generally you'll write something like -// this: -// -// [self reportError:someError]; -// return; -// -// If the error is terminal, and you want to avoid retry, report an error with `error.isFatal = YES` otherwise the -// operation will retry if possible. -- (void)reportError:(NSError *)error; - -@end - -NS_ASSUME_NONNULL_END diff --git a/SignalUtilitiesKit/Utilities/OWSOperation.m b/SignalUtilitiesKit/Utilities/OWSOperation.m deleted file mode 100644 index 47e511990..000000000 --- a/SignalUtilitiesKit/Utilities/OWSOperation.m +++ /dev/null @@ -1,253 +0,0 @@ -// -// Copyright (c) 2019 Open Whisper Systems. All rights reserved. -// - -#import "OWSOperation.h" -#import "OWSError.h" -#import -#import -#import -#import - -NS_ASSUME_NONNULL_BEGIN - -NSString *const OWSOperationKeyIsExecuting = @"isExecuting"; -NSString *const OWSOperationKeyIsFinished = @"isFinished"; - -@interface OWSOperation () - -@property (nullable) NSError *failingError; -@property (atomic) OWSOperationState operationState; -@property (nonatomic) OWSBackgroundTask *backgroundTask; - -// This property should only be accessed on the main queue. -@property (nonatomic) NSTimer *_Nullable retryTimer; - -@end - -@implementation OWSOperation - -- (instancetype)init -{ - self = [super init]; - if (!self) { - return self; - } - - _operationState = OWSOperationStateNew; - _backgroundTask = [OWSBackgroundTask backgroundTaskWithLabel:self.logTag]; - - // Operations are not retryable by default. - _remainingRetries = 0; - - return self; -} - -- (void)dealloc -{ - OWSLogDebug(@"in dealloc"); -} - -#pragma mark - Subclass Overrides - -// Called one time only -- (nullable NSError *)checkForPreconditionError -{ - // OWSOperation have a notion of failure, which is inferred by the presence of a `failingError`. - // - // By default, any failing dependency cascades that failure to it's dependent. - // If you'd like different behavior, override this method (`checkForPreconditionError`) without calling `super`. - for (NSOperation *dependency in self.dependencies) { - if (![dependency isKindOfClass:[OWSOperation class]]) { - // Native operations, like NSOperation and NSBlockOperation have no notion of "failure". - // So there's no `failingError` to cascade. - continue; - } - - OWSOperation *dependentOperation = (OWSOperation *)dependency; - - // Don't proceed if dependency failed - surface the dependency's error. - NSError *_Nullable dependencyError = dependentOperation.failingError; - if (dependencyError != nil) { - return dependencyError; - } - } - - return nil; -} - -// Called every retry, this is where the bulk of the operation's work should go. -- (void)run -{ - OWSAbstractMethod(); -} - -// Called at most one time. -- (void)didSucceed -{ - // no-op - // Override in subclass if necessary -} - -// Called at most one time. -- (void)didCancel -{ - // no-op - // Override in subclass if necessary -} - -// Called zero or more times, retry may be possible -- (void)didReportError:(NSError *)error -{ - // no-op - // Override in subclass if necessary -} - -// Called at most one time, once retry is no longer possible. -- (void)didFailWithError:(NSError *)error -{ - // no-op - // Override in subclass if necessary -} - -#pragma mark - NSOperation overrides - -// Do not override this method in a subclass instead, override `run` -- (void)main -{ - OWSLogDebug(@"started."); - NSError *_Nullable preconditionError = [self checkForPreconditionError]; - if (preconditionError) { - [self failOperationWithError:preconditionError]; - return; - } - - if (self.isCancelled) { - [self reportCancelled]; - return; - } - - [self run]; -} - -- (void)runAnyQueuedRetry -{ - dispatch_async(dispatch_get_main_queue(), ^{ - NSTimer *_Nullable retryTimer = self.retryTimer; - self.retryTimer = nil; - [retryTimer invalidate]; - - if (retryTimer != nil) { - dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ - [self run]; - }); - } else { - OWSLogVerbose(@"not re-running since operation is already running."); - } - }); -} - -#pragma mark - Public Methods - -// These methods are not intended to be subclassed -- (void)reportSuccess -{ - OWSLogDebug(@"succeeded."); - [self didSucceed]; - [self markAsComplete]; -} - -// These methods are not intended to be subclassed -- (void)reportCancelled -{ - OWSLogDebug(@"cancelled."); - [self didCancel]; - [self markAsComplete]; -} - -- (void)reportError:(NSError *)error -{ - [self didReportError:error]; - - if (self.remainingRetries == 0) { - [self failOperationWithError:error]; - return; - } - - self.remainingRetries--; - - dispatch_async(dispatch_get_main_queue(), ^{ - OWSAssertDebug(self.retryTimer == nil); - [self.retryTimer invalidate]; - - // The `scheduledTimerWith*` methods add the timer to the current thread's RunLoop. - // Since Operations typically run on a background thread, that would mean the background - // thread's RunLoop. However, the OS can spin down background threads if there's no work - // being done, so we run the risk of the timer's RunLoop being deallocated before it's - // fired. - // - // To ensure the timer's thread sticks around, we schedule it while on the main RunLoop. - self.retryTimer = [NSTimer weakScheduledTimerWithTimeInterval:self.retryInterval - target:self - selector:@selector(runAnyQueuedRetry) - userInfo:nil - repeats:NO]; - }); -} - -// Override in subclass if you want something more sophisticated, e.g. exponential backoff -- (NSTimeInterval)retryInterval -{ - return 0.1; -} - -#pragma mark - Life Cycle - -- (void)failOperationWithError:(NSError *)error -{ - OWSLogDebug(@"failed terminally."); - self.failingError = error; - - [self didFailWithError:error]; - [self markAsComplete]; -} - -- (BOOL)isExecuting -{ - return self.operationState == OWSOperationStateExecuting; -} - -- (BOOL)isFinished -{ - return self.operationState == OWSOperationStateFinished; -} - -- (void)start -{ - [self willChangeValueForKey:OWSOperationKeyIsExecuting]; - self.operationState = OWSOperationStateExecuting; - [self didChangeValueForKey:OWSOperationKeyIsExecuting]; - - [self main]; -} - -- (void)markAsComplete -{ - [self willChangeValueForKey:OWSOperationKeyIsExecuting]; - [self willChangeValueForKey:OWSOperationKeyIsFinished]; - - // Ensure we call the success or failure handler exactly once. - @synchronized(self) - { - OWSAssertDebug(self.operationState != OWSOperationStateFinished); - - self.operationState = OWSOperationStateFinished; - } - - [self didChangeValueForKey:OWSOperationKeyIsExecuting]; - [self didChangeValueForKey:OWSOperationKeyIsFinished]; -} - -@end - -NS_ASSUME_NONNULL_END diff --git a/SignalUtilitiesKit/Utilities/ReachabilityManager.swift b/SignalUtilitiesKit/Utilities/ReachabilityManager.swift index c9b884db8..6f683bcb7 100644 --- a/SignalUtilitiesKit/Utilities/ReachabilityManager.swift +++ b/SignalUtilitiesKit/Utilities/ReachabilityManager.swift @@ -3,6 +3,7 @@ import Foundation import Reachability import SignalCoreKit +import SessionMessagingKit /// **Warning:** The simulator doesn't detect reachability correctly so if you are seeing odd/incorrect reachability states double /// check on an actual device before trying to replace this implementation diff --git a/SignalUtilitiesKit/Utilities/SSKAsserts.h b/SignalUtilitiesKit/Utilities/SSKAsserts.h deleted file mode 100755 index d6c18e4a8..000000000 --- a/SignalUtilitiesKit/Utilities/SSKAsserts.h +++ /dev/null @@ -1,66 +0,0 @@ -// -// Copyright (c) 2018 Open Whisper Systems. All rights reserved. -// - -#import - -NS_ASSUME_NONNULL_BEGIN - -#pragma mark - Singleton Asserts - -// The "singleton asserts" can be used to ensure -// that we only create a singleton once. -// -// The simplest way to use them is the OWSSingletonAssert() macro. -// It is intended to be used inside the singleton's initializer. -// -// If, however, a singleton has multiple possible initializers, -// you need to: -// -// 1. Use OWSSingletonAssertFlag() outside the class definition. -// 2. Use OWSSingletonAssertInit() in each initializer. - -#ifdef DEBUG - -#define ENFORCE_SINGLETONS - -#endif - -#ifdef ENFORCE_SINGLETONS - -#define OWSSingletonAssertFlag() static BOOL _isSingletonCreated = NO; - -#define OWSSingletonAssertInit() \ - @synchronized([self class]) { \ - if (!CurrentAppContext().isRunningTests) { \ - OWSAssertDebug(!_isSingletonCreated); \ - _isSingletonCreated = YES; \ - } \ - } - -#define OWSSingletonAssert() OWSSingletonAssertFlag() OWSSingletonAssertInit() - -#else - -#define OWSSingletonAssertFlag() -#define OWSSingletonAssertInit() -#define OWSSingletonAssert() - -#endif - -#define OWSFailDebugUnlessRunningTests(_messageFormat, ...) \ - do { \ - if (!CurrentAppContext().isRunningTests) { \ - OWSFailDebug(_messageFormat, ##__VA_ARGS__); \ - } \ - } while (0) - -#define OWSCFailDebugUnlessRunningTests(_messageFormat, ...) \ - do { \ - if (!CurrentAppContext().isRunningTests) { \ - OWSCFailDebug(_messageFormat, ##__VA_ARGS__); \ - } \ - } while (NO) - - -NS_ASSUME_NONNULL_END diff --git a/SignalUtilitiesKit/Utilities/SignalIOS.pb.swift b/SignalUtilitiesKit/Utilities/SignalIOS.pb.swift deleted file mode 100644 index 588c729ed..000000000 --- a/SignalUtilitiesKit/Utilities/SignalIOS.pb.swift +++ /dev/null @@ -1,318 +0,0 @@ -// DO NOT EDIT. -// swift-format-ignore-file -// -// Generated by the Swift generator plugin for the protocol buffer compiler. -// Source: SignalIOS.proto -// -// For information on using the generated types, please see the documentation: -// https://github.com/apple/swift-protobuf/ - -//* -// Copyright (C) 2014-2016 Open Whisper Systems -// -// Licensed according to the LICENSE file in this repository. - -/// iOS - since we use a modern proto-compiler, we must specify -/// the legacy proto format. - -import Foundation -import SwiftProtobuf - -// If the compiler emits an error on this type, it is because this file -// was generated by a version of the `protoc` Swift plug-in that is -// incompatible with the version of SwiftProtobuf to which you are linking. -// Please ensure that you are building against the same version of the API -// that was used to generate this file. -fileprivate struct _GeneratedWithProtocGenSwiftVersion: SwiftProtobuf.ProtobufAPIVersionCheck { - struct _2: SwiftProtobuf.ProtobufAPIVersion_2 {} - typealias Version = _2 -} - -struct IOSProtos_BackupSnapshot { - // SwiftProtobuf.Message conformance is added in an extension below. See the - // `Message` and `Message+*Additions` files in the SwiftProtobuf library for - // methods supported on all messages. - - var entity: [IOSProtos_BackupSnapshot.BackupEntity] = [] - - var unknownFields = SwiftProtobuf.UnknownStorage() - - struct BackupEntity { - // SwiftProtobuf.Message conformance is added in an extension below. See the - // `Message` and `Message+*Additions` files in the SwiftProtobuf library for - // methods supported on all messages. - - /// @required - var type: IOSProtos_BackupSnapshot.BackupEntity.TypeEnum { - get {return _type ?? .unknown} - set {_type = newValue} - } - /// Returns true if `type` has been explicitly set. - var hasType: Bool {return self._type != nil} - /// Clears the value of `type`. Subsequent reads from it will return its default value. - mutating func clearType() {self._type = nil} - - /// @required - var entityData: Data { - get {return _entityData ?? SwiftProtobuf.Internal.emptyData} - set {_entityData = newValue} - } - /// Returns true if `entityData` has been explicitly set. - var hasEntityData: Bool {return self._entityData != nil} - /// Clears the value of `entityData`. Subsequent reads from it will return its default value. - mutating func clearEntityData() {self._entityData = nil} - - /// @required - var collection: String { - get {return _collection ?? String()} - set {_collection = newValue} - } - /// Returns true if `collection` has been explicitly set. - var hasCollection: Bool {return self._collection != nil} - /// Clears the value of `collection`. Subsequent reads from it will return its default value. - mutating func clearCollection() {self._collection = nil} - - /// @required - var key: String { - get {return _key ?? String()} - set {_key = newValue} - } - /// Returns true if `key` has been explicitly set. - var hasKey: Bool {return self._key != nil} - /// Clears the value of `key`. Subsequent reads from it will return its default value. - mutating func clearKey() {self._key = nil} - - var unknownFields = SwiftProtobuf.UnknownStorage() - - enum TypeEnum: SwiftProtobuf.Enum { - typealias RawValue = Int - case unknown // = 0 - case migration // = 1 - case thread // = 2 - case interaction // = 3 - case attachment // = 4 - case misc // = 5 - - init() { - self = .unknown - } - - init?(rawValue: Int) { - switch rawValue { - case 0: self = .unknown - case 1: self = .migration - case 2: self = .thread - case 3: self = .interaction - case 4: self = .attachment - case 5: self = .misc - default: return nil - } - } - - var rawValue: Int { - switch self { - case .unknown: return 0 - case .migration: return 1 - case .thread: return 2 - case .interaction: return 3 - case .attachment: return 4 - case .misc: return 5 - } - } - - } - - init() {} - - fileprivate var _type: IOSProtos_BackupSnapshot.BackupEntity.TypeEnum? = nil - fileprivate var _entityData: Data? = nil - fileprivate var _collection: String? = nil - fileprivate var _key: String? = nil - } - - init() {} -} - -#if swift(>=4.2) - -extension IOSProtos_BackupSnapshot.BackupEntity.TypeEnum: CaseIterable { - // Support synthesized by the compiler. -} - -#endif // swift(>=4.2) - -struct IOSProtos_DeviceName { - // SwiftProtobuf.Message conformance is added in an extension below. See the - // `Message` and `Message+*Additions` files in the SwiftProtobuf library for - // methods supported on all messages. - - /// @required - var ephemeralPublic: Data { - get {return _ephemeralPublic ?? SwiftProtobuf.Internal.emptyData} - set {_ephemeralPublic = newValue} - } - /// Returns true if `ephemeralPublic` has been explicitly set. - var hasEphemeralPublic: Bool {return self._ephemeralPublic != nil} - /// Clears the value of `ephemeralPublic`. Subsequent reads from it will return its default value. - mutating func clearEphemeralPublic() {self._ephemeralPublic = nil} - - /// @required - var syntheticIv: Data { - get {return _syntheticIv ?? SwiftProtobuf.Internal.emptyData} - set {_syntheticIv = newValue} - } - /// Returns true if `syntheticIv` has been explicitly set. - var hasSyntheticIv: Bool {return self._syntheticIv != nil} - /// Clears the value of `syntheticIv`. Subsequent reads from it will return its default value. - mutating func clearSyntheticIv() {self._syntheticIv = nil} - - /// @required - var ciphertext: Data { - get {return _ciphertext ?? SwiftProtobuf.Internal.emptyData} - set {_ciphertext = newValue} - } - /// Returns true if `ciphertext` has been explicitly set. - var hasCiphertext: Bool {return self._ciphertext != nil} - /// Clears the value of `ciphertext`. Subsequent reads from it will return its default value. - mutating func clearCiphertext() {self._ciphertext = nil} - - var unknownFields = SwiftProtobuf.UnknownStorage() - - init() {} - - fileprivate var _ephemeralPublic: Data? = nil - fileprivate var _syntheticIv: Data? = nil - fileprivate var _ciphertext: Data? = nil -} - -// MARK: - Code below here is support for the SwiftProtobuf runtime. - -fileprivate let _protobuf_package = "IOSProtos" - -extension IOSProtos_BackupSnapshot: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { - static let protoMessageName: String = _protobuf_package + ".BackupSnapshot" - static let _protobuf_nameMap: SwiftProtobuf._NameMap = [ - 1: .same(proto: "entity"), - ] - - mutating func decodeMessage(decoder: inout D) throws { - while let fieldNumber = try decoder.nextFieldNumber() { - switch fieldNumber { - case 1: try decoder.decodeRepeatedMessageField(value: &self.entity) - default: break - } - } - } - - func traverse(visitor: inout V) throws { - if !self.entity.isEmpty { - try visitor.visitRepeatedMessageField(value: self.entity, fieldNumber: 1) - } - try unknownFields.traverse(visitor: &visitor) - } - - static func ==(lhs: IOSProtos_BackupSnapshot, rhs: IOSProtos_BackupSnapshot) -> Bool { - if lhs.entity != rhs.entity {return false} - if lhs.unknownFields != rhs.unknownFields {return false} - return true - } -} - -extension IOSProtos_BackupSnapshot.BackupEntity: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { - static let protoMessageName: String = IOSProtos_BackupSnapshot.protoMessageName + ".BackupEntity" - static let _protobuf_nameMap: SwiftProtobuf._NameMap = [ - 1: .same(proto: "type"), - 2: .same(proto: "entityData"), - 3: .same(proto: "collection"), - 4: .same(proto: "key"), - ] - - mutating func decodeMessage(decoder: inout D) throws { - while let fieldNumber = try decoder.nextFieldNumber() { - switch fieldNumber { - case 1: try decoder.decodeSingularEnumField(value: &self._type) - case 2: try decoder.decodeSingularBytesField(value: &self._entityData) - case 3: try decoder.decodeSingularStringField(value: &self._collection) - case 4: try decoder.decodeSingularStringField(value: &self._key) - default: break - } - } - } - - func traverse(visitor: inout V) throws { - if let v = self._type { - try visitor.visitSingularEnumField(value: v, fieldNumber: 1) - } - if let v = self._entityData { - try visitor.visitSingularBytesField(value: v, fieldNumber: 2) - } - if let v = self._collection { - try visitor.visitSingularStringField(value: v, fieldNumber: 3) - } - if let v = self._key { - try visitor.visitSingularStringField(value: v, fieldNumber: 4) - } - try unknownFields.traverse(visitor: &visitor) - } - - static func ==(lhs: IOSProtos_BackupSnapshot.BackupEntity, rhs: IOSProtos_BackupSnapshot.BackupEntity) -> Bool { - if lhs._type != rhs._type {return false} - if lhs._entityData != rhs._entityData {return false} - if lhs._collection != rhs._collection {return false} - if lhs._key != rhs._key {return false} - if lhs.unknownFields != rhs.unknownFields {return false} - return true - } -} - -extension IOSProtos_BackupSnapshot.BackupEntity.TypeEnum: SwiftProtobuf._ProtoNameProviding { - static let _protobuf_nameMap: SwiftProtobuf._NameMap = [ - 0: .same(proto: "UNKNOWN"), - 1: .same(proto: "MIGRATION"), - 2: .same(proto: "THREAD"), - 3: .same(proto: "INTERACTION"), - 4: .same(proto: "ATTACHMENT"), - 5: .same(proto: "MISC"), - ] -} - -extension IOSProtos_DeviceName: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { - static let protoMessageName: String = _protobuf_package + ".DeviceName" - static let _protobuf_nameMap: SwiftProtobuf._NameMap = [ - 1: .same(proto: "ephemeralPublic"), - 2: .same(proto: "syntheticIv"), - 3: .same(proto: "ciphertext"), - ] - - mutating func decodeMessage(decoder: inout D) throws { - while let fieldNumber = try decoder.nextFieldNumber() { - switch fieldNumber { - case 1: try decoder.decodeSingularBytesField(value: &self._ephemeralPublic) - case 2: try decoder.decodeSingularBytesField(value: &self._syntheticIv) - case 3: try decoder.decodeSingularBytesField(value: &self._ciphertext) - default: break - } - } - } - - func traverse(visitor: inout V) throws { - if let v = self._ephemeralPublic { - try visitor.visitSingularBytesField(value: v, fieldNumber: 1) - } - if let v = self._syntheticIv { - try visitor.visitSingularBytesField(value: v, fieldNumber: 2) - } - if let v = self._ciphertext { - try visitor.visitSingularBytesField(value: v, fieldNumber: 3) - } - try unknownFields.traverse(visitor: &visitor) - } - - static func ==(lhs: IOSProtos_DeviceName, rhs: IOSProtos_DeviceName) -> Bool { - if lhs._ephemeralPublic != rhs._ephemeralPublic {return false} - if lhs._syntheticIv != rhs._syntheticIv {return false} - if lhs._ciphertext != rhs._ciphertext {return false} - if lhs.unknownFields != rhs.unknownFields {return false} - return true - } -} diff --git a/SignalUtilitiesKit/Utilities/SignalIOSProto.swift b/SignalUtilitiesKit/Utilities/SignalIOSProto.swift deleted file mode 100644 index 5761fbda7..000000000 --- a/SignalUtilitiesKit/Utilities/SignalIOSProto.swift +++ /dev/null @@ -1,409 +0,0 @@ -// -// Copyright (c) 2018 Open Whisper Systems. All rights reserved. -// - -import Foundation - -// WARNING: This code is generated. Only edit within the markers. - -public enum SignalIOSProtoError: Error { - case invalidProtobuf(description: String) -} - -// MARK: - SignalIOSProtoBackupSnapshotBackupEntity - -@objc public class SignalIOSProtoBackupSnapshotBackupEntity: NSObject { - - // MARK: - SignalIOSProtoBackupSnapshotBackupEntityType - - @objc public enum SignalIOSProtoBackupSnapshotBackupEntityType: Int32 { - case unknown = 0 - case migration = 1 - case thread = 2 - case interaction = 3 - case attachment = 4 - case misc = 5 - } - - private class func SignalIOSProtoBackupSnapshotBackupEntityTypeWrap(_ value: IOSProtos_BackupSnapshot.BackupEntity.TypeEnum) -> SignalIOSProtoBackupSnapshotBackupEntityType { - switch value { - case .unknown: return .unknown - case .migration: return .migration - case .thread: return .thread - case .interaction: return .interaction - case .attachment: return .attachment - case .misc: return .misc - } - } - - private class func SignalIOSProtoBackupSnapshotBackupEntityTypeUnwrap(_ value: SignalIOSProtoBackupSnapshotBackupEntityType) -> IOSProtos_BackupSnapshot.BackupEntity.TypeEnum { - switch value { - case .unknown: return .unknown - case .migration: return .migration - case .thread: return .thread - case .interaction: return .interaction - case .attachment: return .attachment - case .misc: return .misc - } - } - - // MARK: - SignalIOSProtoBackupSnapshotBackupEntityBuilder - - @objc public class func builder(type: SignalIOSProtoBackupSnapshotBackupEntityType, entityData: Data, collection: String, key: String) -> SignalIOSProtoBackupSnapshotBackupEntityBuilder { - return SignalIOSProtoBackupSnapshotBackupEntityBuilder(type: type, entityData: entityData, collection: collection, key: key) - } - - // asBuilder() constructs a builder that reflects the proto's contents. - @objc public func asBuilder() -> SignalIOSProtoBackupSnapshotBackupEntityBuilder { - let builder = SignalIOSProtoBackupSnapshotBackupEntityBuilder(type: type, entityData: entityData, collection: collection, key: key) - return builder - } - - @objc public class SignalIOSProtoBackupSnapshotBackupEntityBuilder: NSObject { - - private var proto = IOSProtos_BackupSnapshot.BackupEntity() - - @objc fileprivate override init() {} - - @objc fileprivate init(type: SignalIOSProtoBackupSnapshotBackupEntityType, entityData: Data, collection: String, key: String) { - super.init() - - setType(type) - setEntityData(entityData) - setCollection(collection) - setKey(key) - } - - @objc public func setType(_ valueParam: SignalIOSProtoBackupSnapshotBackupEntityType) { - proto.type = SignalIOSProtoBackupSnapshotBackupEntityTypeUnwrap(valueParam) - } - - @objc public func setEntityData(_ valueParam: Data) { - proto.entityData = valueParam - } - - @objc public func setCollection(_ valueParam: String) { - proto.collection = valueParam - } - - @objc public func setKey(_ valueParam: String) { - proto.key = valueParam - } - - @objc public func build() throws -> SignalIOSProtoBackupSnapshotBackupEntity { - return try SignalIOSProtoBackupSnapshotBackupEntity.parseProto(proto) - } - - @objc public func buildSerializedData() throws -> Data { - return try SignalIOSProtoBackupSnapshotBackupEntity.parseProto(proto).serializedData() - } - } - - fileprivate let proto: IOSProtos_BackupSnapshot.BackupEntity - - @objc public let type: SignalIOSProtoBackupSnapshotBackupEntityType - - @objc public let entityData: Data - - @objc public let collection: String - - @objc public let key: String - - private init(proto: IOSProtos_BackupSnapshot.BackupEntity, - type: SignalIOSProtoBackupSnapshotBackupEntityType, - entityData: Data, - collection: String, - key: String) { - self.proto = proto - self.type = type - self.entityData = entityData - self.collection = collection - self.key = key - } - - @objc - public func serializedData() throws -> Data { - return try self.proto.serializedData() - } - - @objc public class func parseData(_ serializedData: Data) throws -> SignalIOSProtoBackupSnapshotBackupEntity { - let proto = try IOSProtos_BackupSnapshot.BackupEntity(serializedData: serializedData) - return try parseProto(proto) - } - - fileprivate class func parseProto(_ proto: IOSProtos_BackupSnapshot.BackupEntity) throws -> SignalIOSProtoBackupSnapshotBackupEntity { - guard proto.hasType else { - throw SignalIOSProtoError.invalidProtobuf(description: "\(logTag) missing required field: type") - } - let type = SignalIOSProtoBackupSnapshotBackupEntityTypeWrap(proto.type) - - guard proto.hasEntityData else { - throw SignalIOSProtoError.invalidProtobuf(description: "\(logTag) missing required field: entityData") - } - let entityData = proto.entityData - - guard proto.hasCollection else { - throw SignalIOSProtoError.invalidProtobuf(description: "\(logTag) missing required field: collection") - } - let collection = proto.collection - - guard proto.hasKey else { - throw SignalIOSProtoError.invalidProtobuf(description: "\(logTag) missing required field: key") - } - let key = proto.key - - // MARK: - Begin Validation Logic for SignalIOSProtoBackupSnapshotBackupEntity - - - // MARK: - End Validation Logic for SignalIOSProtoBackupSnapshotBackupEntity - - - let result = SignalIOSProtoBackupSnapshotBackupEntity(proto: proto, - type: type, - entityData: entityData, - collection: collection, - key: key) - return result - } - - @objc public override var debugDescription: String { - return "\(proto)" - } -} - -#if DEBUG - -extension SignalIOSProtoBackupSnapshotBackupEntity { - @objc public func serializedDataIgnoringErrors() -> Data? { - return try! self.serializedData() - } -} - -extension SignalIOSProtoBackupSnapshotBackupEntity.SignalIOSProtoBackupSnapshotBackupEntityBuilder { - @objc public func buildIgnoringErrors() -> SignalIOSProtoBackupSnapshotBackupEntity? { - return try! self.build() - } -} - -#endif - -// MARK: - SignalIOSProtoBackupSnapshot - -@objc public class SignalIOSProtoBackupSnapshot: NSObject { - - // MARK: - SignalIOSProtoBackupSnapshotBuilder - - @objc public class func builder() -> SignalIOSProtoBackupSnapshotBuilder { - return SignalIOSProtoBackupSnapshotBuilder() - } - - // asBuilder() constructs a builder that reflects the proto's contents. - @objc public func asBuilder() -> SignalIOSProtoBackupSnapshotBuilder { - let builder = SignalIOSProtoBackupSnapshotBuilder() - builder.setEntity(entity) - return builder - } - - @objc public class SignalIOSProtoBackupSnapshotBuilder: NSObject { - - private var proto = IOSProtos_BackupSnapshot() - - @objc fileprivate override init() {} - - @objc public func addEntity(_ valueParam: SignalIOSProtoBackupSnapshotBackupEntity) { - var items = proto.entity - items.append(valueParam.proto) - proto.entity = items - } - - @objc public func setEntity(_ wrappedItems: [SignalIOSProtoBackupSnapshotBackupEntity]) { - proto.entity = wrappedItems.map { $0.proto } - } - - @objc public func build() throws -> SignalIOSProtoBackupSnapshot { - return try SignalIOSProtoBackupSnapshot.parseProto(proto) - } - - @objc public func buildSerializedData() throws -> Data { - return try SignalIOSProtoBackupSnapshot.parseProto(proto).serializedData() - } - } - - fileprivate let proto: IOSProtos_BackupSnapshot - - @objc public let entity: [SignalIOSProtoBackupSnapshotBackupEntity] - - private init(proto: IOSProtos_BackupSnapshot, - entity: [SignalIOSProtoBackupSnapshotBackupEntity]) { - self.proto = proto - self.entity = entity - } - - @objc - public func serializedData() throws -> Data { - return try self.proto.serializedData() - } - - @objc public class func parseData(_ serializedData: Data) throws -> SignalIOSProtoBackupSnapshot { - let proto = try IOSProtos_BackupSnapshot(serializedData: serializedData) - return try parseProto(proto) - } - - fileprivate class func parseProto(_ proto: IOSProtos_BackupSnapshot) throws -> SignalIOSProtoBackupSnapshot { - var entity: [SignalIOSProtoBackupSnapshotBackupEntity] = [] - entity = try proto.entity.map { try SignalIOSProtoBackupSnapshotBackupEntity.parseProto($0) } - - // MARK: - Begin Validation Logic for SignalIOSProtoBackupSnapshot - - - // MARK: - End Validation Logic for SignalIOSProtoBackupSnapshot - - - let result = SignalIOSProtoBackupSnapshot(proto: proto, - entity: entity) - return result - } - - @objc public override var debugDescription: String { - return "\(proto)" - } -} - -#if DEBUG - -extension SignalIOSProtoBackupSnapshot { - @objc public func serializedDataIgnoringErrors() -> Data? { - return try! self.serializedData() - } -} - -extension SignalIOSProtoBackupSnapshot.SignalIOSProtoBackupSnapshotBuilder { - @objc public func buildIgnoringErrors() -> SignalIOSProtoBackupSnapshot? { - return try! self.build() - } -} - -#endif - -// MARK: - SignalIOSProtoDeviceName - -@objc public class SignalIOSProtoDeviceName: NSObject { - - // MARK: - SignalIOSProtoDeviceNameBuilder - - @objc public class func builder(ephemeralPublic: Data, syntheticIv: Data, ciphertext: Data) -> SignalIOSProtoDeviceNameBuilder { - return SignalIOSProtoDeviceNameBuilder(ephemeralPublic: ephemeralPublic, syntheticIv: syntheticIv, ciphertext: ciphertext) - } - - // asBuilder() constructs a builder that reflects the proto's contents. - @objc public func asBuilder() -> SignalIOSProtoDeviceNameBuilder { - let builder = SignalIOSProtoDeviceNameBuilder(ephemeralPublic: ephemeralPublic, syntheticIv: syntheticIv, ciphertext: ciphertext) - return builder - } - - @objc public class SignalIOSProtoDeviceNameBuilder: NSObject { - - private var proto = IOSProtos_DeviceName() - - @objc fileprivate override init() {} - - @objc fileprivate init(ephemeralPublic: Data, syntheticIv: Data, ciphertext: Data) { - super.init() - - setEphemeralPublic(ephemeralPublic) - setSyntheticIv(syntheticIv) - setCiphertext(ciphertext) - } - - @objc public func setEphemeralPublic(_ valueParam: Data) { - proto.ephemeralPublic = valueParam - } - - @objc public func setSyntheticIv(_ valueParam: Data) { - proto.syntheticIv = valueParam - } - - @objc public func setCiphertext(_ valueParam: Data) { - proto.ciphertext = valueParam - } - - @objc public func build() throws -> SignalIOSProtoDeviceName { - return try SignalIOSProtoDeviceName.parseProto(proto) - } - - @objc public func buildSerializedData() throws -> Data { - return try SignalIOSProtoDeviceName.parseProto(proto).serializedData() - } - } - - fileprivate let proto: IOSProtos_DeviceName - - @objc public let ephemeralPublic: Data - - @objc public let syntheticIv: Data - - @objc public let ciphertext: Data - - private init(proto: IOSProtos_DeviceName, - ephemeralPublic: Data, - syntheticIv: Data, - ciphertext: Data) { - self.proto = proto - self.ephemeralPublic = ephemeralPublic - self.syntheticIv = syntheticIv - self.ciphertext = ciphertext - } - - @objc - public func serializedData() throws -> Data { - return try self.proto.serializedData() - } - - @objc public class func parseData(_ serializedData: Data) throws -> SignalIOSProtoDeviceName { - let proto = try IOSProtos_DeviceName(serializedData: serializedData) - return try parseProto(proto) - } - - fileprivate class func parseProto(_ proto: IOSProtos_DeviceName) throws -> SignalIOSProtoDeviceName { - guard proto.hasEphemeralPublic else { - throw SignalIOSProtoError.invalidProtobuf(description: "\(logTag) missing required field: ephemeralPublic") - } - let ephemeralPublic = proto.ephemeralPublic - - guard proto.hasSyntheticIv else { - throw SignalIOSProtoError.invalidProtobuf(description: "\(logTag) missing required field: syntheticIv") - } - let syntheticIv = proto.syntheticIv - - guard proto.hasCiphertext else { - throw SignalIOSProtoError.invalidProtobuf(description: "\(logTag) missing required field: ciphertext") - } - let ciphertext = proto.ciphertext - - // MARK: - Begin Validation Logic for SignalIOSProtoDeviceName - - - // MARK: - End Validation Logic for SignalIOSProtoDeviceName - - - let result = SignalIOSProtoDeviceName(proto: proto, - ephemeralPublic: ephemeralPublic, - syntheticIv: syntheticIv, - ciphertext: ciphertext) - return result - } - - @objc public override var debugDescription: String { - return "\(proto)" - } -} - -#if DEBUG - -extension SignalIOSProtoDeviceName { - @objc public func serializedDataIgnoringErrors() -> Data? { - return try! self.serializedData() - } -} - -extension SignalIOSProtoDeviceName.SignalIOSProtoDeviceNameBuilder { - @objc public func buildIgnoringErrors() -> SignalIOSProtoDeviceName? { - return try! self.build() - } -} - -#endif diff --git a/SignalUtilitiesKit/Utilities/SwiftSingletons.swift b/SignalUtilitiesKit/Utilities/SwiftSingletons.swift index 2209fcf52..40bee1b66 100644 --- a/SignalUtilitiesKit/Utilities/SwiftSingletons.swift +++ b/SignalUtilitiesKit/Utilities/SwiftSingletons.swift @@ -2,6 +2,7 @@ import Foundation import SignalCoreKit +import SessionUtilitiesKit public class SwiftSingletons: NSObject { public static let shared = SwiftSingletons() @@ -13,12 +14,8 @@ public class SwiftSingletons: NSObject { } public func register(_ singleton: AnyObject) { - guard !CurrentAppContext().isRunningTests else { - return - } - guard _isDebugAssertConfiguration() else { - return - } + guard !SNUtilitiesKit.isRunningTests else { return } + guard _isDebugAssertConfiguration() else { return } let singletonClassName = String(describing: type(of: singleton)) guard !classSet.contains(singletonClassName) else { owsFailDebug("Duplicate singleton: \(singletonClassName).") diff --git a/SignalUtilitiesKit/Utilities/TSConstants.h b/SignalUtilitiesKit/Utilities/TSConstants.h deleted file mode 100644 index 3d8542585..000000000 --- a/SignalUtilitiesKit/Utilities/TSConstants.h +++ /dev/null @@ -1,78 +0,0 @@ -// -// Copyright (c) 2019 Open Whisper Systems. All rights reserved. -// - -#import - -NS_ASSUME_NONNULL_BEGIN - -#ifndef TextSecureKit_Constants_h -#define TextSecureKit_Constants_h - -extern const NSUInteger kOversizeTextMessageSizeThreshold; - -typedef NS_ENUM(NSInteger, TSWhisperMessageType) { - TSUnknownMessageType = 0, - TSEncryptedWhisperMessageType = 1, - TSIgnoreOnIOSWhisperMessageType = 2, // on droid this is the prekey bundle message irrelevant for us - TSPreKeyWhisperMessageType = 3, - TSUnencryptedWhisperMessageType = 4, - TSUnidentifiedSenderMessageType = 6, - TSClosedGroupCiphertextMessageType = 7, - TSFallbackMessageType = 101 -}; - -#pragma mark Server Address - -#define textSecureHTTPTimeOut 10 - -#define kLegalTermsUrlString @"https://getsession.org/privacy-policy/" - -//#ifndef DEBUG - -// Production -#define textSecureWebSocketAPI @"wss://textsecure-service.whispersystems.org/v1/websocket/" -#define textSecureCDNServerURL @"https://cdn.signal.org" -// Use same reflector for service and CDN -#define textSecureServiceReflectorHost @"europe-west1-signal-cdn-reflector.cloudfunctions.net" -#define textSecureCDNReflectorHost @"europe-west1-signal-cdn-reflector.cloudfunctions.net" -#define contactDiscoveryURL @"https://api.directory.signal.org" -#define kUDTrustRoot @"BXu6QIKVz5MA8gstzfOgRQGqyLqOwNKHL6INkv3IHWMF" -#define USING_PRODUCTION_SERVICE - -//#else - -// Staging -//#define textSecureWebSocketAPI @"wss://textsecure-service-staging.whispersystems.org/v1/websocket/" -//#define textSecureServerURL @"https://textsecure-service-staging.whispersystems.org/" -//#define textSecureCDNServerURL @"https://cdn-staging.signal.org" -//#define textSecureServiceReflectorHost @"meek-signal-service-staging.appspot.com"; -//#define textSecureCDNReflectorHost @"meek-signal-cdn-staging.appspot.com"; -//#define contactDiscoveryURL @"https://api-staging.directory.signal.org" -//#define kUDTrustRoot @"BbqY1DzohE4NUZoVF+L18oUPrK3kILllLEJh2UnPSsEx" - -//#endif - -BOOL IsUsingProductionService(void); - -#define textSecureAccountsAPI @"v1/accounts" -#define textSecureAttributesAPI @"/attributes/" - -#define textSecureMessagesAPI @"v1/messages/" -#define textSecureKeysAPI @"v2/keys" -#define textSecureSignedKeysAPI @"v2/keys/signed" -#define textSecureDirectoryAPI @"v1/directory" -#define textSecureAttachmentsAPI @"v1/attachments" -#define textSecureDeviceProvisioningCodeAPI @"v1/devices/provisioning/code" -#define textSecureDeviceProvisioningAPIFormat @"v1/provisioning/%@" -#define textSecureDevicesAPIFormat @"v1/devices/%@" -#define textSecureProfileAPIFormat @"v1/profile/%@" -#define textSecureSetProfileNameAPIFormat @"v1/profile/name/%@" -#define textSecureProfileAvatarFormAPI @"v1/profile/form/avatar" -#define textSecure2FAAPI @"/v1/accounts/pin" - -#define SignalApplicationGroup @"group.com.loki-project.loki-messenger" - -#endif - -NS_ASSUME_NONNULL_END diff --git a/SignalUtilitiesKit/Utilities/TSConstants.m b/SignalUtilitiesKit/Utilities/TSConstants.m deleted file mode 100644 index fbd6607e9..000000000 --- a/SignalUtilitiesKit/Utilities/TSConstants.m +++ /dev/null @@ -1,20 +0,0 @@ -// -// Copyright (c) 2019 Open Whisper Systems. All rights reserved. -// - -#import "TSConstants.h" - -NS_ASSUME_NONNULL_BEGIN - -const NSUInteger kOversizeTextMessageSizeThreshold = 2 * 1024; - -BOOL IsUsingProductionService() -{ -#ifdef USING_PRODUCTION_SERVICE - return YES; -#else - return NO; -#endif -} - -NS_ASSUME_NONNULL_END diff --git a/SignalUtilitiesKit/Utilities/UIView+OWS.swift b/SignalUtilitiesKit/Utilities/UIView+OWS.swift index de1a7c5cd..b158c207c 100644 --- a/SignalUtilitiesKit/Utilities/UIView+OWS.swift +++ b/SignalUtilitiesKit/Utilities/UIView+OWS.swift @@ -3,6 +3,7 @@ import Foundation import SessionUIKit import SignalCoreKit +import SessionUtilitiesKit public extension UIEdgeInsets { init(top: CGFloat, leading: CGFloat, bottom: CGFloat, trailing: CGFloat) { diff --git a/SignalUtilitiesKit/Utilities/UIViewController+OWS.swift b/SignalUtilitiesKit/Utilities/UIViewController+OWS.swift index 6791a15e1..d2904ab77 100644 --- a/SignalUtilitiesKit/Utilities/UIViewController+OWS.swift +++ b/SignalUtilitiesKit/Utilities/UIViewController+OWS.swift @@ -2,6 +2,7 @@ import UIKit import SessionUIKit +import SessionUtilitiesKit public extension UIViewController { func findFrontmostViewController(ignoringAlerts: Bool) -> UIViewController { diff --git a/_SharedTestUtilities/CommonMockedExtensions.swift b/_SharedTestUtilities/CommonMockedExtensions.swift index 97eeee5c9..853dc44f3 100644 --- a/_SharedTestUtilities/CommonMockedExtensions.swift +++ b/_SharedTestUtilities/CommonMockedExtensions.swift @@ -1,6 +1,8 @@ // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import Foundation +import Combine +import GRDB import Sodium import Curve25519Kit import SessionUtilitiesKit @@ -20,3 +22,56 @@ extension ECKeyPair: Mocked { ) } } + +extension Database: Mocked { + static var mockValue: Database { + var result: Database! + try! DatabaseQueue().read { result = $0 } + return result! + } +} + +extension Job: Mocked { + static var mockValue: Job = Job(variant: .messageSend) +} + +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 new file mode 100644 index 000000000..bbf6a87c3 --- /dev/null +++ b/_SharedTestUtilities/MockJobRunner.swift @@ -0,0 +1,52 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import GRDB +import SessionUtilitiesKit + +@testable import SessionMessagingKit + +class MockJobRunner: Mock, JobRunnerType { + // MARK: - Configuration + + func setExecutor(_ executor: JobExecutor.Type, for variant: Job.Variant) { + accept(args: [executor, variant]) + } + + func canStart(queue: JobQueue?) -> Bool { + 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(using dependencies: Dependencies) {} + func appDidBecomeActive(using dependencies: Dependencies) {} + func startNonBlockingQueues(using dependencies: Dependencies) {} + + func stopAndClearPendingJobs(exceptForVariant: Job.Variant?, onComplete: (() -> ())?) { + accept(args: [exceptForVariant, onComplete]) + onComplete?() + } + + // MARK: - Job Scheduling + + @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, using dependencies: Dependencies) { + accept(args: [db, job, canStartJob]) + } + + 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) + } +}